seperate server.js

This commit is contained in:
2025-03-29 18:34:12 +01:00
parent 0da70547aa
commit b9cfe43986
10 changed files with 1116 additions and 792 deletions

56
backend/maxmind.js Normal file
View File

@@ -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,
};

115
backend/routes/dnsLookup.js Normal file
View File

@@ -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;

116
backend/routes/ipinfo.js Normal file
View File

@@ -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;

104
backend/routes/lookup.js Normal file
View File

@@ -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;

82
backend/routes/ping.js Normal file
View File

@@ -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;

View File

@@ -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;

20
backend/routes/version.js Normal file
View File

@@ -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;

View File

@@ -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;

View File

@@ -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<string>} 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=<ip>`);
logger.info(` http://localhost:${PORT}/api/traceroute?targetIp=<ip>`);
logger.info(` http://localhost:${PORT}/api/lookup?targetIp=<ip>`);
logger.info(` http://localhost:${PORT}/api/dns-lookup?domain=<domain>&type=<type>`);
logger.info(` http://localhost:${PORT}/api/whois-lookup?query=<domain_or_ip>`);
// 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));
});

269
backend/utils.js Normal file
View File

@@ -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<string>} 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
};