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