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:
commit
c4c80e98af
9 changed files with 461 additions and 0 deletions
16
.env.example
Normal file
16
.env.example
Normal 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
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.env
|
||||
node_modules/
|
||||
*.log
|
||||
114
README.md
Normal file
114
README.md
Normal 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
57
deploy.sh
Executable 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
203
index.js
Executable 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
30
package-lock.json
generated
Normal 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
15
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
12
systemd/wan-ip-guard.service
Normal file
12
systemd/wan-ip-guard.service
Normal 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
|
||||
11
systemd/wan-ip-guard.timer
Normal file
11
systemd/wan-ip-guard.timer
Normal 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
|
||||
Loading…
Reference in a new issue