diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..76c69b2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.github +node_modules +npm-debug.log +.env +.env.local +.env.production +logs/ +*.log +.vscode +.idea +.DS_Store +README.md +.gitignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8999c94 --- /dev/null +++ b/.env.example @@ -0,0 +1,39 @@ +# Server Configuration +PORT=3000 +NODE_ENV=production + +# InterNetX API Configuration +# Get your credentials from: https://www.internetx.com/ +INTERNETX_API_URL=https://api.autodns.com/v1 +INTERNETX_USER=your-username +INTERNETX_PASSWORD=your-password +INTERNETX_CONTEXT=4 + +# DDNS Configuration +# The domain zone managed by this service (e.g., ddns.netstack.berlin) +DEFAULT_ZONE=ddns.netstack.berlin + +# Default TTL for DNS records (in seconds) +DEFAULT_TTL=300 + +# Security Configuration +# Generate secure tokens with: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" +# Comma-separated list of valid authentication tokens +AUTH_TOKENS=your-token-here,another-token-here + +# Optional: IP Whitelist (comma-separated CIDR ranges) +# Leave empty to allow all IPs +# Example: IP_WHITELIST=192.168.1.0/24,10.0.0.0/8 +IP_WHITELIST= + +# Rate Limiting +# Time window in milliseconds +RATE_LIMIT_WINDOW_MS=300000 + +# Maximum requests per IP within the time window +RATE_LIMIT_MAX_REQUESTS=20 + +# Logging Configuration +LOG_LEVEL=info +LOG_FILE_MAX_SIZE=10m +LOG_FILE_MAX_AGE=30d diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..414cbcf --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Dependencies +node_modules/ +package-lock.json +yarn.lock + +# Environment Variables (CRITICAL - Never commit!) +.env +.env.local +.env.production + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# OS Files +.DS_Store +Thumbs.db +.vscode/ +.idea/ + +# Testing +coverage/ +.nyc_output/ + +# Temporary files +*.tmp +*.temp +.cache/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9f01b6e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +# Use Node.js LTS Alpine as base image +FROM node:24-alpine AS base + +# Build stage +FROM base AS builder + +# Set working directory +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci --only=production + +# Production stage +FROM base AS production + +# Create non-root user +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nodejs -u 1001 + +# Set working directory +WORKDIR /app + +# Copy dependencies from builder +COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules + +# Copy application code +COPY --chown=nodejs:nodejs . . + +# Create logs directory +RUN mkdir -p logs && chown -R nodejs:nodejs logs + +# Switch to non-root user +USER nodejs + +# Expose port +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3000/health', (r) => {r.statusCode === 200 ? process.exit(0) : process.exit(1)})" + +# Start application +CMD ["node", "src/index.js"] diff --git a/README.md b/README.md index b455dcd..8a91ca3 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,382 @@ -# internetx-ddns-updater -a DDNS Updater with InternetX API +# 🌐 InterNetX DDNS Updater + +Ein moderner DDNS-Service fΓΌr InterNetX AutoDNS, der es Routern ermΓΆglicht, DNS-Records automatisch zu aktualisieren. + +[![Node.js](https://img.shields.io/badge/node-%3E%3D24.0.0-brightgreen)](https://nodejs.org/) +[![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE) + +--- + +## ✨ Features + +- πŸ”’ **Sicherheit**: Token-Authentifizierung, Rate Limiting, Helmet.js Security Headers +- 🌍 **IPv4 & IPv6**: UnterstΓΌtzung fΓΌr beide IP-Protokolle +- πŸš€ **InterNetX Integration**: Nutzt das offizielle `js-domainrobot-sdk` +- πŸ“Š **Logging**: Winston Logger mit tΓ€glicher Log-Rotation +- 🐳 **Docker**: Production-ready Container mit Multi-Stage Build +- 🎯 **Router-Kompatibel**: Getestet mit DrayTek Vigor-Serie + +--- + +## πŸ“‹ Voraussetzungen + +- Node.js >= 24.0.0 +- npm >= 10.0.0 +- InterNetX AutoDNS Account mit API-Zugang +- Domain/Zone in InterNetX AutoDNS + +--- + +## πŸš€ Installation + +### Option 1: Node.js (Lokal) + +```bash +# Repository klonen +git clone https://github.com/MrUnknownDE/internetx-ddns-updater.git +cd internetx-ddns-updater + +# Dependencies installieren +npm install + +# .env Datei erstellen +cp .env.example .env + +# .env mit deinen Credentials bearbeiten +# Siehe Konfiguration weiter unten + +# Server starten +npm start +``` + +### Option 2: Docker + +```bash +# .env Datei erstellen +cp .env.example .env + +# docker-compose starten +docker-compose up -d + +# Logs anzeigen +docker-compose logs -f +``` + +--- + +## βš™οΈ Konfiguration + +Bearbeite die `.env` Datei mit deinen Einstellungen: + +### Erforderliche Einstellungen + +```env +# InterNetX API Credentials +INTERNETX_USER=dein-username +INTERNETX_PASSWORD=dein-passwort +INTERNETX_CONTEXT=4 + +# DDNS Zone +DEFAULT_ZONE=ddns.netstack.berlin + +# Authentifizierungs-Tokens (mind. 1 Token erforderlich) +# Token generieren: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" +AUTH_TOKENS=dein-token-hier,noch-ein-token +``` + +### Optionale Einstellungen + +```env +# Server +PORT=3000 +NODE_ENV=production + +# InterNetX API URL (Standard ist OK) +INTERNETX_API_URL=https://api.autodns.com/v1 + +# DNS TTL (in Sekunden) +DEFAULT_TTL=300 + +# IP Whitelist (optional, leer = alle IPs erlaubt) +IP_WHITELIST= + +# Rate Limiting +RATE_LIMIT_WINDOW_MS=300000 # 5 Minuten +RATE_LIMIT_MAX_REQUESTS=20 # Max 20 Anfragen pro IP + +# Logging +LOG_LEVEL=info +LOG_FILE_MAX_SIZE=10m +LOG_FILE_MAX_AGE=30d +``` + +--- + +## πŸ”‘ Token Generierung + +Generiere sichere Tokens mit Node.js: + +```bash +node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" +``` + +Kopiere das generierte Token in deine `.env` Datei unter `AUTH_TOKENS`. + +--- + +## πŸ“‘ Router-Konfiguration + +### DrayTek Vigor-Serie (2927, 3910, etc.) + +1. **Router-Admin ΓΆffnen**: Login in die Web-OberflΓ€che +2. **DDNS konfigurieren**: `Applications` β†’ `Dynamic DNS` +3. **Einstellungen**: + - **Provider**: Custom DDNS + - **Server/Provider**: `ddns.netstack.berlin` (oder deine Domain) + - **Hostname**: `@HOSTNAME@` (z.B. `home.ddns.netstack.berlin`) + - **URL**: `/update?hostname=@HOSTNAME@&myip=@IP@&token=DEIN_TOKEN_HIER` + - **Port**: `443` (HTTPS) + +4. **Test**: Klicke auf den Test-Button + +### Andere Router + +Die meisten Router unterstΓΌtzen "Custom DDNS" mit diesen Parametern: + +- **URL-Format**: `https://ddns.netstack.berlin/update?hostname=&myip=&token=` +- **Parameter**: + - `hostname`: Voller Hostname (z.B. `home.ddns.netstack.berlin`) + - `myip`: IPv4-Adresse (optional, wird automatisch erkannt) + - `myipv6`: IPv6-Adresse (optional) + - `token`: Dein Authentifizierungs-Token + +--- + +## πŸ”’ Sicherheit + +### HTTPS ist PFLICHT! + +⚠️ **WICHTIG**: Der Service MUSS hinter HTTPS laufen! + +#### Nginx Reverse Proxy Beispiel + +```nginx +server { + listen 443 ssl http2; + server_name ddns.netstack.berlin; + + ssl_certificate /etc/letsencrypt/live/ddns.netstack.berlin/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/ddns.netstack.berlin/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + + location / { + proxy_pass http://localhost:3000; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +#### Caddy Reverse Proxy (einfacher) + +```caddy +ddns.netstack.berlin { + reverse_proxy localhost:3000 +} +``` + +Caddy holt automatisch Let's Encrypt Zertifikate! πŸŽ‰ + +### Sicherheitsmaßnahmen + +- βœ… Token-basierte Authentifizierung (256-Bit Entropie) +- βœ… Rate Limiting (20 Anfragen / 5 Minuten pro IP) +- βœ… Input Validation (Joi-Schemas) +- βœ… Helmet.js Security Headers +- βœ… IP-Whitelisting (optional) +- βœ… Audit-Logging aller Anfragen + +--- + +## πŸ“š API-Dokumentation + +### `GET/POST /update` + +Update DNS-Record fΓΌr DDNS. + +#### Parameter + +| Parameter | Typ | Erforderlich | Beschreibung | +|-----------|-----|--------------|--------------| +| `hostname` | string | βœ… | Voller Hostname (z.B. `home.ddns.netstack.berlin`) | +| `myip` | string | ⚠️ | IPv4-Adresse (optional, auto-detect wenn leer) | +| `myipv6` | string | ⚠️ | IPv6-Adresse (optional) | +| `token` | string | βœ… | Authentifizierungs-Token | + +⚠️ Mindestens eine IP (`myip` oder `myipv6`) erforderlich, oder keine (dann Client-IP) + +#### Beispiel-Anfragen + +```bash +# IPv4 Update +curl "https://ddns.netstack.berlin/update?hostname=home.ddns.netstack.berlin&myip=1.2.3.4&token=YOUR_TOKEN" + +# IPv6 Update +curl "https://ddns.netstack.berlin/update?hostname=home.ddns.netstack.berlin&myipv6=2001:db8::1&token=YOUR_TOKEN" + +# Beide gleichzeitig +curl "https://ddns.netstack.berlin/update?hostname=home.ddns.netstack.berlin&myip=1.2.3.4&myipv6=2001:db8::1&token=YOUR_TOKEN" + +# Auto-Detect (Client-IP) +curl "https://ddns.netstack.berlin/update?hostname=home.ddns.netstack.berlin&token=YOUR_TOKEN" +``` + +#### Success Response + +```json +{ + "status": "success", + "message": "DNS record updated successfully", + "hostname": "home.ddns.netstack.berlin", + "updates": [ + { + "type": "A", + "action": "updated", + "value": "1.2.3.4" + } + ] +} +``` + +#### Error Response + +```json +{ + "status": "error", + "message": "Authentication failed" +} +``` + +### `GET /health` + +Health Check Endpoint fΓΌr Monitoring. + +```bash +curl http://localhost:3000/health +``` + +```json +{ + "status": "healthy", + "service": "ddns-updater", + "version": "1.0.0", + "uptime": 12345.67 +} +``` + +--- + +## πŸ› Troubleshooting + +### Router sendet keine Updates + +1. **HTTPS Zertifikat prΓΌfen**: Self-Signed Certs funktionieren oft nicht! +2. **Token ΓΌberprΓΌfen**: In `.env` und Router-Konfiguration identisch? +3. **Logs checken**: `docker-compose logs -f` oder `logs/combined-*.log` +4. **Router-Test**: Nutze die Test-Funktion des Routers + +### DNS-Record wird nicht aktualisiert + +1. **InterNetX Credentials**: Login in AutoDNS Control Panel testen +2. **Zone existiert**: Ist die Zone in InterNetX vorhanden? +3. **Berechtigungen**: Hat der API-User DNS-Rechte? +4. **Logs prΓΌfen**: Fehler in `logs/error-*.log`? + +### Rate Limit Errors + +- **Standard**: 20 Anfragen / 5 Minuten +- **LΓΆsung**: `RATE_LIMIT_MAX_REQUESTS` in `.env` erhΓΆhen +- **Hinweis**: Router machen oft mehrere Retries bei Fehlern + +### "Zone not found" Fehler + +- `DEFAULT_ZONE` in `.env` muss exakt mit InterNetX Zone ΓΌbereinstimmen +- Zone muss in AutoDNS existieren und aktiv sein + +--- + +## πŸ“ Projektstruktur + +``` +internetx-ddns-updater/ +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ config/ +β”‚ β”‚ └── config.js # Konfiguration & Validierung +β”‚ β”œβ”€β”€ middleware/ +β”‚ β”‚ β”œβ”€β”€ auth.middleware.js +β”‚ β”‚ β”œβ”€β”€ validation.middleware.js +β”‚ β”‚ └── rateLimiter.middleware.js +β”‚ β”œβ”€β”€ routes/ +β”‚ β”‚ └── update.route.js # DDNS Update Endpoint +β”‚ β”œβ”€β”€ services/ +β”‚ β”‚ β”œβ”€β”€ internetx.service.js # InterNetX API Integration +β”‚ β”‚ └── ddns.service.js # DDNS Business Logic +β”‚ β”œβ”€β”€ utils/ +β”‚ β”‚ β”œβ”€β”€ logger.js # Winston Logger +β”‚ β”‚ └── ipHelper.js # IP-Validierung +β”‚ └── index.js # Express App Entry +β”œβ”€β”€ public/ +β”‚ β”œβ”€β”€ index.html # Easter Egg Homepage +β”‚ └── logo.png # Dein Logo hier +β”œβ”€β”€ logs/ # Log-Dateien (auto-generiert) +β”œβ”€β”€ .env.example # Konfiguration Example +β”œβ”€β”€ package.json +β”œβ”€β”€ Dockerfile +└── docker-compose.yml +``` + +--- + +## πŸ—οΈ Development + +```bash +# Development Mode (mit Nodemon) +npm run dev + +# Tests ausfΓΌhren (wenn implementiert) +npm test + +# Linting +npm run lint +``` + +--- + +## 🌟 Easter Egg + +Besuche die Homepage des Services im Browser: `https://ddns.netstack.berlin/` + +Du findest eine nette Überraschung! πŸŽ‰ + +--- + +## πŸ“„ Lizenz + +MIT License - siehe [LICENSE](LICENSE) Datei + +--- + +## 🀝 Support + +Bei Fragen oder Problemen: + +- **Issues**: [GitHub Issues](https://github.com/netstack/internetx-ddns-updater/issues) +- **Email**: support@netstack.berlin +- **Website**: [netstack.berlin](https://www.netstack.berlin) + +--- + +## πŸ‘¨β€πŸ’» Made with ❀️ by [Netstack GmbH](https://www.netstack.berlin) + +**Interesse an coolen Projekten?** [Wir stellen ein!](https://www.netstack.berlin/karriere) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3d67f96 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,50 @@ +version: '3.8' + +services: + ddns-updater: + build: . + container_name: internetx-ddns-updater + restart: unless-stopped + + ports: + - "3000:3000" + + environment: + - NODE_ENV=production + - PORT=3000 + # InterNetX API - Configure via .env file + - INTERNETX_API_URL=${INTERNETX_API_URL} + - INTERNETX_USER=${INTERNETX_USER} + - INTERNETX_PASSWORD=${INTERNETX_PASSWORD} + - INTERNETX_CONTEXT=${INTERNETX_CONTEXT} + # DDNS Configuration + - DEFAULT_ZONE=${DEFAULT_ZONE} + - DEFAULT_TTL=${DEFAULT_TTL} + # Security + - AUTH_TOKENS=${AUTH_TOKENS} + - IP_WHITELIST=${IP_WHITELIST} + # Rate Limiting + - RATE_LIMIT_WINDOW_MS=${RATE_LIMIT_WINDOW_MS} + - RATE_LIMIT_MAX_REQUESTS=${RATE_LIMIT_MAX_REQUESTS} + # Logging + - LOG_LEVEL=${LOG_LEVEL} + - LOG_FILE_MAX_SIZE=${LOG_FILE_MAX_SIZE} + - LOG_FILE_MAX_AGE=${LOG_FILE_MAX_AGE} + + volumes: + - ./logs:/app/logs + - ./public:/app/public:ro + + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', (r) => {r.statusCode === 200 ? process.exit(0) : process.exit(1)})"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 10s + + networks: + - ddns-network + +networks: + ddns-network: + driver: bridge diff --git a/package.json b/package.json new file mode 100644 index 0000000..0c634c5 --- /dev/null +++ b/package.json @@ -0,0 +1,45 @@ +{ + "name": "internetx-ddns-updater", + "version": "1.0.0", + "description": "DDNS Bridge for InterNetX AutoDNS - Update DNS records via router requests", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "nodemon src/index.js", + "test": "jest --coverage", + "test:watch": "jest --watch", + "lint": "eslint src/**/*.js" + }, + "keywords": [ + "ddns", + "internetx", + "autodns", + "dns", + "router", + "draytek" + ], + "author": "Netstack GmbH", + "license": "MIT", + "dependencies": { + "dotenv": "^16.3.1", + "express": "^4.18.2", + "express-rate-limit": "^7.1.5", + "helmet": "^7.1.0", + "joi": "^17.11.0", + "js-domainrobot-sdk": "^2.1.17", + "winston": "^3.11.0", + "winston-daily-rotate-file": "^4.7.1" + }, + "devDependencies": { + "eslint": "^8.56.0", + "jest": "^29.7.0", + "nodemon": "^3.0.2" + }, + "overrides": { + "axios": "^1.7.9" + }, + "engines": { + "node": ">=24.0.0", + "npm": ">=10.0.0" + } +} \ No newline at end of file diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..93a9f73 --- /dev/null +++ b/public/index.html @@ -0,0 +1,194 @@ + + + + + + + DDNS Updater - Netstack GmbH + + + + +
+ + +

Hey, DDNS Updater am Start!

+ +

+ Krass, dass du das gefunden hast! πŸš€ +

+ +

+ Dieser Service ist ein DDNS-Updater fΓΌr unsere Standorte
+ Er ermΓΆglicht Routern wie DrayTek Vigor automatische DNS-Updates. +

+ +
+

Bock bei uns zu arbeiten? 🀝

+

+ Die Netstack GmbH sucht talentierte Administratoren,
+ die Lust auf coole Projekte wie diesen haben! +

+ + Jetzt bewerben! β†’ + +
+
+ + + \ No newline at end of file diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 0000000..bf0beaa --- /dev/null +++ b/public/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/config/config.js b/src/config/config.js new file mode 100644 index 0000000..2647423 --- /dev/null +++ b/src/config/config.js @@ -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; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..1155146 --- /dev/null +++ b/src/index.js @@ -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; diff --git a/src/middleware/auth.middleware.js b/src/middleware/auth.middleware.js new file mode 100644 index 0000000..3e29b3b --- /dev/null +++ b/src/middleware/auth.middleware.js @@ -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; diff --git a/src/middleware/rateLimiter.middleware.js b/src/middleware/rateLimiter.middleware.js new file mode 100644 index 0000000..9371cad --- /dev/null +++ b/src/middleware/rateLimiter.middleware.js @@ -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, +}; diff --git a/src/middleware/validation.middleware.js b/src/middleware/validation.middleware.js new file mode 100644 index 0000000..70aa481 --- /dev/null +++ b/src/middleware/validation.middleware.js @@ -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, +}; diff --git a/src/routes/update.route.js b/src/routes/update.route.js new file mode 100644 index 0000000..98f80d3 --- /dev/null +++ b/src/routes/update.route.js @@ -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; diff --git a/src/services/ddns.service.js b/src/services/ddns.service.js new file mode 100644 index 0000000..310632e --- /dev/null +++ b/src/services/ddns.service.js @@ -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} - 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} - 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, +}; diff --git a/src/services/internetx.service.js b/src/services/internetx.service.js new file mode 100644 index 0000000..437fd0b --- /dev/null +++ b/src/services/internetx.service.js @@ -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} - 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} - 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} - 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} - 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, +}; diff --git a/src/utils/ipHelper.js b/src/utils/ipHelper.js new file mode 100644 index 0000000..e7b0008 --- /dev/null +++ b/src/utils/ipHelper.js @@ -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, +}; diff --git a/src/utils/logger.js b/src/utils/logger.js new file mode 100644 index 0000000..86c202c --- /dev/null +++ b/src/utils/logger.js @@ -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), +};