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.
281 lines
9.2 KiB
JavaScript
Executable file
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);
|
|
});
|