PullMD Part 13: An Old Rule – Simply Explained
The previous part ended on a hard stop. My server was ready, but the other side would not let requests through. I waited. For four days, nothing happened.
Something Started to Nag
One evening, on the project’s GitHub discussion page, someone posted a detailed explanation for why the connection was being blocked. The short version: the system on the other side checks the server’s address before it even tries to connect. If the address looks like it belongs to a private home network, the connection gets dropped immediately.
That explanation fit neatly onto a specific type of server setup. It did not fit mine. My server sits behind a proper public address, verifiable from the outside. Later that night I left a polite comment:
“Posting anyway because the explanation maps cleanly onto fbs.synology.me but doesn’t seem to fit my setup, and I’d like a sanity check before this is considered fully resolved. … If there’s an additional check beyond the IP-routability validation — e.g. ASN/range classification, the host_not_allowed allowlist mentioned in #256, or something specific to Cloudflare Tunnel egress IPs — knowing what specifically my hosts are tripping would let me address it on my side rather than guess.” —
syswave-dev(issue comment)
In plain English: your explanation doesn’t match my setup. Could you take another look?
Two Logs, Two Stories
The next morning I pulled the log files from my server. Log files are like a visitor book at the front door: every visitor leaves an entry. What the server log showed: nothing. Not a single request from the other side had ever arrived. The server was not staying silent. It was never asked in the first place.
So the block was happening earlier, somewhere upstream. And that is where the second log came in. My website sits behind a protective service called Cloudflare: a kind of bouncer standing in front of the actual server. Cloudflare keeps its own log of everything it blocks. There they were: every connection attempt from the other side, blocked by the same rule, every single one. I had simply never looked at that log the first time around, and had not questioned its absence either. Because the GitHub discussion made everything look like “not our problem” anyway.
When other people have problems, I always preach “look at the logs!” This time it got me.
The Rule That Had Outlived Itself
The cause was a WAF rule I had written myself. WAF stands for Web Application Firewall: a security guard that stands in front of the actual server and decides who gets in and who does not.
A long time ago I wanted certain services I use while travelling to be reachable only from Germany. The rule was simple: anyone not coming from Germany gets turned away. It applied to the entire zone, meaning every website and service under that umbrella.
The problem: I do not even use those services any more. But the rule kept running quietly. When I put my Markdown service under the same zone, it fell under the rule automatically. The other side tries to reach my server from the US. The US is not Germany.
The signal from the previous part still held: two independent servers failing in exactly the same way, so the problem was not in the code. But both servers sat behind the same Cloudflare zone. The rule did exactly what I had written it to do. Only everything around it had changed.
The Self-Correction
The fix was a simple exception rule for the other side’s server addresses. After that, the login mechanism from the previous part was live.
That same afternoon, a public correction on the GitHub discussion:
“Update — happy to confirm the fix worked on my end. … For the record: the actual culprit on my side was a country-based GeoBlocking custom rule I had set up long ago for an unrelated service —
(ip.geoip.country ne "DE")— which I’d completely forgotten about. It was happily blocking the Anthropic outbound IPs (US-based) along with everything else non-DE. Server logs showed zero traffic because Cloudflare was returning the block at the edge. So the symptom matched the pattern in this thread, but the root cause was fully on my side.” —syswave-dev(issue comment)
And a note for anyone else who might run into the same thing:
“Worth flagging for anyone else hitting this: check custom WAF rules with country/ASN/IP-range conditions, not just Bot Fight Mode. An old rule that was reasonable when you wrote it can quietly catch new legitimate traffic.” —
syswave-dev(issue comment)
Owning the mistake openly, so the next person with the same problem finds it when they search.
Two Releases in One Day
Once the bottleneck was cleared, the pipeline was open. What was still pending fit into a single day.
The recipe engine had already shipped five days earlier. It came from a user’s bug report: on a particular news site, the text extractor was throwing away paragraphs that sat next to a recommendation widget. The user suggested a switch to pick the extraction strategy per site. That became a small config file: one entry per tricky website, describing how to best pull its content. Like a recipe collection for difficult pages. It shipped as version 2.2.
The login mechanism from the previous part had been sitting finished on the shelf because the door on the other side would not open. With the old rule out of the way, it was ready. 558 tests green. Shipped as version 2.3.
A toggle for a nicely formatted view came from another user request: show the text not just as raw code, but also nicely formatted. Done in a single afternoon. A small toggle in the result view, both themes styled, the copy button still copies the raw text. When someone asks politely, the effort is worth it. For the person on the other side, it is the difference between “someone actually read this” and “the bot replied”. Shipped as version 2.4.
In between, a small invisible mistake surfaced. The Docker image (the pre-packaged version that self-hosters download) had a label saying “this is the current stable release,” and that label had been silently overwritten by every new release when it should have stayed on the older version a little longer. What had kept the error invisible: a stale note in the project memory that claimed the label was still correct. I had never gone back to actually check.
What Stays
The four days of waiting had nothing to do with someone else blocking me. It was my own rule: written for a purpose that had long since disappeared. It kept working quietly and caught new traffic that had nothing to do with its original purpose. That is how technology ages when nobody is watching.
The lesson: when something looks like a problem on the other side, check your own side first.
After that it got quieter for a while. The next larger rebuild only came a month later. In June, Microsoft’s document conversion tool MarkItDown landed in PullMD as its own helper for PDFs, Word files, PowerPoint, and the rest of the document zoo. Three parts of it got ripped straight back out. One would have quietly sent audio files to Google’s servers for processing. That is what Part 14 is about.
Part 13 of 15 in the PullMD series.
← PullMD Part 12: Spec-Compliance Isn’t Enough | PullMD Part 14: Wiring in Microsoft’s MarkItDown — and ripping three parts back out →