mirror of
https://github.com/MrUnknownDE/internetx-ddns-updater.git
synced 2026-04-29 11:13:45 +02:00
feat: Implement the core InterNetX DDNS updater application with API, services, middleware, Docker support, and a detailed README.
This commit is contained in:
79
src/config/config.js
Normal file
79
src/config/config.js
Normal file
@@ -0,0 +1,79 @@
|
||||
require('dotenv').config();
|
||||
|
||||
const config = {
|
||||
// Server Configuration
|
||||
server: {
|
||||
port: parseInt(process.env.PORT || '3000', 10),
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
},
|
||||
|
||||
// InterNetX API Configuration
|
||||
internetx: {
|
||||
url: process.env.INTERNETX_API_URL || 'https://api.autodns.com/v1',
|
||||
auth: {
|
||||
user: process.env.INTERNETX_USER,
|
||||
password: process.env.INTERNETX_PASSWORD,
|
||||
context: parseInt(process.env.INTERNETX_CONTEXT || '4', 10),
|
||||
},
|
||||
},
|
||||
|
||||
// DDNS Configuration
|
||||
ddns: {
|
||||
defaultZone: process.env.DEFAULT_ZONE || 'ddns.netstack.berlin',
|
||||
defaultTTL: parseInt(process.env.DEFAULT_TTL || '300', 10),
|
||||
},
|
||||
|
||||
// Security Configuration
|
||||
security: {
|
||||
authTokens: (process.env.AUTH_TOKENS || '').split(',').filter(t => t.length > 0),
|
||||
ipWhitelist: (process.env.IP_WHITELIST || '').split(',').filter(ip => ip.length > 0),
|
||||
},
|
||||
|
||||
// Rate Limiting Configuration
|
||||
rateLimit: {
|
||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '300000', 10), // 5 minutes
|
||||
maxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '20', 10),
|
||||
},
|
||||
|
||||
// Logging Configuration
|
||||
logging: {
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
maxSize: process.env.LOG_FILE_MAX_SIZE || '10m',
|
||||
maxAge: process.env.LOG_FILE_MAX_AGE || '30d',
|
||||
},
|
||||
};
|
||||
|
||||
// Validation: Critical configuration must be present
|
||||
const validateConfig = () => {
|
||||
const errors = [];
|
||||
|
||||
if (!config.internetx.auth.user) {
|
||||
errors.push('INTERNETX_USER is required');
|
||||
}
|
||||
if (!config.internetx.auth.password) {
|
||||
errors.push('INTERNETX_PASSWORD is required');
|
||||
}
|
||||
if (config.security.authTokens.length === 0) {
|
||||
errors.push('AUTH_TOKENS is required (at least one token)');
|
||||
}
|
||||
if (!config.ddns.defaultZone) {
|
||||
errors.push('DEFAULT_ZONE is required');
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Configuration validation failed:\n${errors.join('\n')}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Validate on load
|
||||
if (config.server.nodeEnv !== 'test') {
|
||||
try {
|
||||
validateConfig();
|
||||
} catch (error) {
|
||||
console.error('❌ Configuration Error:', error.message);
|
||||
console.error('💡 Please check your .env file or environment variables');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = config;
|
||||
135
src/index.js
Normal file
135
src/index.js
Normal file
@@ -0,0 +1,135 @@
|
||||
const express = require('express');
|
||||
const helmet = require('helmet');
|
||||
const path = require('path');
|
||||
const config = require('./config/config');
|
||||
const { logger } = require('./utils/logger');
|
||||
const updateRoute = require('./routes/update.route');
|
||||
|
||||
// Create Express app
|
||||
const app = express();
|
||||
|
||||
// Trust proxy (important for IP detection behind load balancer)
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// Security Headers
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: false, // Allow inline styles for homepage
|
||||
hsts: {
|
||||
maxAge: 31536000,
|
||||
includeSubDomains: true,
|
||||
},
|
||||
}));
|
||||
|
||||
// Body parsers
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Static files (for logo and homepage)
|
||||
app.use(express.static(path.join(__dirname, '../public')));
|
||||
|
||||
// Request logging
|
||||
app.use((req, res, next) => {
|
||||
logger.debug('Incoming request', {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('user-agent'),
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use(updateRoute);
|
||||
|
||||
// Homepage route
|
||||
app.get('/', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../public/index.html'));
|
||||
});
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
service: 'ddns-updater',
|
||||
version: '1.0.0',
|
||||
uptime: process.uptime(),
|
||||
});
|
||||
});
|
||||
|
||||
// 404 Handler
|
||||
app.use((req, res) => {
|
||||
logger.warn('Route not found', {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
ip: req.ip,
|
||||
});
|
||||
|
||||
res.status(404).json({
|
||||
error: 'Not Found',
|
||||
message: 'The requested endpoint does not exist',
|
||||
});
|
||||
});
|
||||
|
||||
// Global Error Handler
|
||||
app.use((err, req, res, next) => {
|
||||
logger.error('Unhandled error', {
|
||||
error: err.message,
|
||||
stack: err.stack,
|
||||
path: req.path,
|
||||
ip: req.ip,
|
||||
});
|
||||
|
||||
res.status(err.status || 500).json({
|
||||
error: 'Internal Server Error',
|
||||
message: config.server.nodeEnv === 'development'
|
||||
? err.message
|
||||
: 'An unexpected error occurred',
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
const PORT = config.server.port;
|
||||
|
||||
const server = app.listen(PORT, () => {
|
||||
logger.info('🚀 DDNS Updater started', {
|
||||
port: PORT,
|
||||
env: config.server.nodeEnv,
|
||||
zone: config.ddns.defaultZone,
|
||||
});
|
||||
|
||||
console.log(`
|
||||
╔═══════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ 🌐 DDNS Updater by Netstack GmbH ║
|
||||
║ ║
|
||||
║ Status: ✅ Running ║
|
||||
║ Port: ${PORT} ║
|
||||
║ Zone: ${config.ddns.defaultZone.padEnd(33)}║
|
||||
║ Env: ${config.server.nodeEnv.padEnd(33)}║
|
||||
║ ║
|
||||
║ Homepage: http://localhost:${PORT} ║
|
||||
║ Health: http://localhost:${PORT}/health ║
|
||||
║ Update: /update ║
|
||||
║ ║
|
||||
╚═══════════════════════════════════════════════════════╝
|
||||
`);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
logger.info('SIGTERM received, shutting down gracefully');
|
||||
server.close(() => {
|
||||
logger.info('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('SIGINT received, shutting down gracefully');
|
||||
server.close(() => {
|
||||
logger.info('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
47
src/middleware/auth.middleware.js
Normal file
47
src/middleware/auth.middleware.js
Normal file
@@ -0,0 +1,47 @@
|
||||
const config = require('../config/config');
|
||||
const { logger, redactToken } = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Authentication middleware - validates token
|
||||
*/
|
||||
const authenticate = (req, res, next) => {
|
||||
const token = req.query.token || req.body.token;
|
||||
|
||||
if (!token) {
|
||||
logger.warn('Authentication failed: No token provided', {
|
||||
ip: req.ip,
|
||||
path: req.path
|
||||
});
|
||||
return res.status(401).json({
|
||||
error: 'Authentication required',
|
||||
message: 'Missing token parameter'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if token is valid
|
||||
const isValid = config.security.authTokens.includes(token);
|
||||
|
||||
if (!isValid) {
|
||||
logger.warn('Authentication failed: Invalid token', {
|
||||
ip: req.ip,
|
||||
token: redactToken(token),
|
||||
path: req.path
|
||||
});
|
||||
return res.status(401).json({
|
||||
error: 'Authentication failed',
|
||||
message: 'Invalid token'
|
||||
});
|
||||
}
|
||||
|
||||
// Token is valid, attach redacted version for logging
|
||||
req.tokenPrefix = redactToken(token);
|
||||
|
||||
logger.debug('Authentication successful', {
|
||||
ip: req.ip,
|
||||
token: req.tokenPrefix
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = authenticate;
|
||||
50
src/middleware/rateLimiter.middleware.js
Normal file
50
src/middleware/rateLimiter.middleware.js
Normal file
@@ -0,0 +1,50 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const config = require('../config/config');
|
||||
const { logger } = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Rate limiter for update endpoint
|
||||
*/
|
||||
const updateLimiter = rateLimit({
|
||||
windowMs: config.rateLimit.windowMs,
|
||||
max: config.rateLimit.maxRequests,
|
||||
|
||||
message: {
|
||||
error: 'Too many requests',
|
||||
message: 'Rate limit exceeded. Please try again later.',
|
||||
},
|
||||
|
||||
standardHeaders: true, // Return rate limit info in `RateLimit-*` headers
|
||||
legacyHeaders: false, // Disable `X-RateLimit-*` headers
|
||||
|
||||
// Custom handler for rate limit exceeded
|
||||
handler: (req, res) => {
|
||||
logger.warn('Rate limit exceeded', {
|
||||
ip: req.ip,
|
||||
path: req.path,
|
||||
limit: config.rateLimit.maxRequests,
|
||||
window: `${config.rateLimit.windowMs / 1000}s`,
|
||||
});
|
||||
|
||||
res.status(429).json({
|
||||
error: 'Too many requests',
|
||||
message: 'You have exceeded the rate limit. Please try again later.',
|
||||
retryAfter: Math.ceil(config.rateLimit.windowMs / 1000),
|
||||
});
|
||||
},
|
||||
|
||||
// Skip rate limiting for successful requests (optional)
|
||||
skip: (req) => {
|
||||
// Could skip based on certain conditions, e.g., whitelisted IPs
|
||||
return false;
|
||||
},
|
||||
|
||||
// Key generator - rate limit per IP
|
||||
keyGenerator: (req) => {
|
||||
return req.ip;
|
||||
},
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
updateLimiter,
|
||||
};
|
||||
87
src/middleware/validation.middleware.js
Normal file
87
src/middleware/validation.middleware.js
Normal file
@@ -0,0 +1,87 @@
|
||||
const Joi = require('joi');
|
||||
const config = require('../config/config');
|
||||
const { logger } = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Validation schema for update endpoint
|
||||
*/
|
||||
const updateSchema = Joi.object({
|
||||
hostname: Joi.string()
|
||||
.hostname()
|
||||
.pattern(new RegExp(`^[a-z0-9\\-]+\\.${config.ddns.defaultZone.replace('.', '\\.')}$`, 'i'))
|
||||
.required()
|
||||
.messages({
|
||||
'string.pattern.base': `Hostname must be a subdomain of ${config.ddns.defaultZone}`,
|
||||
'any.required': 'Hostname is required',
|
||||
}),
|
||||
|
||||
myip: Joi.string()
|
||||
.ip({ version: ['ipv4'], cidr: 'forbidden' })
|
||||
.optional()
|
||||
.messages({
|
||||
'string.ip': 'Invalid IPv4 address format',
|
||||
}),
|
||||
|
||||
myipv6: Joi.string()
|
||||
.ip({ version: ['ipv6'], cidr: 'forbidden' })
|
||||
.optional()
|
||||
.messages({
|
||||
'string.ip': 'Invalid IPv6 address format',
|
||||
}),
|
||||
|
||||
token: Joi.string()
|
||||
.min(8)
|
||||
.max(256)
|
||||
.required()
|
||||
.messages({
|
||||
'string.min': 'Token is too short',
|
||||
'any.required': 'Token is required',
|
||||
}),
|
||||
}).or('myip', 'myipv6')
|
||||
.messages({
|
||||
'object.missing': 'At least one IP address (myip or myipv6) is required',
|
||||
});
|
||||
|
||||
/**
|
||||
* Validation middleware factory
|
||||
* @param {Object} schema - Joi validation schema
|
||||
* @returns {Function} - Express middleware
|
||||
*/
|
||||
const validate = (schema) => {
|
||||
return (req, res, next) => {
|
||||
// Merge query and body for validation
|
||||
const data = { ...req.query, ...req.body };
|
||||
|
||||
const { error, value } = schema.validate(data, {
|
||||
abortEarly: false, // Return all errors, not just the first
|
||||
stripUnknown: true, // Remove unknown fields
|
||||
});
|
||||
|
||||
if (error) {
|
||||
const errors = error.details.map(detail => ({
|
||||
field: detail.path.join('.'),
|
||||
message: detail.message,
|
||||
}));
|
||||
|
||||
logger.warn('Validation failed', {
|
||||
ip: req.ip,
|
||||
errors,
|
||||
data: { ...data, token: '***' } // Redact token in logs
|
||||
});
|
||||
|
||||
return res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
details: errors,
|
||||
});
|
||||
}
|
||||
|
||||
// Attach validated data to request
|
||||
req.validatedData = value;
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
validate,
|
||||
updateSchema,
|
||||
};
|
||||
98
src/routes/update.route.js
Normal file
98
src/routes/update.route.js
Normal file
@@ -0,0 +1,98 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const ddnsService = require('../services/ddns.service');
|
||||
const authenticate = require('../middleware/auth.middleware');
|
||||
const { validate, updateSchema } = require('../middleware/validation.middleware');
|
||||
const { updateLimiter } = require('../middleware/rateLimiter.middleware');
|
||||
const { getClientIP } = require('../utils/ipHelper');
|
||||
const { logger } = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* GET/POST /update
|
||||
* Update DNS record for router DDNS
|
||||
*
|
||||
* Query/Body Parameters:
|
||||
* - hostname: Full hostname (e.g., "home.ddns.netstack.berlin")
|
||||
* - myip: IPv4 address (optional)
|
||||
* - myipv6: IPv6 address (optional)
|
||||
* - token: Authentication token
|
||||
*/
|
||||
router.all('/update',
|
||||
updateLimiter,
|
||||
validate(updateSchema),
|
||||
authenticate,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { hostname, myip, myipv6 } = req.validatedData;
|
||||
const clientIP = getClientIP(req);
|
||||
|
||||
// Use provided IPs or fallback to client IP
|
||||
let ipv4 = myip;
|
||||
let ipv6 = myipv6;
|
||||
|
||||
// If no IP provided, use client IP (auto-detect version)
|
||||
if (!ipv4 && !ipv6) {
|
||||
const { detectIPVersion } = require('../utils/ipHelper');
|
||||
const version = detectIPVersion(clientIP);
|
||||
|
||||
if (version === 'ipv4') {
|
||||
ipv4 = clientIP;
|
||||
} else if (version === 'ipv6') {
|
||||
ipv6 = clientIP;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('DDNS update request', {
|
||||
hostname,
|
||||
ipv4,
|
||||
ipv6,
|
||||
clientIP,
|
||||
token: req.tokenPrefix,
|
||||
});
|
||||
|
||||
// Update DNS record
|
||||
const result = await ddnsService.updateDNSRecord(hostname, ipv4, ipv6);
|
||||
|
||||
if (!result.success) {
|
||||
logger.error('DDNS update failed', {
|
||||
hostname,
|
||||
error: result.error,
|
||||
});
|
||||
|
||||
return res.status(500).json({
|
||||
status: 'error',
|
||||
message: result.error || 'Failed to update DNS record',
|
||||
});
|
||||
}
|
||||
|
||||
// Success response
|
||||
logger.info('DDNS update successful', {
|
||||
hostname,
|
||||
results: result.results,
|
||||
});
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
message: 'DNS record updated successfully',
|
||||
hostname,
|
||||
updates: result.results.map(r => ({
|
||||
type: r.type,
|
||||
action: r.action,
|
||||
value: r.record?.value,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Unexpected error in update endpoint', {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
message: 'An unexpected error occurred',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
125
src/services/ddns.service.js
Normal file
125
src/services/ddns.service.js
Normal file
@@ -0,0 +1,125 @@
|
||||
const internetxService = require('./internetx.service');
|
||||
const { isValidIPv4, isValidIPv6, detectIPVersion } = require('../utils/ipHelper');
|
||||
const { logger } = require('../utils/logger');
|
||||
const config = require('../config/config');
|
||||
|
||||
/**
|
||||
* Update DNS record for a hostname
|
||||
* @param {string} hostname - Full hostname (e.g., "home.ddns.netstack.berlin")
|
||||
* @param {string} ipv4 - IPv4 address (optional)
|
||||
* @param {string} ipv6 - IPv6 address (optional)
|
||||
* @returns {Promise<Object>} - Result of update operation
|
||||
*/
|
||||
const updateDNSRecord = async (hostname, ipv4 = null, ipv6 = null) => {
|
||||
try {
|
||||
// Extract subdomain from hostname
|
||||
const zoneName = config.ddns.defaultZone;
|
||||
const subdomain = hostname.replace(`.${zoneName}`, '');
|
||||
|
||||
if (subdomain === hostname) {
|
||||
// Hostname doesn't match the zone
|
||||
logger.warn('Hostname does not match configured zone', { hostname, zoneName });
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid hostname - must be a subdomain of ' + zoneName,
|
||||
};
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const ttl = config.ddns.defaultTTL;
|
||||
|
||||
// Update IPv4 record if provided
|
||||
if (ipv4) {
|
||||
if (!isValidIPv4(ipv4)) {
|
||||
logger.warn('Invalid IPv4 address', { ipv4 });
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid IPv4 address format',
|
||||
};
|
||||
}
|
||||
|
||||
logger.info('Updating A record', { hostname, ipv4 });
|
||||
const result = await internetxService.upsertRecord(
|
||||
zoneName,
|
||||
subdomain,
|
||||
'A',
|
||||
ipv4,
|
||||
ttl
|
||||
);
|
||||
results.push({ type: 'A', ...result });
|
||||
}
|
||||
|
||||
// Update IPv6 record if provided
|
||||
if (ipv6) {
|
||||
if (!isValidIPv6(ipv6)) {
|
||||
logger.warn('Invalid IPv6 address', { ipv6 });
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid IPv6 address format',
|
||||
};
|
||||
}
|
||||
|
||||
logger.info('Updating AAAA record', { hostname, ipv6 });
|
||||
const result = await internetxService.upsertRecord(
|
||||
zoneName,
|
||||
subdomain,
|
||||
'AAAA',
|
||||
ipv6,
|
||||
ttl
|
||||
);
|
||||
results.push({ type: 'AAAA', ...result });
|
||||
}
|
||||
|
||||
// Check if any updates failed
|
||||
const failures = results.filter(r => !r.success);
|
||||
if (failures.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: failures.map(f => f.error).join(', '),
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
// All successful
|
||||
logger.info('DNS update successful', { hostname, results });
|
||||
return {
|
||||
success: true,
|
||||
hostname,
|
||||
results,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('DNS update failed', { hostname, error: error.message });
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Auto-detect IP version and update accordingly
|
||||
* @param {string} hostname - Full hostname
|
||||
* @param {string} ip - IP address (auto-detect v4 or v6)
|
||||
* @returns {Promise<Object>} - Result of update operation
|
||||
*/
|
||||
const autoUpdateDNSRecord = async (hostname, ip) => {
|
||||
const ipVersion = detectIPVersion(ip);
|
||||
|
||||
if (!ipVersion) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid IP address format',
|
||||
};
|
||||
}
|
||||
|
||||
if (ipVersion === 'ipv4') {
|
||||
return await updateDNSRecord(hostname, ip, null);
|
||||
} else {
|
||||
return await updateDNSRecord(hostname, null, ip);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
updateDNSRecord,
|
||||
autoUpdateDNSRecord,
|
||||
};
|
||||
190
src/services/internetx.service.js
Normal file
190
src/services/internetx.service.js
Normal file
@@ -0,0 +1,190 @@
|
||||
const DomainRobot = require('js-domainrobot-sdk').DomainRobot;
|
||||
const config = require('../config/config');
|
||||
const { logger } = require('../utils/logger');
|
||||
|
||||
// Initialize DomainRobot SDK
|
||||
const domainRobot = new DomainRobot({
|
||||
url: config.internetx.url,
|
||||
auth: {
|
||||
user: config.internetx.auth.user,
|
||||
password: config.internetx.auth.password,
|
||||
context: config.internetx.auth.context,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a DNS zone by name
|
||||
* @param {string} zoneName - Zone name (e.g., "ddns.netstack.berlin")
|
||||
* @returns {Promise<Object>} - Zone object
|
||||
*/
|
||||
const getZone = async (zoneName) => {
|
||||
try {
|
||||
logger.debug('Fetching zone', { zoneName });
|
||||
const zone = await domainRobot.zone().info(zoneName);
|
||||
return zone;
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch zone', {
|
||||
zoneName,
|
||||
error: error.message,
|
||||
code: error.code
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update zone with new resource records
|
||||
* @param {Object} zone - Zone object from getZone()
|
||||
* @returns {Promise<Object>} - Updated zone
|
||||
*/
|
||||
const updateZone = async (zone) => {
|
||||
try {
|
||||
logger.debug('Updating zone', { zoneName: zone.origin });
|
||||
const result = await domainRobot.zone().update(zone);
|
||||
logger.info('Zone updated successfully', { zoneName: zone.origin });
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('Failed to update zone', {
|
||||
zoneName: zone.origin,
|
||||
error: error.message,
|
||||
code: error.code
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Find a specific record in a zone
|
||||
* @param {Object} zone - Zone object
|
||||
* @param {string} name - Record name (subdomain)
|
||||
* @param {string} type - Record type (A, AAAA, etc.)
|
||||
* @returns {Object|null} - Record object or null if not found
|
||||
*/
|
||||
const findRecord = (zone, name, type) => {
|
||||
if (!zone.resourceRecords || zone.resourceRecords.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return zone.resourceRecords.find(
|
||||
record => record.name === name && record.type === type
|
||||
) || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Add or update a DNS record in a zone
|
||||
* @param {string} zoneName - Zone name
|
||||
* @param {string} subdomain - Subdomain (e.g., "home" for home.ddns.netstack.berlin)
|
||||
* @param {string} type - Record type (A or AAAA)
|
||||
* @param {string} value - IP address
|
||||
* @param {number} ttl - Time to live in seconds
|
||||
* @returns {Promise<Object>} - Result of operation
|
||||
*/
|
||||
const upsertRecord = async (zoneName, subdomain, type, value, ttl = 300) => {
|
||||
try {
|
||||
// Get the zone
|
||||
const zone = await getZone(zoneName);
|
||||
|
||||
// Find existing record
|
||||
const existingRecord = findRecord(zone, subdomain, type);
|
||||
|
||||
if (existingRecord) {
|
||||
// Update existing record
|
||||
logger.debug('Updating existing record', { subdomain, type, value });
|
||||
existingRecord.value = value;
|
||||
existingRecord.ttl = ttl;
|
||||
} else {
|
||||
// Add new record
|
||||
logger.debug('Creating new record', { subdomain, type, value });
|
||||
if (!zone.resourceRecords) {
|
||||
zone.resourceRecords = [];
|
||||
}
|
||||
|
||||
zone.resourceRecords.push({
|
||||
name: subdomain,
|
||||
type: type,
|
||||
value: value,
|
||||
ttl: ttl,
|
||||
});
|
||||
}
|
||||
|
||||
// Update the zone
|
||||
const result = await updateZone(zone);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
action: existingRecord ? 'updated' : 'created',
|
||||
record: {
|
||||
name: subdomain,
|
||||
type: type,
|
||||
value: value,
|
||||
ttl: ttl,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to upsert record', {
|
||||
zoneName,
|
||||
subdomain,
|
||||
type,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
code: error.code || 'UNKNOWN_ERROR',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a DNS record from a zone
|
||||
* @param {string} zoneName - Zone name
|
||||
* @param {string} subdomain - Subdomain
|
||||
* @param {string} type - Record type
|
||||
* @returns {Promise<Object>} - Result of operation
|
||||
*/
|
||||
const deleteRecord = async (zoneName, subdomain, type) => {
|
||||
try {
|
||||
const zone = await getZone(zoneName);
|
||||
const recordIndex = zone.resourceRecords?.findIndex(
|
||||
record => record.name === subdomain && record.type === type
|
||||
);
|
||||
|
||||
if (recordIndex === -1 || recordIndex === undefined) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record not found',
|
||||
};
|
||||
}
|
||||
|
||||
zone.resourceRecords.splice(recordIndex, 1);
|
||||
await updateZone(zone);
|
||||
|
||||
logger.info('Record deleted', { subdomain, type });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
action: 'deleted',
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete record', {
|
||||
zoneName,
|
||||
subdomain,
|
||||
type,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getZone,
|
||||
updateZone,
|
||||
findRecord,
|
||||
upsertRecord,
|
||||
deleteRecord,
|
||||
};
|
||||
55
src/utils/ipHelper.js
Normal file
55
src/utils/ipHelper.js
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Extract client IP from request
|
||||
* Handles X-Forwarded-For and X-Real-IP headers from proxies/load balancers
|
||||
*/
|
||||
const getClientIP = (req) => {
|
||||
// Check X-Forwarded-For header (standard from proxies)
|
||||
const forwarded = req.headers['x-forwarded-for'];
|
||||
if (forwarded) {
|
||||
// X-Forwarded-For can contain multiple IPs: "client, proxy1, proxy2"
|
||||
// We want the first one (original client)
|
||||
return forwarded.split(',')[0].trim();
|
||||
}
|
||||
|
||||
// Check X-Real-IP header (nginx)
|
||||
const realIP = req.headers['x-real-ip'];
|
||||
if (realIP) {
|
||||
return realIP;
|
||||
}
|
||||
|
||||
// Fallback to Express's req.ip
|
||||
return req.ip || req.connection.remoteAddress;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate IPv4 address format
|
||||
*/
|
||||
const isValidIPv4 = (ip) => {
|
||||
const ipv4Regex = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
return ipv4Regex.test(ip);
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate IPv6 address format
|
||||
*/
|
||||
const isValidIPv6 = (ip) => {
|
||||
// Simplified IPv6 regex - covers most cases
|
||||
const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;
|
||||
return ipv6Regex.test(ip);
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect if IP is IPv4 or IPv6
|
||||
*/
|
||||
const detectIPVersion = (ip) => {
|
||||
if (isValidIPv4(ip)) return 'ipv4';
|
||||
if (isValidIPv6(ip)) return 'ipv6';
|
||||
return null;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getClientIP,
|
||||
isValidIPv4,
|
||||
isValidIPv6,
|
||||
detectIPVersion,
|
||||
};
|
||||
71
src/utils/logger.js
Normal file
71
src/utils/logger.js
Normal file
@@ -0,0 +1,71 @@
|
||||
const winston = require('winston');
|
||||
const DailyRotateFile = require('winston-daily-rotate-file');
|
||||
const config = require('../config/config');
|
||||
|
||||
// Custom format for logs
|
||||
const customFormat = winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json()
|
||||
);
|
||||
|
||||
// Console format for development
|
||||
const consoleFormat = winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.timestamp({ format: 'HH:mm:ss' }),
|
||||
winston.format.printf(({ level, message, timestamp, ...meta }) => {
|
||||
let msg = `${timestamp} [${level}] ${message}`;
|
||||
if (Object.keys(meta).length > 0) {
|
||||
msg += ` ${JSON.stringify(meta)}`;
|
||||
}
|
||||
return msg;
|
||||
})
|
||||
);
|
||||
|
||||
// Create logger instance
|
||||
const logger = winston.createLogger({
|
||||
level: config.logging.level,
|
||||
format: customFormat,
|
||||
transports: [
|
||||
// Error log file
|
||||
new DailyRotateFile({
|
||||
filename: 'logs/error-%DATE%.log',
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
level: 'error',
|
||||
maxSize: config.logging.maxSize,
|
||||
maxFiles: config.logging.maxAge,
|
||||
}),
|
||||
// Combined log file
|
||||
new DailyRotateFile({
|
||||
filename: 'logs/combined-%DATE%.log',
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
maxSize: config.logging.maxSize,
|
||||
maxFiles: config.logging.maxAge,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Add console transport in development
|
||||
if (config.server.nodeEnv !== 'production') {
|
||||
logger.add(new winston.transports.Console({
|
||||
format: consoleFormat,
|
||||
}));
|
||||
}
|
||||
|
||||
// Helper function to redact sensitive data
|
||||
const redactToken = (token) => {
|
||||
if (!token || token.length < 8) return '***';
|
||||
return `${token.substring(0, 8)}...`;
|
||||
};
|
||||
|
||||
// Export logger with helper functions
|
||||
module.exports = {
|
||||
logger,
|
||||
redactToken,
|
||||
|
||||
// Convenience methods
|
||||
info: (message, meta = {}) => logger.info(message, meta),
|
||||
warn: (message, meta = {}) => logger.warn(message, meta),
|
||||
error: (message, meta = {}) => logger.error(message, meta),
|
||||
debug: (message, meta = {}) => logger.debug(message, meta),
|
||||
};
|
||||
Reference in New Issue
Block a user