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
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);
|
||||
});
|
||||
Loading…
Reference in a new issue