<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Pro Gineous]]></title><description><![CDATA[Pro Gineous]]></description><link>https://progineous.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Thu, 22 Oct 2026 04:41:23 GMT</lastBuildDate><atom:link href="https://progineous.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Using RDAP to Check a Domain's Expiry Date and Lock Status]]></title><description><![CDATA[WHOIS is no longer required for most domain endings. Here's how to query RDAP with curl and jq, read the status values that matter, and run a small cron…]]></description><link>https://progineous.hashnode.dev/using-rdap-to-check-a-domain-s-expiry-date-and-lock-status</link><guid isPermaLink="true">https://progineous.hashnode.dev/using-rdap-to-check-a-domain-s-expiry-date-and-lock-status</guid><category><![CDATA[#domains]]></category><category><![CDATA[dns]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Bash]]></category><category><![CDATA[Linux]]></category><dc:creator><![CDATA[Pro Gineous]]></dc:creator><pubDate>Wed, 21 Oct 2026 08:16:11 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad04d5123635e400ceca45a/1bbf4eed-5cba-4a20-8dcc-72b83bbc7c52.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Every domain has a record at its registry that says when it expires, which locks are on it, which registrar controls it and which nameservers it hands visitors to. For years you read that record with <code>whois</code>, and every registry formatted the answer a little differently. That job now belongs to RDAP, the Registration Data Access Protocol. Since January 28, 2025, ICANN treats RDAP as the definitive source of registration data for generic TLDs, and most gTLD registries and registrars no longer have to run WHOIS at all (.com, .name and .post are the exceptions for now).</p>
<p>The nice part, if you look after more than a couple of domains: RDAP is plain HTTPS that returns JSON. You can query it with <code>curl</code>, filter it with <code>jq</code>, and turn it into an expiry check that runs from cron. Below is how to run an RDAP lookup from the terminal, read what comes back, make sense of the status values, and set up a small script that warns you before a renewal slips.</p>
<h2>One RDAP lookup, nothing new to install</h2>
<p>An RDAP server answers at a predictable path: its base URL, then <code>domain/</code>, then the name. For .com that server belongs to Verisign:</p>
<pre><code class="language-bash">curl -s https://rdap.verisign.com/com/v1/domain/example.com | jq .
</code></pre>
<p>If you don't know which server handles a TLD, the public redirector at rdap.org looks it up and sends you there with a 302, so <code>curl -sL https://rdap.org/domain/example.com</code> works for any TLD it knows. ICANN offers the same lookup in a browser at lookup.icann.org.</p>
<p>The full reply is long and mostly notices and links. The parts you care about fit in one <code>jq</code> filter:</p>
<pre><code class="language-bash">curl -s https://rdap.verisign.com/com/v1/domain/example.com | jq '{
  name: .ldhName,
  status,
  events: ([.events[] | {(.eventAction): .eventDate}] | add),
  nameservers: [.nameservers[].ldhName],
  dnssec: .secureDNS.delegationSigned
}'
</code></pre>
<p><img src="https://blog.progineous.com/wp-content/uploads/2026/10/rdap-domain-lookup-summary.png" alt="Terminal screenshot of an RDAP lookup: the IANA bootstrap file returning Verisign's RDAP base URL for .com, then a curl and jq query for example.com showing client delete, transfer and update prohibited statuses, a 1995 registration date, a 2027-08-13 expiration date, two Cloudflare nameservers and DNSSEC enabled, followed by example.org returning the three server prohibited statuses" /></p>
<h2>Where the server address comes from</h2>
<p>Hard-coding Verisign works for .com and .net. The proper way to find a server is the IANA bootstrap file at <code>https://data.iana.org/rdap/dns.json</code>, which maps each TLD to its RDAP base URL. It's the same file rdap.org and ICANN's lookup read:</p>
<pre><code class="language-bash">curl -s https://data.iana.org/rdap/dns.json \
  | jq -r '.services[] | select(.[0] | index("com")) | .[1][0]'
</code></pre>
<p>That prints <code>https://rdap.verisign.com/com/v1/</code>. Every gTLD is in the file, plus a growing number of country-code TLDs, but not all of them. When we checked this week, .uk was listed while .de, .io and .co were not. Country-code registries aren't bound by ICANN's contracts, so for those you still use the registry's own WHOIS or web lookup.</p>
<h2>Reading the reply</h2>
<p>Take the fields in the order you'd check them when something has gone wrong.</p>
<p><strong>events</strong> is a list of dated actions. <code>registration</code> is when the name was first created, <code>expiration</code> is the date the registry has on file, and <code>last changed</code> is the most recent update to the domain itself, such as new nameservers, a status change or a renewal. Every date is in UTC. <code>last update of RDAP database</code> only tells you how fresh the answer is.</p>
<p><strong>status</strong> uses the same values registrars and registries exchange over EPP, written out in plain words. <code>client transfer prohibited</code> is the transfer lock your registrar sets, the one you toggle in its panel. The <code>server ...</code> versions come from the registry, and only the registry can lift them.</p>
<p><strong>nameservers</strong> is the delegation, the servers the TLD sends resolvers to. If this list doesn't match your DNS provider, nothing you edit in that provider's zone will ever be seen. It's the same mismatch that <a href="https://blog.progineous.com/read-dig-output-dns/">dig +trace exposes</a>, viewed from the registry's side.</p>
<p><strong>secureDNS</strong> says whether a DS record is published. <code>delegationSigned: true</code> means resolvers will validate DNSSEC for the zone. If you're moving DNS to a new provider and this says true, sort out the DS record before you switch nameservers, or the domain fails validation the moment the change lands.</p>
<p><strong>entities</strong> holds the registrar's name and IANA ID, plus an abuse contact. Registrant details are usually missing or marked as redacted. That's privacy policy at work, not a broken lookup.</p>
<h2>Registry answer or registrar answer</h2>
<p>For .com and .net the registry holds only part of the record. The rest sits with the registrar, and the registry reply points there with a link of type <code>related</code>:</p>
<pre><code class="language-bash">curl -s https://rdap.verisign.com/com/v1/domain/example.com \
  | jq -r '.links[] | select(.rel == "related") | .href'
</code></pre>
<p>example.com is a reserved name held by IANA, so it has no such link, but a normally registered .com returns a URL on its registrar's own RDAP server. Query that one as well when you want the registrar's view.</p>
<p>It matters most around expiry. When a .com reaches its expiration date, the registry renews it automatically for a year, and the registrar then has a grace window of up to 45 days to keep it or delete it. So the registry's date can jump a year ahead even though nobody has paid. If the status list includes <code>auto renew period</code>, don't trust that date. Log in to your registrar account and check that the renewal really went through.</p>
<h2>Status values worth a second look</h2>
<p><img src="https://blog.progineous.com/wp-content/uploads/2026/10/rdap-domain-lookup-status-codes.png" alt="Flat diagram grouping RDAP domain status values into three columns: the usual registrar lock (client transfer, update and delete prohibited, plus active), the registry lock that only the registry can lift (server transfer, update and delete prohibited), and statuses that need action now (client or server hold, pending transfer, auto renew period, redemption period and pending delete)" /></p>
<p>Most domains show some mix of the three <code>client ... prohibited</code> values. That's the normal registrar lock, and it's what you want on a name you aren't moving. These are the ones that mean something is happening:</p>
<ul>
<li><strong><code>client hold</code> or <code>server hold</code></strong>: the domain is pulled from the zone and doesn't resolve at all. Registrars set it for things like an unpaid renewal or a registrant email that was never verified.</li>
<li><strong><code>pending transfer</code></strong>: a move to another registrar is in progress. If nobody on your side asked for one, call your registrar today.</li>
<li><strong><code>redemption period</code></strong>: the domain was deleted and can still be restored, usually for a much higher fee than a renewal.</li>
<li><strong><code>pending delete</code></strong>: redemption is over. The name gets released within days and can't be renewed.</li>
<li><strong><code>server ... prohibited</code></strong> on its own: a registry-level lock. It's either a paid registry lock or a name the registry is protecting, which is why example.org carries all three.</li>
<li><strong><code>active</code></strong>: no statuses at all. Fine for a moment during a transfer, but on a name you're keeping it means only the auth code stands between the domain and a transfer request.</li>
</ul>
<p>If you're about to move a domain, <a href="https://blog.progineous.com/how-to-unlock-a-domain/">unlocking a domain before a transfer</a> covers which of these you can clear yourself.</p>
<h2>A small expiry check for cron</h2>
<p>Registrars send renewal reminders, but they go to whatever email was on the account when it was opened. A script that reads the registry directly doesn't care about a dead mailbox. This one takes a file with one domain per line, finds each TLD's server in the bootstrap file, and prints the days left. It exits with 1 when anything needs a look, so it slots into cron or a monitoring check.</p>
<pre><code class="language-bash">#!/usr/bin/env bash
# Usage: domain-expiry.sh domains.txt [warn_days]
# Needs curl, jq and GNU date.
list=$1; warn=${2:-30}; rc=0
boot=$(curl -fsS https://data.iana.org/rdap/dns.json) || exit 2

while read -r d; do
  [ -z "$d" ] &amp;&amp; continue
  tld=${d##*.}
  base=$(jq -r --arg t "$tld" \
    'first(.services[] | select(.[0] | index($t)) | .[1][0]) // empty' &lt;&lt;&lt;"$boot")
  if [ -z "$base" ]; then
    printf '%-22s no RDAP server for .%s, check by hand\n' "$d" "$tld"; continue
  fi

  json=$(curl -s -w '\n%{http_code}' "${base%/}/domain/$d")
  code=${json##*$'\n'}; json=${json%$'\n'*}
  if [ "$code" != 200 ]; then
    printf '%-22s lookup failed: HTTP %s\n' "$d" "$code"; rc=1; continue
  fi

  exp=$(jq -r 'first(.events[] | select(.eventAction=="expiration") | .eventDate) // empty' &lt;&lt;&lt;"$json")
  flags=$(jq -r '[.status[] | select(test("hold|redemption|pending delete|auto renew period"))] | join(", ")' &lt;&lt;&lt;"$json")
  if [ -z "$exp" ]; then
    printf '%-22s no expiration date in the RDAP reply\n' "$d"; rc=1; continue
  fi
  days=$(( ($(date -d "$exp" +%s) - $(date +%s)) / 86400 ))

  note=ok
  [ "$days" -lt "$warn" ] &amp;&amp; { note="RENEW SOON"; rc=1; }
  [ -n "$flags" ] &amp;&amp; { note="CHECK: $flags"; rc=1; }
  printf '%-22s expires %s  %4d days  %s\n' "$d" "${exp%%T*}" "$days" "$note"
  sleep 1
done &lt; "$list"
exit $rc
</code></pre>
<p><img src="https://blog.progineous.com/wp-content/uploads/2026/10/rdap-domain-lookup-expiry-script.png" alt="Terminal screenshot of the domain-expiry.sh script checking five domains: example.com, example.net and example.org show their expiry dates and days left as ok, example.de has no RDAP server in the IANA bootstrap file, a misspelled domain returns HTTP 404, the script exits with status 1, and a second run with a 300-day threshold flags example.com as RENEW SOON" /></p>
<p>A few notes on what it does and why:</p>
<ul>
<li><strong>It sleeps a second between lookups.</strong> Registries rate-limit RDAP, and a burst of queries can earn you HTTP 429 replies instead of data.</li>
<li><strong>A 404 means the registry has no such name.</strong> In a list of domains you own, that's either a typo or a domain that's already gone. If it's gone, run a <a href="https://progineous.com/domain-search.php?utm_source=hashnode">domain search</a> right away to see whether you can register it again.</li>
<li><strong>TLDs missing from the bootstrap file get reported</strong>, not skipped silently, so a .de name doesn't fall off your radar.</li>
<li><strong>It uses GNU <code>date -d</code>.</strong> On macOS, install coreutils and swap in <code>gdate</code>.</li>
</ul>
<p>To run it every Monday and only hear about problems, wrap it in <code>chronic</code> from the moreutils package, which stays quiet unless the command exits with an error:</p>
<pre><code class="language-text">MAILTO=ops@example.com
0 8 * * 1  chronic /usr/local/bin/domain-expiry.sh /etc/domains.txt 45
</code></pre>
<p>A 45-day threshold leaves room to fix a declined card or a dead login before anything breaks. Want to see the warning fire once? Pass a big number, such as 300, like the second run in the screenshot.</p>
<h2>What RDAP can't tell you</h2>
<p>RDAP shows what the registry knows. It can't tell you whether the card on file still works, whether auto-renew is switched on, or who at your company holds the registrar login. Those live in the registrar account, and they're covered in <a href="https://blog.progineous.com/how-to-renew-a-domain-name/">renewing a domain name before it expires</a>. Keep the script running next to that checklist, not instead of it.</p>
<p>If the audit turns up names scattered across old registrar accounts, you can bring them under one login with a <a href="https://progineous.com/transfer-domain.php?utm_source=hashnode">domain transfer to Pro Gineous</a>, and every lookup above works the same afterwards.</p>
<h3>Related reading</h3>
<ul>
<li><a href="https://blog.progineous.com/how-to-unlock-a-domain/">Unlocking a Domain Before a Transfer</a></li>
<li><a href="https://blog.progineous.com/how-to-renew-a-domain-name/">Renewing a Domain Name Before It Expires</a></li>
<li><a href="https://blog.progineous.com/what-to-check-after-a-transfer/">What to Check After a Domain Transfer</a></li>
<li><a href="https://blog.progineous.com/read-dig-output-dns/">How to Read dig Output When DNS Looks Wrong</a></li>
</ul>
<p><em>Originally published on the <a href="https://blog.progineous.com/rdap-domain-lookup/">Pro Gineous blog</a>.</em></p>
]]></content:encoded></item><item><title><![CDATA[How to Read DMARC Reports Before You Move to p=reject]]></title><description><![CDATA[Most domains that have DMARC at all are sitting on p=none. Someone added the record while setting up email, pointed rua= at a mailbox, and the reports have…]]></description><link>https://progineous.hashnode.dev/read-dmarc-reports</link><guid isPermaLink="true">https://progineous.hashnode.dev/read-dmarc-reports</guid><category><![CDATA[email]]></category><category><![CDATA[dns]]></category><category><![CDATA[Security]]></category><category><![CDATA[Devops]]></category><category><![CDATA[sysadmin]]></category><dc:creator><![CDATA[Pro Gineous]]></dc:creator><pubDate>Tue, 20 Oct 2026 08:14:17 GMT</pubDate><content:encoded><![CDATA[<p>Most domains that have DMARC at all are sitting on <code>p=none</code>. Someone added the record while setting up email, pointed <code>rua=</code> at a mailbox, and the reports have been arriving every day since: small zip and gzip attachments from Google, Outlook and other receivers, usually unopened.</p>
<p>Those files answer the one question you need settled before tightening the policy: who is sending mail with your domain in the From line, and would that mail survive <code>p=quarantine</code> or <code>p=reject</code>? Below is how to read them with nothing more than <code>dig</code>, <code>unzip</code> and a small XML tool, and how to move up the policy ladder without losing invoices or password resets on the way. If SPF and DKIM aren't set up yet, start with <a href="https://blog.progineous.com/how-to-set-up-business-email/">how to set up business email on your own domain</a> and come back once those records exist.</p>
<h2>First, confirm what you've published</h2>
<p>Reports describe how receivers judged your mail against your DNS records, so read those records the way a receiver would. Three lookups cover it:</p>
<pre><code class="language-bash">dig +short TXT _dmarc.example.com
dig +short TXT example.com
dig +short TXT s1._domainkey.example.com
</code></pre>
<p><img src="https://blog.progineous.com/wp-content/uploads/2026/10/read-dmarc-reports-dns-records.png" alt="Terminal screenshot of three dig lookups: the DMARC record at _dmarc.example.com showing v=DMARC1 with p=none and a rua reporting address, the SPF record for example.com ending in -all next to a site verification TXT record, and a 2048-bit DKIM public key for selector s1 split into two quoted strings" /></p>
<p>The DMARC record lives at the <code>_dmarc</code> label, not on the domain itself. <code>p=none</code> means "report, but don't act", and <code>rua=</code> is where aggregate reports go. If that address is on a different domain, such as a reporting service, the other domain has to publish a small authorization record (<code>example.com._report._dmarc.reports.example.net</code> with the value <code>v=DMARC1</code>). Without it, receivers quietly skip sending.</p>
<p>On the domain itself, look for exactly one TXT record starting with <code>v=spf1</code>. Two of them make SPF return a permanent error, which counts as a fail for DMARC. Other TXT records, like site verification strings, can sit alongside it.</p>
<p>DKIM is the awkward one, because there's no way to list a domain's keys. You need the selector, which is the <code>s=</code> tag in the <code>DKIM-Signature</code> header of any message you've sent (use "show original" or "view source" in your mail client). Your mailbox provider uses one selector, and every other service that signs as your domain uses its own. A 2048-bit key is longer than the 255-character limit for a single TXT string, so <code>dig</code> prints it as two quoted chunks. That's normal. Receivers join them back together.</p>
<h2>Open one report</h2>
<p>Reports arrive as attachments named after the receiver, your domain and the time window they cover:</p>
<pre><code class="language-text">google.com!example.com!1792281600!1792367999.zip
enterprise.protection.outlook.com!example.com!1792281600!1792368000.xml.gz
</code></pre>
<p>The two long numbers are the start and end of the window in Unix time. <code>date -u -d @1792281600</code> turns the first one into midnight UTC on October 18. Some receivers zip the file and others gzip it, so <code>unzip</code> or <code>gunzip</code> gets you to the XML. After a short header naming the reporter and repeating the policy it found, there's one <code>&lt;record&gt;</code> per combination of sending IP and result. This one deserves a close look:</p>
<pre><code class="language-xml">&lt;record&gt;
  &lt;row&gt;
    &lt;source_ip&gt;198.51.100.24&lt;/source_ip&gt;
    &lt;count&gt;212&lt;/count&gt;
    &lt;policy_evaluated&gt;
      &lt;disposition&gt;none&lt;/disposition&gt;
      &lt;dkim&gt;fail&lt;/dkim&gt;
      &lt;spf&gt;fail&lt;/spf&gt;
    &lt;/policy_evaluated&gt;
  &lt;/row&gt;
  &lt;identifiers&gt;
    &lt;header_from&gt;example.com&lt;/header_from&gt;
  &lt;/identifiers&gt;
  &lt;auth_results&gt;
    &lt;dkim&gt;
      &lt;domain&gt;mailer.example.net&lt;/domain&gt;
      &lt;result&gt;pass&lt;/result&gt;
      &lt;selector&gt;m1&lt;/selector&gt;
    &lt;/dkim&gt;
    &lt;spf&gt;
      &lt;domain&gt;bounces.mailer.example.net&lt;/domain&gt;
      &lt;result&gt;pass&lt;/result&gt;
    &lt;/spf&gt;
  &lt;/auth_results&gt;
&lt;/record&gt;
</code></pre>
<p>Read it in this order:</p>
<ul>
<li><strong><code>source_ip</code> and <code>count</code>:</strong> 212 messages from one IP, all showing <code>example.com</code> in the From header (<code>header_from</code>).</li>
<li><strong><code>auth_results</code>:</strong> the raw checks. DKIM passed and so did SPF. On their own, they look fine.</li>
<li><strong><code>policy_evaluated</code>:</strong> DMARC's verdict, and both say <code>fail</code>. DMARC only counts a pass when the domain that passed matches the From domain. DKIM was signed by <code>mailer.example.net</code> and SPF checked <code>bounces.mailer.example.net</code>. Neither is <code>example.com</code>, so neither is aligned.</li>
<li><strong><code>disposition</code>:</strong> what the receiver actually did. <code>none</code>, because that's what the policy asked for. Under <code>p=reject</code>, those 212 messages would have bounced.</li>
</ul>
<p>Passing in <code>auth_results</code> but failing in <code>policy_evaluated</code> is the pattern you'll see most, and it's nearly always a legitimate service you forgot about, not an attacker. Reports that follow the updated aggregate reporting spec (RFC 9990) may add an <code>xmlns</code> attribute and a few extra fields, but the record layout is the same.</p>
<h2>Turn a folder of reports into a sender list</h2>
<p>One report is readable by eye. A month of them from a dozen receivers isn't, so pull out the useful columns with <code>xmlstarlet</code> (<code>apt install xmlstarlet</code> on Debian and Ubuntu, <code>brew install xmlstarlet</code> on a Mac):</p>
<pre><code class="language-bash">unzip 'google.com!example.com!1792281600!1792367999.zip'
gunzip 'enterprise.protection.outlook.com!example.com!1792281600!1792368000.xml.gz'
xmlstarlet sel -t -m //record -v row/source_ip -o ' ' -v row/count \
  -o ' dkim=' -v row/policy_evaluated/dkim -o ' spf=' -v row/policy_evaluated/spf \
  -o ' signer=' -v auth_results/dkim/domain -n *.xml | sort -k2 -rn
</code></pre>
<p>Then ask DNS who each unfamiliar IP belongs to with a reverse lookup, for example <code>dig +short -x 198.51.100.24</code>.</p>
<p><img src="https://blog.progineous.com/wp-content/uploads/2026/10/read-dmarc-reports-sender-list.png" alt="Terminal screenshot of extracting DMARC aggregate reports with unzip and gunzip, then an xmlstarlet one-liner listing each sending IP with its message count, aligned DKIM and SPF results and DKIM signer, followed by reverse DNS lookups that identify a newsletter platform, the company web server and an IP with no reverse DNS" /></p>
<p>Every row lands in one of four buckets, and each bucket has its own fix:</p>
<ol>
<li><strong>Your mailbox provider</strong> (192.0.2.25 here). DKIM and SPF are both aligned. Nothing to do.</li>
<li><strong>A service sending on your behalf</strong> (198.51.100.24, which reverse-resolves to a newsletter platform). It passes, but for the wrong domain. Most of these services have a "domain authentication" or "custom sending domain" setting that hands you one or two CNAME records, such as <code>m1._domainkey.example.com</code> pointing at their key. Once they sign with <code>d=example.com</code>, DKIM aligns and DMARC passes. Aligning SPF too, with a custom return-path on a subdomain, is nice to have but not required. One aligned pass is enough.</li>
<li><strong>Your own servers</strong> (203.0.113.80, the web server). A contact form or shop sending straight from PHP, with no DKIM signature and an IP that isn't in SPF. Resist the urge to just add the IP to SPF. Send that mail through your mailbox provider's SMTP submission port with a real account instead, so it gets the same aligned signature as everything else.</li>
<li><strong>Nobody you recognize</strong> (203.0.113.199, no reverse DNS, nine messages). That's someone using your domain without permission, and it's exactly the mail <code>p=reject</code> exists to stop. Leave it failing.</li>
</ol>
<p>Expect a fuzzy fifth group as well: forwarded mail and mailing lists. A list that adds a footer breaks the DKIM signature, and a forwarder's IP was never in your SPF, so a small trickle of failures from university or ISP ranges is normal and can't be fixed from your side. Some large receivers use ARC to give that mail the benefit of the doubt. Don't chase it.</p>
<p>When the volume outgrows a shell one-liner, open-source parsers like parsedmarc can load reports into a dashboard, and hosted report services do the same job. The four buckets stay the same.</p>
<h2>Tighten in steps, not in one edit</h2>
<p><img src="https://blog.progineous.com/wp-content/uploads/2026/10/read-dmarc-reports-rollout.png" alt="Flat diagram of a safe DMARC rollout in three stages, from p=none for monitoring to p=quarantine to p=reject, with the check to pass before each step: every legitimate sender aligned in the reports, nothing legitimate quarantined, and a lowered TTL on the _dmarc record so the change can be rolled back" /></p>
<p>Once every legitimate source in the last few weeks of reports is aligned, raise the policy one level at a time:</p>
<pre><code class="language-text">v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@example.com
v=DMARC1; p=reject; sp=reject; rua=mailto:dmarc-reports@example.com
</code></pre>
<p>Older guides ramp up with <code>pct=10</code>, then <code>pct=25</code>, and so on. RFC 9989, the DMARC standard published in May 2026, retires <code>pct</code> because receivers applied the in-between values inconsistently. Its replacement is <code>t=y</code>, which asks receivers to apply one level below what you published, so <code>p=reject; t=y</code> behaves like quarantine at receivers that support it. Plain steps work everywhere: stay on quarantine for a couple of weeks, confirm that nothing legitimate shows up with a <code>quarantine</code> disposition, then move to reject.</p>
<p>A few habits keep each change uneventful:</p>
<ul>
<li><strong>Keep <code>rua=</code> for good.</strong> Reports are how you'll spot the next forgotten sender, like a helpdesk or billing tool someone signs up for next year.</li>
<li><strong>Lower the TTL on the <code>_dmarc</code> record</strong> to 300 or 600 seconds before each change, so stepping back takes minutes rather than hours.</li>
<li><strong>Decide on subdomains deliberately.</strong> <code>sp=</code> sets the policy for existing subdomains, which otherwise inherit <code>p</code>. Receivers that follow RFC 9989 also read <code>np=reject</code>, which covers subdomains that don't exist at all.</li>
<li><strong>Lock down domains that never send mail.</strong> A parked domain can publish <code>v=spf1 -all</code> and <code>v=DMARC1; p=reject</code> on day one. There's nothing to monitor.</li>
</ul>
<p>After each step, send a test message from every service in bucket two and check that the <code>Authentication-Results</code> header on the received copy shows <code>dmarc=pass</code>.</p>
<h2>What reject actually buys you</h2>
<p>At <code>p=reject</code>, receivers that honor DMARC refuse mail that claims to be from your domain and can't prove it. Phishing that copies your exact address stops reaching customers' inboxes, and your own mail stops sharing a reputation with whoever was spoofing it. Look-alike domains are a separate problem, and DMARC doesn't cover them.</p>
<p>None of this needs special software. A few weeks of reading the reports, fixing the senders you recognize, and reject becomes a short DNS edit instead of a gamble. If you're moving mailboxes as part of the cleanup, Pro Gineous <a href="https://progineous.com/business-email.php?utm_source=hashnode">business email</a> gives each address a mailbox on your own domain, and every check above works the same there as with any other provider.</p>
<h3>Related reading</h3>
<ul>
<li><a href="https://blog.progineous.com/how-to-keep-email-out-of-spam/">Why Is My Email Going to Spam?</a></li>
<li><a href="https://blog.progineous.com/read-dig-output-dns/">How to Read dig Output When DNS Looks Wrong</a></li>
<li><a href="https://blog.progineous.com/how-to-set-up-dns-records/">Setting Up DNS Records for Your Website and Mail</a></li>
</ul>
<p><em>Originally published on the <a href="https://blog.progineous.com/read-dmarc-reports/">Pro Gineous blog</a>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Why WordPress Missed Your Scheduled Post (and How to Fix WP-Cron)]]></title><description><![CDATA[You scheduled a post for 11:00, closed the laptop, and came back after lunch to find it still sitting in the Posts list with a red "Missed schedule" label…]]></description><link>https://progineous.hashnode.dev/wordpress-missed-schedule-wp-cron</link><guid isPermaLink="true">https://progineous.hashnode.dev/wordpress-missed-schedule-wp-cron</guid><category><![CDATA[WordPress]]></category><category><![CDATA[PHP]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Linux]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Pro Gineous]]></dc:creator><pubDate>Mon, 19 Oct 2026 08:40:55 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad04d5123635e400ceca45a/c4a66b4e-ae89-4c54-b7bc-5aa99d4eeb50.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>You scheduled a post for 11:00, closed the laptop, and came back after lunch to find it still sitting in the Posts list with a red "Missed schedule" label where the publish date should be. Nothing crashed. No error email arrived. WordPress just never got around to it.</p>
<p>The cause is almost always WP-Cron, the scheduler built into WordPress. It makes one big assumption about your traffic, and when that assumption fails, scheduled posts, backups and digest emails all slip together. Here's how it works, how to confirm it's the culprit, and how to replace it with something that runs on time.</p>
<h2>WP-Cron isn't a clock</h2>
<p>When you click Schedule, WordPress doesn't set a timer anywhere. It saves the post with the status <code>future</code> and adds a one-off job called <code>publish_future_post</code> to a list stored in the database (the <code>cron</code> option in <code>wp_options</code>). Plugin chores like backups and update checks live in the same list.</p>
<p>Nothing watches that list on its own. Instead, on each page request WordPress checks whether anything on it is due. If something is, it fires a quick HTTP request at its own <code>wp-cron.php</code> (a loopback request) and carries on without waiting for the reply. That second request is what actually publishes your post.</p>
<img src="https://blog.progineous.com/wp-content/uploads/2026/10/wp-cron-flow.png" alt="Flat diagram comparing two ways WordPress publishes a scheduled post: the default visit-triggered WP-Cron path (visitor arrives, WordPress checks the job list, loopback request to wp-cron.php, job runs) with its failure points, and a server cron job running wp cron event run --due-now every five minutes" style="display:block;margin:0 auto" />

<p>So the default setup needs three things to line up: a visit that reaches PHP, a loopback request that gets through, and a server that lets the job finish. Take away any one of them and the schedule drifts.</p>
<h2>Why the schedule slips</h2>
<p><strong>Nobody visited.</strong> A small site can go hours without one uncached request. The post is due at 11:00, the first real visit comes at 12:40, and that's when it goes live. Posts on a quiet site appearing late by a random amount is the classic pattern.</p>
<p><strong>The visits never reached WordPress.</strong> Full-page caching (a caching plugin's static files, a server-level cache, or a CDN in front of the site) is great for speed, but a cached page is served without loading WordPress, so nothing checks the job list. A busy site behind a good cache can behave exactly like a site with no visitors.</p>
<p><strong>The loopback request fails.</strong> The server has to be able to reach its own domain over HTTP. A staging password (HTTP basic auth) answers <code>401</code>. A security plugin or firewall rule may block <code>wp-cron.php</code>. After a migration, the server can still resolve your domain to the old IP (DNS answers are cached, as covered in <a href="https://blog.progineous.com/how-long-dns-changes-take/">how long DNS changes take to show up</a>) and send the request to a machine that no longer hosts the site. WordPress flags this in Tools &gt; Site Health as "Your site could not complete a loopback request."</p>
<p><strong>Cron was switched off and never replaced.</strong> Someone added <code>define( 'DISABLE_WP_CRON', true );</code> to <code>wp-config.php</code> to stop cron running on page loads, which is a sensible thing to do, but the server cron job that's meant to take over was never created. Or it existed on the old server and didn't come along in the move, because it lives in the server's crontab, not in WordPress.</p>
<p><strong>A heavy job ran out of time.</strong> Cron jobs run in a normal PHP request, so they're bound by the same time limit. A big backup or import that hits that limit gets killed mid-run, and whatever was queued after it waits for the next spawn.</p>
<p>Also rule out the time zone under Settings &gt; General. If it's set to UTC while you think in local time, every post goes out "late" by the same few hours. That's a settings problem, not a scheduler one.</p>
<h2>Ask WordPress what it thinks is due</h2>
<p>If your hosting gives you SSH, WP-CLI answers this in three commands. Run them from the site's folder (often <code>public_html</code>):</p>
<pre><code class="language-bash">wp post list --post_status=future --fields=ID,post_title,post_date
wp cron event list
wp cron test
</code></pre>
<img src="https://blog.progineous.com/wp-content/uploads/2026/10/wp-cli-cron-diagnose.png" alt="Terminal screenshot of WP-CLI showing a scheduled post with ID 1482 still in future status, wp cron event list with publish_future_post and core jobs overdue as now, and wp cron test failing with cURL error 28 operation timed out" style="display:block;margin:0 auto" />

<p>The first command lists posts still waiting to publish, with the time they were due in the site's own time zone. In <code>wp cron event list</code>, the <code>next_run_gmt</code> column is in UTC, so on a site set to Cairo time (UTC+3) the <code>08:00:00</code> job is the 11:00 post. Any row whose <code>next_run_relative</code> says <code>now</code> is overdue: WordPress knows the job should run and nothing has run it. One overdue row could be bad luck. A column full of <code>now</code>, including core jobs like <code>wp_version_check</code>, means cron hasn't run for hours.</p>
<p><code>wp cron test</code> makes the same loopback request WordPress would, but waits up to three seconds for the answer. Here's how to read the result:</p>
<ul>
<li><p><code>Success: WP-Cron spawning is working as expected.</code> The loopback path is fine, so look at traffic and caching instead.</p>
</li>
<li><p><code>cURL error 28</code> (a timeout, as in the screenshot) usually points at a firewall rule, a DNS answer pointing somewhere else, or a server that can't reach itself.</p>
</li>
<li><p><code>HTTP status code: 401</code> means a password sits in front of the site.</p>
</li>
<li><p>An error saying the <code>DISABLE_WP_CRON</code> constant is set means cron is switched off on purpose, so go and find the server cron job that should exist.</p>
</li>
</ul>
<p>No SSH? The free WP Crontrol plugin shows the same event list inside wp-admin, and Site Health reports late scheduled events and failed loopback requests.</p>
<h2>Hand the schedule to the server</h2>
<p>The reliable fix is to stop depending on visits at all. Let the server's own scheduler run the queue every few minutes, and tell WordPress to stop trying on page loads.</p>
<p>Start by switching off the visit-triggered runner:</p>
<pre><code class="language-bash">wp config set DISABLE_WP_CRON true --raw
</code></pre>
<p>That writes <code>define( 'DISABLE_WP_CRON', true );</code> into <code>wp-config.php</code>. You can also add the line by hand, anywhere above the <code>/* That's all, stop editing! */</code> comment.</p>
<p>Next, add a server cron job. Cron runs with a very short <code>PATH</code>, so find the full path to WP-CLI with <code>which wp</code> and use it in the line. With WP-CLI available, this is the cleanest version, because it runs due events directly in PHP instead of going through HTTP:</p>
<pre><code class="language-bash">*/5 * * * * cd /home/exampleco/public_html &amp;&amp; /usr/local/bin/wp cron event run --due-now --quiet
</code></pre>
<p>Without WP-CLI, request <code>wp-cron.php</code> over HTTP instead:</p>
<pre><code class="language-bash">*/5 * * * * curl -s -o /dev/null "https://example.com/wp-cron.php?doing_wp_cron"
</code></pre>
<p>The HTTP version still depends on the server reaching itself, so if <code>wp cron test</code> failed earlier, fix that first or stick with WP-CLI. Over SSH, add the line with <code>crontab -e</code>. On cPanel hosting, the Cron Jobs page takes the same command and has a ready-made "Once Per Five Minutes" option. Five minutes is a good default: scheduled posts go out within a few minutes of their time, and the server barely notices.</p>
<p>Finally, clear the backlog once by hand with <code>wp cron event run --due-now</code>, then check the stuck post with <code>wp post get 1482 --field=post_status</code>. It should now say <code>publish</code>.</p>
<img src="https://blog.progineous.com/wp-content/uploads/2026/10/wp-cli-cron-fix.png" alt="Terminal screenshot of fixing WP-Cron with WP-CLI: wp config set DISABLE_WP_CRON true, a crontab line running wp cron event run --due-now every five minutes, the due events executing including publish_future_post, and post 1482 now showing publish status" style="display:block;margin:0 auto" />

<h3>If you really can't add a cron job</h3>
<p>Some plans offer neither cron nor SSH. One fallback is an external cron service that requests <code>https://example.com/wp-cron.php?doing_wp_cron</code> every few minutes, which works as long as that URL isn't blocked or cached. The other is <code>define( 'ALTERNATE_WP_CRON', true );</code>, which makes WordPress run cron by redirecting a visitor's own request instead of making a loopback call. It sidesteps a broken loopback but still needs visits, and visitors may see <code>?doing_wp_cron=</code> in the address bar. Treat both as stopgaps.</p>
<h2>Prove it the next day</h2>
<p>The day after the change, check that it actually works:</p>
<ul>
<li><p>Schedule a test post ten minutes out and stay off the site. If it goes live on time with no visits from you, the server cron is doing the work. (If new posts are shared to social media automatically, pause that first, then trash the test post.)</p>
</li>
<li><p>Run <code>wp cron event list</code> again. Recurring jobs like <code>wp_version_check</code> should show a future time, not <code>now</code>.</p>
</li>
<li><p>Add the cron line to your migration notes. The next time the site moves hosts, it's the one setting that won't move with the files and database, and if both old and new servers keep running cron against the same site during a cutover, scheduled emails can go out twice.</p>
</li>
</ul>
<p>WP-Cron is a reasonable default for a site that gets steady, uncached traffic. For anything that publishes on a schedule or runs nightly backups, a five-minute server cron job is the boring, dependable choice. Pro Gineous <a href="https://progineous.com/shared-hosting.php?utm_source=hashnode">shared hosting</a> plans include cPanel, SSH and WP-CLI, so everything above can be set up from the control panel or the terminal in a few minutes.</p>
<h3>Related reading</h3>
<ul>
<li><p><a href="https://blog.progineous.com/how-to-speed-up-wordpress/">Speeding Up WordPress Without More Plugins</a></p>
</li>
<li><p><a href="https://blog.progineous.com/how-to-move-a-wordpress-site/">How to Move a WordPress Site to a New Host</a></p>
</li>
<li><p><a href="https://blog.progineous.com/how-to-fix-a-wordpress-error/">How to Fix a WordPress Error Without Making It Worse</a></p>
</li>
</ul>
<p><em>Originally published on the</em> <a href="https://blog.progineous.com/wordpress-missed-schedule-wp-cron/"><em>Pro Gineous blog</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[How to Read dig Output When DNS Looks Wrong]]></title><description><![CDATA[You moved a site to a new server an hour ago and updated the A record. Your phone loads the new site, your laptop still shows the old one, and a teammate…]]></description><link>https://progineous.hashnode.dev/read-dig-output-dns</link><guid isPermaLink="true">https://progineous.hashnode.dev/read-dig-output-dns</guid><category><![CDATA[dns]]></category><category><![CDATA[Linux]]></category><category><![CDATA[Devops]]></category><category><![CDATA[networking]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Pro Gineous]]></dc:creator><pubDate>Sun, 18 Oct 2026 08:43:33 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad04d5123635e400ceca45a/7f2bb201-74c5-46aa-b384-95489d86e5c7.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>You moved a site to a new server an hour ago and updated the A record. Your phone loads the new site, your laptop still shows the old one, and a teammate gets "server not found." Three devices, three different answers, and the DNS panel insists everything is correct.</p>
<p>This is where the <code>dig</code> command earns its place. A browser hides DNS behind layers of caching; <code>dig</code> asks one specific server one specific question and prints exactly what came back. Once you can read that output, "DNS is broken" turns into a short list of facts: who answered, what they said, and how long they will keep saying it.</p>
<p><code>dig</code> ships with macOS and most Linux distributions (on Debian and Ubuntu, install <code>dnsutils</code>). The examples use <code>example.com</code>; in our scenario the old server is <code>203.0.113.10</code> and the new one is <code>203.0.113.20</code>.</p>
<h2>Start with one plain query</h2>
<pre><code class="language-bash">dig example.com
</code></pre>
<pre><code class="language-text">; &lt;&lt;&gt;&gt; DiG 9.18.30-0ubuntu0.24.04.2-Ubuntu &lt;&lt;&gt;&gt; example.com
;; global options: +cmd
;; Got answer:
;; -&gt;&gt;HEADER&lt;&lt;- opcode: QUERY, status: NOERROR, id: 48211
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 65494
;; QUESTION SECTION:
;example.com.                   IN      A

;; ANSWER SECTION:
example.com.            2917    IN      A       203.0.113.10

;; Query time: 3 msec
;; SERVER: 127.0.0.53#53(127.0.0.53) (UDP)
;; WHEN: Sat Oct 17 10:42:18 EEST 2026
;; MSG SIZE  rcvd: 56
</code></pre>
<p>The screenshot adds a second query for a name that doesn't exist, so you can see <code>NXDOMAIN</code> next to a normal answer.</p>
<img src="https://blog.progineous.com/wp-content/uploads/2026/10/dig-answer.png" alt="Terminal screenshot of the dig command: dig example.com returning status NOERROR with an A record of 203.0.113.10 and 2917 seconds of TTL left, followed by dig shop.example.com returning NXDOMAIN with the SOA record in the AUTHORITY section" style="display:block;margin:0 auto" />

<p>It looks noisy, but it's only a few blocks.</p>
<h3>The header: status and flags</h3>
<p>The <code>-&gt;&gt;HEADER&lt;&lt;-</code> line holds the most important word in the output: <code>status</code>. Check it before looking at any IP. <code>NOERROR</code> means the server answered without a problem, not that the answer is the one you wanted.</p>
<p>The <code>flags</code> line tells you what kind of server you were talking to:</p>
<ul>
<li><p><code>qr</code>: this is a response. Always there.</p>
</li>
<li><p><code>rd</code>: you asked for recursion. dig does this by default.</p>
</li>
<li><p><code>ra</code>: the server offers recursion, so you're talking to a resolver, not the source of the record.</p>
</li>
<li><p><code>aa</code>: authoritative answer. The server holds the zone itself. You only see this when you query the domain's own nameservers.</p>
</li>
</ul>
<h3>QUESTION and ANSWER</h3>
<p>QUESTION echoes what you asked: name, class <code>IN</code>, type <code>A</code>. A typo or a forgotten <code>MX</code> shows up here.</p>
<p>ANSWER holds the records in five columns: name, TTL, class, type, value. Here the resolver says <code>example.com</code> points to <code>203.0.113.10</code>, the old server.</p>
<h3>TTL is a countdown</h3>
<p>The second column is the TTL in seconds. On a resolver it's the time left before that cached copy expires, not the number you typed into your DNS panel. Run the query twice and you'll watch it drop. Above, 2917 seconds remain, so this resolver will hand out the old IP for roughly another 48 minutes, whatever you change in the meantime. On the authoritative server, the TTL is always the full configured value.</p>
<h3>AUTHORITY, ADDITIONAL and the footer</h3>
<p>AUTHORITY mostly shows up when there's no answer. On an <code>NXDOMAIN</code> or an empty response you'll find the zone's SOA record there, and its last number caps how long the "doesn't exist" result gets cached. When you query a TLD server, AUTHORITY is where the delegation (the NS records) lives. ADDITIONAL carries helpers such as nameserver IPs.</p>
<p>In the footer, the <code>SERVER</code> line matters. <code>127.0.0.53</code> is systemd-resolved on your own machine, which means you're looking at your local cache, not at the internet.</p>
<h2>What the status codes are telling you</h2>
<table>
<thead>
<tr>
<th>Status</th>
<th>What it means</th>
<th>Where to look next</th>
</tr>
</thead>
<tbody><tr>
<td><code>NOERROR</code> with answers</td>
<td>The name exists and has records of that type</td>
<td>Check the value and the TTL</td>
</tr>
<tr>
<td><code>NOERROR</code>, <code>ANSWER: 0</code></td>
<td>The name exists, but not with that record type (often called NODATA)</td>
<td>Wrong type, or the record was never created</td>
</tr>
<tr>
<td><code>NXDOMAIN</code></td>
<td>The name doesn't exist in the zone that answered</td>
<td>A typo, a missing record, or a resolver still reading the old zone</td>
</tr>
<tr>
<td><code>SERVFAIL</code></td>
<td>The resolver couldn't get a usable answer</td>
<td>Broken delegation, unreachable nameservers, or a DNSSEC failure</td>
</tr>
<tr>
<td><code>REFUSED</code></td>
<td>The server won't answer this query</td>
<td>You asked a server that isn't authoritative for the zone and won't recurse for you</td>
</tr>
</tbody></table>
<p>On <code>SERVFAIL</code>, retry with <code>+cd</code> (checking disabled). If an answer appears, DNSSEC validation is failing, often because a domain moved to new nameservers while an old DS record still sits at the registrar. And <code>REFUSED</code> from a nameserver you expected to be authoritative usually means the zone was never created, or never activated, on that provider.</p>
<h2>Ask a specific resolver, then ask the source</h2>
<p>By default dig uses your system's resolver. Point it elsewhere with <code>@</code>:</p>
<pre><code class="language-bash">dig example.com @1.1.1.1
dig example.com @8.8.8.8
</code></pre>
<p>Public resolvers are still caches. To see what the zone really says, find its authoritative nameservers and query one directly:</p>
<pre><code class="language-bash">dig +short NS example.com
dig example.com @ns1.example.net +norecurse
</code></pre>
<pre><code class="language-text">; &lt;&lt;&gt;&gt; DiG 9.18.30-0ubuntu0.24.04.2-Ubuntu &lt;&lt;&gt;&gt; example.com @ns1.example.net +norecurse
; (1 server found)
;; global options: +cmd
;; Got answer:
;; -&gt;&gt;HEADER&lt;&lt;- opcode: QUERY, status: NOERROR, id: 61027
;; flags: qr aa; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 1232
;; QUESTION SECTION:
;example.com.                   IN      A

;; ANSWER SECTION:
example.com.            3600    IN      A       203.0.113.10
</code></pre>
<p><code>+short</code> strips everything except the values. In the authoritative reply, look for the <code>aa</code> flag and the full TTL. If the authoritative server already returns the new IP and a resolver still returns the old one, nothing is broken; you're waiting for cached copies to expire. If the authoritative server returns the old IP, like the one above, your change never reached the zone that's actually live. That leads to the next check.</p>
<h2>Follow the chain with +trace</h2>
<pre><code class="language-bash">dig +trace +nodnssec example.com
</code></pre>
<p><code>+trace</code> skips your resolver's cache and walks the delegation itself. It asks a root server who handles <code>.com</code>, asks a <code>.com</code> server who handles <code>example.com</code>, then asks that nameserver for the record. The <code>+nodnssec</code> part just hides the signature records so the output stays readable.</p>
<img src="https://blog.progineous.com/wp-content/uploads/2026/10/dns-path.png" alt="Flat diagram of DNS resolution: a laptop asks a recursive resolver, which queries a root server, the .com TLD server and then the authoritative nameserver ns1.example.net for example.com" style="display:block;margin:0 auto" />

<p>Here are the last two hops, trimmed:</p>
<pre><code class="language-text">example.com.            172800  IN      NS      ns1.example.net.
example.com.            172800  IN      NS      ns2.example.net.
;; Received 87 bytes from 192.5.6.30#53(a.gtld-servers.net) in 24 ms

example.com.            3600    IN      A       203.0.113.10
;; Received 56 bytes from 198.51.100.53#53(ns1.example.net) in 31 ms
</code></pre>
<p>Each <code>Received ... from</code> line names the server that sent the block above it. The hop from <code>a.gtld-servers.net</code> is the delegation your registrar published. The final hop is what the zone itself says.</p>
<h2>When the registrar and the zone disagree</h2>
<p>This is the classic "I changed the record and nothing happened." You moved DNS to a new provider and created the records there, but the registrar still delegates to the old nameservers. The new zone is perfect, and nobody is reading it.</p>
<p>Ask a <code>.com</code> TLD server directly what the delegation looks like:</p>
<pre><code class="language-bash">dig NS example.com @a.gtld-servers.net
</code></pre>
<p>The NS records come back in the AUTHORITY section, not ANSWER, because the TLD server is referring you elsewhere rather than answering itself. That's also why <code>+short</code> prints nothing here. Then compare with the zone you've been editing and a public resolver:</p>
<pre><code class="language-bash">dig +short NS example.com @ns1.example.org
dig +short example.com @ns1.example.org
dig +short example.com @1.1.1.1
</code></pre>
<img src="https://blog.progineous.com/wp-content/uploads/2026/10/dig-delegation.png" alt="Terminal screenshot comparing dig NS example.com at a.gtld-servers.net, which still delegates to ns1.example.net, with the new nameserver ns1.example.org returning 203.0.113.20 while 1.1.1.1 still returns 203.0.113.10" style="display:block;margin:0 auto" />

<p>Here the TLD still points to <code>ns1.example.net</code> and <code>ns2.example.net</code>, while the new provider's zone lists <code>ns1.example.org</code> and already holds the new IP. Resolvers follow the delegation, so they keep reading the old zone. The fix lives at the registrar: update the nameservers there, and remove any stale DS record if DNSSEC was enabled. Delegation records at the TLD usually carry a 172800-second TTL (two days), so resolvers that cached the old NS set can take a while to catch up.</p>
<h2>Quick checks for MX, TXT and CNAME</h2>
<p>Add the record type, and <code>+short</code> makes each check one line:</p>
<pre><code class="language-bash">$ dig +short MX example.com
10 mail.example.com.
20 mail2.example.com.
$ dig +short TXT example.com
"v=spf1 ip4:198.51.100.25 include:_spf.example.net -all"
$ dig +noall +answer www.example.com
www.example.com.        3600    IN      CNAME   example.com.
example.com.            2917    IN      A       203.0.113.10
</code></pre>
<p>Worth checking while you're there:</p>
<ul>
<li><p>MX records must point to hostnames, never to IP addresses or CNAMEs. The lower number has priority.</p>
</li>
<li><p>A domain should publish exactly one <code>v=spf1</code> TXT record. Two of them is an error, and receivers treat it as an SPF failure.</p>
</li>
<li><p>DKIM and DMARC live on their own names: <code>dig +short TXT selector1._domainkey.example.com</code> and <code>dig +short TXT _dmarc.example.com</code>.</p>
</li>
<li><p><code>+noall +answer</code> shows a whole CNAME chain in one go. A name that has a CNAME can't hold any other records, which is why the bare domain usually can't be a CNAME.</p>
</li>
</ul>
<h2>Clear the cache on your own machine</h2>
<p>Once the authoritative servers and public resolvers agree, your own computer may still hold the old answer.</p>
<p>On Linux with systemd-resolved:</p>
<pre><code class="language-bash">sudo resolvectl flush-caches
resolvectl statistics    # "Current Cache Size" drops to 0
</code></pre>
<p>On macOS:</p>
<pre><code class="language-bash">sudo dscacheutil -flushcache
sudo killall -HUP mDNSResponder
</code></pre>
<p>On Windows it's <code>ipconfig /flushdns</code>. Browsers keep their own DNS cache, so restart the browser too. Public resolvers can't be flushed from your machine, though the big public DNS services offer a purge page for single names.</p>
<h2>A short troubleshooting checklist</h2>
<ol>
<li><p>Run <code>dig name type</code> and read <code>status</code> before anything else.</p>
</li>
<li><p>Check the <code>SERVER</code> line so you know whose cache you're looking at.</p>
</li>
<li><p>Find the nameservers with <code>dig NS</code>, query one with <code>@</code>, and look for the <code>aa</code> flag.</p>
</li>
<li><p>Compare that with the TLD delegation (<code>dig NS name @a.gtld-servers.net</code> or <code>+trace</code>). They must match.</p>
</li>
<li><p>Ask <code>@1.1.1.1</code> and <code>@8.8.8.8</code> and note the remaining TTL. That's how long you'll wait.</p>
</li>
<li><p>On <code>SERVFAIL</code>, retry with <code>+cd</code> to rule DNSSEC in or out.</p>
</li>
<li><p>Flush local caches only after the authoritative answer is correct.</p>
</li>
</ol>
<p>Most DNS problems aren't mysterious. They're a mismatch between two layers (registrar and zone, zone and cache, cache and browser), and the dig command shows you exactly which two. If your domains and DNS are managed at <a href="https://progineous.com/?utm_source=hashnode">Pro Gineous</a>, our support team is happy to walk through these checks with you whenever something looks off.</p>
<p>Every domain registered through our <a href="https://progineous.com/domain-search.php?utm_source=hashnode">domain search and registration</a> page comes with DNS you can manage yourself, so you can check your records with dig as you change them.</p>
<h3>Related reading</h3>
<ul>
<li><p><a href="https://blog.progineous.com/how-long-dns-changes-take/">How Long Do DNS Changes Take to Show Up?</a></p>
</li>
<li><p><a href="https://blog.progineous.com/what-an-a-record-does/">What an A Record Does in DNS</a></p>
</li>
<li><p><a href="https://blog.progineous.com/how-to-set-up-dns-records/">Setting Up DNS Records for Your Website and Mail</a></p>
</li>
</ul>
<p><em>Originally published on the</em> <a href="https://blog.progineous.com/read-dig-output-dns/"><em>Pro Gineous blog</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Secure a Linux VPS in 15 Minutes]]></title><description><![CDATA[You just got root on a fresh Ubuntu box. That is power and risk in the same login. Attackers scan new IPs within minutes. This walkthrough hardens a…]]></description><link>https://progineous.hashnode.dev/secure-a-linux-vps-in-15-minutes</link><guid isPermaLink="true">https://progineous.hashnode.dev/secure-a-linux-vps-in-15-minutes</guid><category><![CDATA[Linux]]></category><category><![CDATA[Security]]></category><category><![CDATA[Devops]]></category><dc:creator><![CDATA[Pro Gineous]]></dc:creator><pubDate>Sat, 17 Oct 2026 05:55:19 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad04d5123635e400ceca45a/a3cb06c9-a6a4-4c78-9685-51370a8bf6d3.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>You just got root on a fresh Ubuntu box. That is power and risk in the same login. Attackers scan new IPs within minutes. This walkthrough hardens a typical Ubuntu 22.04 or 24.04 VPS in about a quarter of an hour. It works on any honest provider. If you use Pro Gineous, the steps are the same.</p>
<p>Assumptions: you can SSH in as root (or with sudo), you have a second terminal ready, and you are okay restarting SSH carefully.</p>
<h2>Minutes 0-3: update and create a user</h2>
<pre><code class="language-bash">apt update &amp;&amp; apt upgrade -y
adduser deploy
usermod -aG sudo deploy
</code></pre>
<p>Use a real password for <code>deploy</code> even if you plan to disable password SSH later. Keep the root session open until key login works.</p>
<h2>Minutes 3-6: SSH keys only</h2>
<p>On your laptop:</p>
<pre><code class="language-bash">ssh-copy-id deploy@YOUR_SERVER_IP
</code></pre>
<p>Confirm you can open a <strong>second</strong> SSH session as <code>deploy</code> before you change sshd. Then edit <code>/etc/ssh/sshd_config</code> (or a drop-in under <code>/etc/ssh/sshd_config.d/</code>):</p>
<pre><code class="language-plaintext">PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
</code></pre>
<p>Reload SSH (name varies by release):</p>
<pre><code class="language-bash">sudo systemctl reload ssh || sudo systemctl reload sshd
</code></pre>
<img src="https://blog.progineous.com/wp-content/uploads/2026/10/ssh-keys.png" alt="Terminal showing ssh-copy-id installing the key for user deploy, logging in as deploy, and sshd settings confirming PermitRootLogin no, PubkeyAuthentication yes and PasswordAuthentication no" style="display:block;margin:0 auto" />

<p>If the second session still works, you are safe. Only then close the root session.</p>
<h2>Minutes 6-8: firewall</h2>
<pre><code class="language-bash">ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
ufw status verbose
</code></pre>
<img src="https://blog.progineous.com/wp-content/uploads/2026/10/ufw-status.png" alt="Terminal showing sudo ufw enable, then sudo ufw status verbose listing the active rules: OpenSSH, 80/tcp and 443/tcp allowed inbound over IPv4 and IPv6, with default deny incoming" style="display:block;margin:0 auto" />

<p>If you use a non-default SSH port, allow that port instead of <code>OpenSSH</code>.</p>
<h2>Minutes 8-10: fail2ban</h2>
<pre><code class="language-bash">apt install -y fail2ban
systemctl enable --now fail2ban
</code></pre>
<p>Default jails already watch SSH on many Ubuntu images. Check:</p>
<pre><code class="language-bash">fail2ban-client status
fail2ban-client status sshd
</code></pre>
<p>If <code>sshd</code> is missing, add a simple jail under <code>/etc/fail2ban/jail.d/sshd.local</code>:</p>
<pre><code class="language-plaintext">[sshd]
enabled = true
</code></pre>
<p>Then <code>systemctl restart fail2ban</code>.</p>
<h2>Minutes 10-12: automatic security updates</h2>
<pre><code class="language-bash">apt install -y unattended-upgrades
dpkg-reconfigure -plow unattended-upgrades
</code></pre>
<p>Confirm it is active:</p>
<pre><code class="language-bash">systemctl status unattended-upgrades --no-pager
</code></pre>
<img src="https://blog.progineous.com/wp-content/uploads/2026/10/fail2ban-unattended.png" alt="Terminal showing sudo fail2ban-client status sshd with banned IP addresses, followed by systemctl status unattended-upgrades reporting the service as active and running" style="display:block;margin:0 auto" />

<h2>Minutes 12-14: timezone, swap, noise</h2>
<pre><code class="language-bash">timedatectl set-timezone UTC   # or your region
ss -tulpn
</code></pre>
<p>Close ports and services you do not need. On a 1-2 GB box with no swap:</p>
<pre><code class="language-bash">fallocate -l 1G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' &gt;&gt; /etc/fstab
</code></pre>
<p>Skip swap if your provider already gives you enough RAM or managed swap.</p>
<h2>Minutes 14-15: snapshot and backups</h2>
<ol>
<li><p>Take a provider snapshot <strong>now</strong>, while the box is clean and hardened.</p>
</li>
<li><p>Schedule off-server backups for anything that will hold real data (database dumps, <code>/var/www</code>, secrets). A snapshot is not a tested restore.</p>
</li>
<li><p>Write down where backups live and how you would restore them.</p>
</li>
</ol>
<h2>What this does not cover</h2>
<ul>
<li><p>Application security (WordPress plugins, dependency CVEs, auth bugs)</p>
</li>
<li><p>Secrets management (API keys in git, world-readable <code>.env</code> files)</p>
</li>
<li><p>Monitoring and alerting</p>
</li>
<li><p>DDoS or network-layer attacks beyond a basic firewall</p>
</li>
</ul>
<p>Those are next layers. This checklist only makes a fresh VPS less of an open door.</p>
<h2>Closing</h2>
<p>Own the server, own the failure modes. Fifteen minutes of boring hygiene beats a weekend of incident cleanup.</p>
<p>If you need a server to practice on, <a href="https://progineous.com/?utm_source=hashnode&amp;utm_medium=social&amp;utm_campaign=week1026">Pro Gineous</a> VPS plans start at $6.99/mo. Whatever provider you pick, own your domain, keep SSH locked down, and test a restore at least once.</p>
]]></content:encoded></item></channel></rss>