#!/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', whatsappToken: process.env.WHATSAPP_TOKEN || '', 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}/chat/send/text`; const phone = `${CONFIG.adminPhone}`.replace(/[^0-9]/g, ''); try { const resp = await fetchWithTimeout(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Token: CONFIG.whatsappToken }, body: JSON.stringify({ Phone: phone, Body: message }), }, 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); });