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

14
.dockerignore Normal file
View File

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

39
.env.example Normal file
View File

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

31
.gitignore vendored Normal file
View File

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

46
Dockerfile Normal file
View File

@@ -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"]

384
README.md
View File

@@ -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=<hostname>&myip=<ip>&token=<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)

50
docker-compose.yml Normal file
View File

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

45
package.json Normal file
View File

@@ -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"
}
}

194
public/index.html Normal file
View File

@@ -0,0 +1,194 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DDNS Updater - Netstack GmbH</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #ff694a 0%, #87120d 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 60px 40px;
max-width: 600px;
text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
animation: fadeIn 0.6s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.logo {
max-width: 200px;
height: auto;
margin-bottom: 30px;
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.1));
}
h1 {
color: #333;
font-size: 2.5em;
margin-bottom: 20px;
font-weight: 700;
}
.emoji {
font-size: 3em;
margin-bottom: 20px;
display: inline-block;
animation: wave 1s ease-in-out infinite;
}
@keyframes wave {
0%,
100% {
transform: rotate(0deg);
}
25% {
transform: rotate(20deg);
}
75% {
transform: rotate(-20deg);
}
}
p {
color: #555;
font-size: 1.2em;
line-height: 1.6;
margin-bottom: 15px;
}
.highlight {
color: #ff694a;
font-weight: 600;
}
.cta {
margin-top: 40px;
padding: 30px;
background: linear-gradient(135deg, #ff694a 0%, #87120d 100%);
border-radius: 15px;
color: white;
}
.cta h2 {
font-size: 1.8em;
margin-bottom: 15px;
}
.cta p {
color: rgba(255, 255, 255, 0.95);
font-size: 1.1em;
margin-bottom: 20px;
}
.cta a {
display: inline-block;
padding: 15px 40px;
background: white;
color: #ff694a;
text-decoration: none;
border-radius: 50px;
font-weight: 600;
font-size: 1.1em;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.cta a:hover {
transform: translateY(-3px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
}
.info {
margin-top: 30px;
padding: 20px;
background: #f8f9fa;
border-radius: 10px;
font-size: 0.9em;
color: #666;
}
.info code {
background: #e9ecef;
padding: 2px 8px;
border-radius: 4px;
font-family: 'Courier New', monospace;
color: #d63384;
}
@media (max-width: 768px) {
.container {
padding: 40px 20px;
}
h1 {
font-size: 2em;
}
.emoji {
font-size: 2.5em;
}
}
</style>
</head>
<body>
<div class="container">
<img src="/logo.svg" alt="Netstack GmbH Logo" class="logo">
<h1>Hey, DDNS Updater am Start!</h1>
<p>
<strong class="highlight">Krass, dass du das gefunden hast! 🚀</strong>
</p>
<p>
Dieser Service ist ein DDNS-Updater für unsere Standorte<br>
Er ermöglicht Routern wie DrayTek Vigor automatische DNS-Updates.
</p>
<div class="cta">
<h2>Bock bei uns zu arbeiten? 🤝</h2>
<p>
Die <strong>Netstack GmbH</strong> sucht talentierte Administratoren,<br>
die Lust auf coole Projekte wie diesen haben!
</p>
<a href="https://www.netstack.de/karriere" target="_blank" rel="noopener">
Jetzt bewerben! →
</a>
</div>
</div>
</body>
</html>

1
public/logo.svg Normal file
View File

@@ -0,0 +1 @@
<svg id="ea1ebfaf-9156-47d0-bcd1-53b0d3c2f606" data-name="logo rot" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 66.667"><polygon points="41.783 19.852 50.233 26.592 66.667 0 41.783 19.852" fill="#e94a44"/><polygon points="0 66.667 16.435 40.074 10.557 44.763 0 66.667" fill="#e94a44"/><polygon points="56.11 44.763 0 0 66.667 66.667 66.667 0 56.11 44.763" fill="#e94a44"/><polygon points="16.435 40.074 0 66.667 24.884 46.815 16.435 40.074" fill="#bb1912"/><polygon points="66.667 0 50.233 26.592 56.11 21.904 66.667 0" fill="#bb1912"/><polygon points="0 0 0 66.667 10.557 21.904 66.667 66.667 0 0" fill="#bb1912"/><polygon points="10.557 21.904 10.557 44.763 0 66.667 10.557 21.904" fill="#cd2d23"/><polygon points="66.667 0 56.11 21.904 56.11 44.763 66.667 0" fill="#cd2d23"/><path d="M104.771,49.354q-3.088-5.485-6.684-10.834a110.579,110.579,0,0,0-7.654-10.1V49.354h-7.1V17.4h5.855q1.522,1.521,3.366,3.735t3.758,4.726q1.911,2.512,3.8,5.209t3.55,5.187V17.4h7.146v31.95Z" fill="#37363b"/><path d="M113.942,49.354V17.4h21.577v6.04H121.134v6.27h12.771v5.9H121.134v7.7h15.445v6.039Z" fill="#37363b"/><path d="M164.823,17.4v6.132h-9.636V49.354h-7.193V23.536h-9.635V17.4Z" fill="#37363b"/><path d="M176.3,43.868a10.32,10.32,0,0,0,2.512-.254,4.617,4.617,0,0,0,1.591-.691,2.32,2.32,0,0,0,.83-1.038,3.708,3.708,0,0,0,.23-1.336,3.089,3.089,0,0,0-1.475-2.606,21.189,21.189,0,0,0-5.071-2.236q-1.567-.552-3.135-1.267a11.564,11.564,0,0,1-2.812-1.8,8.585,8.585,0,0,1-2.029-2.628,8.229,8.229,0,0,1-.784-3.758,9.311,9.311,0,0,1,.83-3.988,8.518,8.518,0,0,1,2.351-3.02,10.76,10.76,0,0,1,3.689-1.913,16.571,16.571,0,0,1,4.886-.668,19.527,19.527,0,0,1,9.452,2.213l-2.075,5.671a19.673,19.673,0,0,0-2.973-1.222,12.9,12.9,0,0,0-3.942-.531,7.236,7.236,0,0,0-3.712.715,2.413,2.413,0,0,0-1.129,2.19,2.53,2.53,0,0,0,.415,1.476,4.034,4.034,0,0,0,1.175,1.083,10.33,10.33,0,0,0,1.752.876q.992.391,2.19.806,2.49.924,4.334,1.821a11.587,11.587,0,0,1,3.066,2.1,7.6,7.6,0,0,1,1.821,2.812,11.313,11.313,0,0,1,.6,3.919,8.321,8.321,0,0,1-3.136,6.938Q182.618,50,176.3,50a26.339,26.339,0,0,1-3.827-.253,24.863,24.863,0,0,1-3.02-.623,17.177,17.177,0,0,1-2.259-.784,16.921,16.921,0,0,1-1.59-.784l2.028-5.716a18.053,18.053,0,0,0,3.527,1.406A18.3,18.3,0,0,0,176.3,43.868Z" fill="#37363b"/><path d="M215.353,17.4v6.132h-9.636V49.354h-7.192V23.536h-9.636V17.4Z" fill="#37363b"/><path d="M235.774,49.354q-.534-1.665-1.147-3.42T233.4,42.439H220.926q-.606,1.749-1.213,3.505t-1.133,3.41h-7.468q1.8-5.162,3.422-9.543t3.167-8.253q1.55-3.873,3.052-7.353t3.122-6.8h6.818q1.573,3.32,3.1,6.8t3.08,7.353q1.552,3.873,3.173,8.253t3.428,9.543Zm-8.641-24.711q-.242.7-.705,1.894t-1.055,2.768q-.592,1.571-1.324,3.458t-1.487,3.959H231.7q-.733-2.077-1.421-3.972t-1.306-3.464q-.62-1.569-1.086-2.767T227.133,24.643Z" fill="#37363b"/><path d="M259.152,50q-7.792,0-11.872-4.334T243.2,33.357a18.922,18.922,0,0,1,1.245-7.077,15.212,15.212,0,0,1,3.412-5.256,14.256,14.256,0,0,1,5.163-3.251,18.615,18.615,0,0,1,6.5-1.106,20.812,20.812,0,0,1,3.689.3,24.027,24.027,0,0,1,2.9.691,14.426,14.426,0,0,1,2.076.807q.828.414,1.2.645l-2.075,5.809a17.442,17.442,0,0,0-3.435-1.337,16.382,16.382,0,0,0-4.449-.553,9.826,9.826,0,0,0-3.25.553,7.358,7.358,0,0,0-2.813,1.776,8.826,8.826,0,0,0-1.958,3.157,13.229,13.229,0,0,0-.738,4.7,16.769,16.769,0,0,0,.484,4.126,8.745,8.745,0,0,0,1.568,3.3,7.508,7.508,0,0,0,2.835,2.19,10.084,10.084,0,0,0,4.242.807,19.321,19.321,0,0,0,2.812-.185,20.679,20.679,0,0,0,2.213-.438,11.531,11.531,0,0,0,1.705-.576q.738-.323,1.338-.6l1.982,5.763a16.835,16.835,0,0,1-4.287,1.659A25.033,25.033,0,0,1,259.152,50Z" fill="#37363b"/><path d="M291.45,49.354q-.972-1.559-2.289-3.37t-2.841-3.652q-1.524-1.839-3.186-3.528a34.492,34.492,0,0,0-3.327-2.982V49.354h-7.192V17.4h7.192V29.412q2.774-2.913,5.635-6.094t5.286-5.914h8.534Q296,21.289,292.7,24.873t-6.947,7.215a54.749,54.749,0,0,1,7.4,7.588A94.517,94.517,0,0,1,300,49.354Z" fill="#37363b"/></svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

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