mirror of
https://github.com/MrUnknownDE/utools.git
synced 2026-04-06 00:32:04 +02:00
seperate server.js
This commit is contained in:
56
backend/maxmind.js
Normal file
56
backend/maxmind.js
Normal 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
115
backend/routes/dnsLookup.js
Normal 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
116
backend/routes/ipinfo.js
Normal 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
104
backend/routes/lookup.js
Normal 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
82
backend/routes/ping.js
Normal 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;
|
||||
172
backend/routes/traceroute.js
Normal file
172
backend/routes/traceroute.js
Normal 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
20
backend/routes/version.js
Normal 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;
|
||||
86
backend/routes/whoisLookup.js
Normal file
86
backend/routes/whoisLookup.js
Normal 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;
|
||||
@@ -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
269
backend/utils.js
Normal 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
|
||||
};
|
||||
Reference in New Issue
Block a user