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
This commit is contained in:
Eddy de Vink 2026-06-21 09:53:28 +02:00
commit c4c80e98af
9 changed files with 461 additions and 0 deletions

16
.env.example Normal file
View file

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

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
.env
node_modules/
*.log

114
README.md Normal file
View file

@ -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 <jouw-repo-url> 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
```

57
deploy.sh Executable file
View file

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

203
index.js Executable file
View file

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

30
package-lock.json generated Normal file
View file

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

15
package.json Normal file
View file

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

View file

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

View file

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