wan-ip-guard/index.js
Eddy de Vink 4f9b2c064e feat: DNS-free WAN IP detection + add calibre to records
Use Cloudflare 1.1.1.1/cdn-cgi/trace as primary IP source (fixed IP, no resolver dependency — survives AdGuard/upstream DNS outages). Add calibre to DNS_RECORDS: its A record points at the dynamic WAN IP but was not guarded, so it would go stale on the next WAN change.
2026-09-25 10:44:53 +02:00

281 lines
9.2 KiB
JavaScript
Executable file

#!/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,
matrixApi: process.env.MATRIX_API_ENDPOINT || 'https://whatsed.eddydevink.nl',
matrixTokenFile: process.env.MATRIX_TOKEN_FILE || '/home/aiaiaiaiagent/whatsed-new/secrets/sherpa-token',
matrixRoomFile: process.env.MATRIX_ROOM_FILE || '/home/aiaiaiaiagent/whatsed-new/secrets/eddy-report-room',
dnsRecords: (process.env.DNS_RECORDS || '').split(',').map(s => s.trim()).filter(Boolean),
euroDynDnsUser: process.env.EURODYNDNS_USER,
euroDynDnsPass: process.env.EURODYNDNS_PASS,
euroDynDnsHosts: (process.env.EURODYNDNS_HOSTS || '').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() {
// Primary source is DNS-free: Cloudflare's 1.1.1.1 serves /cdn-cgi/trace over
// HTTPS on a fixed IP, so detection keeps working when the local resolver
// (AdGuard on this host) is down. The echo services below are fallbacks.
const services = [
{
url: 'https://1.1.1.1/cdn-cgi/trace',
parse: t => (t.split('\n').find(l => l.startsWith('ip=')) || '').split('=')[1],
},
{ url: 'https://ifconfig.me', parse: t => t },
{ url: 'https://api.ipify.org', parse: t => t },
{ url: 'https://icanhazip.com', parse: t => t },
];
for (const { url, parse } of services) {
try {
const resp = await fetchWithTimeout(url, {}, 10000);
if (resp.ok) {
const ip = (parse(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 = '';
}
if (!records[name]) records[name] = [];
records[name].push(match[2]);
}
}
return records;
}
async function deleteDnsRecord(name, ip) {
const recordValue = `name=${name}&value=${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 updateEuroDynDns(hostname, ip) {
const url = `https://update.eurodyndns.org/update/?hostname=${encodeURIComponent(hostname)}&myip=${encodeURIComponent(ip)}`;
const auth = Buffer.from(`${CONFIG.euroDynDnsUser}:${CONFIG.euroDynDnsPass}`).toString('base64');
const resp = await fetchWithTimeout(url, {
headers: { Authorization: `Basic ${auth}` },
});
const text = (await resp.text()).trim();
const ok = resp.ok && /^(good|nochg)\b/.test(text);
return { ok, text };
}
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 sendMatrix(message) {
try {
const token = fs.readFileSync(CONFIG.matrixTokenFile, 'utf8').trim();
const room = fs.readFileSync(CONFIG.matrixRoomFile, 'utf8').trim();
if (!token || !room) throw new Error('Matrix token or room is empty');
const txn = `${Date.now()}-${process.pid}`;
const url = `${CONFIG.matrixApi}/_matrix/client/v3/rooms/${encodeURIComponent(room)}/send/m.room.message/${txn}`;
const resp = await fetchWithTimeout(url, {
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ msgtype: 'm.text', body: message }),
}, 10000);
if (!resp.ok) {
logError(`Matrix notification failed: ${resp.status}`);
} else {
log('Matrix notification sent.');
}
} catch (e) {
logError(`Matrix notification 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 oldIps = dnsRecords[name] || dnsRecords[record] || [];
try {
for (const oldIp of oldIps) {
await deleteDnsRecord(name, oldIp);
}
await addDnsRecord(name, currentIp);
results.push(`✅ ${label}`);
log(`Updated ${label}: ${oldIps.join(', ') || '(none)'} -> ${currentIp}`);
} catch (e) {
results.push(`❌ ${label}: ${e.message}`);
logError(`Failed to update ${label}: ${e.message}`);
}
}
// EuroDynDNS (domeinen in de EuroDNS-zone, bv. eddydev.ink)
if (CONFIG.euroDynDnsHosts.length > 0 && !(CONFIG.euroDynDnsUser && CONFIG.euroDynDnsPass)) {
logError('EURODYNDNS_HOSTS is set but EURODYNDNS_USER/PASS are missing. Skipping EuroDynDNS updates.');
results.push(`❌ EuroDynDNS: missing EURODYNDNS_USER/PASS`);
} else {
for (const hostname of CONFIG.euroDynDnsHosts) {
try {
const { ok, text } = await updateEuroDynDns(hostname, currentIp);
if (ok) {
results.push(`✅ ${hostname} (EuroDynDNS)`);
log(`EuroDynDNS updated ${hostname} -> ${currentIp}: ${text}`);
} else {
results.push(`❌ ${hostname} (EuroDynDNS): ${text}`);
logError(`EuroDynDNS failed for ${hostname}: ${text}`);
}
} catch (e) {
results.push(`❌ ${hostname} (EuroDynDNS): ${e.message}`);
logError(`EuroDynDNS error for ${hostname}: ${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);
await sendMatrix(message);
if (failCount === 0) {
writeState(currentIp);
log(`State saved: ${currentIp}`);
} else {
log(`State NOT saved: ${failCount} DNS update(s) failed. Will retry on next run.`);
}
log('Done.');
}
main().catch(e => {
logError(`Fatal: ${e.message}`);
process.exit(1);
});