From b9cfe439867d949873f8ff09524797efc52136b6 Mon Sep 17 00:00:00 2001 From: MrUnknownDE Date: Sat, 29 Mar 2025 18:34:12 +0100 Subject: [PATCH] seperate server.js --- backend/maxmind.js | 56 +++ backend/routes/dnsLookup.js | 115 +++++ backend/routes/ipinfo.js | 116 +++++ backend/routes/lookup.js | 104 ++++ backend/routes/ping.js | 82 ++++ backend/routes/traceroute.js | 172 +++++++ backend/routes/version.js | 20 + backend/routes/whoisLookup.js | 86 ++++ backend/server.js | 888 ++++------------------------------ backend/utils.js | 269 ++++++++++ 10 files changed, 1116 insertions(+), 792 deletions(-) create mode 100644 backend/maxmind.js create mode 100644 backend/routes/dnsLookup.js create mode 100644 backend/routes/ipinfo.js create mode 100644 backend/routes/lookup.js create mode 100644 backend/routes/ping.js create mode 100644 backend/routes/traceroute.js create mode 100644 backend/routes/version.js create mode 100644 backend/routes/whoisLookup.js create mode 100644 backend/utils.js diff --git a/backend/maxmind.js b/backend/maxmind.js new file mode 100644 index 0000000..9464c40 --- /dev/null +++ b/backend/maxmind.js @@ -0,0 +1,56 @@ +// backend/maxmind.js +const geoip = require('@maxmind/geoip2-node'); +const pino = require('pino'); +const Sentry = require("@sentry/node"); + +// Minimaler Logger für dieses Modul +const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); + +let cityReaderInstance = null; +let asnReaderInstance = null; + +async function initializeMaxMind() { + if (cityReaderInstance && asnReaderInstance) { + logger.debug('MaxMind databases already loaded.'); + return { cityReader: cityReaderInstance, asnReader: asnReaderInstance }; + } + + try { + logger.info('Loading MaxMind databases...'); + const cityDbPath = process.env.GEOIP_CITY_DB || './data/GeoLite2-City.mmdb'; + const asnDbPath = process.env.GEOIP_ASN_DB || './data/GeoLite2-ASN.mmdb'; + logger.info({ cityDbPath, asnDbPath }, 'Database paths'); + + // Verwende Promise.all für paralleles Laden + const [cityReader, asnReader] = await Promise.all([ + geoip.Reader.open(cityDbPath), + geoip.Reader.open(asnDbPath) + ]); + + cityReaderInstance = cityReader; + asnReaderInstance = asnReader; + logger.info('MaxMind databases loaded successfully.'); + return { cityReader: cityReaderInstance, asnReader: asnReaderInstance }; + + } catch (error) { + logger.fatal({ error: error.message, stack: error.stack }, 'Could not initialize MaxMind databases.'); + Sentry.captureException(error); + // Wirf den Fehler weiter, damit der Serverstart fehlschlägt + throw error; + } +} + +// Funktion zum Abrufen der Reader (stellt sicher, dass sie initialisiert wurden) +function getMaxMindReaders() { + if (!cityReaderInstance || !asnReaderInstance) { + // Dieser Fall sollte im normalen Betrieb nicht auftreten, da initialize() beim Serverstart aufgerufen wird. + logger.error('MaxMind readers accessed before initialization!'); + throw new Error('MaxMind readers not initialized. Call initializeMaxMind() first.'); + } + return { cityReader: cityReaderInstance, asnReader: asnReaderInstance }; +} + +module.exports = { + initializeMaxMind, + getMaxMindReaders, +}; \ No newline at end of file diff --git a/backend/routes/dnsLookup.js b/backend/routes/dnsLookup.js new file mode 100644 index 0000000..835714b --- /dev/null +++ b/backend/routes/dnsLookup.js @@ -0,0 +1,115 @@ +// backend/routes/dnsLookup.js +const express = require('express'); +const Sentry = require("@sentry/node"); +const dns = require('dns').promises; +const pino = require('pino'); + +// Import utilities +const { isValidDomain } = require('../utils'); + +// Logger for this module +const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); + +const router = express.Router(); + +// Supported DNS record types +const VALID_DNS_TYPES = ['A', 'AAAA', 'MX', 'TXT', 'NS', 'CNAME', 'SOA', 'SRV', 'PTR', 'ANY']; + +// Route handler for / (relative to /api/dns-lookup) +router.get('/', async (req, res, next) => { + const domainRaw = req.query.domain; + const domain = typeof domainRaw === 'string' ? domainRaw.trim() : domainRaw; + const typeRaw = req.query.type; + // Default to 'ANY' if type is missing or invalid, convert valid types to uppercase + let type = typeof typeRaw === 'string' ? typeRaw.trim().toUpperCase() : 'ANY'; + if (!VALID_DNS_TYPES.includes(type)) { + logger.warn({ requestIp: req.ip, domain, requestedType: typeRaw }, 'Invalid record type requested, defaulting to ANY'); + type = 'ANY'; // Default to 'ANY' for invalid types + } + + const requestIp = req.ip || req.socket.remoteAddress; + + logger.info({ requestIp, domain, type }, 'DNS lookup request received'); + + if (!isValidDomain(domain)) { + logger.warn({ requestIp, domain }, 'Invalid domain for DNS lookup'); + return res.status(400).json({ success: false, error: 'Invalid domain name provided.' }); + } + + // Note: No isPrivateIp check here as DNS lookups for internal domains might be valid use cases. + + try { + let records; + if (type === 'ANY') { + // Define types to query for 'ANY' - exclude PTR as it requires an IP + const typesToQuery = ['A', 'AAAA', 'MX', 'TXT', 'NS', 'CNAME', 'SOA', 'SRV']; + const promises = typesToQuery.map(t => + dns.resolve(domain, t) + .then(result => ({ type: t, records: result })) // Wrap result with type + .catch(err => ({ type: t, error: err })) // Wrap error with type + ); + + const results = await Promise.allSettled(promises); + + records = {}; + results.forEach(result => { + if (result.status === 'fulfilled') { + const data = result.value; + if (data.error) { + // Log DNS resolution errors for specific types as warnings/debug + if (data.error.code !== 'ENOTFOUND' && data.error.code !== 'ENODATA') { + logger.warn({ requestIp, domain, type: data.type, error: data.error.message, code: data.error.code }, `DNS lookup failed for type ${data.type}`); + } else { + logger.debug({ requestIp, domain, type: data.type, code: data.error.code }, `No record found for type ${data.type}`); + } + // Optionally include error details in response (or just omit the type) + // records[data.type] = { error: `Lookup failed (${data.error.code || 'Unknown'})` }; + } else if (data.records && data.records.length > 0) { + // Only add if records exist + records[data.type] = data.records; + } + } else { + // Handle unexpected errors from Promise.allSettled (should be rare) + logger.error({ requestIp, domain, type: 'ANY', error: result.reason?.message }, 'Unexpected error during Promise.allSettled for ANY DNS lookup'); + } + }); + + if (Object.keys(records).length === 0) { + // If no records found for any type + logger.info({ requestIp, domain, type }, 'DNS lookup for ANY type yielded no records.'); + // Send success: true, but with an empty records object or a note + // return res.json({ success: true, domain, type, records: {}, note: 'No records found for queried types.' }); + } + + } else { + // Handle specific type query + try { + records = await dns.resolve(domain, type); + } catch (error) { + if (error.code === 'ENOTFOUND' || error.code === 'ENODATA') { + logger.info({ requestIp, domain, type, code: error.code }, `DNS lookup failed (No record) for type ${type}`); + // Return success: true, but indicate no records found + return res.json({ success: true, domain, type, records: [], note: `No ${type} records found.` }); + } else { + // Rethrow other errors to be caught by the outer catch block + throw error; + } + } + } + + logger.info({ requestIp, domain, type }, 'DNS lookup successful'); + // For specific type, records will be an array. For ANY, it's an object. + res.json({ success: true, domain, type, records }); + + } catch (error) { + // Catches errors from specific type lookups (not ENOTFOUND/ENODATA) or unexpected errors + logger.error({ requestIp, domain, type, error: error.message, code: error.code }, 'DNS lookup failed'); + Sentry.captureException(error, { extra: { requestIp, domain, type } }); + // Send appropriate status code based on error if possible, otherwise 500 + const statusCode = error.code === 'ESERVFAIL' ? 502 : 500; + res.status(statusCode).json({ success: false, error: `DNS lookup failed: ${error.message} (Code: ${error.code || 'Unknown'})` }); + // next(error); // Optional: Pass to Sentry error handler + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/routes/ipinfo.js b/backend/routes/ipinfo.js new file mode 100644 index 0000000..9647b87 --- /dev/null +++ b/backend/routes/ipinfo.js @@ -0,0 +1,116 @@ +// backend/routes/ipinfo.js +const express = require('express'); +const Sentry = require("@sentry/node"); +const dns = require('dns').promises; +const pino = require('pino'); // Assuming logger is needed, or pass it down + +// Import utilities and MaxMind reader access +const { isValidIp, getCleanIp } = require('../utils'); +const { getMaxMindReaders } = require('../maxmind'); + +// Create a logger instance for this route module +// Ideally, the main logger instance should be passed down or configured globally +const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); + +const router = express.Router(); + +// Route handler for / (relative to where this router is mounted, e.g., /api/ipinfo) +router.get('/', async (req, res, next) => { + const requestIp = req.ip || req.socket.remoteAddress; + logger.info({ ip: requestIp, method: req.method, url: req.originalUrl }, 'ipinfo request received'); + const clientIp = getCleanIp(requestIp); + logger.debug({ rawIp: requestIp, cleanedIp: clientIp }, 'IP cleaning result'); + + if (!clientIp || !isValidIp(clientIp)) { + if (clientIp === '127.0.0.1' || clientIp === '::1') { + logger.info({ ip: clientIp }, 'Responding with localhost info'); + return res.json({ + ip: clientIp, + geo: { note: 'Localhost IP, no Geo data available.' }, + asn: { note: 'Localhost IP, no ASN data available.' }, + rdns: ['localhost'], + }); + } + logger.error({ rawIp: requestIp, cleanedIp: clientIp }, 'Could not determine a valid client IP'); + Sentry.captureMessage('Could not determine a valid client IP', { + level: 'error', + extra: { rawIp: requestIp, cleanedIp: clientIp } + }); + // Use 400 for client error (invalid IP derived) + return res.status(400).json({ error: 'Could not determine a valid client IP address.', rawIp: requestIp, cleanedIp: clientIp }); + } + + try { + // Get initialized MaxMind readers + const { cityReader, asnReader } = getMaxMindReaders(); + + let geo = null; + try { + const geoData = cityReader.city(clientIp); + geo = { + city: geoData.city?.names?.en, + region: geoData.subdivisions?.[0]?.isoCode, + country: geoData.country?.isoCode, + countryName: geoData.country?.names?.en, + postalCode: geoData.postal?.code, + latitude: geoData.location?.latitude, + longitude: geoData.location?.longitude, + timezone: geoData.location?.timeZone, + }; + // Remove null/undefined values + geo = Object.fromEntries(Object.entries(geo).filter(([_, v]) => v != null)); + logger.debug({ ip: clientIp, geo }, 'GeoIP lookup successful'); + } catch (e) { + // Log as warning, as this is expected for private IPs or IPs not in DB + logger.warn({ ip: clientIp, error: e.message }, `MaxMind City lookup failed`); + geo = { error: 'GeoIP lookup failed (IP not found in database or private range).' }; + } + + let asn = null; + try { + const asnData = asnReader.asn(clientIp); + asn = { + number: asnData.autonomousSystemNumber, + organization: asnData.autonomousSystemOrganization, + }; + asn = Object.fromEntries(Object.entries(asn).filter(([_, v]) => v != null)); + logger.debug({ ip: clientIp, asn }, 'ASN lookup successful'); + } catch (e) { + logger.warn({ ip: clientIp, error: e.message }, `MaxMind ASN lookup failed`); + asn = { error: 'ASN lookup failed (IP not found in database or private range).' }; + } + + let rdns = null; + try { + const hostnames = await dns.reverse(clientIp); + rdns = hostnames; + logger.debug({ ip: clientIp, rdns }, 'rDNS lookup successful'); + } catch (e) { + // Log non-existence as debug, other errors as warn + if (e.code !== 'ENOTFOUND' && e.code !== 'ENODATA') { + logger.warn({ ip: clientIp, error: e.message, code: e.code }, `rDNS lookup error`); + } else { + logger.debug({ ip: clientIp, code: e.code }, 'rDNS lookup failed (No record)'); + } + // Provide a structured error in the response + rdns = { error: `rDNS lookup failed (${e.code || 'Unknown error'})` }; + } + + res.json({ + ip: clientIp, + // Only include geo/asn if they don't contain an error and have data + geo: geo.error ? geo : (Object.keys(geo).length > 0 ? geo : null), + asn: asn.error ? asn : (Object.keys(asn).length > 0 ? asn : null), + rdns // rdns will contain either the array of hostnames or the error object + }); + + } catch (error) { + // Catch unexpected errors during processing (e.g., issues with getMaxMindReaders) + logger.error({ ip: clientIp, error: error.message, stack: error.stack }, 'Error processing ipinfo'); + Sentry.captureException(error, { extra: { ip: clientIp } }); + // Pass the error to the Sentry error handler middleware + next(error); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/routes/lookup.js b/backend/routes/lookup.js new file mode 100644 index 0000000..d9afa81 --- /dev/null +++ b/backend/routes/lookup.js @@ -0,0 +1,104 @@ +// backend/routes/lookup.js +const express = require('express'); +const Sentry = require("@sentry/node"); +const dns = require('dns').promises; +const pino = require('pino'); + +// Import utilities and MaxMind reader access +const { isValidIp, isPrivateIp } = require('../utils'); +const { getMaxMindReaders } = require('../maxmind'); + +// Logger for this module +const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); + +const router = express.Router(); + +// Route handler for / (relative to /api/lookup) +router.get('/', async (req, res, next) => { + const targetIpRaw = req.query.targetIp; + const targetIp = typeof targetIpRaw === 'string' ? targetIpRaw.trim() : targetIpRaw; + const requestIp = req.ip || req.socket.remoteAddress; // IP of the client making the request + + logger.info({ requestIp, targetIp }, 'Lookup request received'); + + if (!isValidIp(targetIp)) { + logger.warn({ requestIp, targetIp }, 'Invalid target IP for lookup'); + return res.status(400).json({ success: false, error: 'Invalid IP address provided for lookup.' }); + } + if (isPrivateIp(targetIp)) { + logger.warn({ requestIp, targetIp }, 'Attempt to lookup private IP blocked'); + return res.status(403).json({ success: false, error: 'Lookup for private or local IP addresses is not supported.' }); + } + + try { + // Get initialized MaxMind readers + const { cityReader, asnReader } = getMaxMindReaders(); + + // Perform lookups in parallel + const geoPromise = cityReader.city(targetIp) + .then(geoData => { + let geo = { + city: geoData.city?.names?.en, region: geoData.subdivisions?.[0]?.isoCode, + country: geoData.country?.isoCode, countryName: geoData.country?.names?.en, + postalCode: geoData.postal?.code, latitude: geoData.location?.latitude, + longitude: geoData.location?.longitude, timezone: geoData.location?.timeZone, + }; + geo = Object.fromEntries(Object.entries(geo).filter(([_, v]) => v != null)); + logger.debug({ targetIp, geo }, 'GeoIP lookup successful for lookup'); + return Object.keys(geo).length > 0 ? geo : null; // Return null if empty + }) + .catch(e => { + logger.warn({ targetIp, error: e.message }, `MaxMind City lookup failed for lookup`); + return { error: 'GeoIP lookup failed (IP not found in database or private range).' }; + }); + + const asnPromise = asnReader.asn(targetIp) + .then(asnData => { + let asn = { number: asnData.autonomousSystemNumber, organization: asnData.autonomousSystemOrganization }; + asn = Object.fromEntries(Object.entries(asn).filter(([_, v]) => v != null)); + logger.debug({ targetIp, asn }, 'ASN lookup successful for lookup'); + return Object.keys(asn).length > 0 ? asn : null; // Return null if empty + }) + .catch(e => { + logger.warn({ targetIp, error: e.message }, `MaxMind ASN lookup failed for lookup`); + return { error: 'ASN lookup failed (IP not found in database or private range).' }; + }); + + const rdnsPromise = dns.reverse(targetIp) + .then(hostnames => { + logger.debug({ targetIp, rdns: hostnames }, 'rDNS lookup successful for lookup'); + return hostnames; // Returns array of hostnames + }) + .catch(e => { + if (e.code !== 'ENOTFOUND' && e.code !== 'ENODATA') { + logger.warn({ targetIp, error: e.message, code: e.code }, `rDNS lookup error for lookup`); + } else { + logger.debug({ targetIp, code: e.code }, 'rDNS lookup failed (No record) for lookup'); + } + return { error: `rDNS lookup failed (${e.code || 'Unknown error'})` }; + }); + + // Wait for all promises to settle + const [geoResult, asnResult, rdnsResult] = await Promise.all([ + geoPromise, + asnPromise, + rdnsPromise + ]); + + res.json({ + success: true, // Indicate overall success of the request processing + ip: targetIp, + geo: geoResult, // Will be the geo object, null, or error object + asn: asnResult, // Will be the asn object, null, or error object + rdns: rdnsResult // Will be the hostname array or error object + }); + + } catch (error) { + // Catch unexpected errors (e.g., issue with getMaxMindReaders or Promise.all) + logger.error({ targetIp, requestIp, error: error.message, stack: error.stack }, 'Error processing lookup'); + Sentry.captureException(error, { extra: { targetIp, requestIp } }); + next(error); // Pass to the main error handler + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/routes/ping.js b/backend/routes/ping.js new file mode 100644 index 0000000..8c0bda8 --- /dev/null +++ b/backend/routes/ping.js @@ -0,0 +1,82 @@ +// backend/routes/ping.js +const express = require('express'); +const Sentry = require("@sentry/node"); +const pino = require('pino'); + +// Import utilities +const { isValidIp, isPrivateIp, executeCommand, parsePingOutput } = require('../utils'); + +// Logger for this module +const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); + +const router = express.Router(); + +// Route handler for / (relative to /api/ping) +router.get('/', async (req, res, next) => { + const targetIpRaw = req.query.targetIp; + const targetIp = typeof targetIpRaw === 'string' ? targetIpRaw.trim() : targetIpRaw; + const requestIp = req.ip || req.socket.remoteAddress; + + logger.info({ requestIp, targetIp }, 'Ping request received'); + + if (!isValidIp(targetIp)) { + logger.warn({ requestIp, targetIp }, 'Invalid target IP for ping'); + return res.status(400).json({ success: false, error: 'Invalid target IP address provided.' }); + } + if (isPrivateIp(targetIp)) { + logger.warn({ requestIp, targetIp }, 'Attempt to ping private IP blocked'); + return res.status(403).json({ success: false, error: 'Operations on private or local IP addresses are not allowed.' }); + } + + try { + const pingCount = process.env.PING_COUNT || '4'; + let countArg = parseInt(pingCount, 10); // Use let as it might be reassigned + // Validate countArg to prevent potential issues + if (isNaN(countArg) || countArg <= 0 || countArg > 10) { // Limit count for safety + logger.warn({ requestIp, targetIp, requestedCount: pingCount }, 'Invalid or excessive ping count requested, using default.'); + countArg = 4; // Default to 4 if invalid + } + + const args = ['-c', `${countArg}`, targetIp]; + const command = 'ping'; + + logger.info({ requestIp, targetIp, command: `${command} ${args.join(' ')}` }, 'Executing ping'); + const output = await executeCommand(command, args); + const parsedResult = parsePingOutput(output); + + if (parsedResult.error) { + logger.warn({ requestIp, targetIp, error: parsedResult.error, rawOutput: parsedResult.rawOutput }, 'Ping command executed but resulted in an error state'); + // Send 200 OK but indicate failure in the response body + return res.status(200).json({ + success: false, + error: parsedResult.error, + rawOutput: parsedResult.rawOutput, + stats: parsedResult.stats // Include stats even if there's an error message + }); + } + + logger.info({ requestIp, targetIp, stats: parsedResult.stats }, 'Ping successful'); + res.json({ success: true, ...parsedResult }); + + } catch (error) { + // This catch block handles errors from executeCommand (e.g., command not found, non-zero exit code) + logger.error({ requestIp, targetIp, error: error.message, stderr: error.stderr }, 'Ping command failed execution'); + Sentry.captureException(error, { extra: { requestIp, targetIp, stderr: error.stderr } }); + + // Attempt to parse the error output (might be stdout or stderr from the error object) + const errorOutput = error.stderr || error.stdout || error.message; + const parsedError = parsePingOutput(errorOutput); + + // Send 500 Internal Server Error, but include parsed details if available + res.status(500).json({ + success: false, + // Prioritize parsed error message, fallback to original error message + error: `Ping command failed: ${parsedError.error || error.message}`, + rawOutput: parsedError.rawOutput || errorOutput // Include raw output for debugging + }); + // Optionally call next(error) if you want the main Sentry error handler to also catch this + // next(error); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/routes/traceroute.js b/backend/routes/traceroute.js new file mode 100644 index 0000000..2c7f051 --- /dev/null +++ b/backend/routes/traceroute.js @@ -0,0 +1,172 @@ +// backend/routes/traceroute.js +const express = require('express'); +const Sentry = require("@sentry/node"); +const { spawn } = require('child_process'); +const pino = require('pino'); + +// Import utilities +const { isValidIp, isPrivateIp, parseTracerouteLine } = require('../utils'); + +// Logger for this module +const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); + +const router = express.Router(); + +// Route handler for / (relative to /api/traceroute) +router.get('/', (req, res) => { + const targetIpRaw = req.query.targetIp; + const targetIp = typeof targetIpRaw === 'string' ? targetIpRaw.trim() : targetIpRaw; + const requestIp = req.ip || req.socket.remoteAddress; + + logger.info({ requestIp, targetIp }, 'Traceroute stream request received'); + + if (!isValidIp(targetIp)) { + logger.warn({ requestIp, targetIp }, 'Invalid target IP for traceroute'); + // Send JSON error for consistency, even though it's an SSE endpoint initially + return res.status(400).json({ success: false, error: 'Invalid target IP address provided.' }); + } + if (isPrivateIp(targetIp)) { + logger.warn({ requestIp, targetIp }, 'Attempt to traceroute private IP blocked'); + return res.status(403).json({ success: false, error: 'Operations on private or local IP addresses are not allowed.' }); + } + + // Start Sentry transaction for the stream + const transaction = Sentry.startTransaction({ + op: "traceroute.stream", + name: `/api/traceroute?targetIp=${targetIp}`, // Use sanitized targetIp + }); + // Set scope for this request to associate errors/events with the transaction + Sentry.configureScope(scope => { + scope.setSpan(transaction); + scope.setContext("request", { ip: requestIp, targetIp }); + }); + + try { + logger.info({ requestIp, targetIp }, `Starting traceroute stream...`); + // Set SSE headers + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); // Important for Nginx buffering + res.flushHeaders(); // Send headers immediately + + // Traceroute command arguments (using -n to avoid DNS lookups within traceroute itself) + const args = ['-n', targetIp]; + const command = 'traceroute'; + const proc = spawn(command, args); + logger.info({ requestIp, targetIp, command: `${command} ${args.join(' ')}` }, 'Spawned traceroute process'); + + let buffer = ''; // Buffer for incomplete lines + + // Helper function to send SSE events safely + const sendEvent = (event, data) => { + try { + // Check if the connection is still writable before sending + if (!res.writableEnded) { + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + } else { + logger.warn({ requestIp, targetIp, event }, "Attempted to write to closed SSE stream."); + } + } catch (e) { + // Catch errors during write (e.g., client disconnected) + logger.error({ requestIp, targetIp, event, error: e.message }, "Error writing to SSE stream (client likely disconnected)"); + Sentry.captureException(e, { level: 'warning', extra: { requestIp, targetIp, event } }); + // Clean up: kill process, end response, finish transaction + if (proc && !proc.killed) proc.kill(); + if (!res.writableEnded) res.end(); + transaction.setStatus('internal_error'); + transaction.finish(); + } + }; + + // Handle stdout data (traceroute output) + proc.stdout.on('data', (data) => { + buffer += data.toString(); + let lines = buffer.split('\n'); + buffer = lines.pop() || ''; // Keep the last potentially incomplete line + lines.forEach(line => { + const parsed = parseTracerouteLine(line); + if (parsed) { + logger.debug({ requestIp, targetIp, hop: parsed.hop, ip: parsed.ip }, 'Sending hop data'); + sendEvent('hop', parsed); + } else if (line.trim()) { + // Send non-hop lines as info messages (e.g., header) + logger.debug({ requestIp, targetIp, message: line.trim() }, 'Sending info data'); + sendEvent('info', { message: line.trim() }); + } + }); + }); + + // Handle stderr data + proc.stderr.on('data', (data) => { + const errorMsg = data.toString().trim(); + logger.warn({ requestIp, targetIp, stderr: errorMsg }, 'Traceroute stderr output'); + Sentry.captureMessage('Traceroute stderr output', { level: 'warning', extra: { requestIp, targetIp, stderr: errorMsg } }); + sendEvent('error', { error: errorMsg }); // Send stderr as an error event + }); + + // Handle process errors (e.g., command not found) + proc.on('error', (err) => { + logger.error({ requestIp, targetIp, error: err.message }, `Failed to start traceroute command`); + Sentry.captureException(err, { extra: { requestIp, targetIp } }); + sendEvent('error', { error: `Failed to start traceroute: ${err.message}` }); + if (!res.writableEnded) res.end(); // Ensure response is ended + transaction.setStatus('internal_error'); + transaction.finish(); + }); + + // Handle process close event + proc.on('close', (code) => { + // Process any remaining data in the buffer + if (buffer) { + const parsed = parseTracerouteLine(buffer); + if (parsed) sendEvent('hop', parsed); + else if (buffer.trim()) sendEvent('info', { message: buffer.trim() }); + } + + if (code !== 0) { + logger.error({ requestIp, targetIp, exitCode: code }, `Traceroute command finished with error code ${code}`); + Sentry.captureMessage('Traceroute command failed', { level: 'error', extra: { requestIp, targetIp, exitCode: code } }); + sendEvent('error', { error: `Traceroute command failed with exit code ${code}` }); + transaction.setStatus('unknown_error'); // Or more specific if possible + } else { + logger.info({ requestIp, targetIp }, `Traceroute stream completed successfully.`); + transaction.setStatus('ok'); + } + sendEvent('end', { exitCode: code }); // Signal the end of the stream + if (!res.writableEnded) res.end(); // Ensure response is ended + transaction.finish(); // Finish Sentry transaction + }); + + // Handle client disconnection + req.on('close', () => { + logger.info({ requestIp, targetIp }, 'Client disconnected from traceroute stream, killing process.'); + if (proc && !proc.killed) proc.kill(); // Kill the traceroute process + if (!res.writableEnded) res.end(); // Ensure response is ended + transaction.setStatus('cancelled'); // Mark transaction as cancelled + transaction.finish(); + }); + + } catch (error) { + // Catch errors during initial setup (before headers sent) + logger.error({ requestIp, targetIp, error: error.message, stack: error.stack }, 'Error setting up traceroute stream'); + Sentry.captureException(error, { extra: { requestIp, targetIp } }); + transaction.setStatus('internal_error'); + transaction.finish(); + + // If headers haven't been sent, send a standard JSON error + if (!res.headersSent) { + res.status(500).json({ success: false, error: `Failed to initiate traceroute: ${error.message}` }); + } else { + // If headers were sent, try to send an error event via SSE (best effort) + try { + if (!res.writableEnded) { + sendEvent('error', { error: `Internal server error during setup: ${error.message}` }); + res.end(); + } + } catch (e) { logger.error({ requestIp, targetIp, error: e.message }, "Error writing final setup error to SSE stream"); } + } + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/routes/version.js b/backend/routes/version.js new file mode 100644 index 0000000..89e3b32 --- /dev/null +++ b/backend/routes/version.js @@ -0,0 +1,20 @@ +// backend/routes/version.js +const express = require('express'); +const pino = require('pino'); + +// Logger for this module +const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); + +const router = express.Router(); + +// Route handler for / (relative to /api/version) +router.get('/', (req, res) => { + // Read commit SHA from environment variable (set during build/deploy) + const commitSha = process.env.GIT_COMMIT_SHA || 'unknown'; + const requestIp = req.ip || req.socket.remoteAddress; + + logger.info({ requestIp, commitSha }, 'Version request received'); + res.json({ commitSha }); +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/routes/whoisLookup.js b/backend/routes/whoisLookup.js new file mode 100644 index 0000000..6d965c7 --- /dev/null +++ b/backend/routes/whoisLookup.js @@ -0,0 +1,86 @@ +// backend/routes/whoisLookup.js +const express = require('express'); +const Sentry = require("@sentry/node"); +const whois = require('whois-json'); +const pino = require('pino'); + +// Import utilities +const { isValidIp, isValidDomain } = require('../utils'); + +// Logger for this module +const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); + +const router = express.Router(); + +// Route handler for / (relative to /api/whois-lookup) +router.get('/', async (req, res, next) => { + const queryRaw = req.query.query; + const query = typeof queryRaw === 'string' ? queryRaw.trim() : queryRaw; + const requestIp = req.ip || req.socket.remoteAddress; + + logger.info({ requestIp, query }, 'WHOIS lookup request received'); + + // Validate if the query is either a valid IP or a valid domain + if (!isValidIp(query) && !isValidDomain(query)) { + logger.warn({ requestIp, query }, 'Invalid query for WHOIS lookup'); + return res.status(400).json({ success: false, error: 'Invalid domain name or IP address provided for WHOIS lookup.' }); + } + + // Note: No isPrivateIp check here, as WHOIS for IPs might be desired regardless of range, + // and domain lookups don't involve IP ranges. + + try { + // Execute WHOIS lookup with a timeout + const result = await whois(query, { + timeout: parseInt(process.env.WHOIS_TIMEOUT || '10000', 10), // Configurable timeout (default 10s), ensure integer + // follow: 3, // Optional: limit number of redirects followed + // verbose: true // Optional: get raw text output as well + }); + + // Check if the result indicates an error (some servers return structured errors) + // This check might need adjustment based on the 'whois-json' library's output for errors. + if (result && (result.error || result.Error)) { + logger.warn({ requestIp, query, whoisResult: result }, 'WHOIS lookup returned an error structure'); + return res.status(404).json({ success: false, error: `WHOIS lookup failed: ${result.error || result.Error}`, result }); + } + // Basic check if the result is empty or just contains the query itself (might indicate no data) + if (!result || Object.keys(result).length === 0 || (Object.keys(result).length === 1 && (result.domainName === query || result.query === query))) { + logger.info({ requestIp, query }, 'WHOIS lookup returned no detailed data.'); + // Consider 404 Not Found if no data is available + return res.status(404).json({ success: false, error: 'No detailed WHOIS information found for the query.', query }); + } + + + logger.info({ requestIp, query }, 'WHOIS lookup successful'); + res.json({ success: true, query, result }); + + } catch (error) { + logger.error({ requestIp, query, error: error.message }, 'WHOIS lookup failed'); + Sentry.captureException(error, { extra: { requestIp, query } }); + + // Provide more user-friendly error messages based on common errors + let errorMessage = error.message; + let statusCode = 500; // Default to Internal Server Error + + if (error.message.includes('ETIMEDOUT') || error.message.includes('ESOCKETTIMEDOUT')) { + errorMessage = 'WHOIS server timed out.'; + statusCode = 504; // Gateway Timeout + } else if (error.message.includes('ENOTFOUND')) { + // This might indicate the domain doesn't exist or the WHOIS server for the TLD couldn't be found + errorMessage = 'Domain or IP not found, or the corresponding WHOIS server is unavailable.'; + statusCode = 404; // Not Found + } else if (error.message.includes('ECONNREFUSED')) { + errorMessage = 'Connection to WHOIS server refused.'; + statusCode = 503; // Service Unavailable + } else if (error.message.includes('No WHOIS server found for')) { + errorMessage = 'Could not find a WHOIS server for the requested domain/TLD.'; + statusCode = 404; // Not Found (as the server for it isn't known) + } + // Add more specific error handling if needed based on observed errors + + res.status(statusCode).json({ success: false, error: `WHOIS lookup failed: ${errorMessage}` }); + // next(error); // Optional: Pass to Sentry error handler + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index ceca048..b38b6d0 100644 --- a/backend/server.js +++ b/backend/server.js @@ -8,10 +8,9 @@ const Sentry = require("@sentry/node"); // Initialize Sentry BEFORE requiring any other modules! Sentry.init({ // DSN should now be available from process.env if set in .env - // Using a syntactically valid but fake DSN as default dsn: process.env.SENTRY_DSN || "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@oooooooooooooooo.ingest.sentry.io/123456", - // Minimal configuration for debugging - // tracesSampleRate: 1.0, // Keep tracing enabled if needed later + // Enable tracing - Adjust sample rate as needed + tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0, }); // DEBUG: Check Sentry object after init @@ -19,872 +18,176 @@ console.log("Sentry object after init:", typeof Sentry, Sentry ? Object.keys(Sen // --- Ende Sentry Initialisierung --- -// Require other modules AFTER Sentry is initialized +// Require necessary core modules AFTER Sentry is initialized const express = require('express'); const cors = require('cors'); -const geoip = require('@maxmind/geoip2-node'); -const net = require('net'); // Node.js built-in module for IP validation -const { spawn } = require('child_process'); -const dns = require('dns').promises; const pino = require('pino'); // Logging library const rateLimit = require('express-rate-limit'); // Rate limiting middleware -const whois = require('whois-json'); // Added for WHOIS -// REMOVED: const oui = require('oui'); + +// Import local modules +const { initializeMaxMind } = require('./maxmind'); // MaxMind DB initialization +const ipinfoRoutes = require('./routes/ipinfo'); +const pingRoutes = require('./routes/ping'); +const tracerouteRoutes = require('./routes/traceroute'); +const lookupRoutes = require('./routes/lookup'); +const dnsLookupRoutes = require('./routes/dnsLookup'); +const whoisLookupRoutes = require('./routes/whoisLookup'); +const versionRoutes = require('./routes/version'); // --- Logger Initialisierung --- const logger = pino({ - level: process.env.LOG_LEVEL || 'info', // Configurable log level (e.g., 'debug', 'info', 'warn', 'error') - // Pretty print only in Development, otherwise JSON for better machine readability + level: process.env.LOG_LEVEL || 'info', transport: process.env.NODE_ENV !== 'production' ? { target: 'pino-pretty', options: { colorize: true, translateTime: 'SYS:standard', ignore: 'pid,hostname' } } : undefined, }); -// Create Express app instance AFTER requiring express +// Create Express app instance const app = express(); const PORT = process.env.PORT || 3000; -// DEBUG: Check Sentry.Handlers before use -console.log("Sentry.Handlers before use:", typeof Sentry.Handlers, Sentry.Handlers ? Object.keys(Sentry.Handlers) : 'Sentry.Handlers is undefined/null'); - -// --- Sentry Request Handler (AS FIRST MIDDLEWARE!) --- -// This handler must be the first middleware on the app. -// It needs to be called AFTER Sentry.init() +// --- Sentry Middleware (Request Handler & Tracing) --- +// Must be the first middleware if (Sentry.Handlers && Sentry.Handlers.requestHandler) { app.use(Sentry.Handlers.requestHandler()); } else { - console.error("Sentry.Handlers.requestHandler is not available!"); - // Optional: process.exit(1); // Exit if Sentry handler is crucial + logger.error("Sentry.Handlers.requestHandler is not available!"); } -// --- Ende Sentry Request Handler --- - -// --- Sentry Tracing Handler (AFTER requestHandler, BEFORE routes) --- -// This handler must be after requestHandler and before any routes. -// It adds tracing information to incoming requests. +// Must be after requestHandler, before routes if (Sentry.Handlers && Sentry.Handlers.tracingHandler) { app.use(Sentry.Handlers.tracingHandler()); } else { - console.error("Sentry.Handlers.tracingHandler is not available!"); + logger.error("Sentry.Handlers.tracingHandler is not available!"); } -// --- Ende Sentry Tracing Handler --- +// --- Ende Sentry Middleware --- -// --- Globale Variablen für MaxMind Reader --- -let cityReader; -let asnReader; - -// --- Hilfsfunktionen --- - -/** - * Validiert eine IP-Adresse (v4 oder v6) mit Node.js' eingebautem net Modul. - * @param {string} ip - Die zu validierende IP-Adresse. - * @returns {boolean} True, wenn gültig (als v4 oder v6), sonst false. - */ -function isValidIp(ip) { - if (!ip || typeof ip !== 'string' || ip.trim() === '') { - return false; - } - const trimmedIp = ip.trim(); - const ipVersion = net.isIP(trimmedIp); // Gibt 0, 4 oder 6 zurück - return ipVersion === 4 || ipVersion === 6; -} - -/** - * Prüft, ob eine IP-Adresse im privaten, Loopback- oder Link-Local-Bereich liegt. - * @param {string} ip - Die zu prüfende IP-Adresse (bereits validiert). - * @returns {boolean} True, wenn die IP privat/lokal ist, sonst false. - */ -function isPrivateIp(ip) { - if (!ip) return false; - const ipVersion = net.isIP(ip); - - if (ipVersion === 4) { - const parts = ip.split('.').map(Number); - return ( - parts[0] === 10 || // 10.0.0.0/8 - (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || // 172.16.0.0/12 - (parts[0] === 192 && parts[1] === 168) || // 192.168.0.0/16 - parts[0] === 127 || // 127.0.0.0/8 (Loopback) - (parts[0] === 169 && parts[1] === 254) // 169.254.0.0/16 (Link-local) - ); - } else if (ipVersion === 6) { - const lowerCaseIp = ip.toLowerCase(); - return ( - lowerCaseIp === '::1' || // ::1/128 (Loopback) - lowerCaseIp.startsWith('fc') || lowerCaseIp.startsWith('fd') || // fc00::/7 (Unique Local) - lowerCaseIp.startsWith('fe8') || lowerCaseIp.startsWith('fe9') || // fe80::/10 (Link-local) - lowerCaseIp.startsWith('fea') || lowerCaseIp.startsWith('feb') - ); - } - return false; -} - -/** - * Validiert einen Domainnamen (sehr einfache Prüfung). - * @param {string} domain - Der zu validierende Domainname. - * @returns {boolean} True, wenn wahrscheinlich gültig, sonst false. - */ -function isValidDomain(domain) { - if (!domain || typeof domain !== 'string' || domain.trim().length < 3) { - return false; - } - const domainRegex = /^(?:[a-z0-9\p{L}](?:[a-z0-9\p{L}-]{0,61}[a-z0-9\p{L}])?\.)+[a-z0-9\p{L}][a-z0-9\p{L}-]{0,61}[a-z0-9\p{L}]$/iu; - return domainRegex.test(domain.trim()); -} - -// REMOVED: isValidMac function - -/** - * Bereinigt eine IP-Adresse (z.B. entfernt ::ffff: Präfix von IPv4-mapped IPv6). - * @param {string} ip - Die IP-Adresse. - * @returns {string} Die bereinigte IP-Adresse. - */ -function getCleanIp(ip) { - if (!ip) return ip; - const trimmedIp = ip.trim(); - if (trimmedIp.startsWith('::ffff:')) { - const potentialIp4 = trimmedIp.substring(7); - if (net.isIP(potentialIp4) === 4) { - return potentialIp4; - } - } - if (trimmedIp === '::1' || trimmedIp === '127.0.0.1') { - return trimmedIp; - } - return trimmedIp; -} - -/** - * Führt einen Shell-Befehl sicher aus und gibt stdout zurück. (Nur für Ping verwendet) - * @param {string} command - Der Befehl (z.B. 'ping'). - * @param {string[]} args - Die Argumente als Array. - * @returns {Promise} Eine Promise, die mit stdout aufgelöst wird. - */ -function executeCommand(command, args) { - return new Promise((resolve, reject) => { - args.forEach(arg => { - if (typeof arg === 'string' && /[;&|`$()<>]/.test(arg)) { - const error = new Error(`Invalid character detected in command argument.`); - logger.error({ command, arg }, "Potential command injection attempt detected in argument"); - Sentry.captureException(error); // An Sentry senden - return reject(error); - } - }); - - const proc = spawn(command, args); - let stdout = ''; - let stderr = ''; - - proc.stdout.on('data', (data) => { stdout += data.toString(); }); - proc.stderr.on('data', (data) => { stderr += data.toString(); }); - proc.on('error', (err) => { - const error = new Error(`Failed to start command ${command}: ${err.message}`); - logger.error({ command, args, error: err.message }, `Failed to start command`); - Sentry.captureException(error); // An Sentry senden - reject(error); - }); - proc.on('close', (code) => { - if (code !== 0) { - const error = new Error(`Command ${command} failed with code ${code}: ${stderr || 'No stderr output'}`); - logger.error({ command, args, exitCode: code, stderr: stderr.trim(), stdout: stdout.trim() }, `Command failed`); - Sentry.captureException(error, { extra: { stdout: stdout.trim(), stderr: stderr.trim() } }); // An Sentry senden - reject(error); - } else { - resolve(stdout); - } - }); - }); -} - -/** - * Parst die Ausgabe des Linux/macOS ping Befehls. - * @param {string} pingOutput - Die rohe stdout Ausgabe von ping. - * @returns {object} Ein Objekt mit geparsten Daten oder Fehlern. - */ -function parsePingOutput(pingOutput) { - const result = { - rawOutput: pingOutput, - stats: null, - error: null, - }; - - try { - let packetsTransmitted = 0; - let packetsReceived = 0; - let packetLossPercent = 100; - let rtt = { min: null, avg: null, max: null, mdev: null }; - - const lines = pingOutput.trim().split('\n'); - const statsLine = lines.find(line => line.includes('packets transmitted')); - if (statsLine) { - const transmittedMatch = statsLine.match(/(\d+)\s+packets transmitted/); - const receivedMatch = statsLine.match(/(\d+)\s+(?:received|packets received)/); - const lossMatch = statsLine.match(/([\d.]+)%\s+packet loss/); - if (transmittedMatch) packetsTransmitted = parseInt(transmittedMatch[1], 10); - if (receivedMatch) packetsReceived = parseInt(receivedMatch[1], 10); - if (lossMatch) packetLossPercent = parseFloat(lossMatch[1]); - } - - const rttLine = lines.find(line => line.startsWith('rtt min/avg/max/mdev') || line.startsWith('round-trip min/avg/max/stddev')); - if (rttLine) { - const rttMatch = rttLine.match(/([\d.]+)\/([\d.]+)\/([\d.]+)\/([\d.]+)/); - if (rttMatch) { - rtt = { - min: parseFloat(rttMatch[1]), - avg: parseFloat(rttMatch[2]), - max: parseFloat(rttMatch[3]), - mdev: parseFloat(rttMatch[4]), - }; - } - } - - result.stats = { - packets: { transmitted: packetsTransmitted, received: packetsReceived, lossPercent: packetLossPercent }, - rtt: rtt.avg !== null ? rtt : null, - }; - if (packetsTransmitted > 0 && rtt.avg === null && packetsReceived === 0) { - result.error = "Request timed out or host unreachable."; - } - - } catch (parseError) { - logger.error({ error: parseError.message, output: pingOutput }, "Failed to parse ping output"); - Sentry.captureException(parseError, { extra: { pingOutput } }); // An Sentry senden - result.error = "Failed to parse ping output."; - } - return result; -} - -/** - * Parst eine einzelne Zeile der Linux/macOS traceroute Ausgabe. - * @param {string} line - Eine Zeile aus stdout. - * @returns {object | null} Ein Objekt mit Hop-Daten oder null bei uninteressanten Zeilen. - */ -function parseTracerouteLine(line) { - line = line.trim(); - if (!line || line.startsWith('traceroute to')) return null; - - const hopMatch = line.match(/^(\s*\d+)\s+(?:([a-zA-Z0-9\.\-]+)\s+\(([\d\.:a-fA-F]+)\)|([\d\.:a-fA-F]+))\s+(.*)$/); - const timeoutMatch = line.match(/^(\s*\d+)\s+(\*\s+\*\s+\*)/); - - if (timeoutMatch) { - return { - hop: parseInt(timeoutMatch[1].trim(), 10), - hostname: null, - ip: null, - rtt: ['*', '*', '*'], - rawLine: line, - }; - } else if (hopMatch) { - const hop = parseInt(hopMatch[1].trim(), 10); - const hostname = hopMatch[2]; - const ipInParen = hopMatch[3]; - const ipDirect = hopMatch[4]; - const restOfLine = hopMatch[5].trim(); - const ip = ipInParen || ipDirect; - - const rttParts = restOfLine.split(/\s+/); - const rtts = rttParts.map(p => p === '*' ? '*' : p.replace(/\s*ms$/, '')).filter(p => p === '*' || !isNaN(parseFloat(p))).slice(0, 3); - while (rtts.length < 3) rtts.push('*'); - - return { - hop: hop, - hostname: hostname || null, - ip: ip, - rtt: rtts, - rawLine: line, - }; - } - return null; -} +// --- Core Middleware --- +app.use(cors()); // Enable CORS +app.use(express.json()); // Parse JSON bodies +app.set('trust proxy', parseInt(process.env.TRUST_PROXY_COUNT || '1', 10)); // Adjust based on your proxy setup, ensure integer -// --- Initialisierung (MaxMind DBs laden) --- -async function initialize() { - try { - logger.info('Loading MaxMind databases...'); - const cityDbPath = process.env.GEOIP_CITY_DB || './data/GeoLite2-City.mmdb'; - const asnDbPath = process.env.GEOIP_ASN_DB || './data/GeoLite2-ASN.mmdb'; - logger.info({ cityDbPath, asnDbPath }, 'Database paths'); - cityReader = await geoip.Reader.open(cityDbPath); - asnReader = await geoip.Reader.open(asnDbPath); - logger.info('MaxMind databases loaded successfully.'); - - // REMOVED: OUI database loading - - } catch (error) { - logger.fatal({ error: error.message, stack: error.stack }, 'Could not initialize databases. Exiting.'); - Sentry.captureException(error); // An Sentry senden - process.exit(1); - } -} - -// --- Middleware --- -app.use(cors()); -app.use(express.json()); -app.set('trust proxy', 2); - -// Rate Limiter +// --- Rate Limiter --- +// Apply a general limiter to most routes const generalLimiter = rateLimit({ - windowMs: 5 * 60 * 1000, // 5 Minuten - max: process.env.NODE_ENV === 'production' ? 20 : 200, - standardHeaders: true, - legacyHeaders: false, - message: { error: 'Too many requests from this IP, please try again after 5 minutes' }, - keyGenerator: (req, res) => req.ip || req.socket.remoteAddress, + windowMs: 5 * 60 * 1000, // 5 minutes + max: parseInt(process.env.RATE_LIMIT_MAX || (process.env.NODE_ENV === 'production' ? '20' : '200'), 10), // Requests per window per IP, ensure integer + standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers + legacyHeaders: false, // Disable the `X-RateLimit-*` headers + message: { success: false, error: 'Too many requests from this IP, please try again after 5 minutes' }, + keyGenerator: (req, res) => req.ip, // Use client IP address from Express handler: (req, res, next, options) => { - logger.warn({ ip: req.ip || req.socket.remoteAddress, route: req.originalUrl }, 'Rate limit exceeded'); - // Optional: Rate Limit Info an Sentry senden + logger.warn({ ip: req.ip, route: req.originalUrl }, 'Rate limit exceeded'); Sentry.captureMessage('Rate limit exceeded', { level: 'warning', - extra: { ip: req.ip || req.socket.remoteAddress, route: req.originalUrl } + extra: { ip: req.ip, route: req.originalUrl } }); res.status(options.statusCode).send(options.message); } }); -// Wende Limiter auf alle API-Routen an (außer /api/version und /api/ipinfo) +// Apply the limiter to specific API routes that perform external actions +// Note: /api/ipinfo and /api/version are often excluded as they are less resource-intensive app.use('/api/ping', generalLimiter); app.use('/api/traceroute', generalLimiter); app.use('/api/lookup', generalLimiter); app.use('/api/dns-lookup', generalLimiter); app.use('/api/whois-lookup', generalLimiter); -// REMOVED: app.use('/api/mac-lookup', generalLimiter); -// --- Routen --- - -// Haupt-Endpunkt: Liefert alle Infos zur IP des Clients -app.get('/api/ipinfo', async (req, res, next) => { // next hinzugefügt für Sentry Error Handler - const requestIp = req.ip || req.socket.remoteAddress; - logger.info({ ip: requestIp, method: req.method, url: req.originalUrl }, 'ipinfo request received'); - const clientIp = getCleanIp(requestIp); - logger.debug({ rawIp: requestIp, cleanedIp: clientIp }, 'IP cleaning result'); - - if (!clientIp || !isValidIp(clientIp)) { - if (clientIp === '127.0.0.1' || clientIp === '::1') { - logger.info({ ip: clientIp }, 'Responding with localhost info'); - return res.json({ - ip: clientIp, - geo: { note: 'Localhost IP, no Geo data available.' }, - asn: { note: 'Localhost IP, no ASN data available.' }, - rdns: ['localhost'], - }); - } - logger.error({ rawIp: requestIp, cleanedIp: clientIp }, 'Could not determine a valid client IP'); - // Fehler an Sentry senden, bevor die Antwort gesendet wird - Sentry.captureMessage('Could not determine a valid client IP', { - level: 'error', - extra: { rawIp: requestIp, cleanedIp: clientIp } - }); - return res.status(400).json({ error: 'Could not determine a valid client IP address.', rawIp: requestIp, cleanedIp: clientIp }); - } - - try { - let geo = null; - try { - const geoData = cityReader.city(clientIp); - geo = { - city: geoData.city?.names?.en, - region: geoData.subdivisions?.[0]?.isoCode, - country: geoData.country?.isoCode, - countryName: geoData.country?.names?.en, - postalCode: geoData.postal?.code, - latitude: geoData.location?.latitude, - longitude: geoData.location?.longitude, - timezone: geoData.location?.timeZone, - }; - geo = Object.fromEntries(Object.entries(geo).filter(([_, v]) => v != null)); - logger.debug({ ip: clientIp, geo }, 'GeoIP lookup successful'); - } catch (e) { - logger.warn({ ip: clientIp, error: e.message }, `MaxMind City lookup failed`); - // Optional: GeoIP Fehler an Sentry senden (kann viel Lärm verursachen) - // Sentry.captureException(e, { level: 'warning', extra: { ip: clientIp } }); - geo = { error: 'GeoIP lookup failed (IP not found in database or private range).' }; - } - - let asn = null; - try { - const asnData = asnReader.asn(clientIp); - asn = { - number: asnData.autonomousSystemNumber, - organization: asnData.autonomousSystemOrganization, - }; - asn = Object.fromEntries(Object.entries(asn).filter(([_, v]) => v != null)); - logger.debug({ ip: clientIp, asn }, 'ASN lookup successful'); - } catch (e) { - logger.warn({ ip: clientIp, error: e.message }, `MaxMind ASN lookup failed`); - // Optional: ASN Fehler an Sentry senden - // Sentry.captureException(e, { level: 'warning', extra: { ip: clientIp } }); - asn = { error: 'ASN lookup failed (IP not found in database or private range).' }; - } - - let rdns = null; - try { - const hostnames = await dns.reverse(clientIp); - rdns = hostnames; - logger.debug({ ip: clientIp, rdns }, 'rDNS lookup successful'); - } catch (e) { - if (e.code !== 'ENOTFOUND' && e.code !== 'ENODATA') { - logger.warn({ ip: clientIp, error: e.message, code: e.code }, `rDNS lookup error`); - // Optional: rDNS Fehler an Sentry senden - // Sentry.captureException(e, { level: 'warning', extra: { ip: clientIp } }); - } else { - logger.debug({ ip: clientIp, code: e.code }, 'rDNS lookup failed (No record)'); - } - rdns = { error: `rDNS lookup failed (${e.code || 'Unknown error'})` }; - } - - res.json({ - ip: clientIp, - geo: geo.error ? geo : (Object.keys(geo).length > 0 ? geo : null), - asn: asn.error ? asn : (Object.keys(asn).length > 0 ? asn : null), - rdns - }); - - } catch (error) { - logger.error({ ip: clientIp, error: error.message, stack: error.stack }, 'Error processing ipinfo'); - Sentry.captureException(error, { extra: { ip: clientIp } }); // An Sentry senden - next(error); // Fehler an Sentry Error Handler weiterleiten - } -}); - -// Ping Endpunkt -app.get('/api/ping', async (req, res, next) => { // next hinzugefügt - const targetIpRaw = req.query.targetIp; - const targetIp = typeof targetIpRaw === 'string' ? targetIpRaw.trim() : targetIpRaw; - const requestIp = req.ip || req.socket.remoteAddress; - - logger.info({ requestIp, targetIp }, 'Ping request received'); - - if (!isValidIp(targetIp)) { - logger.warn({ requestIp, targetIp }, 'Invalid target IP for ping'); - return res.status(400).json({ error: 'Invalid target IP address provided.' }); - } - if (isPrivateIp(targetIp)) { - logger.warn({ requestIp, targetIp }, 'Attempt to ping private IP blocked'); - return res.status(403).json({ error: 'Operations on private or local IP addresses are not allowed.' }); - } - - try { - const pingCount = process.env.PING_COUNT || '4'; - const countArg = parseInt(pingCount, 10) || 4; - const args = ['-c', `${countArg}`, targetIp]; - const command = 'ping'; - - logger.info({ requestIp, targetIp, command: `${command} ${args.join(' ')}` }, 'Executing ping'); - const output = await executeCommand(command, args); - const parsedResult = parsePingOutput(output); - - logger.info({ requestIp, targetIp, stats: parsedResult.stats }, 'Ping successful'); - res.json({ success: true, ...parsedResult }); - - } catch (error) { - logger.error({ requestIp, targetIp, error: error.message }, 'Ping command failed'); - Sentry.captureException(error, { extra: { requestIp, targetIp } }); // An Sentry senden - const parsedError = parsePingOutput(error.message); // Versuch, Fehler aus der Ausgabe zu parsen - // Sende 500, aber mit Fehlerdetails im Body - res.status(500).json({ - success: false, - error: `Ping command failed: ${parsedError.error || error.message}`, - rawOutput: parsedError.rawOutput || error.message - }); - // next(error); // Optional: Fehler auch an Sentry Error Handler weiterleiten - } -}); - -// Traceroute Endpunkt (Server-Sent Events) -app.get('/api/traceroute', (req, res) => { // Kein next hier, da SSE anders behandelt wird - const targetIpRaw = req.query.targetIp; - const targetIp = typeof targetIpRaw === 'string' ? targetIpRaw.trim() : targetIpRaw; - const requestIp = req.ip || req.socket.remoteAddress; - - logger.info({ requestIp, targetIp }, 'Traceroute stream request received'); - - if (!isValidIp(targetIp)) { - logger.warn({ requestIp, targetIp }, 'Invalid target IP for traceroute'); - return res.status(400).json({ error: 'Invalid target IP address provided.' }); - } - if (isPrivateIp(targetIp)) { - logger.warn({ requestIp, targetIp }, 'Attempt to traceroute private IP blocked'); - return res.status(403).json({ error: 'Operations on private or local IP addresses are not allowed.' }); - } - - // Sentry Transaction für den Stream starten - const transaction = Sentry.startTransaction({ - op: "traceroute.stream", - name: `/api/traceroute?targetIp=${targetIp}`, - }); - // Scope für diese Anfrage setzen, damit Fehler/Events der Transaktion zugeordnet werden - Sentry.configureScope(scope => { - scope.setSpan(transaction); - scope.setContext("request", { ip: requestIp, targetIp }); - }); - - try { - logger.info({ requestIp, targetIp }, `Starting traceroute stream...`); - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache'); - res.setHeader('Connection', 'keep-alive'); - res.setHeader('X-Accel-Buffering', 'no'); - res.flushHeaders(); - - const args = ['-n', targetIp]; - const command = 'traceroute'; - const proc = spawn(command, args); - logger.info({ requestIp, targetIp, command: `${command} ${args.join(' ')}` }, 'Spawned traceroute process'); - - let buffer = ''; - - const sendEvent = (event, data) => { - try { - if (!res.writableEnded) { - res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); - } - } catch (e) { - logger.error({ requestIp, targetIp, event, error: e.message }, "Error writing to SSE stream (client likely disconnected)"); - Sentry.captureException(e, { level: 'warning', extra: { requestIp, targetIp, event } }); - if (!proc.killed) proc.kill(); - if (!res.writableEnded) res.end(); - transaction.setStatus('internal_error'); - transaction.finish(); - } - }; - - proc.stdout.on('data', (data) => { - buffer += data.toString(); - let lines = buffer.split('\n'); - buffer = lines.pop() || ''; - lines.forEach(line => { - const parsed = parseTracerouteLine(line); - if (parsed) { - logger.debug({ requestIp, targetIp, hop: parsed.hop, ip: parsed.ip }, 'Sending hop data'); - sendEvent('hop', parsed); - } else if (line.trim()) { - logger.debug({ requestIp, targetIp, message: line.trim() }, 'Sending info data'); - sendEvent('info', { message: line.trim() }); - } - }); - }); - - proc.stderr.on('data', (data) => { - const errorMsg = data.toString().trim(); - logger.warn({ requestIp, targetIp, stderr: errorMsg }, 'Traceroute stderr output'); - Sentry.captureMessage('Traceroute stderr output', { level: 'warning', extra: { requestIp, targetIp, stderr: errorMsg } }); - sendEvent('error', { error: errorMsg }); - }); - - proc.on('error', (err) => { - logger.error({ requestIp, targetIp, error: err.message }, `Failed to start traceroute command`); - Sentry.captureException(err, { extra: { requestIp, targetIp } }); - sendEvent('error', { error: `Failed to start traceroute: ${err.message}` }); - if (!res.writableEnded) res.end(); - transaction.setStatus('internal_error'); - transaction.finish(); - }); - - proc.on('close', (code) => { - if (buffer) { - const parsed = parseTracerouteLine(buffer); - if (parsed) sendEvent('hop', parsed); - else if (buffer.trim()) sendEvent('info', { message: buffer.trim() }); - } - if (code !== 0) { - logger.error({ requestIp, targetIp, exitCode: code }, `Traceroute command finished with error code ${code}`); - Sentry.captureMessage('Traceroute command failed', { level: 'error', extra: { requestIp, targetIp, exitCode: code } }); - sendEvent('error', { error: `Traceroute command failed with exit code ${code}` }); - transaction.setStatus('unknown_error'); // Oder spezifischer, falls möglich - } else { - logger.info({ requestIp, targetIp }, `Traceroute stream completed successfully.`); - transaction.setStatus('ok'); - } - sendEvent('end', { exitCode: code }); - if (!res.writableEnded) res.end(); - transaction.finish(); - }); - - req.on('close', () => { - logger.info({ requestIp, targetIp }, 'Client disconnected from traceroute stream, killing process.'); - if (!proc.killed) proc.kill(); - if (!res.writableEnded) res.end(); - transaction.setStatus('cancelled'); // Client hat abgebrochen - transaction.finish(); - }); - - } catch (error) { - logger.error({ requestIp, targetIp, error: error.message, stack: error.stack }, 'Error setting up traceroute stream'); - Sentry.captureException(error, { extra: { requestIp, targetIp } }); - transaction.setStatus('internal_error'); - transaction.finish(); - if (!res.headersSent) { - res.status(500).json({ success: false, error: `Failed to initiate traceroute: ${error.message}` }); - } else { - try { - if (!res.writableEnded) { - res.write(`event: error\ndata: ${JSON.stringify({ error: `Internal server error: ${error.message}` })}\n\n`); - res.end(); - } - } catch (e) { logger.error({ requestIp, targetIp, error: e.message }, "Error writing final error to SSE stream"); } - } - } -}); +// --- API Routes --- +// Mount the imported route handlers +app.use('/api/ipinfo', ipinfoRoutes); +app.use('/api/ping', pingRoutes); +app.use('/api/traceroute', tracerouteRoutes); +app.use('/api/lookup', lookupRoutes); +app.use('/api/dns-lookup', dnsLookupRoutes); +app.use('/api/whois-lookup', whoisLookupRoutes); +app.use('/api/version', versionRoutes); -// Lookup Endpunkt für beliebige IP (GeoIP, ASN, rDNS) -app.get('/api/lookup', async (req, res, next) => { // next hinzugefügt - const targetIpRaw = req.query.targetIp; - const targetIp = typeof targetIpRaw === 'string' ? targetIpRaw.trim() : targetIpRaw; - const requestIp = req.ip || req.socket.remoteAddress; - - logger.info({ requestIp, targetIp }, 'Lookup request received'); - - if (!isValidIp(targetIp)) { - logger.warn({ requestIp, targetIp }, 'Invalid target IP for lookup'); - return res.status(400).json({ error: 'Invalid IP address provided for lookup.' }); - } - if (isPrivateIp(targetIp)) { - logger.warn({ requestIp, targetIp }, 'Attempt to lookup private IP blocked'); - return res.status(403).json({ error: 'Lookup for private or local IP addresses is not supported.' }); - } - - try { - let geo = null; - try { - const geoData = cityReader.city(targetIp); - geo = { - city: geoData.city?.names?.en, region: geoData.subdivisions?.[0]?.isoCode, - country: geoData.country?.isoCode, countryName: geoData.country?.names?.en, - postalCode: geoData.postal?.code, latitude: geoData.location?.latitude, - longitude: geoData.location?.longitude, timezone: geoData.location?.timeZone, - }; - geo = Object.fromEntries(Object.entries(geo).filter(([_, v]) => v != null)); - logger.debug({ targetIp, geo }, 'GeoIP lookup successful for lookup'); - } catch (e) { - logger.warn({ targetIp, error: e.message }, `MaxMind City lookup failed for lookup`); - // Optional: Sentry.captureException(e, { level: 'warning', extra: { targetIp } }); - geo = { error: 'GeoIP lookup failed (IP not found in database or private range).' }; - } - - let asn = null; - try { - const asnData = asnReader.asn(targetIp); - asn = { number: asnData.autonomousSystemNumber, organization: asnData.autonomousSystemOrganization }; - asn = Object.fromEntries(Object.entries(asn).filter(([_, v]) => v != null)); - logger.debug({ targetIp, asn }, 'ASN lookup successful for lookup'); - } catch (e) { - logger.warn({ targetIp, error: e.message }, `MaxMind ASN lookup failed for lookup`); - // Optional: Sentry.captureException(e, { level: 'warning', extra: { targetIp } }); - asn = { error: 'ASN lookup failed (IP not found in database or private range).' }; - } - - let rdns = null; - try { - const hostnames = await dns.reverse(targetIp); - rdns = hostnames; - logger.debug({ targetIp, rdns }, 'rDNS lookup successful for lookup'); - } catch (e) { - if (e.code !== 'ENOTFOUND' && e.code !== 'ENODATA') { - logger.warn({ targetIp, error: e.message, code: e.code }, `rDNS lookup error for lookup`); - // Optional: Sentry.captureException(e, { level: 'warning', extra: { targetIp } }); - } else { - logger.debug({ targetIp, code: e.code }, 'rDNS lookup failed (No record) for lookup'); - } - rdns = { error: `rDNS lookup failed (${e.code || 'Unknown error'})` }; - } - - res.json({ - ip: targetIp, - geo: geo.error ? geo : (Object.keys(geo).length > 0 ? geo : null), - asn: asn.error ? asn : (Object.keys(asn).length > 0 ? asn : null), - rdns, - }); - - } catch (error) { - logger.error({ targetIp, error: error.message, stack: error.stack }, 'Error processing lookup'); - Sentry.captureException(error, { extra: { targetIp, requestIp } }); // An Sentry senden - next(error); // An Sentry Error Handler weiterleiten - } -}); - -// --- NEUE ENDPUNKTE --- - -// DNS Lookup Endpunkt -app.get('/api/dns-lookup', async (req, res, next) => { // next hinzugefügt - const domainRaw = req.query.domain; - const domain = typeof domainRaw === 'string' ? domainRaw.trim() : domainRaw; - const typeRaw = req.query.type; - const type = typeof typeRaw === 'string' ? typeRaw.trim().toUpperCase() : 'ANY'; - const requestIp = req.ip || req.socket.remoteAddress; - - logger.info({ requestIp, domain, type }, 'DNS lookup request received'); - - if (!isValidDomain(domain)) { - logger.warn({ requestIp, domain }, 'Invalid domain for DNS lookup'); - return res.status(400).json({ success: false, error: 'Invalid domain name provided.' }); - } - - const validTypes = ['A', 'AAAA', 'MX', 'TXT', 'NS', 'CNAME', 'SOA', 'SRV', 'PTR', 'ANY']; - if (!validTypes.includes(type)) { - logger.warn({ requestIp, domain, type }, 'Invalid record type for DNS lookup'); - return res.status(400).json({ success: false, error: `Invalid record type provided. Valid types are: ${validTypes.join(', ')}` }); - } - - try { - let records; - if (type === 'ANY') { - // Führe Lookups parallel aus, fange Fehler einzeln ab - const promises = [ - dns.resolve(domain, 'A').catch(() => []), dns.resolve(domain, 'AAAA').catch(() => []), - dns.resolve(domain, 'MX').catch(() => []), dns.resolve(domain, 'TXT').catch(() => []), - dns.resolve(domain, 'NS').catch(() => []), dns.resolve(domain, 'CNAME').catch(() => []), - dns.resolve(domain, 'SOA').catch(() => []), - ]; - // Warte auf alle Promises, auch wenn einige fehlschlagen - const results = await Promise.allSettled(promises); - - // Verarbeite die Ergebnisse - records = { - A: results[0].status === 'fulfilled' ? results[0].value : { error: results[0].reason?.message || 'Lookup failed' }, - AAAA: results[1].status === 'fulfilled' ? results[1].value : { error: results[1].reason?.message || 'Lookup failed' }, - MX: results[2].status === 'fulfilled' ? results[2].value : { error: results[2].reason?.message || 'Lookup failed' }, - TXT: results[3].status === 'fulfilled' ? results[3].value : { error: results[3].reason?.message || 'Lookup failed' }, - NS: results[4].status === 'fulfilled' ? results[4].value : { error: results[4].reason?.message || 'Lookup failed' }, - CNAME: results[5].status === 'fulfilled' ? results[5].value : { error: results[5].reason?.message || 'Lookup failed' }, - SOA: results[6].status === 'fulfilled' ? results[6].value : { error: results[6].reason?.message || 'Lookup failed' }, - }; - // Entferne leere Arrays oder Fehlerobjekte, wenn keine Daten vorhanden sind - records = Object.fromEntries(Object.entries(records).filter(([_, v]) => (Array.isArray(v) && v.length > 0) || (typeof v === 'object' && v !== null && !Array.isArray(v) && !v.error))); - - } else { - records = await dns.resolve(domain, type); - } - - logger.info({ requestIp, domain, type }, 'DNS lookup successful'); - res.json({ success: true, domain, type, records }); - - } catch (error) { - // Dieser Catch-Block wird nur für den spezifischen Typ-Lookup oder bei Fehlern in Promise.allSettled erreicht - logger.error({ requestIp, domain, type, error: error.message, code: error.code }, 'DNS lookup failed'); - Sentry.captureException(error, { extra: { requestIp, domain, type } }); // An Sentry senden - // Sende 500, aber mit Fehlerdetails im Body - res.status(500).json({ success: false, error: `DNS lookup failed: ${error.message} (Code: ${error.code})` }); - // next(error); // Optional: Fehler auch an Sentry Error Handler weiterleiten - } -}); - -// WHOIS Lookup Endpunkt -app.get('/api/whois-lookup', async (req, res, next) => { // next hinzugefügt - const queryRaw = req.query.query; - const query = typeof queryRaw === 'string' ? queryRaw.trim() : queryRaw; - const requestIp = req.ip || req.socket.remoteAddress; - - logger.info({ requestIp, query }, 'WHOIS lookup request received'); - - if (!isValidIp(query) && !isValidDomain(query)) { - logger.warn({ requestIp, query }, 'Invalid query for WHOIS lookup'); - return res.status(400).json({ success: false, error: 'Invalid domain name or IP address provided for WHOIS lookup.' }); - } - - try { - const result = await whois(query, { timeout: 10000 }); // Timeout hinzugefügt - logger.info({ requestIp, query }, 'WHOIS lookup successful'); - res.json({ success: true, query, result }); - - } catch (error) { - logger.error({ requestIp, query, error: error.message }, 'WHOIS lookup failed'); - Sentry.captureException(error, { extra: { requestIp, query } }); // An Sentry senden - let errorMessage = error.message; - if (error.message.includes('ETIMEDOUT') || error.message.includes('ESOCKETTIMEDOUT')) errorMessage = 'WHOIS server timed out.'; - else if (error.message.includes('ENOTFOUND')) errorMessage = 'Domain or IP not found or WHOIS server unavailable.'; - // Sende 500, aber mit Fehlerdetails im Body - res.status(500).json({ success: false, error: `WHOIS lookup failed: ${errorMessage}` }); - // next(error); // Optional: Fehler auch an Sentry Error Handler weiterleiten - } -}); - -// REMOVED: MAC Address Lookup Endpunkt - -// Version Endpunkt -app.get('/api/version', (req, res) => { - const commitSha = process.env.GIT_COMMIT_SHA || 'unknown'; - logger.info({ commitSha }, 'Version request received'); - res.json({ commitSha }); -}); - - -// --- Sentry Error Handler (NACH ALLEN ROUTEN, VOR ANDEREN ERROR HANDLERN) --- -// Wichtig: Der Error Handler muss 4 Argumente haben, damit Express ihn als Error Handler erkennt. -// Er muss NACH allen anderen Middlewares und Routen stehen. +// --- Sentry Error Handler --- +// Must be AFTER all controllers and BEFORE any other error handling middleware if (Sentry.Handlers && Sentry.Handlers.errorHandler) { app.use(Sentry.Handlers.errorHandler({ shouldHandleError(error) { - // Hier können Sie entscheiden, ob ein Fehler an Sentry gesendet werden soll - // z.B. keine 404-Fehler senden - if (error.status === 404) { - return false; - } - return true; + // Capture all 500 errors + if (error.status === 500) return true; + // Capture specific client errors if needed, e.g., 403 + // if (error.status === 403) return true; + // By default, capture only server errors (5xx) + return error.status >= 500; }, })); } else { - console.error("Sentry.Handlers.errorHandler is not available!"); + logger.error("Sentry.Handlers.errorHandler is not available!"); } // --- Ende Sentry Error Handler --- -// Optional: Ein generischer Fallback-Error-Handler nach Sentry + +// --- Fallback Error Handler --- +// Optional: Catches errors not handled by Sentry or passed via next(err) app.use((err, req, res, next) => { - // Dieser Handler wird nur aufgerufen, wenn Sentry den Fehler nicht behandelt hat - // oder wenn Sie `next(err)` im Sentry-Handler aufrufen. - logger.error({ error: err.message, stack: err.stack, url: req.originalUrl }, 'Unhandled error caught by fallback handler'); - res.statusCode = err.status || 500; - // res.sentry wird vom Sentry errorHandler gesetzt und enthält die Sentry Event ID - res.end((res.sentry ? `Event ID: ${res.sentry}\n` : '') + (err.message || 'Internal Server Error') + "\n"); + logger.error({ + error: err.message, + stack: err.stack, + url: req.originalUrl, + method: req.method, + status: err.status, + sentryId: res.sentry // Sentry ID if available + }, 'Unhandled error caught by fallback handler'); + + // Avoid sending stack trace in production + const errorResponse = { + error: err.message || 'Internal Server Error', + ...(res.sentry && { sentryId: res.sentry }) // Include Sentry ID if available + }; + + res.status(err.status || 500).json(errorResponse); }); -// --- Server starten --- -let server; // Variable für den HTTP-Server +// --- Server Start --- +let server; // Variable to hold the server instance for graceful shutdown -initialize().then(() => { - server = app.listen(PORT, () => { // Server-Instanz speichern +// Initialize external resources (like MaxMind DBs) then start the server +initializeMaxMind().then(() => { + server = app.listen(PORT, () => { logger.info({ port: PORT, node_env: process.env.NODE_ENV || 'development' }, `Server listening`); - logger.info(`API endpoints available at:`); - logger.info(` http://localhost:${PORT}/api/ipinfo`); - logger.info(` http://localhost:${PORT}/api/ping?targetIp=`); - logger.info(` http://localhost:${PORT}/api/traceroute?targetIp=`); - logger.info(` http://localhost:${PORT}/api/lookup?targetIp=`); - logger.info(` http://localhost:${PORT}/api/dns-lookup?domain=&type=`); - logger.info(` http://localhost:${PORT}/api/whois-lookup?query=`); - // REMOVED: MAC lookup log message - logger.info(` http://localhost:${PORT}/api/version`); + // Log available routes (optional) + logger.info(`API base URL: http://localhost:${PORT}/api`); }); }).catch(error => { - logger.fatal("Server could not start due to initialization errors."); - Sentry.captureException(error); // Fehler beim Starten an Sentry senden - process.exit(1); + logger.fatal({ error: error.message, stack: error.stack }, "Server could not start due to initialization errors."); + Sentry.captureException(error); // Capture initialization errors + process.exit(1); // Exit if initialization fails }); -// Graceful Shutdown Handling + +// --- Graceful Shutdown --- const signals = { 'SIGINT': 2, 'SIGTERM': 15 }; async function gracefulShutdown(signal) { logger.info(`Received ${signal}, shutting down gracefully...`); if (server) { - server.close(async () => { // async hinzugefügt + server.close(async () => { logger.info('HTTP server closed.'); - // Sentry schließen, um sicherzustellen, dass alle Events gesendet werden + // Close Sentry to allow time for events to be sent try { - await Sentry.close(2000); // Timeout von 2 Sekunden, await verwenden + await Sentry.close(2000); // 2 second timeout logger.info('Sentry closed.'); } catch (e) { logger.error({ error: e.message }, 'Error closing Sentry'); } finally { - process.exit(128 + signals[signal]); + process.exit(128 + signals[signal]); // Standard exit code for signals } }); } else { - // Wenn der Server nie gestartet ist, Sentry trotzdem schließen + // If server never started, still try to close Sentry and exit + logger.warn('Server was not running, attempting to close Sentry and exit.'); try { - await Sentry.close(2000); // await verwenden + await Sentry.close(2000); logger.info('Sentry closed (server never started).'); } catch (e) { logger.error({ error: e.message }, 'Error closing Sentry (server never started)'); @@ -893,13 +196,14 @@ async function gracefulShutdown(signal) { } } - // Fallback-Timeout, falls das Schließen hängt + // Force exit after a timeout if graceful shutdown hangs setTimeout(() => { logger.warn('Graceful shutdown timed out, forcing exit.'); process.exit(1); - }, 5000); // 5 Sekunden Timeout + }, 5000); // 5 seconds } +// Register signal handlers Object.keys(signals).forEach((signal) => { process.on(signal, () => gracefulShutdown(signal)); }); \ No newline at end of file diff --git a/backend/utils.js b/backend/utils.js new file mode 100644 index 0000000..5acfa4d --- /dev/null +++ b/backend/utils.js @@ -0,0 +1,269 @@ +// backend/utils.js +const net = require('net'); // Node.js built-in module for IP validation +const { spawn } = require('child_process'); +const pino = require('pino'); // Import pino for logging within utils if needed +const Sentry = require("@sentry/node"); // Import Sentry for error reporting + +// Logger instance (assuming a logger is initialized elsewhere and passed or created here) +// For simplicity, creating a basic logger here. Ideally, pass the main logger instance. +const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); + +/** + * Validiert eine IP-Adresse (v4 oder v6) mit Node.js' eingebautem net Modul. + * @param {string} ip - Die zu validierende IP-Adresse. + * @returns {boolean} True, wenn gültig (als v4 oder v6), sonst false. + */ +function isValidIp(ip) { + if (!ip || typeof ip !== 'string' || ip.trim() === '') { + return false; + } + const trimmedIp = ip.trim(); + const ipVersion = net.isIP(trimmedIp); // Gibt 0, 4 oder 6 zurück + return ipVersion === 4 || ipVersion === 6; +} + +/** + * Prüft, ob eine IP-Adresse im privaten, Loopback- oder Link-Local-Bereich liegt. + * @param {string} ip - Die zu prüfende IP-Adresse (bereits validiert). + * @returns {boolean} True, wenn die IP privat/lokal ist, sonst false. + */ +function isPrivateIp(ip) { + if (!ip) return false; + const ipVersion = net.isIP(ip); + + if (ipVersion === 4) { + const parts = ip.split('.').map(Number); + return ( + parts[0] === 10 || // 10.0.0.0/8 + (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || // 172.16.0.0/12 + (parts[0] === 192 && parts[1] === 168) || // 192.168.0.0/16 + parts[0] === 127 || // 127.0.0.0/8 (Loopback) + (parts[0] === 169 && parts[1] === 254) // 169.254.0.0/16 (Link-local) + ); + } else if (ipVersion === 6) { + const lowerCaseIp = ip.toLowerCase(); + return ( + lowerCaseIp === '::1' || // ::1/128 (Loopback) + lowerCaseIp.startsWith('fc') || lowerCaseIp.startsWith('fd') || // fc00::/7 (Unique Local) + lowerCaseIp.startsWith('fe8') || lowerCaseIp.startsWith('fe9') || // fe80::/10 (Link-local) + lowerCaseIp.startsWith('fea') || lowerCaseIp.startsWith('feb') + ); + } + return false; +} + +/** + * Validiert einen Domainnamen (sehr einfache Prüfung). + * @param {string} domain - Der zu validierende Domainname. + * @returns {boolean} True, wenn wahrscheinlich gültig, sonst false. + */ +function isValidDomain(domain) { + if (!domain || typeof domain !== 'string' || domain.trim().length < 3) { + return false; + } + // Regex updated to be more robust and handle international characters (IDNs) + const domainRegex = /^(?:[a-z0-9\p{L}](?:[a-z0-9\p{L}-]{0,61}[a-z0-9\p{L}])?\.)+[a-z0-9\p{L}][a-z0-9\p{L}-]{0,61}[a-z0-9\p{L}]$/iu; + return domainRegex.test(domain.trim()); +} + + +/** + * Bereinigt eine IP-Adresse (z.B. entfernt ::ffff: Präfix von IPv4-mapped IPv6). + * @param {string} ip - Die IP-Adresse. + * @returns {string} Die bereinigte IP-Adresse. + */ +function getCleanIp(ip) { + if (!ip) return ip; + const trimmedIp = ip.trim(); + if (trimmedIp.startsWith('::ffff:')) { + const potentialIp4 = trimmedIp.substring(7); + if (net.isIP(potentialIp4) === 4) { + return potentialIp4; + } + } + // Keep localhost IPs as they are + if (trimmedIp === '::1' || trimmedIp === '127.0.0.1') { + return trimmedIp; + } + // Return trimmed IP for other cases + return trimmedIp; +} + + +/** + * Führt einen Shell-Befehl sicher aus und gibt stdout zurück. (Nur für Ping verwendet) + * @param {string} command - Der Befehl (z.B. 'ping'). + * @param {string[]} args - Die Argumente als Array. + * @returns {Promise} Eine Promise, die mit stdout aufgelöst wird. + */ +function executeCommand(command, args) { + return new Promise((resolve, reject) => { + // Basic argument validation + args.forEach(arg => { + if (typeof arg === 'string' && /[;&|`$()<>]/.test(arg)) { + const error = new Error(`Invalid character detected in command argument.`); + logger.error({ command, arg }, "Potential command injection attempt detected in argument"); + Sentry.captureException(error); // Send to Sentry + return reject(error); + } + }); + + const proc = spawn(command, args); + let stdout = ''; + let stderr = ''; + + proc.stdout.on('data', (data) => { stdout += data.toString(); }); + proc.stderr.on('data', (data) => { stderr += data.toString(); }); + proc.on('error', (err) => { + const error = new Error(`Failed to start command ${command}: ${err.message}`); + logger.error({ command, args, error: err.message }, `Failed to start command`); + Sentry.captureException(error); // Send to Sentry + reject(error); + }); + proc.on('close', (code) => { + if (code !== 0) { + const error = new Error(`Command ${command} failed with code ${code}: ${stderr || 'No stderr output'}`); + // Attach stdout/stderr to the error object for better context in rejection + error.stdout = stdout; + error.stderr = stderr; + logger.error({ command, args, exitCode: code, stderr: stderr.trim(), stdout: stdout.trim() }, `Command failed`); + Sentry.captureException(error, { extra: { stdout: stdout.trim(), stderr: stderr.trim() } }); // Send to Sentry + reject(error); + } else { + resolve(stdout); + } + }); + }); +} + +/** + * Parst die Ausgabe des Linux/macOS ping Befehls. + * @param {string} pingOutput - Die rohe stdout Ausgabe von ping. + * @returns {object} Ein Objekt mit geparsten Daten oder Fehlern. + */ +function parsePingOutput(pingOutput) { + const result = { + rawOutput: pingOutput, + stats: null, + error: null, + }; + + try { + let packetsTransmitted = 0; + let packetsReceived = 0; + let packetLossPercent = 100; + let rtt = { min: null, avg: null, max: null, mdev: null }; + + const lines = pingOutput.trim().split('\n'); + const statsLine = lines.find(line => line.includes('packets transmitted')); + if (statsLine) { + const transmittedMatch = statsLine.match(/(\d+)\s+packets transmitted/); + const receivedMatch = statsLine.match(/(\d+)\s+(?:received|packets received)/); + const lossMatch = statsLine.match(/([\d.]+)%\s+packet loss/); + if (transmittedMatch) packetsTransmitted = parseInt(transmittedMatch[1], 10); + if (receivedMatch) packetsReceived = parseInt(receivedMatch[1], 10); + if (lossMatch) packetLossPercent = parseFloat(lossMatch[1]); + } + + // Handle both 'rtt' and 'round-trip' prefixes for broader compatibility + const rttLine = lines.find(line => line.startsWith('rtt min/avg/max/mdev') || line.startsWith('round-trip min/avg/max/stddev')); + if (rttLine) { + const rttMatch = rttLine.match(/([\d.]+)\/([\d.]+)\/([\d.]+)\/([\d.]+)/); + if (rttMatch) { + rtt = { + min: parseFloat(rttMatch[1]), + avg: parseFloat(rttMatch[2]), + max: parseFloat(rttMatch[3]), + mdev: parseFloat(rttMatch[4]), // Note: mdev/stddev might have different meanings + }; + } + } + + result.stats = { + packets: { transmitted: packetsTransmitted, received: packetsReceived, lossPercent: packetLossPercent }, + rtt: rtt.avg !== null ? rtt : null, // Only include RTT if average is available + }; + + // Check for common error messages or patterns + if (packetsTransmitted > 0 && packetsReceived === 0) { + result.error = "Request timed out or host unreachable."; + } else if (pingOutput.includes('unknown host') || pingOutput.includes('Name or service not known')) { + result.error = "Unknown host."; + } + + } catch (parseError) { + logger.error({ error: parseError.message, output: pingOutput }, "Failed to parse ping output"); + Sentry.captureException(parseError, { extra: { pingOutput } }); // Send to Sentry + result.error = "Failed to parse ping output."; + } + return result; +} + + +/** + * Parst eine einzelne Zeile der Linux/macOS traceroute Ausgabe. + * @param {string} line - Eine Zeile aus stdout. + * @returns {object | null} Ein Objekt mit Hop-Daten oder null bei uninteressanten Zeilen. + */ +function parseTracerouteLine(line) { + line = line.trim(); + // Ignore header lines and empty lines + if (!line || line.startsWith('traceroute to') || line.includes('hops max')) return null; + + // Regex to capture hop number, hostname (optional), IP address, and RTT times + // Handles cases with or without hostname, and different spacing + const hopMatch = line.match(/^(\s*\d+)\s+(?:([a-zA-Z0-9\.\-]+)\s+\(([\d\.:a-fA-F]+)\)|([\d\.:a-fA-F]+))\s+(.*)$/); + const timeoutMatch = line.match(/^(\s*\d+)\s+(\*\s+\*\s+\*)/); // Match lines with only timeouts + + if (timeoutMatch) { + // Handle timeout line + return { + hop: parseInt(timeoutMatch[1].trim(), 10), + hostname: null, + ip: null, + rtt: ['*', '*', '*'], // Represent timeouts as '*' + rawLine: line, + }; + } else if (hopMatch) { + // Handle successful hop line + const hop = parseInt(hopMatch[1].trim(), 10); + const hostname = hopMatch[2]; // Hostname if present + const ipInParen = hopMatch[3]; // IP if hostname is present + const ipDirect = hopMatch[4]; // IP if hostname is not present + const restOfLine = hopMatch[5].trim(); + const ip = ipInParen || ipDirect; // Determine the correct IP + + // Extract RTT times, handling '*' for timeouts and removing ' ms' units + const rttParts = restOfLine.split(/\s+/); + const rtts = rttParts + .map(p => p === '*' ? '*' : p.replace(/\s*ms$/, '')) // Keep '*' or remove ' ms' + .filter(p => p === '*' || !isNaN(parseFloat(p))) // Ensure it's '*' or a number + .slice(0, 3); // Take the first 3 valid RTT values + + // Pad with '*' if fewer than 3 RTTs were found (e.g., due to timeouts) + while (rtts.length < 3) rtts.push('*'); + + return { + hop: hop, + hostname: hostname || null, // Use null if hostname wasn't captured + ip: ip, + rtt: rtts, + rawLine: line, + }; + } + + // Return null if the line doesn't match expected formats + return null; +} + + +module.exports = { + isValidIp, + isPrivateIp, + isValidDomain, + getCleanIp, + executeCommand, + parsePingOutput, + parseTracerouteLine, + // Note: logger is not exported, assuming it's managed globally or passed where needed +}; \ No newline at end of file