duckdns.js
Rama main · réplica de lectura del repositorio original.
'use strict';
// ── DuckDNS: keeping one A record honest ─────────────────────────────────────
//
// This machine is reachable from outside under a name, and that name is worth
// exactly as much as the A record behind it. The record is not a setting. It is
// a claim about where the server is right now, and it goes stale the moment the
// ISP hands out a different address — which, on a German consumer line, is
// roughly every night.
//
// The version this replaces sent one HTTP request every five minutes and
// believed whatever came back. That is not enough, for four separate reasons,
// and each one of them showed up as the same symptom: the server was sometimes
// not reachable and nothing anywhere said why.
//
// **A failed attempt used to cost a full interval.** The tick that matters is
// the first one after the line comes back up, and that is precisely the tick
// most likely to fail — the PPPoE re-dial is still in progress, the resolver is
// not answering yet. One `warn` line later the updater went back to sleep for
// another five minutes. So the failure was not random: it was reliably
// triggered by the very event it existed to handle. Now a failure backs off
// from 30 s rather than waiting out the full cadence, and gives up to the
// normal interval only after it has actually been failing for a while.
//
// **Success used to mean "DuckDNS said OK".** It does not. DuckDNS echoing an
// address back proves the request arrived, nothing more. It cannot tell you
// that the record it wrote is the record the world sees, that some other
// updater — a router with the same token, a second copy of this server — has
// not overwritten it since, or that the address it detected is the one your
// port forward actually lives behind. So after an update we ask the
// authoritative nameservers what they are serving, and compare. That check is
// the only thing here that can catch a wrong record, as opposed to an old one.
//
// **Nothing was watching for the events that change an address.** A fixed
// timer is blind to suspend/resume and to the interface coming back with a new
// lease. Worse, Node's timers run on a clock that does not advance while the
// machine is asleep, so on a box that sleeps the tick after waking is *late* —
// exactly when the address is most likely to be new. A cheap 30-second
// heartbeat watches the wall clock and the local interfaces and pulls the
// update forward when either jumps.
//
// **A dead updater looked identical to a healthy one.** The status the UI got
// was the last address DuckDNS ever confirmed, with no notion of how long ago
// that was. Everything below that reports state reports enough to tell "fresh"
// from "frozen": when we last tried, when we last succeeded, what the last
// error was, and what the nameservers are actually serving.
//
// One thing deliberately absent: this never writes an `ip=` parameter. DuckDNS
// detects the source address of the request, and that is the address we want —
// the one traffic from outside will arrive on. A locally-discovered address
// would be the LAN side of the NAT and would point the record at nothing.
const https = require('https');
const dns = require('dns');
const os = require('os');
// Cadence when things are working. DuckDNS serves these records with a short
// TTL, so five minutes is the dominant term in how long a changed address stays
// wrong — not the DNS caching underneath it.
const OK_INTERVAL_MS = 5 * 60 * 1000;
// First retry after a failure, then doubling. 30 s is chosen to be longer than
// a PPPoE re-dial and shorter than anyone's patience.
const RETRY_BASE_MS = 30 * 1000;
// Backoff ceiling. Never slower than the healthy cadence — a failing updater
// that checks in less often than a working one has the incentive backwards.
const RETRY_MAX_MS = OK_INTERVAL_MS;
// Matches the ACME helper. A stalled connection to duckdns.org must not hold
// the scheduler, an admin's "update now" click, or a cert renewal step.
const REQUEST_TIMEOUT_MS = 30 * 1000;
// The heartbeat that notices suspend/resume and interface changes. Cheap: it
// reads the clock and the interface list, and touches the network only when one
// of them has moved.
const HEARTBEAT_MS = 30 * 1000;
// How far the wall clock may run past the heartbeat before we call it a
// suspend. Generous, because a loaded box can genuinely be a few seconds late
// and we do not want an update storm out of ordinary timer jitter.
const CLOCK_JUMP_MS = 90 * 1000;
// How old the last *successful* update may be before the state we report counts
// as stale. Three healthy cycles plus room for a round of backoff.
const STALE_AFTER_MS = 20 * 60 * 1000;
// How far ahead of us a recorded timestamp may sit before we stop believing the
// clock rather than the record.
const CLOCK_SKEW_TOLERANCE_MS = 60 * 1000;
// When the out-of-process fallback (scripts/duckdns-fallback.js) decides this
// server has stopped updating its own record and takes over.
//
// Everything above only runs while the server does. The case it cannot cover is
// the server being down — a failed deploy, a reboot where pm2 never came back —
// because that is precisely when nothing in this file is executing, and the
// record then points at wherever the line used to be until somebody notices.
//
// The number is set against the retry ladder above, not against the banner. A
// server that is merely having a bad few minutes keeps retrying — 30 s, 60 s,
// 120 s, 240 s, then 300 s forever — and is 12.5 minutes past its last success
// by the time that ladder reaches its ceiling. A threshold under that figure
// wakes the fallback *while the server is still trying*, so both processes ask
// duckdns.org to set the same name: exactly the double update the fallback is
// documented not to cause. Fifteen minutes clears the ramp with margin.
//
// It was previously chosen to sit below STALE_AFTER_MS so the banner could
// never go red while the fallback was covering. That was backwards. The banner
// answers "is this server's own updater working", the fallback covering is a
// separate fact, and hiding the first behind the second is what let a
// permanently broken updater look healthy for days. They are now different
// columns and the ordering between them carries no meaning.
const FALLBACK_AFTER_MS = 15 * 60 * 1000;
// Verify against the nameservers every N successful updates — and always right
// after DuckDNS reports it changed something, which is when a wrong write would
// happen. Six ticks is about half an hour of steady state.
const VERIFY_EVERY_TICKS = 6;
// A record that stays wrong after this many corrections is not a race we are
// going to win by trying harder — it is two updaters fighting over one name.
// Say so once and stop hammering; the state we report keeps the evidence.
const MAX_CORRECTIONS = 3;
// c-ares settings for our own lookups. The default resolver has no timeout we
// can set, and a DNS check that can hang is a DNS check that can wedge the
// scheduler.
const DNS_TIMEOUT_MS = 5000;
const DNS_TRIES = 2;
// ── Pure helpers ─────────────────────────────────────────────────────────────
/**
* Is DuckDNS switched on and complete enough to use?
*
* One definition, because there were four: the scheduler's tick, updateNow, the
* fallback's stand-down check and the fallback's `--check` probe each carried
* their own copy. `update_server.sh` gates its install reminder on one of them
* while the timer that runs five minutes later is gated by another, so the day
* they disagreed the deploy log would recommend installing a timer that then
* refused every five minutes.
*/
function isConfigured(cfg) {
return !!(cfg && cfg.enabled && cfg.domain && cfg.token);
}
/**
* DuckDNS' verbose reply, as a decided answer.
*
* The documented shape is four lines: OK or KO, the IPv4 address, the IPv6
* address, and UPDATED or NOCHANGE. Every line is trimmed before it is read.
* The version this replaces compared the raw first line against 'OK', so a
* trailing carriage return — or an HTML error page from the load balancer in
* front of duckdns.org — was indistinguishable from a refused token.
*
* `reason` is truncated because the body on a bad day is a whole error page and
* this string ends up in a log line and in the admin UI.
*/
function parseUpdate(body) {
const lines = String(body == null ? '' : body)
.split('\n')
.map((l) => l.trim());
const ok = lines[0] === 'OK';
return {
ok,
ip: ok && lines[1] ? lines[1] : null,
ipv6: ok && lines[2] ? lines[2] : null,
changed: lines[3] === 'UPDATED',
reason: ok ? null : (lines[0] || 'empty response').slice(0, 80)
};
}
/**
* How long to wait before the next attempt, given how many have failed in a row.
*
* Zero failures is the healthy cadence. After that, 30 s doubling to a ceiling
* of the healthy cadence: 30 s, 60 s, 120 s, 240 s, 300 s, 300 s…
*/
function nextDelay(failures) {
if (!failures || failures <= 0) return OK_INTERVAL_MS;
return Math.min(RETRY_BASE_MS * Math.pow(2, failures - 1), RETRY_MAX_MS);
}
/**
* How old the last successful update is, and whether that counts as stale.
*
* Never having succeeded is stale — an updater that has been enabled since boot
* and has nothing to show for it is exactly the case the old UI painted green.
*
* A timestamp in the *future* is stale too, and that is not pedantry. A board
* with no battery-backed clock comes up hours behind, which makes every
* recorded success look like it has not happened yet; subtracting gives a
* negative age, and anything that reads "younger than the threshold" as healthy
* then reports healthy forever — the banner stays green and the fallback stands
* down, both during exactly the post-reboot window they exist for. An
* unreliable clock is not evidence of freshness.
*
* `thresholdMs` is a parameter because two callers ask this question with
* different deadlines: the banner asks "has this server's updater died"
* (STALE_AFTER_MS) and the fallback asks "should I take over" (FALLBACK_AFTER_MS).
* They were separate implementations, and the copy in the fallback was the one
* missing the clamp.
*/
function staleness(lastIpUpdate, now, thresholdMs) {
const limit = thresholdMs == null ? STALE_AFTER_MS : thresholdMs;
const at = lastIpUpdate ? Date.parse(lastIpUpdate) : NaN;
if (!Number.isFinite(at)) return { ageMs: null, stale: true, future: false };
// A little slack, because a timestamp written moments ago on a machine whose
// clock is being disciplined can legitimately land a second ahead.
const future = at - now > CLOCK_SKEW_TOLERANCE_MS;
const ageMs = Math.max(0, now - at);
return { ageMs, stale: future || ageMs > limit, future };
}
/**
* The non-loopback IPv4 addresses this machine currently holds, as one
* comparable string.
*
* Not what we send — DuckDNS detects the public address itself, and this is the
* LAN side. It is a *change signal*: when the interface list moves, the lease
* was renewed or the link came back, and the public address has very likely
* moved with it.
*/
function localIpv4Signature(interfaces) {
const list = interfaces || os.networkInterfaces() || {};
const out = [];
for (const ifaces of Object.values(list)) {
for (const i of ifaces || []) {
if (i && i.family === 'IPv4' && !i.internal) out.push(i.address);
}
}
return out.sort().join(',');
}
/**
* Did the wall clock run further than the timer that was supposed to bound it?
*
* That gap is a suspend. Node schedules timers on a clock that stops while the
* machine is asleep, so the wall clock is the only one of the two that saw the
* missing hours.
*/
function clockJumped(expectedMs, actualMs) {
return actualMs - expectedMs > CLOCK_JUMP_MS;
}
function updateUrl(domain, token, extra) {
return (
'https://www.duckdns.org/update?domains=' +
encodeURIComponent(domain) +
'&token=' +
encodeURIComponent(token) +
(extra || '') +
'&verbose=true'
);
}
// ── Network ──────────────────────────────────────────────────────────────────
/**
* One GET, with a timeout that survives a socket that never says anything.
*
* `deps.httpGet` is the seam the tests use; nothing here reaches the network
* when one is supplied.
*/
function httpGet(url, deps) {
if (deps && deps.httpGet) return deps.httpGet(url);
return new Promise((resolve, reject) => {
const req = https
.get(url, (resp) => {
let data = '';
resp.setEncoding('utf8');
resp.on('data', (c) => {
// A body this size is not a DuckDNS reply, it is an error page. Read
// enough to report it and stop.
if (data.length < 4096) data += c;
});
resp.on('end', () => resolve({ status: resp.statusCode, body: data }));
resp.on('error', reject);
})
.on('error', reject);
req.setTimeout(REQUEST_TIMEOUT_MS, () => req.destroy(new Error('DuckDNS request timed out')));
});
}
/**
* What the authoritative nameservers are serving for this name, right now.
*
* Deliberately not the system resolver: the answer we want is the one that has
* not been through anybody's cache, because a cached answer cannot distinguish
* "the record is wrong" from "the record was wrong five minutes ago". So we ask
* the zone's own nameservers, and only fall back to a plain lookup when that
* chain does not come together.
*
* An empty array is a real answer and not an error — it means the name resolves
* to nothing, which is the single worst state this record can be in and the one
* we most need to be able to see.
*/
function zoneOf(fullDomain) {
const labels = String(fullDomain || '').split('.');
return labels.length > 2 ? labels.slice(1).join('.') : labels.join('.');
}
function resolveAuthoritative(fullDomain, deps) {
if (deps && deps.resolveA) return deps.resolveA(fullDomain);
const missing = (e) => e && (e.code === 'ENOTFOUND' || e.code === 'ENODATA');
const opts = { timeout: DNS_TIMEOUT_MS, tries: DNS_TRIES };
const recursive = () =>
new Promise((resolve, reject) => {
new dns.Resolver(opts).resolve4(fullDomain, (e, addrs) => {
if (e) return missing(e) ? resolve([]) : reject(e);
resolve(addrs || []);
});
});
const nameserver = () =>
new Promise((resolve) => {
new dns.Resolver(opts).resolveNs(zoneOf(fullDomain), (nsErr, servers) => {
if (nsErr || !servers || !servers.length) return resolve(null);
new dns.Resolver(opts).resolve4(servers[0], (aErr, addrs) => {
resolve(aErr || !addrs || !addrs.length ? null : addrs[0]);
});
});
});
return nameserver().then((server) => {
if (!server) return recursive();
return new Promise((resolve, reject) => {
const r = new dns.Resolver(opts);
try {
r.setServers([server]);
} catch {
return recursive().then(resolve, reject);
}
r.resolve4(fullDomain, (e, addrs) => {
// A server answering "no such name" is not the same as the name having
// none. It also answers that way when it is simply not the right server
// to ask — and a false "missing" is the expensive mistake here: it sends
// a correction, then another, and ends up reporting a conflict that does
// not exist. So a missing answer has to be confirmed by an ordinary
// recursive lookup, which reaches whichever server really is
// authoritative. Addresses, by contrast, are believed immediately: they
// came from the source and have not sat in anybody's cache.
if (e || !addrs || !addrs.length) return recursive().then(resolve, reject);
resolve(addrs);
});
});
});
}
/**
* Send the address update. Resolves to the parsed reply; rejects only on a
* transport failure.
*
* A non-2xx status is reported as its own reason rather than being parsed,
* because the body in that case belongs to whatever answered instead of
* DuckDNS.
*/
async function updateIp({ domain, token }, deps) {
const { status, body } = await httpGet(updateUrl(domain, token), deps);
if (status && (status < 200 || status >= 300)) {
return { ok: false, ip: null, ipv6: null, changed: false, reason: 'HTTP ' + status };
}
return parseUpdate(body);
}
/**
* Write the ACME challenge value into the name's TXT record.
*/
async function setTxt({ domain, token, value }, deps) {
const { body } = await httpGet(updateUrl(domain, token, '&txt=' + encodeURIComponent(value)), deps);
const r = parseUpdate(body);
if (!r.ok) throw new Error('DuckDNS TXT update failed: ' + r.reason);
return r;
}
/**
* Empty the TXT record after a certificate run.
*
* ⚠️ **No `clear=true` here, and that is the whole point of this function.**
* DuckDNS documents that parameter twice: on the address update it "clears both
* your records", and on a TXT update it clears the TXT value. It is the same
* endpoint, and what the combination `txt=` *and* `clear=true` does is not
* documented anywhere. The old code sent exactly that combination, from the
* final callback of the certificate routine — which runs on failure as well as
* on success, and which retries every twelve hours for as long as a renewal
* keeps failing. If that call was clearing the address records, the name went
* to nothing until the next update tick, and duckdns.org's zone caches a
* negative answer for ten minutes on top of that.
*
* Sending an empty `txt` empties the TXT value without ever naming the
* parameter that can take the address with it. The caller re-asserts the
* address immediately afterwards regardless, so a stale record cannot outlive
* a cert run under any reading of the API.
*
* Never rejects. This is cleanup on a path that has already decided its own
* outcome, and a failure to tidy up must not turn a successful renewal into a
* failed one — a leftover challenge value is harmless, since the next run
* overwrites it and nothing else reads it.
*/
async function clearTxt({ domain, token }, deps) {
try {
await httpGet(updateUrl(domain, token, '&txt='), deps);
return true;
} catch {
return false;
}
}
// ── Scheduler ────────────────────────────────────────────────────────────────
let timer = null;
let heartbeat = null;
let inFlight = false;
// Everything the status endpoint reports beyond what the database already
// holds. In memory on purpose: it describes this process's run, and a restart
// both clears it and performs an immediate update, so there is no window where
// a stale value here could be mistaken for a current one.
let state = null;
function freshState() {
return {
running: false,
lastAttempt: null,
lastSuccess: null,
lastError: null,
failures: 0,
ticks: 0,
observedIp: null,
lastVerify: null,
verifyOk: null,
corrections: 0,
conflict: false
};
}
/**
* The state the UI needs to tell a working updater from a frozen one.
*
* `stale` is computed against the database's own last-successful timestamp
* rather than this process's, so an updater that has been failing since before
* the last restart still reads as stale.
*/
function status(lastIpUpdate, now) {
const s = state || freshState();
const age = staleness(lastIpUpdate, typeof now === 'number' ? now : Date.now());
return {
running: !!timer && s.running,
lastAttempt: s.lastAttempt,
lastError: s.lastError,
failures: s.failures,
observedIp: s.observedIp,
lastVerify: s.lastVerify,
verifyOk: s.verifyOk,
conflict: s.conflict,
ageMs: age.ageMs,
stale: age.stale
};
}
function stop() {
if (timer) clearTimeout(timer);
if (heartbeat) clearInterval(heartbeat);
timer = null;
heartbeat = null;
if (state) state.running = false;
}
/**
* Start the loop, and keep it started.
*
* Armed whenever this is not a worktree — *including* when DuckDNS is currently
* switched off. The old version decided once, at boot, and never looked again,
* so a configuration that became valid afterwards (a restored backup, a row
* edited directly) stayed dormant until somebody restarted the server. A tick
* with nothing to do costs a database read.
*/
function start({ database, dbApi, log, skip, deps }) {
stop();
state = freshState();
if (skip) {
log('info', 'DuckDNS updater skipped', { reason: 'worktree mode' });
return false;
}
state.running = true;
const now = () => (deps && deps.now ? deps.now() : Date.now());
let lastBeat = now();
let lastSignature = localIpv4Signature(deps && deps.interfaces && deps.interfaces());
const schedule = (ms) => {
if (timer) clearTimeout(timer);
timer = setTimeout(run, ms);
if (timer.unref) timer.unref();
};
async function run(forced) {
// Returning bare here would end the loop: this timer has already fired, and
// nothing else is holding a reference to the next one. Come back shortly
// instead — whatever is in flight is either the admin's button or a
// correction, and both finish in seconds.
if (inFlight) {
schedule(RETRY_BASE_MS);
return;
}
let cfg;
try {
cfg = dbApi.getDuckdnsCfg(database);
} catch (e) {
// Mid-migration, or the file is momentarily locked. Keep the loop.
log('warn', 'DuckDNS config unreadable', { error: e.message });
schedule(OK_INTERVAL_MS);
return;
}
if (!isConfigured(cfg)) {
schedule(OK_INTERVAL_MS);
return;
}
inFlight = true;
state.lastAttempt = new Date(now()).toISOString();
try {
const r = await updateIp(cfg, deps);
if (!r.ok) throw new Error('DuckDNS returned: ' + r.reason);
state.failures = 0;
state.lastError = null;
state.lastSuccess = state.lastAttempt;
state.ticks++;
try {
dbApi.updateDuckdnsStatus(database, {
lastIpUpdate: state.lastSuccess,
lastIp: r.ip || cfg.lastIp
});
} catch (e) {
// The address is updated where it counts. Failing to write our own note
// about it is not a reason to treat the update as failed.
log('warn', 'DuckDNS status write failed', { error: e.message });
}
if (r.changed || forced) log('info', 'DuckDNS IP updated', { ip: r.ip, forced: !!forced });
// Verify after a change, on the slow rota otherwise — and always while a
// correction is outstanding. Without that last clause a correction is
// sent and never checked: DuckDNS answers NOCHANGE because *its* copy of
// the record was right all along, the rota is 30 minutes away, and the
// case this is here to catch — somebody else writing to the same name —
// never gets counted and never gets reported.
let correcting = false;
if (r.changed || state.corrections > 0 || state.ticks % VERIFY_EVERY_TICKS === 1) {
correcting = await verify(cfg, r.ip);
}
// A correction is a request for the *next* tick to happen now. It has to
// be the last word on the timer, or the healthy cadence below silently
// cancels it and the wrong record stands for another five minutes.
schedule(correcting ? 0 : nextDelay(0));
} catch (e) {
state.failures++;
state.lastError = e.message;
const wait = nextDelay(state.failures);
log('warn', 'DuckDNS update failed', { error: e.message, attempt: state.failures, retryInSec: wait / 1000 });
schedule(wait);
} finally {
inFlight = false;
}
}
/**
* Ask the nameservers what they are serving and correct them if it is wrong.
*
* A mismatch buys exactly one immediate re-update. If the record is still
* wrong after a few of those, something else is writing to this name and
* another round of updates will not settle it — so we stop correcting, mark
* the conflict, and let the status endpoint carry the evidence.
*
* Returns whether the caller should pull the next tick forward. It does not
* touch the timer itself: the caller schedules last, and two writers on one
* timer is how the correction got lost the first time.
*/
async function verify(cfg, reportedIp) {
const fullDomain = cfg.domain + '.duckdns.org';
let served;
try {
served = await resolveAuthoritative(fullDomain, deps);
} catch (e) {
// Our own resolver failing says nothing about the record. Not a failure
// of the update, so it must not feed the backoff.
state.verifyOk = null;
log('warn', 'DuckDNS verification lookup failed', { error: e.message });
return false;
}
state.lastVerify = new Date(now()).toISOString();
state.observedIp = served.length ? served.join(',') : null;
const matches = reportedIp ? served.includes(reportedIp) : served.length > 0;
state.verifyOk = matches;
if (matches) {
state.corrections = 0;
state.conflict = false;
return false;
}
if (state.corrections >= MAX_CORRECTIONS) {
if (!state.conflict) {
state.conflict = true;
log('error', 'DuckDNS record keeps disagreeing after corrections — another updater may share this token', {
expected: reportedIp,
served: state.observedIp,
corrections: state.corrections
});
}
return false;
}
state.corrections++;
log('warn', served.length ? 'DuckDNS record does not match — correcting' : 'DuckDNS record missing — restoring', {
expected: reportedIp,
served: state.observedIp
});
return true;
}
// The heartbeat exists for the two things a fixed timer cannot see: the
// machine having been asleep, and the interface having come back with a
// different lease. Both mean the public address is probably new, and both
// would otherwise wait out the remainder of a five-minute tick that is
// already running late.
heartbeat = setInterval(() => {
const t = now();
const elapsed = t - lastBeat;
lastBeat = t;
const signature = localIpv4Signature(deps && deps.interfaces && deps.interfaces());
const moved = signature !== lastSignature;
const woke = clockJumped(HEARTBEAT_MS, elapsed);
if (!moved && !woke) return;
lastSignature = signature;
log('info', 'DuckDNS update pulled forward', { reason: woke ? 'clock jump' : 'local address changed' });
schedule(0);
}, HEARTBEAT_MS);
if (heartbeat.unref) heartbeat.unref();
log('info', 'DuckDNS updater started');
schedule(0);
return true;
}
/**
* Update now, for the admin's button and for the moment a cert run finishes.
*
* Bypasses the scheduler's backoff on purpose — somebody asked — but respects
* the in-flight guard so a held button cannot stack requests.
*/
async function updateNow({ database, dbApi, log, skip, deps, statusFields }) {
// Which column records this run. The server stamps `lastIpUpdate`, which is
// what the admin banner reads to decide the in-process updater is alive; the
// fallback stamps `fallbackLast` instead, so covering for a broken server
// cannot also silence the alarm about it. Defaults to the server's column.
statusFields = statusFields || ((at) => ({ lastIpUpdate: at }));
if (skip) return { ok: false, reason: 'worktree mode' };
const cfg = dbApi.getDuckdnsCfg(database);
if (!isConfigured(cfg)) return { ok: false, reason: 'not configured' };
if (inFlight) return { ok: false, reason: 'busy' };
inFlight = true;
const s = state || (state = freshState());
s.lastAttempt = new Date(Date.now()).toISOString();
try {
const r = await updateIp(cfg, deps);
if (!r.ok) {
s.failures++;
s.lastError = r.reason;
return { ok: false, reason: r.reason };
}
s.failures = 0;
s.lastError = null;
s.lastSuccess = s.lastAttempt;
// The address is updated where it counts, so this stays a success — but the
// caller is told. For the server that distinction is cosmetic; it retries in
// five minutes either way. For the out-of-process fallback it is not: the
// write is the only record that it ran, and a silent failure there used to
// read as a clean exit while nothing had been persisted at all.
let wrote = true;
let writeError = null;
try {
dbApi.updateDuckdnsStatus(database, Object.assign({ lastIp: r.ip || cfg.lastIp }, statusFields(s.lastSuccess)));
} catch (e) {
wrote = false;
writeError = e.message;
log('warn', 'DuckDNS status write failed', { error: e.message });
}
return { ok: true, ip: r.ip, changed: r.changed, wrote, writeError };
} catch (e) {
s.failures++;
s.lastError = e.message;
return { ok: false, reason: e.message };
} finally {
inFlight = false;
}
}
module.exports = {
parseUpdate,
nextDelay,
staleness,
isConfigured,
localIpv4Signature,
clockJumped,
updateUrl,
zoneOf,
resolveAuthoritative,
updateIp,
setTxt,
clearTxt,
start,
stop,
status,
updateNow,
OK_INTERVAL_MS,
RETRY_BASE_MS,
RETRY_MAX_MS,
STALE_AFTER_MS,
FALLBACK_AFTER_MS,
HEARTBEAT_MS,
CLOCK_JUMP_MS,
VERIFY_EVERY_TICKS,
MAX_CORRECTIONS
};