commit c4c80e98af3f776802b243b704ae2e571efe3d88 Author: Eddy de Vink Date: Sun Jun 21 09:53:28 2026 +0200 feat: WAN IP Guard - dynamic DNS updater with WhatsApp notifications Checks public WAN IP every 5 minutes via systemd timer. On change: - Updates A-records via DirectAdmin API (login key auth) - Sends WhatsApp notification via wwebjs-api diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e1a4a6f --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# DirectAdmin API credentials +DA_LOGIN_KEY=your_login_key_here +DA_USERNAME=your_username +DA_BASE_URL=https://panel.example.com:2222 +DA_DOMAIN=example.com + +# WhatsApp API (wwebjs-api) +WHATSAPP_API_ENDPOINT=http://localhost:3001 +WHATSAPP_SESSION=your_session +ADMIN_PHONE=31600000000 + +# DNS records to update (comma-separated, use domain name for apex) +DNS_RECORDS=example.com,git,www + +# State file path (where to store last known IP) +STATE_FILE=/var/lib/wan-ip-guard/current_ip diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53df79a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +node_modules/ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..2cc276e --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# WAN IP Guard + +Bewaakt het publieke WAN IP en update automatisch DNS records bij wijziging, plus WhatsApp notificatie. + +## Wat doet het? + +- Controleert elke 5 minuten het publieke WAN IP +- Bij wijziging: + - Updatet A-records via de DirectAdmin API + - Stuurt een WhatsApp notificatie naar de admin +- Draait als systemd timer + +## Installatie + +### 1. Clone op je server + +```bash +cd ~ +git clone wan-ip-guard +cd wan-ip-guard +``` + +### 2. Configureer + +```bash +cp .env.example .env +# Edit .env met je credentials +nano .env +``` + +### 3. Installeer dependencies + +```bash +npm install --omit=dev +``` + +### 4. Systemd setup + +```bash +# State directory +sudo mkdir -p /var/lib/wan-ip-guard +sudo chown $USER:$USER /var/lib/wan-ip-guard + +# Pas de paden in systemd/*.service aan naar jouw setup +# (gebruiker, WorkingDirectory, ExecStart, EnvironmentFile) + +# Systemd units +sudo cp systemd/wan-ip-guard.service /etc/systemd/system/ +sudo cp systemd/wan-ip-guard.timer /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now wan-ip-guard.timer +``` + +## Configuratie (.env) + +| Variabele | Beschrijving | Voorbeeld | +|-----------|-------------|-----------| +| `DA_LOGIN_KEY` | DirectAdmin Login Key | `your_login_key` | +| `DA_USERNAME` | DirectAdmin gebruiker | `your_username` | +| `DA_BASE_URL` | DirectAdmin base URL | `https://panel.example.com:2222` | +| `DA_DOMAIN` | Domeinnaam | `example.com` | +| `WHATSAPP_API_ENDPOINT` | wwebjs-api URL | `http://localhost:3001` | +| `WHATSAPP_SESSION` | WhatsApp sessie naam | `your_session` | +| `ADMIN_PHONE` | Admin telefoonnummer | `31600000000` | +| `DNS_RECORDS` | Komma-gescheiden records | `example.com,git,www` | +| `STATE_FILE` | Pad naar state file | `/var/lib/wan-ip-guard/current_ip` | + +## DirectAdmin Login Key aanmaken + +1. Log in op het DirectAdmin panel +2. Ga naar **Accountbeheer** → **Login Keys** +3. Maak een nieuwe key aan met rechten voor **DNS Control** +4. Sla de key op (het wordt maar één keer getoond) + +## Beheer + +```bash +# Timer status +systemctl status wan-ip-guard.timer + +# Logs volgen +journalctl -u wan-ip-guard -f + +# Handmatig draaien +cd ~/wan-ip-guard && node index.js + +# Timer herstarten +sudo systemctl restart wan-ip-guard.timer + +# State bestand resetten (forceer update bij volgende run) +rm /var/lib/wan-ip-guard/current_ip +``` + +## Hoe het werkt + +1. **IP detectie:** Haalt het WAN IP op via `ifconfig.me` met fallback naar `api.ipify.org` en `icanhazip.com` +2. **State vergelijking:** Vergelijkt met het laatst opgeslagen IP in de state file +3. **DNS update:** Voor elke record: verwijder oud A-record + voeg nieuw A-record toe via DirectAdmin API +4. **Notificatie:** Stuurt WhatsApp bericht met oud/nieuw IP en status per record + +## Files + +``` +wan-ip-guard/ +├── index.js # Hoofdscript +├── package.json +├── .env.example # Config template +├── deploy.sh # Deployment script +├── systemd/ +│ ├── wan-ip-guard.service +│ └── wan-ip-guard.timer +└── docs/ + └── design-spec.md +``` diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..b3b6d19 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,57 @@ +#!/bin/bash +set -euo pipefail + +# WAN IP Guard - Deployment script +# Configure the variables below for your setup, then run from your local machine + +REMOTE_USER="your-ssh-user" +REMOTE_HOST="your-server" +REMOTE_DIR="/home/your-ssh-user/wan-ip-guard" + +echo "=== WAN IP Guard Deployment ===" +echo "" + +# 1. Sync files to server +echo "[1/5] Syncing files to ${REMOTE_HOST}..." +rsync -avz --delete \ + --exclude 'node_modules' \ + --exclude '.git' \ + --exclude '*.log' \ + --exclude '.env' \ + ./ "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/" +echo " Done." +echo "" + +# 2. Install dependencies +echo "[2/5] Installing npm dependencies..." +ssh "${REMOTE_USER}@${REMOTE_HOST}" "cd ${REMOTE_DIR} && npm install --omit=dev" +echo " Done." +echo "" + +# 3. Create state directory +echo "[3/5] Creating state directory..." +ssh "${REMOTE_USER}@${REMOTE_HOST}" "sudo mkdir -p /var/lib/wan-ip-guard && sudo chown ${REMOTE_USER}:${REMOTE_USER} /var/lib/wan-ip-guard" +echo " Done." +echo "" + +# 4. Install systemd service + timer +echo "[4/5] Installing systemd units..." +ssh "${REMOTE_USER}@${REMOTE_HOST}" " + sudo cp ${REMOTE_DIR}/systemd/wan-ip-guard.service /etc/systemd/system/ + sudo cp ${REMOTE_DIR}/systemd/wan-ip-guard.timer /etc/systemd/system/ + sudo systemctl daemon-reload + sudo systemctl enable wan-ip-guard.timer + sudo systemctl start wan-ip-guard.timer +" +echo " Done." +echo "" + +# 5. Verify +echo "[5/5] Verifying deployment..." +ssh "${REMOTE_USER}@${REMOTE_HOST}" "systemctl status wan-ip-guard.timer --no-pager" +echo "" + +echo "=== Deployment complete! ===" +echo "The timer runs every 5 minutes." +echo "Check logs: ssh ${REMOTE_USER}@${REMOTE_HOST} journalctl -u wan-ip-guard -f" +echo "Run manually: ssh ${REMOTE_USER}@${REMOTE_HOST} cd ${REMOTE_DIR} && node index.js" diff --git a/index.js b/index.js new file mode 100755 index 0000000..ca046d2 --- /dev/null +++ b/index.js @@ -0,0 +1,203 @@ +#!/usr/bin/env node +'use strict'; + +require('dotenv').config(); + +const fs = require('fs'); +const path = require('path'); + +const CONFIG = { + stateFile: process.env.STATE_FILE || '/var/lib/wan-ip-guard/current_ip', + daBaseUrl: process.env.DA_BASE_URL, + daUsername: process.env.DA_USERNAME, + daLoginKey: process.env.DA_LOGIN_KEY, + daDomain: process.env.DA_DOMAIN, + whatsappApi: process.env.WHATSAPP_API_ENDPOINT || 'http://localhost:3001', + whatsappSession: process.env.WHATSAPP_SESSION || 'EdGpt', + adminPhone: process.env.ADMIN_PHONE, + dnsRecords: (process.env.DNS_RECORDS || '').split(',').map(s => s.trim()).filter(Boolean), +}; + +function log(msg) { + console.log(`[${new Date().toISOString()}] ${msg}`); +} + +function logError(msg) { + console.error(`[${new Date().toISOString()}] ERROR: ${msg}`); +} + +async function fetchWithTimeout(url, options = {}, timeout = 15000) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +async function getWanIp() { + const services = [ + 'https://ifconfig.me', + 'https://api.ipify.org', + 'https://icanhazip.com', + ]; + for (const url of services) { + try { + const resp = await fetchWithTimeout(url, {}, 10000); + if (resp.ok) { + const ip = (await resp.text()).trim(); + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) { + log(`WAN IP obtained from ${url}: ${ip}`); + return ip; + } + } + } catch (e) { + log(`Could not get IP from ${url}: ${e.message}`); + } + } + throw new Error('Could not determine WAN IP from any service'); +} + +function readState() { + try { + return fs.readFileSync(CONFIG.stateFile, 'utf8').trim(); + } catch { + return null; + } +} + +function writeState(ip) { + const dir = path.dirname(CONFIG.stateFile); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(CONFIG.stateFile, ip + '\n'); +} + +async function daRequest(endpoint) { + const url = `${CONFIG.daBaseUrl}${endpoint}`; + const auth = Buffer.from(`${CONFIG.daUsername}:${CONFIG.daLoginKey}`).toString('base64'); + const resp = await fetchWithTimeout(url, { + headers: { Authorization: `Basic ${auth}` }, + }); + const text = await resp.text(); + if (!resp.ok) { + throw new Error(`DirectAdmin API ${resp.status}: ${text}`); + } + return text; +} + +async function getDnsRecords() { + const text = await daRequest(`/CMD_API_DNS_CONTROL?domain=${encodeURIComponent(CONFIG.daDomain)}`); + const records = {}; + for (const line of text.split('\n')) { + const match = line.match(/^(.+?)\s+\d+\s+IN\s+A\s+(\d+\.\d+\.\d+\.\d+)$/); + if (match) { + let name = match[1].trim(); + if (name === `${CONFIG.daDomain}.` || name === CONFIG.daDomain) { + name = ''; + } + records[name] = match[2]; + } + } + return records; +} + +async function deleteDnsRecord(name, ip) { + const recordValue = `${name}=${ip}`; + const endpoint = `/CMD_API_DNS_CONTROL?domain=${encodeURIComponent(CONFIG.daDomain)}&action=select&arecs0=${encodeURIComponent(recordValue)}`; + await daRequest(endpoint); +} + +async function addDnsRecord(name, ip) { + const endpoint = `/CMD_API_DNS_CONTROL?domain=${encodeURIComponent(CONFIG.daDomain)}&action=add&type=A&name=${encodeURIComponent(name)}&value=${encodeURIComponent(ip)}`; + await daRequest(endpoint); +} + +async function sendWhatsApp(message) { + const url = `${CONFIG.whatsappApi}/client/sendMessage/${CONFIG.whatsappSession}`; + const chatId = `${CONFIG.adminPhone}@c.us`; + try { + const resp = await fetchWithTimeout(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chatId, content: message, contentType: 'string' }), + }, 10000); + if (!resp.ok) { + logError(`WhatsApp send failed: ${resp.status}`); + } else { + log('WhatsApp notification sent.'); + } + } catch (e) { + logError(`WhatsApp send error: ${e.message}`); + } +} + +async function main() { + log('WAN IP Guard check starting.'); + + if (!CONFIG.daLoginKey || !CONFIG.daUsername || !CONFIG.daDomain) { + throw new Error('Missing required environment variables. Check .env file.'); + } + + const currentIp = await getWanIp(); + const savedIp = readState(); + + if (savedIp === currentIp) { + log(`IP unchanged (${currentIp}). Nothing to do.`); + return; + } + + log(`IP changed: ${savedIp || '(none)'} -> ${currentIp}`); + + let dnsRecords = {}; + try { + dnsRecords = await getDnsRecords(); + log(`Current DNS A records: ${JSON.stringify(dnsRecords)}`); + } catch (e) { + logError(`Could not query DNS records: ${e.message}`); + } + + const results = []; + for (const record of CONFIG.dnsRecords) { + const name = record === CONFIG.daDomain ? '' : record; + const label = name || '(apex)'; + const oldIp = dnsRecords[name] || dnsRecords[record]; + + try { + if (oldIp) { + await deleteDnsRecord(name, oldIp); + } + await addDnsRecord(name, currentIp); + results.push(`✅ ${label}`); + log(`Updated ${label}: ${oldIp || '(none)'} -> ${currentIp}`); + } catch (e) { + results.push(`❌ ${label}: ${e.message}`); + logError(`Failed to update ${label}: ${e.message}`); + } + } + + const successCount = results.filter(r => r.startsWith('✅')).length; + const failCount = results.filter(r => r.startsWith('❌')).length; + + const message = [ + '🔄 *WAN IP Gewijzigd*', + '', + `*Oud:* ${savedIp || '(onbekend)'}`, + `*Nieuw:* ${currentIp}`, + '', + '*DNS Updates:*', + ...results, + '', + `${successCount} gelukt, ${failCount} mislukt`, + ].join('\n'); + + await sendWhatsApp(message); + writeState(currentIp); + log(`State saved: ${currentIp}`); + log('Done.'); +} + +main().catch(e => { + logError(`Fatal: ${e.message}`); + process.exit(1); +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c9128ba --- /dev/null +++ b/package-lock.json @@ -0,0 +1,30 @@ +{ + "name": "wan-ip-guard", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wan-ip-guard", + "version": "1.0.0", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..56f5209 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "wan-ip-guard", + "version": "1.0.0", + "description": "Monitors WAN IP changes and updates DNS records + sends WhatsApp notification", + "main": "index.js", + "scripts": { + "start": "node index.js" + }, + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=18" + } +} diff --git a/systemd/wan-ip-guard.service b/systemd/wan-ip-guard.service new file mode 100644 index 0000000..f6ed4d5 --- /dev/null +++ b/systemd/wan-ip-guard.service @@ -0,0 +1,12 @@ +[Unit] +Description=WAN IP Guard - Dynamic DNS updater +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +# Change 'your-user' and paths to match your setup +User=your-user +EnvironmentFile=/home/your-user/wan-ip-guard/.env +WorkingDirectory=/home/your-user/wan-ip-guard +ExecStart=/usr/bin/node /home/your-user/wan-ip-guard/index.js diff --git a/systemd/wan-ip-guard.timer b/systemd/wan-ip-guard.timer new file mode 100644 index 0000000..f29ffe4 --- /dev/null +++ b/systemd/wan-ip-guard.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Run WAN IP Guard every 5 minutes + +[Timer] +OnBootSec=1min +OnUnitActiveSec=5min +AccuracySec=30s +Persistent=true + +[Install] +WantedBy=timers.target