feat: Implement the core InterNetX DDNS updater application with API, services, middleware, Docker support, and a detailed README.

This commit is contained in:
2026-01-26 19:53:31 +01:00
parent bba71a7272
commit 11d2c1fce2
19 changed files with 1739 additions and 2 deletions

79
src/config/config.js Normal file
View 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
View 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;

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

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

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

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

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

View 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
View 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
View 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),
};