Ctrl K
ComunidadGuías…

duckdns.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
'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
};