<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <title>Pablo Murad</title>
    <subtitle>I build things, explore ideas and share what I learn along the way.</subtitle>
    <link href="https://pablomurad.com/atom-full/" rel="self" type="application/atom+xml"/>
    <link href="https://pablomurad.com/"/>
    <id>https://pablomurad.com/</id>
    <updated>2026-06-27T12:42:25-03:00</updated>
    <generator>pablawn-v1</generator>
    
    <entry>
        <title>One Post, Two Blogs</title>
        <link href="https://pablomurad.com/one-post-two-blogs/"/>
        <id>https://pablomurad.com/one-post-two-blogs/</id>
        <published>2026-06-27T01:42:16-03:00</published>
        <updated>2026-06-27T01:42:16-03:00</updated>
        <author><name>Pablo Murad</name></author>
        <category term="tech"/>
        <summary>I keep two blogs. One lives on prose.sh — terminal-native, plain text, no dashboard. The other runs on Ghost (this one here), which is prettier and friendlier for longer pieces. I like both for different reasons, and for a while I told myself I&#39;d just keep them</summary>
        <content type="html"><![CDATA[<p>I keep two blogs. One lives on prose.sh — terminal-native, plain text, no dashboard. The other runs on Ghost (this one here), which is prettier and friendlier for longer pieces. I like both for different reasons, and for a while I told myself I'd just keep them in sync by hand.</p><p>You already know how that ended.</p><p>Maintaining the same post in two places, by hand, is exactly the kind of chore I quietly abandon around week two. So instead of pretending I'm more disciplined than I am, I automated it. This is the story of how that automation grew — from a single <code>scp</code> to a little bridge that keeps both blogs in sync on its own, and even lets me publish by sending an email.</p><p>This is the part I love, so let me start here. prose.sh has no admin panel. You write a markdown file and copy it to the server over SSH:</p><pre><code class="language-bash">scp my-first-post.md me@prose.sh:/
</code></pre>
<p>The file is just text with a tiny bit of frontmatter on top:</p><pre><code class="language-markdown">---
title: My First Post
date: 2026-06-27
---

Hello from a plain text file.
</code></pre>
<p>That's the whole thing. Your editor, your files, zero lock-in.</p><p>Doing this once is delightful. Doing it for every post — remembering the filename, the frontmatter, the exact <code>scp</code> line — while also keeping a Ghost blog alive, on a week where work ate every evening... the friction adds up. And friction is where my good intentions go to die.</p><p>I wanted to write <em>once</em> and have it show up in both places. That's it.</p><p>First I did the obvious thing and wrapped the boring parts in a small CLI. List my posts, create a new one, publish — all over the same SSH the manual way used, just without me typing the incantation every time.</p><pre><code class="language-bash">blog publish my-first-post.md
</code></pre>
<p>There was one genuinely annoying detail worth mentioning: my SSH key lived on a network share, and OpenSSH flat-out refuses keys with "loose" permissions. So the script learned to copy the key to a safe local spot and lock it down before connecting. Small thing, but it's the difference between "works on my machine" and "works on every machine."</p><p>This already made my life better. But it still meant <em>I</em> had to run something, and it still treated the two blogs as two separate chores.</p><p>The real shift was a decision, not code: <strong>Ghost is canonical, prose is a mirror.</strong> I write in Ghost, and prose should just... follow.</p><p>This is an old idea with an ugly acronym — POSSE: Publish on your Own Site, Syndicate Elsewhere. You keep one home for your writing and let copies flow outward automatically.</p><p>The two halves were already there, waiting to be introduced:</p><ul><li>Ghost can fire a <strong>webhook</strong> the moment you publish.</li><li>prose accepts <strong>markdown over SSH</strong>.</li></ul><p>All that was missing was a small translator in the middle.</p><p>So I wrote one. A tiny service in a container. When I publish in Ghost, it:</p><ol><li>catches the webhook,</li><li>converts the post's HTML into clean Markdown,</li><li>writes the frontmatter for me (title and date — I don't even bother with excerpts),</li><li>and <code>scp</code>s the file to prose.</li></ol><pre><code class="language-text">   Write in Ghost
        │  webhook: "post published"  (HTML)
        ▼
 ┌───────────────────┐
 │   bridge service  │   HTML ──► Markdown ──► add frontmatter
 └───────────────────┘
        │  scp
        ▼
    prose.sh  ──►  notes.example.com
</code></pre>
<p>I publish in one place. A few seconds later the same post is sitting on prose, formatted correctly, no copy-paste, no second chore.</p><h2 id="plot-twist-posting-by-email">Plot twist: posting by email</h2><p>Then I got greedy. Some of my best ideas show up when I'm nowhere near a laptop — in line somewhere, on the couch, on my phone. I wanted to fire off a quick post the laziest way imaginable: send an email.</p><p>So I added one more door. I email a plain message — the <strong>subject becomes the title</strong>, the <strong>body becomes the post</strong> — and it lands on both blogs.</p><p>The trick that made it clean: the email doesn't talk to prose at all. It just creates a Ghost post through Ghost's API. Then Ghost's normal webhook does the rest, exactly like a post I'd written by hand. One road to prose, not two.</p><pre><code class="language-text">   Write in Ghost ───────────────┐
                                 │ webhook
   Send an email                 │
        │                        ▼
        ▼                ┌────────────────┐
  inbound email  ──POST─►│ bridge service │──API──► create post in Ghost
   (mail provider)       │                │◄─webhook─┘
                         └────────────────┘
                                  │ scp
                                  ▼
                              prose.sh
</code></pre>
<p>One more wrinkle: my little box sits at home behind a connection with no public IP, so a stranger on the internet (Ghost's webhook, the mail provider) can't reach it directly. I tunnel those requests in through a cheap VPS, which terminates HTTPS and forwards them down to the container. The box never has to be "on the internet" — it just keeps a quiet outbound tunnel open.</p><pre><code class="language-text">  internet ──► tiny VPS (HTTPS) ──tunnel──► home box ──► bridge service
</code></pre><p>Few things that only became obvious after building it:</p><ul><li><strong>One source of truth saves you from sync hell.</strong> The moment you have two "originals," you have a merge conflict waiting to happen.</li><li><strong>Make every feature funnel through the same path.</strong> Even the email trick ends as a normal Ghost publish, so there's exactly one place that can break — and exactly one place to fix.</li><li><strong>Verify signatures.</strong> The open internet will happily POST garbage at any URL it can find. Both the webhook and the inbound mail are checked before I trust a byte.</li><li><strong>Only mirror what's public.</strong> Members-only drafts stay where they belong.</li><li><strong>Convert, don't paste.</strong> Turning the rendered HTML back into Markdown keeps prose clean instead of full of editor cruft.</li></ul><p>It's the kind of boring magic I like: invisible when it works, and it mostly just works.</p><p>And yes. It's working.</p><p>See you around.</p>]]></content>
    </entry>
    <entry>
        <title>God Gaming? Wtf</title>
        <link href="https://pablomurad.com/god-gaming-wtf/"/>
        <id>https://pablomurad.com/god-gaming-wtf/</id>
        <published>2026-06-26T23:58:20-03:00</published>
        <updated>2026-06-26T23:58:20-03:00</updated>
        <author><name>Pablo Murad</name></author>
        <category term="nice"/>
        <summary>A silly Steam badge, a huge library, 15 years of collecting, and a small digital trophy that says more about my love for games, indies, and digital culture than any number ever could.</summary>
        <content type="html"><![CDATA[
<aside class="gh-post-upgrade-cta">
    <div class="gh-post-upgrade-cta-content" style="background-color: #000000">
                <h2>This post is for subscribers only</h2>
            <a class="gh-btn" data-portal="signup" href="#/portal/signup" style="color:#000000">Subscribe now</a>
            <p><small>Already have an account? <a data-portal="signin" href="#/portal/signin">Sign in</a></small></p>
    </div>
</aside>
]]></content>
    </entry>
    <entry>
        <title>Weekly recap</title>
        <link href="https://pablomurad.com/weekly-recap/"/>
        <id>https://pablomurad.com/weekly-recap/</id>
        <published>2026-06-26T11:13:12-03:00</published>
        <updated>2026-06-26T11:16:46-03:00</updated>
        <author><name>Pablo Murad</name></author>
        <category term="recap"/>
        <summary>I didn’t plan for this week to be about infrastructure. But that’s what it became.</summary>
        <content type="html"><![CDATA[<p>I didn’t plan for this week to be about infrastructure. But that’s what it became. I sat down on Monday to fix one small thing, and by the time I looked up, I had touched almost the entire setup — both servers, the VPS, the notebook, and even a personal project that had nothing to do with servers at all.</p>
<p>I’m writing this down before I forget. Less changelog, more diary.</p>
<h2 id="the-theme-of-the-week-wasn%E2%80%99t-installing-things-it-was-fixing-them">The theme of the week wasn’t installing things. It was fixing them.</h2>
<p>Looking back, almost none of what I did was “spin up a new service.” It was mostly figuring out why something was wrong and fixing the cause instead of treating the symptom. That pattern repeated itself so often that it became the thread running through the whole week.</p>
<p>The cleanest example was the GoToSocial theme. There was a readability bug in the admin panel: almost-white text on a light gray background, basically unreadable. I spent too much time theorizing about which selector was painting the text the wrong color, when the real problem was the background. The <code>--almost-white</code> variable in GoToSocial is not a text color; it is a surface color. I had mapped it to a light value, so the whole panel was getting a light background from there. The fix was <strong>one line</strong>. Same old lesson, learned again: when text “disappears,” the first thing to check is <code>background-color</code> in DevTools’ Computed panel, not whatever theory your brain just invented.</p>
<p>That pattern — stop guessing and read the actual state — showed up again in Unraid, PeerTube, and pretty much everything else.</p>
<h2 id="the-servers">The servers</h2>
<h3 id="nyx-unraid-ryzen-5800x">Nyx (Unraid / Ryzen 5800X)</h3>
<p>I started with a boring permissions problem while moving qBittorrent downloads — the container UMASK was set to 022, so I changed it to 002 — and somehow ended up investigating CPU temperatures. The dashboard temps were way too high.</p>
<p>My guess was a process. Claude’s guess was hardware. Both were partly right. <code>vmstat</code> showed a constant 91% idle, so load wasn’t the issue. The main culprit was the frequency governor being stuck on <code>performance</code>, holding the cores at 4641 MHz even while idle. I switched it to <code>powersave</code> with EPP set to <code>balance_performance</code> through <code>amd-pstate-epp</code>, and the clocks dropped to around 3710 MHz. But the temperature only improved a little. That tells me there’s also a physical cooling limit involved — probably dried-out thermal paste, which is a known pain point with the 5800X. That stays on the list: open the machine and check it properly.</p>
<p>I made the governor and EPP settings persistent in <code>/boot/config/go</code>, in the right order — governor first, EPP after — otherwise the file locks up.</p>
<h3 id="skullserver-debian-contabo">skullserver (Debian / Contabo)</h3>
<p>This was the heaviest day. Four fronts: security, cleanup, MOTD, and IRC.</p>
<p>The discovery that annoyed me the most: UFW had the correct default-deny policy, but Docker injects its own iptables rules and <strong>bypasses UFW</strong>. Result: around thirteen containers were exposed to the internet even though the firewall made it look like they were not. I moved everything from <code>0.0.0.0</code> binds to either <code>127.0.0.1</code> for services behind a proxy, or to the Tailscale IP for direct access. I also found a Shadowsocks service running as init.d that I didn’t even remember anymore — gone. Cleaned up nine orphaned UFW rules too.</p>
<p>I rewrote the MOTD from scratch because the Debian 13 <code>pt_BR</code> locale was breaking the parsing of <code>free</code> — it returned <code>Mem.:</code> instead of <code>Mem:</code>. The new version reads directly from <code>/proc</code> and forces <code>LC_ALL=C</code>. I also gave it the look I wanted: monochrome editorial style, oxblood accent, a thin ruler instead of a box-drawing frame.</p>
<p>The IRC client that kept dropping wasn’t the server after all. It was the residential connection’s NAT timeout. Solved by routing the client through Tailscale with a single line in the Windows <code>/etc/hosts</code>.</p>
<h3 id="muradchat-debian-contabo">murad.chat (Debian / Contabo)</h3>
<p>This one was about load average, which had gone up after I added more federated services — Funkwhale, BookWyrm, Pixelfed, Lemmy, Iceshrimp. But the truth is the load average was lying to me: constant 82–99% idle, zero I/O wait. The high number wasn’t a real problem.</p>
<p>Still, I managed to tighten three services. Funkwhale Celery went from 6 workers down to 2. BookWyrm went from 100 threads down to 12, and Flower was shut off. Pixelfed Horizon was the annoying one — <code>balance=auto</code> enforces at least one worker per queue, so limiting processes didn’t help. The fix was <code>HORIZON_BALANCE_STRATEGY=false</code> with a fixed pool. Horizon RAM dropped from 1.19 GB to around 247 MB.</p>
<h3 id="openbsd">OpenBSD</h3>
<p>Intermittent network drops that only came back after a reboot. The log gave it away immediately: <code>duplicate IP address</code>. ARP conflict — another machine had the same static IP, probably a cloned VM that inherited the config. I moved it to a free IP outside the DHCP pool. No drama.</p>
<h2 id="the-visual-side">The visual side</h2>
<h3 id="gotosocial-%E2%80%94-%E2%80%9Cfediverso-idea%E2%80%9D-theme">GoToSocial — “Fediverso IDEA” theme</h3>
<p>Besides the admin bug I mentioned earlier, the constant challenge here is the 10,000-character limit in the custom CSS field. It forces you to write tight CSS, with no spare comments or junk left behind. The final version landed at around 3,500 characters. Dark space-like background, cyan/violet/lavender palette.</p>
<h3 id="unraid-webui">Unraid WebUI</h3>
<p>I first tried a vaporwave/synthwave theme and wasn’t happy with it — the selectors were too generic and created bordered boxes around everything. I ended up adopting a Nord theme I found and liked better, so the work became filling in the gaps. The theme was written for an older Unraid version and used ID selectors where 7.3.1 uses classes. The dashboard is table-based, and there were some <code>.stopgap</code> elements that are transparent spacers — the theme was painting them, which made the widgets visually bleed into each other. I made them transparent again and unified the cards with matching border radius.</p>
<h3 id="peertube-%E2%80%94-pablotube">PeerTube — pablo.tube</h3>
<p>I reinstalled the PeerTube instance I had previously shut down because of resource usage and unwanted federation. This time the rules were clear: federation <strong>off</strong>, transcoding limited to one job, and a hard 480p ceiling regardless of upload size. Along the way I found a broken acmetool hook that was causing <em>all</em> certificate renewals for <em>all</em> domains to silently fail the nginx reload — it was trying to restart dovecot, which does not even exist there anymore, with <code>set -e</code> enabled. Fixed with <code>|| true</code>. I also gave the instance a “YouTube 2008” look with CSS and a MutationObserver that wraps “Tube” in a red box to make the logo read <code>Pablo.[Tube]</code>.</p>
<h2 id="kubuntu">Kubuntu</h2>
<p>The Dell XPS 13 running Kubuntu was choking on Google Drive — a known KDE bug where Online Accounts authenticates correctly, but Dolphin throws “Access denied” because Google blocks the shared OAuth client used by kio-gdrive. I gave up on kio-gdrive and switched to rclone with FUSE. I mounted two remotes — personal and skull — using a single systemd service template with <code>%i</code> substitution, plus <code>enable-linger</code> so it comes up at boot. I also created my own OAuth client in Google Cloud and published it to production to avoid the trap of tokens expiring every 7 days.</p>
<p>I took the opportunity to clean things up: removed snaps I don’t use anymore — Firefox, Element, Thunderbird — replaced Thunderbird with Melia, and fixed Portuguese spell checking. The system itself was healthy: 5.4s boot, zero failed services, TRIM active. More hygiene than repair.</p>
<h2 id="and-the-project-that-wasn%E2%80%99t-about-servers">And the project that wasn’t about servers</h2>
<p>In the middle of all that, I worked on a family video from 1989 — around 200 minutes of celebrations, including a first birthday. I wanted to improve the quality.</p>
<p>After testing several upscale tools, I learned an annoying truth: the problem with the video is not recoverable noise, it is <strong>intrinsic blur</strong> from the original capture. Detail that was never recorded cannot be reconstructed by upscale magic. What actually worked was CodeFormer, focused on face restoration — it detected the four faces in the frame and produced something visibly sharper and more recognizable. The honest caveat is that it <em>reconstructs</em> a plausible face; it does not recover the lost pixels. The <code>-w 0.7</code> parameter strikes a decent balance between quality and identity fidelity. Good enough to make running it on the whole video worth it.</p>
]]></content>
    </entry>
    <entry>
        <title>My RSS Feed Problem (GoToSocial)</title>
        <link href="https://pablomurad.com/my-rss-feed-problem-gotosocial/"/>
        <id>https://pablomurad.com/my-rss-feed-problem-gotosocial/</id>
        <published>2026-06-24T04:12:15-03:00</published>
        <updated>2026-06-24T04:12:15-03:00</updated>
        <author><name>Pablo Murad</name></author>
        <category term="papers"/>
        <summary>A valid GoToSocial RSS feed returned no posts because stale account metadata left `last_status_at` empty. One database fix brought the feed back to life.</summary>
        <content type="html"><![CDATA[<p>We expected the problem to be boring.</p>
<p>A small ActivityPub instance was up, the profile was public, the account had posts, and the RSS option was enabled in the settings. The URL was exactly where it should have been: the profile path followed by <code>feed.rss</code>. A feed reader should have seen it, fetched it, and moved on with its quiet little life.</p>
<p>Instead, the feed existed but had no posts.</p>
<p>At first glance, everything looked normal. The endpoint returned <code>200 OK</code>. The content type was <code>application/rss+xml</code>. The XML was valid. The channel had a title, a link, a description, a build date, and even the profile image. But there were no <code>&lt;item&gt;</code> entries. Not one. The feed was technically alive, but empty.</p>
<p>That is the kind of bug that wastes time because it does not fail loudly. A 500 error gives you a direction. A 404 tells you the route is missing. Invalid XML gives you a parser problem. But a valid RSS feed with no items politely tells you: “There is nothing to see here.” And then you have to prove that it is lying.</p>
<h2 id="the-first-suspects">The first suspects</h2>
<p>The obvious place to look was the reverse proxy.</p>
<p>The instance was behind Nginx, and feeds can be sensitive to headers, redirects, hostnames, and content negotiation. So we checked the public endpoint first, then bypassed the proxy and hit the service locally with the same <code>Host</code> and forwarded headers. The result was the same in both places. That ruled out Nginx pretty quickly.</p>
<p>Next we looked at the RSS setting itself. The feed was enabled in the user interface, and when enabled it returned a valid RSS document. But after toggling the setting off and on again, the feed briefly disappeared entirely and returned an HTML 404 page. That was the first useful clue: the checkbox in the interface and the value in the database were not always telling the same story.</p>
<p>When the setting was disabled in the database, the feed route returned 404. When it was enabled, the feed came back as RSS. So the route was not broken. The feed feature was not missing. It was being gated by the account setting exactly as expected.</p>
<p>But even with RSS enabled, the feed was still empty.</p>
<h2 id="the-visibility-rabbit-hole">The visibility rabbit hole</h2>
<p>The next theory was visibility.</p>
<p>GoToSocial has a few different visibility levels, and its defaults are not always what people coming from other Fediverse software expect. We wondered whether the posts were not truly public. Maybe they were “unlisted” or “unlocked,” visible on the web profile but excluded from RSS.</p>
<p>That seemed plausible for a while. The public profile showed the posts, the account settings allowed public and unlisted posts to appear, and the RSS documentation focuses on public posts. It would have been a clean explanation: the posts were visible, but not eligible.</p>
<p>So we checked the database.</p>
<p>The statuses were local. They were federated. They were not replies. They were not boosts. They were not pending approval. They had normal text content. Their visibility value matched the public visibility enum for that version of GoToSocial. In other words, the posts were not the problem. They were exactly the kind of posts the RSS feed should include.</p>
<p>That theory died there.</p>
<h2 id="trying-versions-chasing-behavior">Trying versions, chasing behavior</h2>
<p>We also pinned the GoToSocial image instead of relying on a moving tag. The instance had been running a recent 0.21.x build, and we tried to remove uncertainty by using a specific version. The behavior did not change.</p>
<p>That mattered. It meant we were not just fighting a bad container pull or a half-updated image. The problem survived restarts and version pinning within the same release line. We were dealing either with persistent state in the database or a behavior in the application that depended on that state.</p>
<p>At this point the RSS endpoint was doing something very specific:</p>
<ul>
<li>If RSS was disabled for the account, the route returned 404.</li>
<li>If RSS was enabled, the route returned valid RSS.</li>
<li>The feed included channel metadata.</li>
<li>The feed did not include any posts.</li>
<li>The public web profile could still find and render the posts.</li>
<li>The database query for obvious RSS-eligible posts returned all of them.</li>
</ul>
<p>That narrowed the field. The posts existed. The web view could query them. The RSS route was active. Something between “account has posts” and “RSS should render items” was deciding that the account had no eligible latest status.</p>
<h2 id="the-clue-hidden-in-account-stats">The clue hidden in account stats</h2>
<p>The real clue was not in the statuses table.</p>
<p>It was in the account statistics table.</p>
<p>The account had a nonzero status count. The posts existed. But <code>last_status_at</code> was <code>NULL</code>.</p>
<p>That was the contradiction:</p>
<pre><code class="language-text">statuses_count: several posts
last_status_at: NULL
</code></pre>
<p>For an account with public posts, that should not happen.</p>
<p>Once we saw that, the empty RSS feed made sense. The RSS generator appeared to rely on account-level cached/statistical metadata to decide whether there was a recent eligible status for the feed. Since <code>last_status_at</code> was empty, the feed could be generated as a valid channel with no items, even though the posts themselves were perfectly eligible.</p>
<p>The public web profile did not suffer from the same problem because it queried the statuses directly. That explained why the web profile worked while RSS did not. They were not failing through the same code path.</p>
<h2 id="the-fix">The fix</h2>
<p>The fix was deliberately small.</p>
<p>We backed up the SQLite database. Then we updated <code>last_status_at</code> for the account to the <code>created_at</code> value of the newest eligible public status. After restarting the service, the RSS feed immediately populated with all the expected items.</p>
<p>No proxy change. No rewrite rule. No client workaround. No fake feed. No downgrade.</p>
<p>The feed went from a valid-but-empty XML document to a normal RSS feed with every public post listed.</p>
<p>After that, we published more test posts. They appeared in the RSS feed as well. The channel dates also started making sense once <code>last_status_at</code> was corrected.</p>
<h2 id="so-was-it-a-bug">So, was it a bug?</h2>
<p>In practical terms, yes.</p>
<p>Whether it is best described as a GoToSocial bug, a migration edge case, an account stats initialization issue, or stale derived state, the result was the same: account statistics said the account had posts, but also said it had no last status. RSS trusted that derived state and produced an empty feed.</p>
<p>The important part is that the underlying posts were fine. The RSS feature was fine. The URL was fine. The proxy was fine. The broken piece was cached account metadata.</p>
<p>That is also why the problem was so slippery. Every normal check passed. The profile loaded. The posts were public. The feed endpoint responded. The XML validated. The database contained the posts. Nothing looked catastrophically wrong.</p>
<p>Only one field was wrong, and it was not in the first table we looked at.</p>
<p>In GoToSocial, the public profile and RSS feed may use different internal paths and different assumptions. If the profile shows posts but RSS is empty, checking only the statuses is not enough.</p>
<p>Its that account-level derived data matters. Fields like <code>statuses_count</code> and <code>last_status_at</code> are not just decorative. They can influence what other parts of the application think exists.</p>
<p>A <code>200 OK</code> response with valid XML can still be wrong. Sometimes the bug is not that the endpoint failed. Sometimes the bug is that it succeeded with the wrong idea of reality.</p>
<p>In our case, the fix took one small database correction. Finding that correction took a few hours of chasing ghosts: headers, visibility, settings, version tags, profile behavior, API behavior, and finally the stats row that gave the whole thing away.</p>
]]></content>
    </entry>
    <entry>
        <title>The Next Steps</title>
        <link href="https://pablomurad.com/the-next-steps/"/>
        <id>https://pablomurad.com/the-next-steps/</id>
        <published>2026-06-24T01:26:03-03:00</published>
        <updated>2026-06-24T01:26:03-03:00</updated>
        <author><name>Pablo Murad</name></author>
        <category term="updates"/>
        <summary>After briefly writing about what I’ve been up to in “The Last Steps” (https://pablomurad.com/the-last-steps/), I think it’s time to talk about the next ones.

On a personal level, I’m planning to move everything I currently run on VPSs back home, locally. My</summary>
        <content type="html"><![CDATA[<p>After briefly writing about what I’ve been up to in “The Last Steps” (<a href="https://pablomurad.com/the-last-steps/">https://pablomurad.com/the-last-steps/</a>), I think it’s time to talk about the next ones.</p><p>On a personal level, I’m planning to move everything I currently run on VPSs back home, locally. My goal is to do a large-scale migration, which will probably be exhausting, especially for my home network. But I want to take custody of my own data. I’m moving away from big tech as much as possible and giving more attention to small technology companies, self-hosted tools, and open-source software.</p><p>To start this process, I’ve put together a small catalog of the services I currently have available in Murad World (<a href="https://murad.world/">https://murad.world</a>). This nice little catalog was built with Kiki, a system by Tomotama, which you can find here: <a href="https://tomotama.itch.io/kiki">https://tomotama.itch.io/kiki</a>. Its blog was also adapted from another system called Zonelets, available here: <a href="https://zonelets.net/">https://zonelets.net</a>.</p><p>Manual, simple, and easy. It turned out to be an interesting adaptation.</p><p>In Murad World, I’m listing everything personal that I run. A small portion of these services is already hosted on local servers, while the remaining ones are still on Contabo VPSs. By the end of the year, I intend to leave only company servers running on their proper VDSs.</p><p>There isn’t much more to say for now. These are mostly everyday updates, the kind where one small change can take a long time. Further ahead, though, I’ll write about a few other projects that are already finished.</p>]]></content>
    </entry>
    <entry>
        <title>Indieweb theme for Ghost</title>
        <link href="https://pablomurad.com/indieweb-theme-for-ghost/"/>
        <id>https://pablomurad.com/indieweb-theme-for-ghost/</id>
        <published>2026-06-21T21:33:49-03:00</published>
        <updated>2026-06-21T21:33:49-03:00</updated>
        <author><name>Pablo Murad</name></author>
        <category term="papers"/>
        <summary>Recently I found myself thinking about my experiment in making my Ghost CMS compatible with the IndieWeb.


I adapted my whole system so I could tap into the benefits of the small web, and then the idea came to me: why not create a theme that is 100% compatible with</summary>
        <content type="html"><![CDATA[<p>Recently I found myself thinking about my experiment in making my Ghost CMS compatible with the IndieWeb.</p>
<p>I adapted my whole system so I could tap into the benefits of the small web, and then the idea came to me: why not create a theme that is 100% compatible with the IndieWeb?</p>
<p>And here I am.</p>
<p>Download <a href="https://github.com/runawaydevil/idawn-theme">Here</a>.</p>
<p>I already had a theme. <strong>pablawn</strong> was my home: a tiny blog in a <em>retro directory</em> style, a dense list of posts where each text is a single line — date on the left, title in the middle, reading time on the right, and a dotted line stitching the two together like the table of contents of an old book. And underneath that lean aesthetic, I had taught Ghost how to speak <strong>IndieWeb</strong>: an invisible <code>h-card</code> proving who I am, posts marked up as <code>h-entry</code>, and webmentions arriving from across the web.</p>
<p>It worked beautifully — for me. The problem is that it worked <em>only</em> for me. My name was in the HTML. My photo, hosted on my own server, was in the HTML. My eleven <code>rel="me"</code> profiles, my webmention endpoint, my emails, even the array of “owned identities” that filters out the echo of my own publications: everything was <strong>hardcoded into the templates</strong>. It was a theme for one person only.</p>
<p>The idea driving us was simple to state and stubborn to execute: what if that same theme could be <strong>freely distributed</strong>, and anyone could configure their <em>own</em> IndieWeb identity directly inside the Ghost admin — without needing to know how to edit Handlebars, without touching CSS, without touching code? The free theme would be called <strong>indawn</strong>.</p>
<p>Ghost allows a theme to declare panel options — but with strict constraints. There are only a few types (<code>select</code>, <code>boolean</code>, <code>color</code>, <code>image</code>, <code>text</code>); text fields are <strong>single-line only</strong>; <strong>there is no list or repeatable field</strong>; and there is a practical ceiling of around <strong>twenty options</strong> in total.</p>
<p>Then came the first punch of reality: pablawn had <strong>already spent fourteen</strong> of those twenty options on font choices, navigation layout, color scheme, and homepage sections. That left <strong>six slots</strong>. Six. To fit an entire identity plus an arbitrary list of social networks.</p>
<p>And there was a second punch, subtler and more technical. <code>rel="me"</code> — the thread that stitches “this domain and these accounts are the same person” together — <strong>needs to be in the HTML delivered by the server</strong>. IndieWeb validators fetch the raw HTML; they <strong>do not run JavaScript</strong>. That killed the clever route every programmer tries first: store all the links in a single text field and <code>split</code> them with JS at runtime. No dice. Each <code>rel="me"</code> link has to come from a named field, rendered by Handlebars on the server. And Handlebars has no way to split a string. In other words: one social network = one panel field. No shortcut.</p>
<p>Six slots. One entire identity. The math simply did not work — until we stopped trying to fight Ghost and started using it in our favor.</p>
<p>The piece that unlocked everything was realizing that <strong>Ghost already stores a large part of the <code>h-card</code></strong> — just in the native fields everyone fills out without thinking:</p>
<ul>
<li>the name (<code>p-name</code>) is the site title;</li>
<li>the photo (<code>u-photo</code>) is the site icon;</li>
<li>the canonical URL (<code>u-url</code>) is the site URL;</li>
<li>the bio (<code>p-note</code>) is the site description.</li>
</ul>
<p>None of that costs a slot. The central identity of the semantic business card started assembling itself from things the user configures in <em>Settings → General</em> without even knowing they are feeding microformats.</p>
<p>And there was an almost indecent bonus: Ghost natively has two <em>Social accounts</em> fields — X/Twitter and Facebook. Two <code>rel="me"</code> links <strong>for free</strong>, without spending any of the budget. Along the way, we replaced the old <code>{{twitter_url}}</code> helpers with the modern <code>{{social_url type="..."}}</code> helpers — gscan complained, and a distributable theme cannot be born with a warning.</p>
<h2 id="the-six-fields-that-remained">The six fields that remained</h2>
<p>With the identity coming for free, the six remaining slots were available for what truly needed its own field. We spent them like this:</p>
<ul>
<li><strong><code>webmention_endpoint</code></strong> — where the person pastes their own webmention.io address;</li>
<li><strong><code>social_github</code></strong>, <strong><code>social_mastodon</code></strong>, <strong><code>social_bluesky</code></strong>, <strong><code>social_linkedin</code></strong>, <strong><code>social_youtube</code></strong> — one complete URL per network.</li>
</ul>
<p>Five named networks, plus the two native ones, make <strong>seven</strong> in total. The choice was not random: GitHub, Mastodon, and Bluesky are the core of the open web, where <code>rel="me"</code> can actually verify back; LinkedIn covers the professional side; YouTube covers people who create video. Instagram, Threads, and TikTok were left out on purpose — they are walled gardens where <code>rel="me"</code> almost never closes the loop, so they would be decoration, not verification.</p>
<p>Two design decisions hold this arrangement together. The first is the rule <strong>“empty means off”</strong>: no field has a separate <code>on/off</code> switch — if you do not fill it in, it simply does not appear. That saves precious slots and makes the panel self-explanatory. The second is that <strong>every network is a complete URL</strong>, not a “username” — because on Mastodon the instance varies, and asking only for the handle would break half the cases.</p>
<p>Each filled field feeds <strong>two places from a single source</strong>: the hidden <code>h-card</code> at the top (what robots read) and the discreet footer icons (what humans see). One truth, two appearances.</p>
<h2 id="what-we-chose-not-to-do">What we chose <em>not</em> to do</h2>
<p>A large part of the work of a free theme is having the discipline to cut. We consciously left out: the <code>h-card</code> extras (job title, organization, region, country) — too niche; the self-mention filter, which we reset and left commented for anyone who might want it one day; the exotic networks that did not fit the rule; and the <em>sending</em> of webmentions, which was never the job of a theme — it is an external service that watches the RSS feed and notifies other sites for you. The theme only needs to deliver an honest <code>h-entry</code>, and it already does that.</p>
<p>We also cleaned up the personal residue that was not IndieWeb: an old JavaScript trick that swapped the sample texts of the members portal, the hardcoded author photo in posts, the three fixed footer links. Everything that said “Pablo” in the HTML came out — only the author signature in <code>package.json</code> and the README remained, which is exactly where it belongs.</p>
<h2 id="how-it-ended-up-inside">How it ended up inside</h2>
<p>Once the design was clear, the fork became mechanical: copy the theme, rewrite <code>package.json</code> (name <code>indawn</code>, version <code>0.0.1</code>), replace the <code>h-card</code> block with dynamic fields, condition the <code>&lt;link rel="webmention"&gt;</code> on the existence of the endpoint, make the footer dynamic, reset the personal array in JavaScript, and rewrite the documentation.</p>
<p>The verdict came from <code>gscan</code>, Ghost’s official validator: <strong>compatible with Ghost 6.x, zero warnings</strong>. One final search pass confirmed that neither the sources nor the rebuilt files carried any trace of personal identity. Then all that remained was packaging it: <code>indawn.zip</code>, ready to be uploaded to any Ghost site in the world.</p>
<p>The most useful lesson was not technical — it was attitudinal. For quite a while, we fought Ghost, trying to force into it a list of links it was never meant to have. Progress only came when we stopped wanting Ghost to be something else and asked: <em>what does it already know how to do that I am ignoring?</em> The answer — “it already stores your name, your photo, your URL, your bio, and two networks” — solved half the problem before we wrote the first line of configuration.</p>
<p>The other lesson is about the ceiling. indawn is born exactly at twenty out of twenty options, right up against the limit. There is no spare room, and that is honest: I left it recorded, both in the guide and in my head, that gaining a sixth network one day will mean giving something else up. Constraint is not a failure of design; often, it is the design itself.</p>
<p>from the front, instead of coming with it already written on the wall.</p>
]]></content>
    </entry>
    <entry>
        <title>When KDE&#x27;s Native Google Drive Quietly Gave Up</title>
        <link href="https://pablomurad.com/when-kdes-native-google-drive-quietly-gave-up/"/>
        <id>https://pablomurad.com/when-kdes-native-google-drive-quietly-gave-up/</id>
        <published>2026-06-21T15:00:52-03:00</published>
        <updated>2026-06-21T15:00:52-03:00</updated>
        <author><name>Pablo Murad</name></author>
        <category term="papers"/>
        <summary>I run Kubuntu on a Dell XPS 13, customized to within an inch of its life, and for a while I just wanted one boring thing to work: my Google Drive showing up as a folder in Dolphin. KDE advertises this. You open Online Accounts, add your Google account, and</summary>
        <content type="html"><![CDATA[<p>I run Kubuntu on a Dell XPS 13, customized to within an inch of its life, and for a while I just wanted one boring thing to work: my Google Drive showing up as a folder in Dolphin. KDE advertises this. You open <em>Online Accounts</em>, add your Google account, and Dolphin is supposed to grow a tidy little <code>gdrive:/</code> entry you can browse like any other folder.</p>
<p>So I added the account. It authenticated. The entry showed up. I clicked it — <strong>Access denied</strong>.</p>
<p>Not "wrong password." Not "try again later." A flat, permanent denial. Every folder, every time.</p>
<h2 id="its-not-you-its-kio-gdrive">It's not you, it's <code>kio-gdrive</code></h2>
<p>If you go digging in the logs instead of staring at Dolphin, the real message is uglier and a lot more honest:</p>
<pre><code>org.kde.kgapi: Requested resource is forbidden
</code></pre>
<p>Here's what's actually happening. KDE's Google Drive integration runs through <code>kio-gdrive</code>, which talks to Google using the <code>kgapi</code> library and a <strong>shared OAuth client ID</strong> that belongs to the KDE project. Google has been tightening the screws on apps that request "sensitive" scopes — like full Drive access — without going through their verification circus. The KDE project's client got caught in that net. So the authentication step still works (the account adds fine, the entry appears), but the moment you try to <em>read</em> anything, Google slams the door.</p>
<p>It's not your machine. It's not a misconfiguration. It's a credential the whole KDE userbase shares, and Google decided it's had enough.</p>
<p>There's a workaround floating around the forums: register your own OAuth client in Google Cloud and somehow feed it into <code>kio-gdrive</code>. I'd skip it. The people who actually tried it report that it either still fails outright, or — worse — it lists your files but refuses to copy anything down to disk. A file manager that shows you files it can't open is more frustrating than one that just admits defeat.</p>
<p>So I stopped trying to resuscitate <code>kio-gdrive</code> and did the thing everyone who's been burned by it eventually does.</p>
<h2 id="rclone-and-a-real-folder">rclone, and a real folder</h2>
<p><code>rclone</code> mounts your Drive as an actual filesystem over FUSE. That distinction matters more than it sounds. This isn't a KDE-only protocol that lives inside Dolphin — it's a plain folder on disk that <em>every</em> app on the machine can see. Your terminal, your editor, your scripts, your image viewer. All of them.</p>
<p>First, install rclone and FUSE. The version in Ubuntu's repos tends to lag, so I use the official installer:</p>
<pre><code class="language-bash">sudo -v &amp;&amp; curl https://rclone.org/install.sh | sudo bash
sudo apt install -y fuse3
</code></pre>
<p>Then create the remote:</p>
<pre><code class="language-bash">rclone config
</code></pre>
<p>It's an interactive wizard. Pick <code>n</code> for a new remote, name it something you'll recognize (I went with <code>gdrive-pablo</code>), choose <code>drive</code> as the type, leave the client ID and secret blank for now, and pick scope <code>1</code> for full Drive access. When it asks to use auto config, say yes — it pops open your browser, you log in, you authorize, done. Confirm and quit.</p>
<p>Test it:</p>
<pre><code class="language-bash">rclone lsd gdrive-pablo:
</code></pre>
<p>If it lists your top-level folders, you're already further than KDE ever got you.</p>
<h2 id="make-it-survive-real-use">Make it survive real use</h2>
<p>You can mount it by hand:</p>
<pre><code class="language-bash">rclone mount gdrive-pablo: ~/GoogleDrive --vfs-cache-mode full --daemon
</code></pre>
<p>That <code>--vfs-cache-mode full</code> is not optional, and it's the flag most people forget. Without it, editing or writing files that are already open breaks in ugly ways. With it, rclone uses a local disk cache and behaves like a normal disk. It's the single setting that turns rclone from "technically works" into "actually usable."</p>
<p>But mounting by hand every login is a chore, and I had <em>two</em> Drives to deal with. So I let systemd handle it — as a user service, not a system one, because the mount belongs to my session and my credentials.</p>
<p>Rather than write two near-identical service files, I used a <strong>systemd template</strong> — one file with an <code>@</code> in the name. Whatever you put after the <code>@</code> when you enable it becomes the <code>%i</code> variable inside. One file, any number of drives.</p>
<p><code>~/.config/systemd/user/rclone-gdrive@.service</code>:</p>
<pre><code class="language-ini">[Unit]
Description=rclone mount (%i)
After=network-online.target
Wants=network-online.target

[Service]
Type=notify
ExecStartPre=/bin/mkdir -p %h/GoogleDrive/%i
ExecStart=/usr/bin/rclone mount %i: %h/GoogleDrive/%i \
  --vfs-cache-mode full \
  --vfs-cache-max-size 2G \
  --dir-cache-time 12h \
  --poll-interval 15s \
  --umask 022
ExecStop=/bin/fusermount3 -u %h/GoogleDrive/%i
Restart=on-failure
RestartSec=10

[Install]
WantedBy=default.target
</code></pre>
<p>Then enable one instance per drive, and turn on lingering so the mounts come up even before I open a graphical session:</p>
<pre><code class="language-bash">systemctl --user daemon-reload
systemctl --user enable --now rclone-gdrive@gdrive-pablo.service
systemctl --user enable --now rclone-gdrive@gdrive-skull.service
loginctl enable-linger "$USER"
</code></pre>
<p>Now both Drives mount themselves at boot, restart if they crash, and live as ordinary folders under <code>~/GoogleDrive</code>.</p>
<h2 id="the-part-nobody-tells-you-your-own-client-id-and-the-7-day-trap">The part nobody tells you: your own client ID and the 7-day trap</h2>
<p>When you left the client ID blank earlier, rclone fell back to its <em>shared</em> client — the one bundled with the tool and split across millions of users. It works, but under any real load you'll start hitting <code>userRateLimitExceeded</code>. With two drives competing for the same shared quota, it gets old fast.</p>
<p>The fix is to register your own OAuth client in Google Cloud. The short version: create a project, enable the Google Drive API, set up the consent screen with audience <strong>External</strong>, and create an OAuth client of type <strong>Desktop app</strong>. Copy the client ID and secret, drop them into each remote, and reconnect.</p>
<p>And here is the detail that will save you a confused afternoon a week from now.</p>
<p>When you set up the consent screen, Google leaves the app in <strong>Testing</strong> mode by default. In that mode, your refresh token <strong>expires after seven days</strong>. Everything works beautifully — until exactly one week later, when every mount silently dies and you have no idea why.</p>
<p>So before you walk away, <strong>publish the app to production</strong>. Google will warn you that production normally requires verification; ignore it. For a personal app you use yourself, it works fine unverified, and — critically — the token stops expiring. That one toggle is the difference between "set it and forget it" and "re-authenticate every Monday forever."</p>
<p>To apply the new credentials without nuking your config:</p>
<pre><code class="language-bash">rclone config update gdrive-pablo client_id "YOUR_ID" client_secret "YOUR_SECRET"
rclone config reconnect gdrive-pablo:
</code></pre>
<p>The same client ID works for every drive — it identifies the app, not the account, so each remote authorizes its own Google account separately.</p>
<h2 id="two-rough-edges-worth-sanding-down">Two rough edges worth sanding down</h2>
<p>A couple of things surfaced once the mounts were live.</p>
<p>The first was log spam — endless lines like <code>Dangling shortcut "..." detected</code>. Those are broken shortcuts inside Drive itself, pointing at files you've since deleted or lost access to. They're harmless, but rclone tries to resolve each one, which costs API calls and slows down the first listing. Tell it to stop bothering:</p>
<pre><code>--drive-skip-dangling-shortcuts
</code></pre>
<p>The second was Dolphin freezing hard enough to ask me to restart it. The culprit turned out to be thumbnails. Because the mount is FUSE, Dolphin treats it as a <em>local</em> folder — so when it tries to generate a preview for a 4 GB video, it dutifully downloads the whole file to make a thumbnail. Open a folder full of those and it grinds to a halt. The fix lives in <em>Configure Dolphin → General → Previews</em>: uncheck the generators that read entire files (video, archives) and drop the maximum file size to something small like 10 MB. Small images and PDFs still preview; the giant stuff no longer drags everything down.</p>
<p>Two Google accounts, mounted as plain folders at <code>~/GoogleDrive/gdrive-pablo</code> and <code>~/GoogleDrive/gdrive-skull</code>, coming up automatically at boot, on my own API quota, with a token that doesn't quietly expire. Every app on the system can read them, not just Dolphin. The local disk cache makes editing feel native.</p>
<p>KDE's <em>Online Accounts</em> still shows a Google Drive entry. It still throws Access denied. I just don't click it anymore.</p>
]]></content>
    </entry>
    <entry>
        <title>24/7 - Owncast TV</title>
        <link href="https://pablomurad.com/24-7-owncast-tv/"/>
        <id>https://pablomurad.com/24-7-owncast-tv/</id>
        <published>2026-06-20T15:56:04-03:00</published>
        <updated>2026-06-20T15:56:04-03:00</updated>
        <author><name>Pablo Murad</name></author>
        
        <summary>A 24/7 linear TV channel built from two media folders, encoded once on an AMD GPU with ErsatzTV, pushed through ffmpeg, and streamed via Owncast with near-zero CPU usage.</summary>
        <content type="html"><![CDATA[<p>Some time ago, I wrote a small script to control my Owncast instance. Basically, that little program used ffmpeg to handle the streaming. Back then I was using three separate machines: one where the media files lived, another running Owncast, and a middle one running ffmpeg. I wish I could give more detail about that bizarre setup, but I honestly do not remember exactly how it was wired together.</p>
<p>What matters is that today I decided to improve the idea.</p>
<p>I will try to explain how I built a channel for Jellyfin and, at the same time, how I managed to make that live channel stream through Owncast.</p>
<p>For the Jellyfin channel, the answer was simple and straightforward: ErsatzTV.</p>
<p>For Owncast, there is a bit more to unpack.</p>
<p>What I wanted sounded simple enough: take two folders full of video files and turn them into a linear TV channel, playing 24 hours a day and streamed through my Owncast instance. Not just "play a file" — I wanted something closer to a real station, with a schedule, watched by the public on a page, and something I could turn on and off whenever I wanted.</p>
<p>Getting there involved an abandoned tool, a GPU that worked in one place and failed in another, a server refusing connections for a reason that was not the obvious one, and seven rotten files sabotaging the whole thing. This text documents what was done, why it was done, and, most importantly, the problems solved along the way.</p>
<p>The hardware: an Unraid server, nicknamed <code>Nyx</code>, with an AMD GPU, and Owncast already running as the destination.</p>
<h2 id="the-architecture-and-why-it-was-not-my-first-choice">The architecture, and why it was not my first choice</h2>
<p>The split of responsibilities I wanted was clear:</p>
<pre><code class="language-text">[ the station ]       [ the bridge ]       [ the antenna ]
  builds the schedule -&gt; pushes the stream -&gt; distributes it to the public
</code></pre>
<p>The first attempt was <strong>ffplayout</strong> — a robust broadcast tool, probably the most "professional" option for this kind of thing. It died at the installation stage. ffplayout <strong>does not publish a ready-made Docker image</strong>: the ffmpeg build it uses includes <em>non-free</em> components that cannot be legally redistributed, so the official installation expects you to build the image from scratch. On Unraid, that means a heavy build process involving Rust and a frontend, with a final image around 15 GB, plus the burden of manually maintaining updates. Too much effort for something I did not actually need in all its complexity.</p>
<p>I looked at what Unraid's Community Apps had to offer. Most apps with "ffmpeg" in the name are batch file transcoders — useless for continuous streaming. <strong>Broadcaster</strong> and <strong>dizquetv</strong> are direct competitors to ErsatzTV, since they generate channels, but they are not bridges. <strong>Restreamer</strong> by datarhei was the only real candidate for a GUI-based bridge, but it spins up its own RTMP server on port 1935, which would <strong>collide with Owncast</strong>, and it is also a fairly heavy stack for a one-to-one job.</p>
<p>The final choice:</p>
<ul>
<li><strong>ErsatzTV</strong> as the station: it builds and normalizes the schedule, and encodes using the GPU.</li>
<li>A <strong>raw ffmpeg container</strong> as the bridge: it grabs the channel and pushes it to Owncast.</li>
<li><strong>Owncast</strong> as the antenna: it distributes the stream to the public.</li>
</ul>
<p>The raw ffmpeg container beat Restreamer because it is minimal, does not open any ports, only performs an outbound <em>push</em>, creates zero conflict, and — an important detail — because <strong>stopping and starting that container becomes the on/off switch for the stream</strong>. I needed to be able to stop the broadcast without taking ErsatzTV down, and this architecture gave me that for free.</p>
<h2 id="ersatztv-the-station">ErsatzTV: the station</h2>
<h3 id="the-right-image-and-the-gpu">The right image and the GPU</h3>
<p>The Community Apps template offered <code>-vaapi</code> and <code>-nvidia</code> variants. For AMD, VAAPI is the right path. Two details showed up immediately:</p>
<ol>
<li>
<p><strong>The image has been unified.</strong> ErsatzTV now includes both VAAPI and NVIDIA support in the standard image; the suffixes are legacy. The Health Check warns you to remove <code>-vaapi</code> and use the default image instead: <code>ghcr.io/ersatztv/ersatztv:latest</code>. Hardware acceleration still works — the suffix is simply no longer needed.</p>
</li>
<li>
<p><strong>VAAPI requires passing the GPU through.</strong> Without the <code>/dev/dri</code> device inside the container, hardware acceleration simply does not happen. The template text only mentions Intel and NVIDIA, but AMD uses the same path as Intel: add <code>/dev/dri</code> as a <em>Device</em>.</p>
</li>
</ol>
<p>There was also a blue warning about "VAAPI Driver" suggesting the <code>iHD</code> or <code>i965</code> drivers. Those are <strong>Intel</strong> drivers. On AMD, the Default driver is correct, because Mesa autodetects <code>radeonsi</code>. Forcing an Intel driver would break things. I safely ignored it.</p>
<h3 id="only-the-right-folders-and-read-only">Only the right folders, and read-only</h3>
<p>I did not want to expose the entire media folder to ErsatzTV — only two specific subfolders. The solution was to mount each one as its own subdirectory in <strong>read-only</strong> mode:</p>
<pre><code class="language-text">/mnt/user/skull/Mídia/Strangeland/   -&gt;  /media/Strangeland     (RO)
/mnt/user/skull/Mídia/Melted Stuff/  -&gt;  /media/Melted Stuff    (RO)
</code></pre>
<p>This way, the container only sees those two folders, and ErsatzTV can never write to the files. It only needs to read them anyway. Free protection.</p>
<h3 id="library-channel-and-schedule">Library, channel, and schedule</h3>
<p>The flow inside ErsatzTV is made of three linked pieces, and the last one is the part everyone forgets:</p>
<ul>
<li><strong>Library</strong>: <code>Other Videos</code>, the correct type for loose video files. ErsatzTV indexes the files here.</li>
<li><strong>Collection</strong>: the bucket containing what will play.</li>
<li><strong>Schedule</strong> with <em>Playout Mode</em> set to <strong>Flood</strong>: the logic that continuously fills the channel with items from the collection.</li>
<li><strong>Playout</strong>: connects the Schedule to the Channel and generates the timeline. <strong>Without the Playout, nothing plays.</strong></li>
</ul>
<p>I created the channel, <code>MadArabTV</code>, channel number 1, using <strong>HLS Segmenter</strong> mode and a 1080p H.264 profile through VAAPI. I tested the <code>.../iptv/channels.m3u</code> URL in VLC, and it played. The station was on the air.</p>
<hr>
<h2 id="the-bridge-the-ffmpeg-container">The bridge: the ffmpeg container</h2>
<p>The bridge is a minimal container, <code>jrottenberg/ffmpeg</code>, running in <strong>Host</strong> network mode so it can reach ErsatzTV and Owncast on the same machine. It uses <code>--restart=unless-stopped</code>, which means it restarts automatically if it crashes, but <strong>respects a manual Stop</strong>. The command grabs the ErsatzTV channel and pushes it to Owncast over RTMP.</p>
<p>The first version copied the video and converted only the audio to AAC, because Owncast's web player does not play AC3. That should have been the end of it.</p>
<p>It was not.</p>
<hr>
<h2 id="pain-1-owncast-refusing-the-stream">Pain #1: Owncast refusing the stream</h2>
<p>I started the bridge, and ffmpeg died immediately:</p>
<pre><code class="language-text">Error submitting a packet to the muxer: Connection reset by peer
</code></pre>
<p>The obvious reading would be: "wrong stream key." <strong>That was wrong — the reading, not the key.</strong> Owncast's own log told a different story:</p>
<pre><code class="language-text">Inbound stream connected from 192.168.50.7:...
Processing video using codec VA-API
amdgpu: unknown (family_id, chip_external_rev): (145, 16)
Failed to initialise VAAPI connection: resource allocation failed
your copy of ffmpeg may not support your selected codec of h264_vaapi
</code></pre>
<p><code>Inbound stream connected</code> means the key was correct and Owncast accepted the stream. What killed it was that <strong>Owncast was configured to re-encode the video using VA-API</strong> — and the Mesa driver <em>inside the Owncast container</em> did not recognize the AMD GPU: <code>amdgpu: unknown family_id</code>. The transcode failed, Owncast disconnected, and the bridge's ffmpeg saw that as a "connection reset."</p>
<p>This was the most valuable lesson in the whole setup: <strong>"connection reset" is rarely what it looks like</strong>. The destination log, not the source log, revealed the cause. Without checking the Owncast logs, I would have wasted hours recreating stream keys.</p>
<p>The curious detail is that the <strong>same GPU works in ErsatzTV and fails in Owncast</strong>. The difference is the Mesa version inside each container: ErsatzTV's Mesa is recent enough to recognize the card; Owncast's Mesa is too old for it.</p>
<h3 id="the-fix-passthrough">The fix: passthrough</h3>
<p>ffmpeg was already delivering ready-to-use H.264 + AAC. Owncast did not need to re-encode anything — it only needed to pass the stream through. I enabled <strong>Video Passthrough</strong> in Owncast, so it uses the incoming stream as-is. No transcode, no VA-API, problem solved, and as a bonus, zero transcoding CPU usage inside Owncast.</p>
<hr>
<h2 id="pain-2-crashes-during-transitions">Pain #2: crashes during transitions</h2>
<p>With passthrough enabled, the stream finally worked — for about 40 seconds. Then it dropped. And it kept dropping "randomly", at apparently random moments. The logs gave two clues:</p>
<pre><code class="language-text">Stream #0:0: Video: h264 ..., 47.95 fps    (in one clip)
Stream #0:0: Video: h264 ..., 29.97 fps    (in another)
[mpegts] Packet corrupt (stream = 0, dts = ...)
</code></pre>
<p>There were two separate problems:</p>
<ol>
<li>
<p><strong>Variable framerate between clips.</strong> Some videos were 30 fps, some 29.97 fps, others 48 fps. Since both the bridge and Owncast were only copying, nobody was normalizing anything. The defect passed through the chain and RTMP broke during clip changes. It played fine in VLC, which tolerates FPS changes, but broke in streaming, which does not.</p>
</li>
<li>
<p><strong>Zero-duration files.</strong> ErsatzTV's Health Check reported <strong>7 files with zero duration</strong> — corrupted, incomplete, or unreadable files. Whenever the channel rotation hit one of them, the transcode produced garbage and the stream broke. Those were the trigger for the "random" crashes. They were not random at all; they were bad files.</p>
</li>
</ol>
<hr>
<h2 id="the-decision-that-cleaned-everything-up-encode-once-on-the-gpu">The decision that cleaned everything up: encode once, on the GPU</h2>
<p>Instead of throwing a heavy CPU re-encode onto the bridge, the right move was to use the fact that <strong>ErsatzTV was already encoding successfully on the GPU</strong>. All I had to do was let ErsatzTV handle the full job — including framerate normalization, which it does natively in HLS Segmenter mode with <em>Normalize Video</em> enabled — and turn the bridge back into pure <strong><code>copy</code></strong>:</p>
<pre><code class="language-text">ErsatzTV (GPU encode + normalization on AMD) -&gt; ffmpeg (copy, ~0 CPU) -&gt; Owncast (passthrough) -&gt; public
</code></pre>
<p>One encode, on the GPU. Everything else is just forwarding. The bridge's CPU usage stays practically at zero, which mattered because the goal was never to beat the processor to death, especially with the machine already running hot at idle.</p>
<h3 id="killing-the-ghost-files">Killing the ghost files</h3>
<p>The last step was cleaning up the 7 zero-duration files. Since the folders were mounted read-only inside ErsatzTV, deletion had to happen on the host. A temporary ffmpeg container scanned the folders with <code>ffprobe</code>, identified every file with empty duration, unreadable duration, or duration below one second, and deleted it:</p>
<pre><code class="language-bash">docker run --rm -v "/mnt/user/skull/Mídia:/mnt/user/skull/Mídia" \
  --entrypoint /bin/bash jrottenberg/ffmpeg:latest -c '
shopt -s globstar nullglob
base="/mnt/user/skull/Mídia"
for f in "$base/Strangeland"/**/* "$base/Melted Stuff"/**/*; do
  [ -f "$f" ] || continue
  case "${f,,}" in
    *.mp4|*.avi|*.mkv|*.mov|*.m4v|*.ts|*.wmv|*.flv|*.webm|*.mpg|*.mpeg) ;;
    *) continue ;;
  esac
  d=$(ffprobe -v error -show_entries format=duration -of default=nokey=1:noprint_wrappers=1 "$f" 2&gt;/dev/null)
  if [ -z "$d" ] || [ "$d" = "N/A" ] || awk "BEGIN{exit !($d &lt; 1)}"; then
    rm -v -- "$f"
  fi
done
'
</code></pre>
<p>The trick with <code>-v "/host:/host"</code> — mounting the host path at the same path inside the container — makes the paths printed by the script identical to the real host paths, with no translation needed.</p>
<p>After deleting the 7 files, there was one confusing detail left: <strong>the warning kept appearing</strong>. Not because the files were still there, but because ErsatzTV reads that Health Check from its internal database, not directly from the disk in real time. A <strong>library re-scan</strong> reconciled the database with the filesystem, removed the orphaned entries, and the warning disappeared.</p>
<hr>
<h2 id="what-remained-standing">What remained standing</h2>
<p>A 24/7 linear TV channel, built from two media folders and streamed through Owncast:</p>
<ul>
<li><strong>A single encode</strong>, done by ErsatzTV on the AMD GPU. The bridge and Owncast only pass it through.</li>
<li><strong>Bridge CPU usage near zero</strong> — pure <code>copy</code>.</li>
<li><strong>One-click on/off</strong>: stopping the broadcast means hitting <em>Stop</em> on the ffmpeg container in the Docker tab. ErsatzTV keeps running the channel internally; Owncast goes back to "offline." <em>Start</em> brings it back.</li>
<li><strong>Protected media</strong>: mounted read-only, and only the two selected folders — never the whole library.</li>
</ul>
<hr>
<h2 id="lessons-for-next-time">Lessons for next time</h2>
<ul>
<li><strong>The error you see is rarely the cause.</strong> "Connection reset by peer" was not the stream key — it was the destination's VA-API transcode failing. The log on the <em>other side</em> solved it.</li>
<li><strong>The driver inside the container matters.</strong> The same GPU can work in one container with newer Mesa and fail in another with older Mesa. When acceleration fails, suspect the environment, not just the hardware.</li>
<li><strong>Do not re-encode what is already ready.</strong> If the source already outputs the right codec, <em>passthrough</em> and <code>copy</code> save CPU and remove failure points. Put the encode in one place only — preferably where the GPU is.</li>
<li><strong>Linear streams do not forgive bad media.</strong> Zero-duration files or variable framerate can break the broadcast during clip transitions, even when those files play fine in a desktop player. Auditing the library is worth it.</li>
<li><strong>Stale cache is half a bug.</strong> The Health Check warning about already-deleted files was just an outdated database state. Before assuming something failed, check whether the tool is simply reading old state.</li>
</ul>
]]></content>
    </entry>
    <entry>
        <title>The Doors I Forgot Were Open</title>
        <link href="https://pablomurad.com/the-doors-i-forgot-were-open/"/>
        <id>https://pablomurad.com/the-doors-i-forgot-were-open/</id>
        <published>2026-06-20T03:16:34-03:00</published>
        <updated>2026-06-20T03:16:34-03:00</updated>
        <author><name>Pablo Murad</name></author>
        <category term="tech"/>
        <summary>A server that seemed unstable was actually fine. The real work was quieter: closing forgotten doors, moving admin tools off the public internet, deleting what no longer mattered, and learning that maintenance is mostly subtraction.</summary>
        <content type="html"><![CDATA[<p><em>Notes on tending a server that quietly grew into doing everything.</em></p>
<p>It began with a feeling, which is the worst possible way to begin anything technical.</p>
<p>The server <em>felt</em> like it kept falling over. Every so often the load average would drift past two, the terminal would repaint its banner, and some old reflex would mutter <em>there it goes again, rebooted</em>. I believed that reflex for longer than I'd like to admit.</p>
<p>The honest first move in any optimization is to stop trusting the symptom and go look at the number underneath it. Load average might be the most misread figure in all of Linux. People read it as a CPU gauge. It isn't. On a twelve-core machine, a load of two means the box is half asleep — better than eighty percent of its cores idle, waiting for something to do. The figure that actually mattered was idle time, and idle time sat at ninety-four percent. The machine wasn't drowning. It had never been drowning. And those reboots I was so sure about? The uptime counter climbed the entire time. Machines that reboot don't count days; they start again from zero.</p>
<p>So there was no performance problem. That's a real result, not a disappointment. "Nothing is wrong here" is a legitimate answer, and hunting for phantom gains on a healthy machine is just a more sophisticated way of breaking it.</p>
<p>But once you stop asking <em>why is it slow</em> and start asking <em>what is this thing actually doing</em>, a different picture shows up. This was a server that had grown, without my quite noticing, into doing everything — a handful of websites, a git forge, an IRC bouncer, a small app or two I'd forgotten I installed, a sediment of services laid down over years. And every one of them had, at some point, opened a door.</p>
<p>So I listed what was listening, and it was not a short list. A database bound to <code>0.0.0.0</code> — every interface, the public one included. A second database doing the same. A container-management dashboard, the kind of thing that hands you the entire host if its password ever leaks, answering cheerfully on a public port. File sharing. A proxy I'd set up for some reason that had long since died and taken its justification with it. Each of these had been opened deliberately, once, for a purpose that no longer existed, and then simply left.</p>
<p>Here's the part that catches almost everyone, and it caught me: a firewall is not enough when you run Docker.</p>
<p>The mental model most of us carry is that the firewall is the bouncer at the door and nothing gets past it. But Docker writes its own packet-forwarding rules to make published ports reachable, and those rules are consulted before your tidy firewall policy ever gets a vote. You can have a deny-by-default firewall, feel completely safe, and still have a dozen container ports wide open to the internet. I did. The policy said one thing; the actual path a packet would take said another. That gap is the single most useful thing I relearned this week: trust the path, not the policy.</p>
<p>The fix wasn't clever, only disciplined. Anything that already sat behind the reverse proxy didn't need a public port of its own — the proxy reaches it over loopback, on the same machine — so I rebound those services to <code>127.0.0.1</code> and the outside door quietly closed. Nothing changed for visitors; they were always arriving through the domain and the proxy. The direct shortcut, the one only an attacker would ever bother with, just stopped existing.</p>
<p>Everything else — the databases, the dashboards, the things only <em>I</em> ever touch — moved onto the mesh. I already run a WireGuard-style mesh VPN across my machines, and once you have one of those, the public internet stops being the place you administer anything. A service bound to its mesh address is invisible to the world and a single hop away from me. The database that had been sitting exposed for who-knows-how-long became reachable from my own devices and from nowhere else on earth.</p>
<p>Then I deleted things, which is badly underrated. The app I never opened. The proxy with no purpose. A couple of accounts belonging to people who'd moved on long ago. Every one of them was surface area I'd been defending for no reason. Removing a service you don't use is worth more than hardening one you do — there is no more secure version of a thing than its absence.</p>
<p>The remainder was housekeeping of the small, dignified sort. A login banner that had been quietly lying about half its fields, because a language setting had broken the way it parsed memory and processor, got rewritten to read straight from the kernel instead of from translated labels. Stale accounts trimmed. The kind of work nobody thanks you for and everybody quietly benefits from.</p>
<p>And then, near the very end, the original ghost finally showed its face.</p>
<p>The thing that had convinced me the server was falling over wasn't the server at all. It was the IRC link — my client, at home behind an ordinary router, talking to the bouncer across the open internet. Home connections are restless animals. The router's translation table forgets an idle conversation; the next packet lands on a socket that's already dead; the link snaps; the client reconnects; and from the outside it looks exactly like a machine that keeps collapsing. It wasn't. The machine's uptime was measured in serene, uninterrupted days. The <em>road</em> to it was what kept washing out.</p>
<p>The remedy was the same idea as all the rest: stop driving on the public road. I pointed the client at the bouncer through the mesh instead of across the open net. The tunnel keeps itself awake, the translation-table timeouts stopped mattering, and the connection that used to die on the hour simply held. One steady thread where there had been a churn of dying ones.</p>
<p>None of this made the server faster, because the server was never slow. That was the lesson folded inside the whole exercise. We reach for the word <em>optimization</em> and picture squeezing more speed out of something, but most of the real work on a mature system is subtraction: closing doors you forgot you opened, deleting what no longer earns its keep, and learning to read the instrument that tells you the truth rather than the one that merely tells you something.</p>
<p>The machine had been fine the entire time. I just hadn't been looking at it honestly.</p>
<p>A server, in the end, isn't so much <em>optimized</em> as it is <em>tended</em>. And most of tending is knowing what to take away.</p>
]]></content>
    </entry>
    <entry>
        <title>How I Turned a Piece-of-Crap Tablet into a Plane</title>
        <link href="https://pablomurad.com/how-i-turned-a-piece-of-crap-tablet-into-a-plane/"/>
        <id>https://pablomurad.com/how-i-turned-a-piece-of-crap-tablet-into-a-plane/</id>
        <published>2026-06-18T14:57:28-03:00</published>
        <updated>2026-06-18T14:57:28-03:00</updated>
        <author><name>Pablo Murad</name></author>
        <category term="papers"/>
        <summary>Well, I was never much of a tablet person.


I’ve owned a couple of iPads before, and the main thing they did was collect dust on my shelf, which tells you exactly how important they were to me. But then the day came: I needed to sign some company</summary>
        <content type="html"><![CDATA[<p>Well, I was never much of a tablet person.</p>
<p>I’ve owned a couple of iPads before, and the main thing they did was collect dust on my shelf, which tells you exactly how important they were to me. But then the day came: I needed to sign some company documents quickly, and I was not happy with my drawing tablet anymore. I probably should have bought a newer one with a screen, but I’m a terrible enough artist that owning something that “advanced” felt almost insulting. Also, I wanted something portable.</p>
<p>That was it, I thought. I’m buying a tablet.</p>
<p>I walked into the store already sulking because I didn’t want to be there, saw the first one on the shelf for R$1,400, and without thinking too much, threw it into the cart.</p>
<p>I knew I was being robbed, in a way. I knew it cost more than it was worth. I knew our taxes are murderous. I also knew I didn’t even like tablets.</p>
<p>But I needed one.</p>
<p>All I needed was for <a href="https://www.noteshelf.net/index.html">Noteshelf</a> to work.</p>
<p>In practice, the tablet quickly showed me the cruel reality of entry-level devices: a heavy system, bloatware, background services, unnecessary animations, built-in ads, duplicate apps, and an interface trying very hard to look premium on hardware that has absolutely no breathing room for that kind of nonsense.</p>
<p>The device in question is a <strong>Redmi Pad SE</strong>, running <strong>Android 15 / HyperOS</strong>. It has <strong>4 GB of RAM</strong>, and it comes with that lovely marketing phrase: “4 GB + 2 GB,” meaning memory extension. It looks nice on the box, but in reality those extra 2 GB use internal storage as virtual memory. On a tablet with slower storage, that can become micro-stuttering.</p>
<p>And micro-stuttering is exactly what destroys the experience of writing with a stylus.</p>
<p>My disappointment had proven itself correct: I had bought a piece of crap. I had thrown money away, and I was pissed.</p>
<p>I tested the pen and noticed the delay. That killed me. So I went back to using the drawing tablet.</p>
<p>But this thing kept staring at me from the table, looking pathetic and saying: use me.</p>
<p>I had to do something.</p>
<p>The goal, then, was not to turn this tablet into an iPad or a top-of-the-line Galaxy Tab. That would be fantasy. The goal was more realistic: remove the system’s dead weight, reduce interference, cut useless processes, and make the tablet as fluid as possible for handwriting.</p>
<p>This article is the record of that process.</p>
<hr>
<h2 id="the-real-problem">The Real Problem</h2>
<p>The problem was not simply “the tablet is weak.”</p>
<p>That would be a lazy explanation.</p>
<p>The problem was the sum of several things:</p>
<ul>
<li>4 GB of RAM for a heavy modern system;</li>
<li>HyperOS packed with Xiaomi services;</li>
<li>duplicate apps for video, music, browser, notes, weather, scanner, and so on;</li>
<li>telemetry and advertising;</li>
<li>useless notifications;</li>
<li>battery saving interfering with writing apps;</li>
<li>system animations;</li>
<li>memory extension using internal storage;</li>
<li>the natural limitations of the Redmi Pad SE screen and pen.</li>
</ul>
<p>In other words: the tablet does not have much muscle to begin with, and the system still wastes part of it on useless junk.</p>
<p>The idea was simple: if the hardware is limited, the system needs to be leaner.</p>
<hr>
<h2 id="before-even-thinking-about-a-custom-rom">Before Even Thinking About a Custom ROM</h2>
<p>My first idea was: “Can I install a lighter operating system on this thing?”</p>
<p>Yes, technically. The Redmi Pad SE uses the codename <code>xun</code>, and there are custom ROMs for it, including LineageOS and other unofficial builds.</p>
<p>But that comes with risks: unlocking the bootloader, wiping data, relying on a compatible recovery, and accepting possible bugs with touch, pen input, audio, camera, rotation, or battery.</p>
<p>And here is the important part: switching ROMs can make the system lighter, but it can also make the pen experience worse. On Android tablets, stylus performance depends a lot on the kernel, firmware, drivers, and manufacturer-specific tweaks.</p>
<p>So the decision was sensible: before going down the custom ROM route, I would debloat the system through ADB and tune what could be tuned.</p>
<p>No root.<br>
No bootloader unlocking.<br>
No irreversible hackery.</p>
<hr>
<h2 id="confirming-the-model-with-adb">Confirming the Model with ADB</h2>
<p>First, I connected the tablet to Windows using ADB. Since I was using WSL, ADB inside Debian could not see the USB device directly. The simplest solution was to use PowerShell on Windows.</p>
<p>With USB debugging enabled on the tablet, I ran:</p>
<pre><code class="language-powershell">adb devices
</code></pre>
<p>At first, it showed up as <code>unauthorized</code>. That is normal. The tablet displays a window asking you to authorize the computer’s RSA key. After accepting it, the result looked like this:</p>
<pre><code class="language-text">List of devices attached
1c5e93eb        device
</code></pre>
<p>Now ADB was working.</p>
<p>Then I confirmed the model:</p>
<pre><code class="language-powershell">adb shell getprop ro.product.device
adb shell getprop ro.product.model
adb shell getprop ro.product.name
adb shell getprop ro.build.version.release
adb shell getprop ro.miui.ui.version.name
</code></pre>
<p>Result:</p>
<pre><code class="language-text">xun
23073RPBFL
xun_global
15
V816
</code></pre>
<p>So:</p>
<ul>
<li>device: Redmi Pad SE;</li>
<li>codename: <code>xun</code>;</li>
<li>variant: global;</li>
<li>Android: 15;</li>
<li>interface: HyperOS/MIUI V816.</li>
</ul>
<p>This confirmation is essential. Debloating or looking for ROMs without knowing the device codename is asking to do something stupid.</p>
<hr>
<h2 id="saving-the-package-list-before-touching-anything">Saving the Package List Before Touching Anything</h2>
<p>Before removing anything, I saved the complete list of installed packages:</p>
<pre><code class="language-powershell">adb shell pm list packages &gt; "$env:USERPROFILE\Desktop\pacotes-redmi-pad-se.txt"
</code></pre>
<p>That creates a file on the Desktop with all packages. It is not exactly a system backup, but it helps a lot if I need to remember what was there before.</p>
<p>I also listed packages related to Xiaomi, MIUI, video, music, browser, games, ads, and similar junk:</p>
<pre><code class="language-powershell">adb shell pm list packages | findstr /i "miui xiaomi msa analytics browser video music game getapps yellowpage"
</code></pre>
<p>That is when the trash collection showed up.</p>
<hr>
<h2 id="what-i-decided-not-to-remove">What I Decided NOT to Remove</h2>
<p>This part matters.</p>
<p>Good debloating does not mean deleting everything like an animal.</p>
<p>Some packages look useless, but they control critical parts of the system. Remove the wrong thing and you can break permissions, battery management, the launcher, touch behavior, system features, or even the pen experience.</p>
<p>I decided not to remove these packages:</p>
<pre><code class="language-text">com.miui.powerkeeper
com.xiaomi.touchservice
com.miui.securitycenter
com.miui.securitycore
com.lbe.security.miui
com.miui.securityadd
com.miui.core
com.miui.core.internal.services
com.miui.system
com.miui.home
miui.systemui.plugin
com.miui.notification
com.miui.permissioncontroller.overlay
com.xiaomi.account
com.xiaomi.finddevice
com.xiaomi.xmsf
com.xiaomi.xmsfkeeper
com.xiaomi.bluetooth
com.miui.misound
com.miui.daemon
com.miui.misightservice
com.android.systemui.overlay.miui
com.android.settings.overlay.miui
com.android.networkstack.overlay.miui
com.android.wifi.resources.xiaomi
com.google.android.wifi.resources.xiaomi
com.android.inputsettings.overlay.miui
com.miui.settings.rro.device.systemui.overlay
com.miui.system.overlay
com.miui.systemui.devices.overlay
com.miui.systemui.overlay.devices.android
</code></pre>
<p>The most important one in that list is this:</p>
<pre><code class="language-text">com.xiaomi.touchservice
</code></pre>
<p>If the goal is to improve handwriting, messing with something called <code>touchservice</code> would be idiotic. Maybe it is not directly responsible for the pen, but it is too close to the critical area to play games with it.</p>
<p>I also chose not to remove the wallpaper components:</p>
<pre><code class="language-text">com.miui.miwallpaper
com.miui.miwallpaper.overlay
com.miui.miwallpaper.overlay.customize
com.miui.miwallpaper.config.overlay
com.miui.wallpaper.overlay
com.miui.wallpaper.overlay.customize
com.miui.aod
</code></pre>
<p>Could I have been more aggressive? Yes.</p>
<p>But the likely gain is small, and the chance of creating some annoying system behavior is not worth it.</p>
<hr>
<h2 id="the-main-debloat">The Main Debloat</h2>
<p>The removal was done with:</p>
<pre><code class="language-powershell">adb shell pm uninstall --user 0 package.name
</code></pre>
<p>This method removes the app only for the current user. It does not physically delete the app from the system partition. That is good, because it usually allows restoration later.</p>
<p>The main block was this:</p>
<pre><code class="language-powershell">adb shell pm uninstall --user 0 com.miui.analytics
adb shell pm uninstall --user 0 com.miui.msa.global
adb shell pm uninstall --user 0 com.miui.miservice
adb shell pm uninstall --user 0 com.mi.globalbrowser
adb shell pm uninstall --user 0 com.miui.videoplayer
adb shell pm uninstall --user 0 com.miui.player
adb shell pm uninstall --user 0 com.miui.yellowpage
adb shell pm uninstall --user 0 com.xiaomi.payment
adb shell pm uninstall --user 0 com.xiaomi.barrage
adb shell pm uninstall --user 0 com.xiaomi.ugd
adb shell pm uninstall --user 0 com.xiaomi.discover
adb shell pm uninstall --user 0 com.miui.thirdappassistant
adb shell pm uninstall --user 0 com.miui.bugreport
adb shell pm uninstall --user 0 com.xiaomi.mtb
adb shell pm uninstall --user 0 com.xiaomi.miralink
adb shell pm uninstall --user 0 com.xiaomi.smarthome
adb shell pm uninstall --user 0 cn.wps.xiaomi.abroad.lite
adb shell pm uninstall --user 0 com.miui.fm
adb shell pm uninstall --user 0 com.miui.fmservice
adb shell pm uninstall --user 0 com.miui.screenrecorder
adb shell pm uninstall --user 0 com.miui.weather2
adb shell pm uninstall --user 0 com.miui.notes
adb shell pm uninstall --user 0 com.miui.qr
adb shell pm uninstall --user 0 com.xiaomi.scanner
adb shell pm uninstall --user 0 com.google.android.apps.youtube.music
adb shell pm uninstall --user 0 com.google.android.videos
</code></pre>
<p>Not everything was removed. Some packages returned this error:</p>
<pre><code class="language-text">Failure [-1000]
</code></pre>
<p>That happened with a few apps like screen recorder, weather, notes, and scanner. It is not the end of the world. It just means the system blocked the removal of those packages for the current user.</p>
<p>The important stuff did come out:</p>
<ul>
<li>analytics;</li>
<li>MSA/advertising;</li>
<li>Mi Browser;</li>
<li>Mi Video;</li>
<li>Mi Music;</li>
<li>Xiaomi payments;</li>
<li>extra services;</li>
<li>bug report tools;</li>
<li>preinstalled WPS;</li>
<li>FM;</li>
<li>YouTube Music;</li>
<li>Google TV.</li>
</ul>
<p>That already clears out a decent amount of garbage.</p>
<hr>
<h2 id="what-to-do-when-failure1000-shows-up">What to Do When <code>Failure [-1000]</code> Shows Up</h2>
<p>My decision was not to push too hard.</p>
<p>You can try disabling instead of uninstalling:</p>
<pre><code class="language-powershell">adb shell pm disable-user --user 0 com.miui.screenrecorder
adb shell pm disable-user --user 0 com.miui.weather2
adb shell pm disable-user --user 0 com.miui.notes
adb shell pm disable-user --user 0 com.xiaomi.scanner
</code></pre>
<p>But if the system refuses, fine. The performance gain from those idle apps is small. Fighting protected system apps can quickly become a waste of time.</p>
<p>Debloating needs a goal. The goal here is handwriting fluidity, not winning a holy war against every Xiaomi package.</p>
<hr>
<h2 id="rebooting-the-tablet">Rebooting the Tablet</h2>
<p>After removing the packages, I rebooted:</p>
<pre><code class="language-powershell">adb reboot
</code></pre>
<p>This is mandatory. Testing after debloating without rebooting is a bad test.</p>
<hr>
<h2 id="settings-that-actually-matter">Settings That Actually Matter</h2>
<p>Removing bloat helps, but it does not solve everything.</p>
<p>For stylus writing, some system settings are just as important as debloating.</p>
<h3 id="1-set-the-refresh-rate-to-90-hz">1. Set the Refresh Rate to 90 Hz</h3>
<p>On the tablet:</p>
<pre><code class="language-text">Settings → Display → Refresh rate
</code></pre>
<p>Select:</p>
<pre><code class="language-text">90 Hz
</code></pre>
<p>or:</p>
<pre><code class="language-text">High
</code></pre>
<p>If the tablet is running at 60 Hz or using a conservative automatic mode, handwriting can feel more delayed. For pen input, predictability matters.</p>
<hr>
<h3 id="2-disable-or-reduce-animations">2. Disable or Reduce Animations</h3>
<p>First, enable Developer Options:</p>
<pre><code class="language-text">Settings → About tablet → tap "OS version" several times
</code></pre>
<p>Then go to:</p>
<pre><code class="language-text">Settings → Additional settings → Developer options
</code></pre>
<p>Change:</p>
<pre><code class="language-text">Window animation scale: 0.5x
Transition animation scale: 0.5x
Animator duration scale: 0.5x
</code></pre>
<p>If the system still feels heavy:</p>
<pre><code class="language-text">Off
</code></pre>
<p>This does not directly change pen latency, but it removes that dragged-through-mud feeling from the system.</p>
<hr>
<h3 id="3-turn-off-memory-extension">3. Turn Off Memory Extension</h3>
<p>On the tablet:</p>
<pre><code class="language-text">Settings → Additional settings → Memory extension
</code></pre>
<p>or:</p>
<pre><code class="language-text">Settings → About tablet → RAM → Memory extension
</code></pre>
<p>Disable:</p>
<pre><code class="language-text">Memory extension
</code></pre>
<p>Then reboot.</p>
<p>This part is counterintuitive, because marketing sells “more RAM” as a good thing. But on a device with slower storage, virtual memory can create micro-stutters.</p>
<p>For handwriting, micro-stutters are worse than an app reloading.</p>
<p>Better to have less multitasking and more immediate response.</p>
<hr>
<h3 id="4-remove-battery-restrictions-from-the-writing-app">4. Remove Battery Restrictions from the Writing App</h3>
<p>For Noteshelf:</p>
<pre><code class="language-text">Settings → Apps → Manage apps → Noteshelf → Battery saver
</code></pre>
<p>Select:</p>
<pre><code class="language-text">No restrictions
</code></pre>
<p>This also applies to Squid, JNotes, Nebo, OneNote, or Xodo.</p>
<p>If the system tries to save battery while you write, the experience can become bad. Fluid handwriting needs fast response, not aggressive power saving.</p>
<hr>
<h3 id="5-disable-battery-saver-while-writing">5. Disable Battery Saver While Writing</h3>
<p>On the tablet:</p>
<pre><code class="language-text">Settings → Battery
</code></pre>
<p>Disable:</p>
<pre><code class="language-text">Battery saver
Ultra battery saver
</code></pre>
<p>If there is a performance mode, use it while writing.</p>
<p>You cannot demand low latency while the system is trying to cut processing.</p>
<hr>
<h3 id="6-reduce-useless-notifications">6. Reduce Useless Notifications</h3>
<p>On the tablet:</p>
<pre><code class="language-text">Settings → Notifications &amp; status bar → App notifications
</code></pre>
<p>Disable notifications from apps that do not need to wake the system:</p>
<ul>
<li>Themes;</li>
<li>Weather;</li>
<li>Xiaomi Notes, if you do not use it;</li>
<li>Scanner;</li>
<li>Gallery, if you do not need it;</li>
<li>Security, except important alerts;</li>
<li>any useless preinstalled app.</li>
</ul>
<p>A notification is an interruption.<br>
An interruption wakes an app.<br>
A woken app competes for resources.<br>
On a weak tablet, that matters.</p>
<hr>
<h3 id="7-clean-up-the-home-screen">7. Clean Up the Home Screen</h3>
<p>I removed unnecessary widgets and avoided live wallpapers.</p>
<p>Recommended setup:</p>
<ul>
<li>static wallpaper;</li>
<li>no weather widget;</li>
<li>no side feed;</li>
<li>no news cards;</li>
<li>no visual nonsense.</li>
</ul>
<p>The tablet does not need to look like a carrier store demo unit. It needs to open the notes app and respond to the pen.</p>
<hr>
<h2 id="tuning-noteshelf">Tuning Noteshelf</h2>
<p>The original idea was to use Noteshelf. It is beautiful, organized, and good for digital notebooks.</p>
<p>But beautiful usually costs resources.</p>
<p>So the configuration needs to be conservative:</p>
<ul>
<li>use simple pages;</li>
<li>avoid heavy templates;</li>
<li>avoid too many huge notebooks;</li>
<li>avoid massive PDFs;</li>
<li>split large PDFs by chapter;</li>
<li>avoid constant syncing;</li>
<li>avoid recording audio while taking notes;</li>
<li>avoid real-time handwriting recognition if it feels heavy;</li>
<li>avoid AI features or extras that process things in the background.</li>
</ul>
<p>The rule is simple: fluidity first, decoration later.</p>
<hr>
<h2 id="alternative-apps-worth-testing">Alternative Apps Worth Testing</h2>
<p>Noteshelf may work well, but I would not treat it as the only option.</p>
<p>For fluid writing on limited hardware, I would test in this order:</p>
<ol>
<li>Squid;</li>
<li>JNotes;</li>
<li>Noteshelf;</li>
<li>Nebo;</li>
<li>OneNote;</li>
<li>Xodo, mainly for PDFs.</li>
</ol>
<p>The correct test always starts on a blank white page.</p>
<p>If a blank page stutters, the problem is the system, screen, pen, app, or hardware. If the blank page works fine but PDFs stutter, the problem is probably the PDF or the way the app renders the document.</p>
<hr>
<h2 id="final-test-after-debloating">Final Test After Debloating</h2>
<p>After everything, the test needs to be clean:</p>
<ol>
<li>reboot the tablet;</li>
<li>wait about two minutes;</li>
<li>close recent apps;</li>
<li>open only the notes app;</li>
<li>create a simple blank page;</li>
<li>write quickly for five minutes;</li>
<li>repeat in Squid, JNotes, and Noteshelf;</li>
<li>only then test PDFs.</li>
</ol>
<p>If Noteshelf stutters but Squid and JNotes behave well, the problem is the weight of Noteshelf on this hardware.</p>
<p>If all of them stutter, the limitation is deeper: screen, pen, touch layer, hardware, or HyperOS.</p>
<hr>
<h2 id="how-to-restore-a-removed-package">How to Restore a Removed Package</h2>
<p>If something breaks, you can try restoring a package with:</p>
<pre><code class="language-powershell">adb shell cmd package install-existing package.name
</code></pre>
<p>Example:</p>
<pre><code class="language-powershell">adb shell cmd package install-existing com.mi.globalbrowser
</code></pre>
<p>That is why I used <code>pm uninstall --user 0</code>. It is much less dangerous than deleting system partition files with root.</p>
<hr>
<h2 id="automated-script">Automated Script</h2>
<p>After the manual process, I also created a PowerShell script to automate this debloat on Windows.</p>
<p>The idea of the script is to:</p>
<ul>
<li>check whether ADB exists;</li>
<li>try to install Platform Tools through <code>winget</code>, if needed;</li>
<li>confirm that the tablet is connected;</li>
<li>check whether the codename is <code>xun</code>;</li>
<li>export the package list;</li>
<li>apply the recommended debloat;</li>
<li>offer extra options;</li>
<li>generate a log;</li>
<li>allow package restoration.</li>
</ul>
<p>This makes it easier to save as a Gist and repeat the process without relying on memory or copying loose commands around.</p>
<hr>
<h2 id="expected-result">Expected Result</h2>
<p>This process does not perform miracles.</p>
<p>The Redmi Pad SE remains an entry-level tablet. It does not become an iPad Pro. It does not become a Galaxy Tab S with an S Pen. It does not gain a premium digitizer, and it does not suddenly have RAM to spare.</p>
<p>But the process removes a lot of junk, reduces useless services, cuts ads, removes duplicate apps, and leaves the system less burdened.</p>
<p>The goal is simple:</p>
<pre><code class="language-text">less bloat,
fewer useless processes,
fewer interruptions,
less micro-stuttering,
more focus on writing.
</code></pre>
<p>If, after all this, the tablet still cannot deliver acceptable handwriting, then the conclusion is harsh but honest: the limit was not only software. The hardware and pen experience of the Redmi Pad SE may simply not be good enough for heavy handwriting use.</p>
<p>In that case, the next options would be:</p>
<ol>
<li>test a lightweight custom ROM, such as LineageOS, accepting the risk;</li>
<li>change the writing app;</li>
<li>use lighter PDFs;</li>
<li>or accept that, for serious handwriting, the right path is a tablet with a proper active pen and better hardware support.</li>
</ol>
<p>The good part is that ADB debloating is the best first step: it improves what can be improved without unlocking the bootloader, without root, and without turning the tablet into a paperweight.</p>
<hr>
<h2 id="summary-of-the-main-commands">Summary of the Main Commands</h2>
<p>Confirm device:</p>
<pre><code class="language-powershell">adb devices
</code></pre>
<p>Confirm model:</p>
<pre><code class="language-powershell">adb shell getprop ro.product.device
adb shell getprop ro.product.model
adb shell getprop ro.product.name
adb shell getprop ro.build.version.release
adb shell getprop ro.miui.ui.version.name
</code></pre>
<p>Save package list:</p>
<pre><code class="language-powershell">adb shell pm list packages &gt; "$env:USERPROFILE\Desktop\pacotes-redmi-pad-se.txt"
</code></pre>
<p>Main debloat:</p>
<pre><code class="language-powershell">adb shell pm uninstall --user 0 com.miui.analytics
adb shell pm uninstall --user 0 com.miui.msa.global
adb shell pm uninstall --user 0 com.miui.miservice
adb shell pm uninstall --user 0 com.mi.globalbrowser
adb shell pm uninstall --user 0 com.miui.videoplayer
adb shell pm uninstall --user 0 com.miui.player
adb shell pm uninstall --user 0 com.miui.yellowpage
adb shell pm uninstall --user 0 com.xiaomi.payment
adb shell pm uninstall --user 0 com.xiaomi.barrage
adb shell pm uninstall --user 0 com.xiaomi.ugd
adb shell pm uninstall --user 0 com.xiaomi.discover
adb shell pm uninstall --user 0 com.miui.thirdappassistant
adb shell pm uninstall --user 0 com.miui.bugreport
adb shell pm uninstall --user 0 com.xiaomi.mtb
adb shell pm uninstall --user 0 com.xiaomi.miralink
adb shell pm uninstall --user 0 com.xiaomi.smarthome
adb shell pm uninstall --user 0 cn.wps.xiaomi.abroad.lite
adb shell pm uninstall --user 0 com.miui.fm
adb shell pm uninstall --user 0 com.miui.fmservice
adb shell pm uninstall --user 0 com.miui.qr
adb shell pm uninstall --user 0 com.google.android.apps.youtube.music
adb shell pm uninstall --user 0 com.google.android.videos
</code></pre>
<p>Reboot:</p>
<pre><code class="language-powershell">adb reboot
</code></pre>
<p>Restore package:</p>
<pre><code class="language-powershell">adb shell cmd package install-existing package.name
</code></pre>
<hr>
<p>The Redmi Pad SE is not absolute garbage, but it comes loaded with too much crap for what it offers. HyperOS tries to sell a feature-rich experience, but on a device with 4 GB of RAM, that has a price.</p>
<p>For anyone who wants to use a pen and write smoothly, the smartest path is to cut weight:</p>
<ul>
<li>debloat through ADB;</li>
<li>enable 90 Hz;</li>
<li>turn off memory extension;</li>
<li>reduce animations;</li>
<li>remove battery restrictions from the writing app;</li>
<li>disable useless notifications;</li>
<li>use lighter writing apps;</li>
<li>use smaller PDFs.</li>
</ul>
<p>This does not turn the tablet into another device. But it removes a lot of the dirt that was in the way.</p>
<p>And sometimes optimizing a cheap tablet is exactly that: stop expecting miracles and start removing everything that gets in the way.</p>
<p>And that was it. I had a little Cessna-style jet in my hands. It was not the most powerful thing in the sky, but it flew.</p>
<p>Script link: <a href="https://gist.murad.host/pablo/568220483839436d99c97861ad2d5a03">https://gist.murad.host/pablo/568220483839436d99c97861ad2d5a03</a></p>
]]></content>
    </entry>
    <entry>
        <title>The Last Steps</title>
        <link href="https://pablomurad.com/the-last-steps/"/>
        <id>https://pablomurad.com/the-last-steps/</id>
        <published>2026-06-18T13:35:45-03:00</published>
        <updated>2026-06-18T13:35:45-03:00</updated>
        <author><name>Pablo Murad</name></author>
        <category term="updating"/>
        <summary>I can’t deny that over the last few months I’ve created a handful of websites, many of which don’t even exist anymore, mostly as a form of experimentation. Pablo’s Space, for example, has been my target more than once. It went from something consolidated and old,</summary>
        <content type="html"><![CDATA[<p>I can’t deny that over the last few months I’ve created a handful of websites, many of which don’t even exist anymore, mostly as a form of experimentation. <a href="https://pablo.space">Pablo’s Space</a>, for example, has been my target more than once. It went from something consolidated and old, running on WordPress, to my weird little experiments in the IndieWeb. It became a good blog, but I’m restless, so naturally I blew it up.</p>
<p>The other sites in the same vein followed the same path: they were destroyed and rebuilt, tested, adjusted, and remade. Nothing stayed. Everything was temporary.</p>
<p>And I have to confess, this has been very interesting. I’ll probably keep testing things for a while longer until I find myself in this whole “indie” world. The truth is, I don’t want to talk about myself. I want to create, and to create, maybe I need to understand first.</p>
<p>Personal websites follow a different logic than systems. I don’t keep changing systems, and my systems stay intact. They are things I consider good enough, and I’m not arrogant enough to touch them unless it’s to fix small bugs.</p>
<p>Anyway, the truth is that I don’t think I want to talk about myself. Don’t trust the creature, trust the creator. If you’re reading this post on this blog right now, maybe in a week it won’t even exist anymore.</p>
<p>To talk about the next steps, I first need to talk about what I’ve been up to.</p>
<p>I rented another VPS from Contabo — I really like them, first-class service in my opinion, and the best part is that it’s raw: do it yourself — and turned this small, simple VPS into a Pangolin server, to escape Cloudflare’s claws. But I ran into some problems, as I wrote about in <a href="https://pablomurad.com/exposing-self-hosted-streaming-behind-cgnat/">Exposing self-hosted streaming behind CGNAT</a>.</p>
<p>I also took the opportunity to bring my blog, the one you’re reading right now, closer to the IndieWeb without changing its core, only its theme, as I explained in <a href="https://pablomurad.com/teaching-ghost-to-speak-indieweb/">Teaching Ghost to speak IndieWeb</a>.</p>
<p>On that VPS running Pangolin, I placed a few services that are hosted behind CGNAT so they could be exposed to the internet, such as <a href="https://noctem.cafe">Noctem Café</a>, which is an absolutely experimental pubnix running on OpenBSD — my first time messing with this OS.</p>
<p>I also managed to turn a port-scanning tool, <code>ncat</code>, into a posting tool using the SOLED/2 protocol. You can check out the small and pleasant result at <a href="https://soledade.city">Soledade City</a>.</p>
<p>I could list all the services I’m exposing and hosting, but I don’t know, it doesn’t make much sense right now, and there’s quite a lot of stuff — I’m feeling lazy. Maybe I’ll write a post in the future exclusively about self-hosting and how I manage it all.</p>
<p>I also finally found the courage to stop the specific attacks I was receiving on my servers by moving a good part of my infrastructure into the Tailnet, as I wrote in <a href="https://pablomurad.com/the-night-we-put-forgejo-behind-the-tailnet/">The night we put Forgejo behind the Tailnet</a>.</p>
<p>Even though the article talks about Forgejo, it was just the first service I pulled away from the surface. After that, I took a whole bunch with it. 😊</p>
<p>Anyway, everything was created, destroyed, and rebuilt several times, in several different ways, so I could learn. And that’s fine. I’ve learned a lot, and the more I know, the more I want to know.</p>
<p>All I know is that it’s hard to reconcile my work time with my experiments. At least sleeping very little is already something I do, hehe, and in the dead of night, the lab comes alive.</p>
<p><img src="https://shot.1208.pro/uploads/vT14IV5QGvTcNGnrgrU74AwplN1m1IXMniZNSKPJ.png" alt="Covered and censored view of servers I administer or help administer" loading="lazy"></p>
<p>When I say the administration is rough, above are some servers I manage and others I help manage, covered up and censored.</p>
<p>That’s it. Let’s go, because there’s <strong>more coming</strong>.</p>
]]></content>
    </entry>
    <entry>
        <title>Teaching Ghost to Speak IndieWeb</title>
        <link href="https://pablomurad.com/teaching-ghost-to-speak-indieweb/"/>
        <id>https://pablomurad.com/teaching-ghost-to-speak-indieweb/</id>
        <published>2026-06-16T16:59:54-03:00</published>
        <updated>2026-06-16T16:59:54-03:00</updated>
        <author><name>Pablo Murad</name></author>
        <category term="papers"/>
        <summary>How I retrofitted my Ghost blog with an h-card and webmentions — two things Ghost doesn&#39;t ship with.</summary>
        <content type="html"><![CDATA[<h2 id="where-this-started">Where this started</h2>
<p>I like Ghost. It's lightweight, open source, writes well, and stays out of the way. But Ghost was born as a publishing and newsletter platform — not as a citizen of the <strong>IndieWeb</strong>. It doesn't publish an <code>h-card</code> for my identity, doesn't mark up my posts with <code>h-entry</code>, and has no idea how to send or receive <strong>webmentions</strong>. To a machine, my site was just nice-looking HTML: humans could read it, software understood almost nothing.</p>
<p>The idea behind this experiment was simple: keep Ghost exactly as it is — no plugin, no fork, no extra server humming away beside it — and still get my site talking to the open web. Everything you see here lives in the <strong>theme</strong> (Handlebars plus a little CSS and JavaScript) and in one free hosted service. Not a single line of server code.</p>
<p>And I gave myself one non-negotiable rule: <strong>don't lose 1% of the look</strong>. The blog is a lean little list, almost a retro directory. Anything I added had to be either invisible or as understated as everything else.</p>
<h2 id="identity-first-the-invisible-h-card">Identity first: the invisible h-card</h2>
<p>Everything on the IndieWeb starts with proving <strong>who you are</strong>. You do that with microformats — a handful of class names you bolt onto your existing HTML so software can pick out your name, photo, URL, email, and profiles. The <code>h-card</code> is the semantic business card.</p>
<p>In my case, I didn't want a card showing up on screen; I wanted only machines to read it. The solution was a hidden <code>h-card</code> block at the top of every page, using the <code>hidden</code> attribute. Readers see nothing; IndieWeb parsers read the HTML just fine, because they ignore CSS.</p>
<p>Alongside it, I scattered <code>rel="me"</code> links to my profiles (GitHub, Codeberg, SourceHut, Mastodon, and so on). That's what cements "this domain and these accounts are the same person." The detail almost everyone forgets: <code>rel="me"</code> only closes the loop if the <strong>profile links back</strong> to your domain too. Without the return link, the verification never happens.</p>
<h2 id="posts-machines-can-read-h-entry">Posts machines can read: h-entry</h2>
<p>Identity sorted, the posts were next. <code>h-entry</code> is the microformat that marks each post as an "entry" — with a title (<code>p-name</code>), content (<code>e-content</code>), publish date (<code>dt-published</code>), canonical URL (<code>u-url</code>), author, and categories.</p>
<p>The beauty of it is that, again, <strong>it's just class names</strong> added to elements that were already there. The <code>&lt;article&gt;</code> got <code>h-entry</code>, the <code>&lt;h1&gt;</code> got <code>p-name</code>, the body got <code>e-content</code>. The post's tags became <code>p-category</code>. The author sits embedded as a hidden <code>p-author h-card</code>, reusing the same photo from my card.</p>
<p>The one technical thing that needed care was the date. <code>dt-published</code> wants an ISO 8601 format <strong>with a timezone</strong>, and the theme only showed <code>day/month</code>. The fix was to split the two apart: the <code>datetime</code> attribute holds the full machine-readable date, while the text on screen stays exactly as it was. The reader sees "05 Jun"; the parser sees <code>2026-06-05T23:51:21-03:00</code>. Zero visual change.</p>
<p>I marked up both the individual posts and the home listing — because IndieWeb feed readers consume both.</p>
<h2 id="receiving-conversations-webmentions-with-no-server">Receiving conversations: webmentions with no server</h2>
<p>This is where it gets interesting. <strong>Webmention</strong> is the open web's way of doing what likes and comments do inside social networks — except between independent sites. When someone replies to or mentions one of my posts from their own site, I get notified, and it can show up as a comment here.</p>
<p>The catch: receiving a webmention, in theory, needs a server to validate and store each notification. I didn't want to run that. The way out was <strong>webmention.io</strong>, a free hosted service that does all the heavy lifting. On my end, all it took was <strong>announcing its address</strong> with a single invisible line in the <code>&lt;head&gt;</code>:</p>
<pre><code class="language-html">&lt;link rel="webmention" href="https://webmention.io/yourdomain.com/webmention"&gt;
</code></pre>
<p>That's it. From there on, any mention of my site gets received, verified, and archived by them. One elegant detail: logging into webmention.io happens <strong>through my own domain</strong>, using exactly the <code>rel="me"</code> links I'd already added. The pieces clicked together.</p>
<p>(I made a deliberate choice <strong>not</strong> to enable legacy pingbacks — they pull in way too much spam.)</p>
<h2 id="displaying-it-my-way">Displaying it my way</h2>
<p>Receiving is half the story; I wanted to <strong>show</strong> these conversations — but in my own style, not in some generic third-party widget fighting the blog's look.</p>
<p>I went with a small bit of my own JavaScript. It reads webmention.io's public API by the post's URL (no key needed), splits <strong>reactions</strong> (likes, reposts) from <strong>replies</strong>, and builds the markup by hand. Reactions become a compact row of avatars; replies become a clean list with the same monospaced date and the same dotted line I use on the home page. The commenter's name lights up in the brand color on hover.</p>
<p>And the part that mattered most for the aesthetic: <strong>if there are no mentions, the section just doesn't exist</strong>. No "0 comments," no empty box. Silence, the way it should be on a minimalist blog.</p>
<p>This is where I closed a funny little loop: I'd turned off Ghost's native comments because I didn't want "discussion" locked inside a membership platform. Webmentions are the open version of that — the conversation happens across the whole web and only echoes here.</p>
<h2 id="what-still-lives-outside">What still lives outside</h2>
<p>Technical honesty: one half of webmention — <strong>sending</strong> — still isn't inside the theme, and it doesn't need to be. When I link to another IndieWeb site, I have to fire off a notification to it. That's handled by an external service (like Bridgy or webmention.app) that watches my RSS feed and sends them automatically. The theme only needs solid <code>h-entry</code> markup — which is already there — for those notifications to go out correctly formed.</p>
<p>So: the "passive" half (being an identity, having readable posts, receiving and displaying conversations) all lives in the theme, invisible or understated. The "active" half (reaching out to notify other sites) is a service wired to RSS, no code.</p>
<p>In the end, none of this changes how the blog looks. A distracted visitor won't notice a thing. But underneath, the site stopped being an island. It now introduces itself, proves who it is, marks up its writing in a way any open tool understands, and is ready to receive conversations from any corner of the web — without leaning on Ghost for it, without leaning on any social network.</p>
<p>Ghost is still Ghost. I just taught it a second language — the one the open web speaks — and did it without Ghost (or the reader) ever noticing.</p>
]]></content>
    </entry>
    <entry>
        <title>Exposing Self-Hosted Streaming Behind CGNAT</title>
        <link href="https://pablomurad.com/exposing-self-hosted-streaming-behind-cgnat/"/>
        <id>https://pablomurad.com/exposing-self-hosted-streaming-behind-cgnat/</id>
        <published>2026-06-15T12:54:59-03:00</published>
        <updated>2026-06-15T12:57:39-03:00</updated>
        <author><name>Pablo Murad</name></author>
        <category term="papers"/>
        <summary>How I exposed self-hosted streaming behind CGNAT — after Cloudflare, Tailscale, and Pangolin let me down, an FRP persistent TCP tunnel fixed the real bottleneck.</summary>
        <content type="html"><![CDATA[<p>A little over two years ago I left Vero (my ISP), where I had a working public IP, and moved to TIM Ultrafibra. The reason was simple: TIM was offering monstrous speeds, and I happened to have the hardware to handle them. On Vero I was perfectly happy with my 1 Gb, but I wanted TIM's 2.5 Gb — so I switched.</p>
<p>The unpleasant surprise was finding out TIM doesn't give you a public IP, and there was no room to negotiate either. After a string of completely failed attempts, I decided to find another way to expose a few streaming services.</p>
<p>Lucky for me I have a decent grasp of networking, which saved me a lot of grief. But I'd still need to learn more if I wanted the whole thing running properly.</p>
<p>When you're on CGNAT — the famous shared IP — the streaming experience (upload especially) tends to be pretty rough, for two main reasons:</p>
<ol>
<li>Badly configured MTU.</li>
<li>UDP ports recycling at absurd speeds.</li>
</ol>
<p>There are a few other causes out there, but those two are the ones that really make it miserable. It's also why so many people try to negotiate a public IP with their ISP, and the same reason FPS gamers get the headaches they do.</p>
<p>So here's what I tried, and what actually worked.</p>
<h2 id="first-attempts-cloudflare-and-tailscale">First attempts: Cloudflare and Tailscale</h2>
<p>The first thing that came to mind was exposing the services through Cloudflare, using Tunnel, or through Tailscale. I never got around to testing Tailscale.</p>
<p>Cloudflare worked perfectly well — fast, robust, with an excellent keep-alive for dealing with UDP's abrupt changes. But it had a problem, and it was the same one Tailscale had (which is why I didn't bother trying it): under both companies' policies, you can't serve streaming. If you wanted to, you'd have to use their servers, meaning you'd pay for it. The risk is high — they can ban you from the service with no real explanation.</p>
<p>I've got a bunch of services running through Cloudflare and I couldn't take that risk. Moving to Tailscale Funnel was off the table for the same reason. And I didn't want to keep a closed WireGuard-style network where only I had access: my cousins and relatives aren't exactly tech people, and that would cost me setup time. What I wanted was to hand them a domain and have them just open it.</p>
<p>So what now?</p>
<h2 id="pangolin-good-but-only-good">Pangolin: good, but only good</h2>
<p>Then I remembered Pangolin. Maybe if I grabbed a dirt-cheap VPS and ran Pangolin on it, I could "emulate" what Cloudflare Tunnel offered at a similar level.</p>
<p>The Pangolin container image looked perfect: it runs on Docker and ships with Traefik, Gerbil, and CrowdSec out of the box. Just spin it up, configure the domains, install Newt on the local machines, and done. I'd be streaming from home, behind CGNAT and exposed to the world. Problem solved — or so I thought.</p>
<p>I left it running for about two months, but the sluggishness was getting on my nerves. I hadn't dug into the real causes yet, until I finally had no choice. The setup was good — but only good. And I wanted excellent.</p>
<p>My first move was basic testing: checking routes, DNS, MTU, and so on. Funny enough, even though that wasn't the heart of the problem, I fixed a few unrelated issues along the way. So it did have some minor influence.</p>
<p>But cleaner logs and more precise tests showed what I'd already suspected: UDP and ping/keep-alive. UDP was recycling extremely aggressively, on very short intervals, and for streaming that's pure hell. Picture trying to load video, the player, range requests, a chat WebSocket... all of that needs to flow smoothly.</p>
<p>A new problem that would take some creativity to solve. The decision was to stop trying to make a UDP tunnel behave on an aggressive CGNAT and swap the core of the architecture for a persistent TCP tunnel. But how? I had no idea. So I left it on standby. I didn't have time to mess with my streaming anyway, and it was already up — even if in a less-than-ideal state.</p>
<p>A few weeks went by.</p>
<h2 id="stumbling-onto-frp">Stumbling onto FRP</h2>
<p>I was on the Unraid dashboard, browsing the apps in the store. Then, completely by chance, something caught my eye: FRP. Honestly, I'd never used it, and to me it was just another proxy service like any other. I opened the image out of pure curiosity — you know when you look at something but don't actually <em>see</em> (read) it? I wasn't even thinking about my streaming anymore, and one thing grabbed me: the "F" at the start of FRP, which stands for <em>Fast</em>.</p>
<p>That made me more curious. Why would this one be different? Why <em>fast</em>?</p>
<p>After reading up a bit, the explanation was simple.</p>
<p>FRP (<em>Fast Reverse Proxy</em>) was built specifically to expose local services sitting behind NAT/firewall, using a public server as a relay. The architecture is:</p>
<ul>
<li><code>frps</code> = the FRP server on the VPS with a public IP.</li>
<li><code>frpc</code> = the FRP client on the local machine, behind NAT/CGNAT.</li>
</ul>
<p>The local machine opens an outbound connection to the VPS. The VPS then exposes public ports (temporary or internal), which Traefik later uses as HTTP backends.</p>
<p>Pangolin (Newt/Gerbil) uses a more complete architecture: Traefik + identity + WireGuard/Gerbil/Newt tunnels. It's great as a "platform," but it adds more moving parts. Pangolin's own docs describe Gerbil as the WireGuard tunnel manager and Newt as the edge client.</p>
<p>FRP was made for exactly this — exposing a local service behind NAT/firewall through a public relay server — and it supports TCP, UDP, HTTP, and HTTPS.</p>
<p>In my case, that was the advantage. The two setups boiled down to:</p>
<ul>
<li><strong>FRP</strong> = a persistent TCP connection going out from the local network → public VPS → Traefik.</li>
<li><strong>Pangolin</strong> = a more sophisticated tunnel/overlay → more dependent on how the tunnel behaves.</li>
</ul>
<p>The FRP route was better not because FRP is "superior" to Pangolin across the board, but because it solves the actual bottleneck: it stops you from fighting UDP on a bad CGNAT.</p>
<h2 id="the-final-architecture">The final architecture</h2>
<pre><code>External user
  ↓ HTTPS
Traefik on the VPS (Pangolin)
  ↓ internal HTTP to the FRP port
frps on the VPS (Pangolin)
  ↓ persistent TCP tunnel
frpc on the local machine
  ↓
Local media service
</code></pre>
<p>On the VPS that already had Pangolin, I basically installed and configured <code>frps</code> (the server) along with a token. Each machine that would act as a client got <code>frpc</code> (the client) with the server's token.</p>
<p>The config was actually simple: bind the IP/port, set the token, validate, and start it.</p>
<p>There was one catch, though: I had to do something slightly different with the Traefik already running on the server. Instead of recreating the resources through the Pangolin UI, I used the dynamic File Provider. The result was striking — the practical gain was immediate.</p>
<p>Before, access through Newt/Pangolin suffered from high TTFB and instability caused by the CGNAT. After switching to FRP, the services started responding instantly and with rock-solid stability.</p>
<p>And yes. It's working.</p>
<p>The change worked because the problem was never just "slow proxy." It was architecture.</p>
<p>Cloudflare Tunnel was fast, but unsuitable as the main solution for heavy self-hosted streaming if you stay within the proper policies. Newt/Pangolin was elegant for exposing private services, but the aggressive CGNAT was wrecking the stability of the UDP path. FRP solved the exact pain point: it created a persistent TCP tunnel between the local machines and the public VPS.</p>
<p>Traefik kept doing what it does well: terminate HTTPS, apply rules per domain, and forward to the internal backends. FRP took over the transport. Each tool ended up in its proper role.</p>
<p>I'll put together a short tutorial soon on how this was set up and how you can do it on your own network.</p>
<p>See you around.</p>
]]></content>
    </entry>
    <entry>
        <title>How Usenet Serves Files</title>
        <link href="https://pablomurad.com/how-usenet-serves-files/"/>
        <id>https://pablomurad.com/how-usenet-serves-files/</id>
        <published>2026-06-14T20:23:40-03:00</published>
        <updated>2026-06-14T20:23:40-03:00</updated>
        <author><name>Pablo Murad</name></author>
        <category term="papers"/>
        <summary>There is something fascinating about Usenet: it was not created as a file download system. Its original purpose was much simpler and, in a way, more elegant. It was made to distribute messages. Text. Discussions. Forums before modern forums. People posted to groups, servers exchanged articles, and NNTP clients downloaded</summary>
        <content type="html"><![CDATA[<p>There is something fascinating about Usenet: it was not created as a file download system. Its original purpose was much simpler and, in a way, more elegant. It was made to distribute messages. Text. Discussions. Forums before modern forums. People posted to groups, servers exchanged articles, and NNTP clients downloaded messages and replies.</p>
<p>Then, as usually happens on the internet, someone looked at that system and thought: “What if I put files in here?”</p>
<p>And that is exactly what happened.</p>
<p>Usenet does not serve files the way HTTP, FTP, or BitTorrent do. It was not designed to say: “Here is <code>video.mkv</code>, download it.” What Usenet does is store and distribute articles. So the solution was to turn files into articles.</p>
<p>It sounds like a hack, and it is. But it is a very clever hack.</p>
<h2 id="the-file-is-not-uploaded-as-one-piece">The file is not uploaded as one piece</h2>
<p>When someone posts a large file to Usenet, the file is not uploaded as a single block. That would be impractical. Instead, the file is split into many smaller pieces. Each piece is encoded into a format that can travel inside an NNTP message, and those messages are posted to specific newsgroups, usually under <code>alt.binaries.*</code>.</p>
<p>A single file can become hundreds or thousands of separate messages.</p>
<p>The simplified flow looks like this:</p>
<pre><code class="language-text">original file
→ split into parts
→ encoded into a message-friendly format
→ posted as multiple NNTP articles
→ stored by Usenet servers
→ downloaded by a client
→ decoded
→ reassembled
→ final file
</code></pre>
<p>So the Usenet server is not “serving a file” in the traditional sense. It is serving articles. The client understands that those articles, together, form a file.</p>
<p>That distinction matters.</p>
<h2 id="yenc-the-boring-but-essential-magic">yEnc: the boring but essential magic</h2>
<p>In the early days, encodings like <code>uuencode</code> were common. Later, <code>yEnc</code> became the de facto standard for binaries on Usenet.</p>
<p>The role of yEnc is to take binary data and encode it in a way that works inside Usenet messages. It is more efficient than older methods because it wastes less space. When you are dealing with millions of messages and very large files, that difference matters.</p>
<p>So when a file is posted, it is broken into chunks, and each chunk is encoded with yEnc. Each chunk becomes an NNTP article whose body may look like nonsense if you view it raw. But to a Usenet client, it is a perfectly valid piece of a larger file.</p>
<p>The regular user does not need to see this process. The download client handles it automatically.</p>
<h2 id="the-altbinaries-groups">The <code>alt.binaries.*</code> groups</h2>
<p>The file-sharing side of Usenet became strongly associated with the <code>alt.binaries.*</code> hierarchy.</p>
<p>Examples include:</p>
<pre><code class="language-text">alt.binaries.movies
alt.binaries.multimedia
alt.binaries.sounds
alt.binaries.pictures
alt.binaries.ebooks
alt.binaries.software
</code></pre>
<p>The name gives away the purpose: these groups are meant for binary content, not just text conversations.</p>
<p>Technically, a binary file could be posted to other groups too, if the server allows it. But in practice, binaries concentrated in these hierarchies. That also helped servers decide what to accept, what to reject, and how long to keep each kind of content.</p>
<p>Because text is cheap. Binary data is not.</p>
<p>Keeping text discussions for years is relatively easy. Keeping large files for years requires a lot of disk, bandwidth, and administrative discipline.</p>
<h2 id="why-so-many-rar-files">Why so many RAR files?</h2>
<p>Anyone who has dealt with Usenet binaries has probably seen sets like this:</p>
<pre><code class="language-text">file.part001.rar
file.part002.rar
file.part003.rar
file.part004.rar
...
</code></pre>
<p>That is not random. RAR became common because it helps split large files into smaller volumes. If something fails, you do not necessarily need to download everything again. These volumes also fit nicely into the Usenet workflow, where everything is already being broken into many smaller parts.</p>
<p>So the original content is often compressed and split into multiple RAR volumes before being encoded and posted as articles.</p>
<p>In the end, you have layers on top of layers:</p>
<pre><code class="language-text">original file
→ RAR volumes
→ smaller pieces
→ yEnc articles
→ NNTP messages
</code></pre>
<p>It may look excessive, but it solves real problems.</p>
<h2 id="par2-insurance-against-missing-pieces">PAR2: insurance against missing pieces</h2>
<p>Usenet does not guarantee that every article will arrive intact on every server. One server may miss a part. Another may expire older articles. Another may have corruption. Another may not carry a certain group at all.</p>
<p>That is where PAR2 files come in.</p>
<p><code>.par2</code> files are recovery files. They use parity data to rebuild missing or damaged parts. In simple terms, they are mathematical backup material for repairing a download.</p>
<p>A set may look like this:</p>
<pre><code class="language-text">file.part001.rar
file.part002.rar
file.part003.rar
file.part004.rar
file.vol000+01.par2
file.vol001+02.par2
file.vol003+04.par2
</code></pre>
<p>If one or two pieces are missing, the client can use the PAR2 files to repair the set. Of course, there is a limit. If too much is missing, there is no magic fix.</p>
<p>Still, this repair layer is one of the reasons Usenet worked so well for large files. Without PAR2, downloading large binary posts would be much more frustrating.</p>
<h2 id="nzb-the-treasure-map">NZB: the treasure map</h2>
<p>Now comes a central piece: the NZB file.</p>
<p>Imagine a file split into 3,000 NNTP articles. How would a user find all those articles manually? They would not. It would be impossible.</p>
<p>The <code>.nzb</code> file solves this. It is basically an index, or a manifest. It lists the <code>Message-ID</code>s of the articles needed to reconstruct a given file.</p>
<p>The client reads the NZB and thinks:</p>
<p>“Alright, I need to fetch these 3,000 articles from the NNTP server.”</p>
<p>Then it connects to the server and starts requesting article after article. Once it has the pieces, it joins them, decodes them, repairs them if needed, and gives the user the final file.</p>
<p>In short:</p>
<pre><code class="language-text">NZB = map of the articles
NNTP = protocol used to fetch the articles
Usenet server = where the articles are stored
client = software that downloads, repairs, and rebuilds everything
</code></pre>
<p>The NZB does not contain the file itself. It contains the instructions for finding the file’s pieces inside Usenet.</p>
<p>It is similar to the difference between a <code>.torrent</code> file and the actual torrent data. The <code>.torrent</code> is not the movie, book, or program. It only tells the client where and how to find the pieces. NZB plays a similar role, but inside the Usenet ecosystem.</p>
<h2 id="does-the-server-know-what-it-is-delivering">Does the server know what it is delivering?</h2>
<p>Most of the time, the server does not need to understand the final file. It only stores articles.</p>
<p>An NNTP client asks for something like:</p>
<pre><code class="language-text">ARTICLE &lt;some-message-id&gt;
</code></pre>
<p>The server responds with the corresponding article. The body of that article contains an encoded chunk, usually in yEnc. To the server, it is just a message. To the client, it is part of a larger file.</p>
<p>That separation is the whole trick.</p>
<p>The intelligence is mostly on the client and indexer side, not in each individual server.</p>
<h2 id="the-role-of-commercial-providers">The role of commercial providers</h2>
<p>Today, when people talk about downloading from Usenet, they are usually talking about commercial providers. These providers sell access to servers with high retention, high speed, multiple connections, and SSL/TLS.</p>
<p>The key word here is retention.</p>
<p>Retention means how long the provider keeps articles available. For text groups, keeping years and years of material is relatively cheap. For binary groups, keeping years of content requires massive infrastructure.</p>
<p>When a provider says it offers thousands of days of binary retention, it is saying that it keeps old binary articles for many years. That is expensive, because the amount of data in binary groups is enormous.</p>
<p>That is why modern binary Usenet is not exactly a garage hobby. A text-only server can run on a small VPS. A serious binary server requires huge storage, heavy bandwidth, and a very clear abuse policy.</p>
<h2 id="usenet-is-not-torrenting">Usenet is not torrenting</h2>
<p>People often compare Usenet with BitTorrent, but the logic is very different.</p>
<p>With BitTorrent, users download pieces from each other. A file exists as long as there are seeders. You participate in a peer-to-peer swarm.</p>
<p>With Usenet, you download from a server. You do not need to upload pieces to other users. Availability depends on the retention of the server or provider, not on seeders.</p>
<p>Put simply:</p>
<pre><code class="language-text">BitTorrent:
- users share with each other
- depends on seeders
- decentralization lives in the peer swarm
- you usually upload while downloading

Usenet:
- you download from NNTP servers
- depends on provider retention
- decentralization comes from the old server federation model
- you do not need to upload to other users
</code></pre>
<p>Usenet ends up feeling more like downloading from a premium server, even though underneath it all there is this older structure of articles, groups, propagation, and retention.</p>
<h2 id="can-you-run-a-server-like-that">Can you run a server like that?</h2>
<p>Technically, yes.</p>
<p>Practically, that is a different conversation.</p>
<p>Running a text-only NNTP server is completely realistic. You install something like INN, create a few groups, control access, configure TLS, and use it with an NNTP reader. For learning, small communities, or internal projects, it is a great experiment.</p>
<p>Running a public or federated binary server is much heavier. You need to deal with:</p>
<pre><code class="language-text">huge storage requirements
heavy bandwidth
short or expensive retention
spam
abuse
problematic content
legal notices
authentication
peering
queues
constant monitoring
</code></pre>
<p>If you accept groups like <code>alt.binaries.*</code>, data growth can become brutal. This is not something you throw onto a cheap VPS and forget about.</p>
<p>If the goal is to learn, the smart path is to create a small private binary group, for example:</p>
<pre><code class="language-text">local.binaries.test
</code></pre>
<p>Then you can post small files, see how yEnc works, generate NZBs, download with a client, and understand the whole flow without drowning in terabytes of data and legal headaches.</p>
<h2 id="the-short-version">The short version</h2>
<p>Usenet serves files without really being made to serve files.</p>
<p>It stores articles. People learned to break files into pieces, encode those pieces, post them as NNTP messages, and reconstruct the file on the other side.</p>
<p>The complete process looks like this:</p>
<pre><code class="language-text">original file
→ compression/splitting into RAR volumes
→ yEnc encoding
→ posting as NNTP articles
→ storage on Usenet servers
→ NZB indexing
→ client download
→ PAR2 repair
→ final file reconstruction
</code></pre>
<p>It is old, strange, and a little underground, but it is also incredibly clever. The modern web tries to hide complexity behind polished buttons. Usenet does not. Usenet shows the gears. And maybe that is exactly what makes it so interesting.</p>
]]></content>
    </entry>
    <entry>
        <title>How Debrid Services Work</title>
        <link href="https://pablomurad.com/how-debrid-services-work/"/>
        <id>https://pablomurad.com/how-debrid-services-work/</id>
        <published>2026-06-14T19:51:12-03:00</published>
        <updated>2026-06-14T19:51:12-03:00</updated>
        <author><name>Pablo Murad</name></author>
        <category term="papers"/>
        <summary>There is a category of internet service that feels almost magical the first time you use it: you paste a link, a magnet, a torrent, sometimes an NZB file, and the service gives you back a clean, fast, direct link ready to download or stream. No queue, no captcha, no</summary>
        <content type="html"><![CDATA[<p>There is a category of internet service that feels almost magical the first time you use it: you paste a link, a magnet, a torrent, sometimes an NZB file, and the service gives you back a clean, fast, direct link ready to download or stream. No queue, no captcha, no leaving your computer on, no depending on your home connection to do all the heavy lifting.</p>
<p>That is the world of <strong>debrid</strong> services.</p>
<p>But the name is a little misleading. “Debrid” is not one single technology, like “PostgreSQL” or “Docker”. It is more like a bundle of things: cache, high-bandwidth servers, file-hoster integration, remote downloading, temporary storage, APIs, and a convenience layer on top. At the core, these services are intermediaries: they sit between you and external file sources, turning an ugly experience — slow, limited, broken, full of friction — into something direct.</p>
<p>The short version is this: a debrid service takes a link or magnet, resolves it on its own infrastructure, downloads it or reuses an existing cached copy, and gives you a fast HTTP link or stream.</p>
<p>The interesting part is what happens underneath.</p>
<h2 id="debrid-services-do-not-%E2%80%9Chave-everything%E2%80%9D-they-resolve-and-cache">Debrid services do not “have everything”. They resolve and cache.</h2>
<p>The first common mistake is thinking that a debrid service is some hidden pirate Netflix with a neatly organized internal library. Not exactly. Technically, most of these services do not start from an editorial catalog of their own. They work from what users submit.</p>
<p>You send a file-hoster link, a magnet, a torrent, an NZB, or a URL. The system checks whether that exact content is already cached. If it is, the response can be almost instant. If it is not, the service queues the job, downloads the file on its own servers, and then makes it available to you.</p>
<p>That is why sometimes you click something and it “appears” immediately. It is not because the server downloaded 80 GB in three seconds. It is because someone, at some point, requested the same thing before, and the service kept a reusable copy.</p>
<p>That is the economic heart of the business: <strong>shared cache</strong>.</p>
<p>The cost of downloading and storing a popular file once can be spread across thousands of users. An obscure old file is the opposite: it costs more, relatively speaking. It consumes storage, burns bandwidth, and may never be touched again. These services live by balancing retention, popularity, disk cost, and traffic cost.</p>
<h2 id="the-basic-architecture">The basic architecture</h2>
<p>If I had to describe a debrid service in a simple but honest way, it would have these parts:</p>
<ol>
<li>
<p><strong>Frontend</strong><br>
Website, app, browser extension, or integration with Stremio, Kodi, Plex, rclone, JDownloader, Radarr/Sonarr, and similar tools.</p>
</li>
<li>
<p><strong>API</strong><br>
Almost everything runs through an API. The user submits links, checks status, lists files, generates direct links, and removes items. Real-Debrid, AllDebrid, Premiumize, and TorBox all expose public or documented APIs at some level.</p>
</li>
<li>
<p><strong>Queue system</strong><br>
When a link or magnet is not ready, the service has to queue the work: download, verify, extract, maybe rename, maybe prepare metadata, maybe publish links.</p>
</li>
<li>
<p><strong>Download workers</strong><br>
Machines specialized in fetching content. They may use BitTorrent clients, multi-connection HTTP downloaders, Usenet clients, direct download handlers, or source-specific modules.</p>
</li>
<li>
<p><strong>Cache and deduplication</strong><br>
The system tries not to store the same thing ten times. Torrents have hashes. Files can be identified by size, checksum, piece hash, metadata, origin, or internal signatures.</p>
</li>
<li>
<p><strong>Storage</strong><br>
Cheap HDD storage for bulk volume. SSD/NVMe for hot cache, metadata, databases, in-progress chunks, and high-IOPS workloads.</p>
</li>
<li>
<p><strong>Delivery</strong><br>
Direct HTTP, streaming with range requests, WebDAV, apps, web player, CDN, or edge servers. For video, the key is seek support: you jump to minute 50 and the server needs to deliver that part without making your device download the entire file first.</p>
</li>
<li>
<p><strong>Abuse control</strong><br>
Per-user limits, per-hoster limits, rate limits, blocked links, takedown handling, operational logs, fraud controls, and compliance mechanisms.</p>
</li>
</ol>
<p>None of this is exotic. The hard part is running it at scale without bleeding money on bandwidth and disks.</p>
<h2 id="how-do-you-program-something-like-this">How do you program something like this?</h2>
<p>You can build a small, legal, limited home version with common tools: <code>aria2</code> for HTTP/FTP/SFTP/BitTorrent, <code>libtorrent</code> or qBittorrent for torrents, a Usenet client for NZBs, a database, a queue like Redis or RabbitMQ, local or S3-compatible storage, and an API written in Go, Rust, Python, Node, or any solid language.</p>
<p>But that would only be a cloud downloader. A commercial debrid service is more annoying.</p>
<p>Downloading a file is not the hard part. Any script can do that. The hard part is:</p>
<ul>
<li>controlling thousands of concurrent downloads;</li>
<li>avoiding useless duplication;</li>
<li>prioritizing hot cache;</li>
<li>dealing with broken sources;</li>
<li>monitoring each hoster’s limits;</li>
<li>generating secure temporary links;</li>
<li>making streaming seek work properly;</li>
<li>keeping speed predictable;</li>
<li>preventing one user from destroying the whole infrastructure;</li>
<li>deciding when to delete files;</li>
<li>handling legal complaints;</li>
<li>running payments, support, fraud, and chargebacks.</li>
</ul>
<p>A typical backend would probably be split into multiple services. One receives the request. Another validates and normalizes the link. Another checks the cache. Another schedules the download. Another executes it. Another verifies integrity. Another publishes the file. Another generates signed URLs. Another handles statistics and quotas.</p>
<p>A modern implementation could look roughly like this:</p>
<pre><code class="language-text">API Gateway
↓
Auth / Billing / Quotas
↓
Link Resolver
↓
Cache Lookup
↓
Queue
↓
Download Workers
↓
Storage Layer
↓
Streaming / Direct Download Edge
</code></pre>
<p>Could this run on Kubernetes? Sure. Does it have to? Not necessarily. Many companies in this space can run perfectly well on dedicated servers, simple queues, strong databases, and custom automation. Sometimes the “ugly but predictable” architecture beats whatever is trendy.</p>
<h2 id="the-real-secret-is-cache">The real secret is cache</h2>
<p>Cache is the difference between a profitable service and a money-burning machine.</p>
<p>Imagine one hundred users request the same 40 GB file. If the service downloads it one hundred times, it is stupid. If it downloads it once and serves it one hundred times, the math starts to work.</p>
<p>That is why these services love popular content. Popularity is efficiency. The more people request the same thing, the better the margin.</p>
<p>The retention logic probably works something like this:</p>
<ul>
<li>frequently requested content stays longer;</li>
<li>recently requested content gets priority;</li>
<li>rare content expires quickly;</li>
<li>incomplete or broken files are removed;</li>
<li>expensive storage, like NVMe, is reserved for hot cache;</li>
<li>large HDD pools hold colder bulk data;</li>
<li>metadata lives in a fast database;</li>
<li>user-generated links expire.</li>
</ul>
<p>We cannot know the exact retention policy of each company. But operationally, it is almost impossible for it not to be something close to this.</p>
<h2 id="how-do-they-%E2%80%9Cget%E2%80%9D-content">How do they “get” content?</h2>
<p>This has to be said carefully, because the wording can give the wrong impression.</p>
<p>Debrid services usually do not go out “looking for content” like an editor or a streaming platform. They receive user input. The user provides a link, magnet, torrent, or NZB. The service then tries to fetch it from the indicated source or return an already cached copy.</p>
<p>For file hosters, the service may rely on premium accounts, partnerships, technical integrations, link-resolution infrastructure, or other methods allowed under whatever rules apply to them. For torrents, the content comes from the BitTorrent network: peers, seeders, trackers, and DHT. For Usenet, it comes from Usenet servers and indexers, depending on how the service is built.</p>
<p>The gray area — and the legally sensitive part — is that many users use these tools to access material they do not have the right to access. That does not change the technical architecture, but it completely changes the risk. A service can be used to download a Linux ISO, a public dataset, a personal backup, or copyrighted material. The tool is generic. The use case is what can become a problem.</p>
<p>So, technically, they get content through <strong>user-submitted input + external sources + shared cache</strong>. Not because they own some magic internal library.</p>
<h2 id="file-hosters-torrents-usenet-three-different-worlds">File hosters, torrents, Usenet: three different worlds</h2>
<p>A modern debrid service usually relies on three major source types.</p>
<h3 id="1-file-hosters">1. File hosters</h3>
<p>These are file-hosting websites. Many limit speed, require waiting, show captchas, or push users toward paid accounts. The debrid service works as an intermediary: it resolves the link and gives the user a more convenient delivery path.</p>
<p>This is the classic debrid model.</p>
<h3 id="2-torrents">2. Torrents</h3>
<p>Here the service behaves like a simplified seedbox. It takes the magnet or torrent, downloads it in a data center, and then delivers it to the user over HTTP or streaming. If the torrent is already cached, the result is instant.</p>
<p>This model is very strong for media players and streaming apps.</p>
<h3 id="3-usenet">3. Usenet</h3>
<p>Some services also support NZB/Usenet. In that case, the download comes from Usenet servers, not from BitTorrent peers. It is a different ecosystem, with its own providers, retention windows, indexers, and rules.</p>
<h2 id="average-infrastructure-what-kind-of-servers-are-we-talking-about">Average infrastructure: what kind of servers are we talking about?</h2>
<p>There is no universal number here. Real-Debrid, AllDebrid, Premiumize, TorBox, put.io, Seedr, and commercial seedboxes do not publish complete infrastructure blueprints. But we can estimate from comparable services and public seedbox specs.</p>
<p>Public seedbox providers often advertise 1 Gbps, 10 Gbps, 40 Gbps, 50 Gbps, and even 100 Gbps shared networking, depending on the plan and provider. Whatbox, for example, lists HDD plans with shared 40 Gbps networking and NVMe plans with 100 Gbps in public plan listings. Ultra.cc advertises NVMe-powered servers on a 25+25 Gbps / 50 Gbps shared network. Those numbers do not mean each user gets that speed alone; they are almost always shared capacity.</p>
<p>For storage, there are two worlds:</p>
<ul>
<li><strong>Large HDD storage</strong>: 2 TB, 4 TB, 8 TB, 16 TB, 20 TB+ per plan or server, good for bulk storage.</li>
<li><strong>Smaller but fast NVMe storage</strong>: hundreds of GB to a few TB per plan, good for hot cache and fast operations.</li>
</ul>
<p>For CPU, the main bottleneck is usually not processing power unless video transcoding is involved. For direct downloads and direct streaming, network and disk matter more. For Plex/Jellyfin-style transcoding, CPU and GPU suddenly matter a lot.</p>
<p>A typical machine for this kind of operation could look like this:</p>
<pre><code class="language-text">Dedicated storage/cache server:
- CPU: Xeon, EPYC, or server-grade Ryzen
- RAM: 64 GB to 256 GB
- Disk: multiple 12-22 TB HDDs or an NVMe pool
- Network: 10 Gbps or more
- System: Linux
- Role: cache node, download worker, streaming edge, or storage node
</code></pre>
<p>For a large commercial service, it is not “one server”. It is a fleet. Some nodes download. Some store. Some serve. Some handle metadata. Some sit at the edge to deliver traffic.</p>
<h2 id="data-centers-where-does-this-run">Data centers: where does this run?</h2>
<p>This type of service usually likes data centers with three traits:</p>
<ol>
<li><strong>Cheap bandwidth</strong></li>
<li><strong>Good international connectivity</strong></li>
<li><strong>Operational tolerance for heavy traffic</strong></li>
</ol>
<p>Europe appears a lot in this world, especially the Netherlands, France, Germany, and other countries with strong data center markets and competitive transit pricing. The United States also appears, but the legal and copyright environment can be more aggressive. Singapore and other regions come into play when a service wants better latency for Asia.</p>
<p>This does not mean “lawless territory”. It means bandwidth, cost, and jurisdiction matter. A lot.</p>
<h2 id="why-does-the-user-pay">Why does the user pay?</h2>
<p>The user pays because the service buys three difficult things for them:</p>
<ul>
<li><strong>bandwidth</strong></li>
<li><strong>storage</strong></li>
<li><strong>convenience</strong></li>
</ul>
<p>You are not paying only for the file. You are paying to avoid maintaining a server, configuring a torrent client, dealing with file hosters, leaving a PC on, suffering with residential upload, waiting for captchas, or building a media pipeline.</p>
<p>It is the difference between “I can do this myself” and “do I really want to administer this?”</p>
<p>For a technical user, debrid can look like laziness. But operational laziness is a perfectly valid product. Half the SaaS world exists because of it.</p>
<h2 id="debrid-vs-seedbox-vs-putio">Debrid vs seedbox vs put.io</h2>
<p>The honest comparison:</p>
<pre><code class="language-text">Debrid:
best for fast links, cache, Stremio/Kodi, cheap convenience.

Seedbox:
best for private trackers, ratio, control, FTP/SFTP, apps, Plex/Jellyfin.

put.io / Seedr / Bitport:
best for a simple cloud downloader with a temporary library.

Self-hosted VPS:
best for people who want full control and accept maintenance.
</code></pre>
<p>Debrid is more of a fast bridge. A seedbox is more like your own remote server. put.io is more like a cloud downloads folder. A VPS is “do it yourself and accept the consequences”.</p>
<h2 id="the-business-model">The business model</h2>
<p>The model only works if most users consume less than they think they will.</p>
<p>Like any “unlimited” or “almost unlimited” service, this is a statistical game. Many users pay and barely use it. A few users hammer it. The service applies limits, quotas, short retention, fairness rules, queues, variable speeds, or hoster-specific restrictions to keep the whole thing alive.</p>
<p>The margin comes from:</p>
<ul>
<li>reused cache;</li>
<li>controlled overselling;</li>
<li>temporary storage;</li>
<li>negotiated bulk traffic;</li>
<li>plans with explicit or implicit limits;</li>
<li>occasional users subsidizing heavy users;</li>
<li>strong automation to reduce support load.</li>
</ul>
<p>If every subscriber suddenly decided to download tens of terabytes per month, the math would break quickly.</p>
<h2 id="the-legal-and-ethical-part">The legal and ethical part</h2>
<p>There is no point pretending this market is squeaky clean. Debrid, seedboxes, and cloud downloaders have legitimate uses, but they are also widely used to access copyrighted material. That is the elephant in the room.</p>
<p>The technology itself is neutral: downloading a Linux ISO through a debrid service is not the same thing as downloading a newly released film without a license. But technical neutrality does not erase legal responsibility. Operators have to deal with takedowns, abuse, payments, payment processors, copyright pressure, and unstable hosters.</p>
<p>For the user, the rule is simple: if the content requires a subscription, purchase, or license, using an intermediary to get around that can put you in ugly legal territory. And for the operator, building this kind of service without legal counsel and abuse policies is asking to get hit.</p>
<h2 id="why-is-this-so-interesting">Why is this so interesting?</h2>
<p>Because debrid is sophisticated duct tape. It combines heavy infrastructure with an extremely simple user experience. From the outside, it looks like “paste link, get file”. Under the hood, it is queues, cache, networking, disks, quotas, APIs, state polling, workers, storage, and cost engineering.</p>
<p>It is the kind of service that exposes an uncomfortable truth about the internet: sometimes the difference between a bad experience and a good one is not the content itself. It is the delivery layer.</p>
<p>The file may already be somewhere. The problem is getting to it without wasting time, patience, and bandwidth. Debrid services sell exactly that: a cleaner route through a mess that already exists.</p>
<p>It is not magic. It is not always elegant. And it is not always defensible. But technically, it is fascinating.</p>
<p>That's it... see you.</p>
]]></content>
    </entry>
    <entry>
        <title>My Small Monster of 8.2 Million Words</title>
        <link href="https://pablomurad.com/my-small-monster-of-8-2-million-words/"/>
        <id>https://pablomurad.com/my-small-monster-of-8-2-million-words/</id>
        <published>2026-06-13T23:20:19-03:00</published>
        <updated>2026-06-13T23:20:18-03:00</updated>
        <author><name>Pablo Murad</name></author>
        <category term="life"/>
        <summary>Well, let’s recap the past week.

As usual — and honestly, it couldn’t have been any other way — I had a week packed with work. And of course, I mixed that with personal projects, which left me with almost no time to... sleep. Truth is, I haven’t been</summary>
        <content type="html"><![CDATA[<p>Well, let’s recap the past week.</p><p>As usual — and honestly, it couldn’t have been any other way — I had a week packed with work. And of course, I mixed that with personal projects, which left me with almost no time to... sleep. Truth is, I haven’t been sleeping much for a long time now.</p><p>This week, I decided to put the ordinary worries of corporate life aside for a bit and focus on organizing everything I had created during whatever “free time” I managed to find.</p><p>I started by opening the folder where I had dumped EVERYTHING I had written. And everything I had researched.</p><p>The idea had been simple: I would create a text file for each research topic, each exercise, each translation, each piece of writing, and so on.</p><p>My surprise was realizing that the folder itself had become a monstrosity — something I had let grow completely out of control over the past two years. At that point, I would probably need more time to organize it than I had spent creating it.</p><p>So I decided to pay for one month of Claude and put my friend over there to work organizing my files. A way to save time. And no, I’m not possessive about what I create, nor am I afraid of being “stolen from.”</p><p>Once my little monster — the archive — was finally organized, I had an idea. I asked it to generate some stats on what had been produced, and one number really stood out:</p><p>8.2 million words.</p><figure class="kg-card kg-image-card"><img src="https://pablomurad.com/content/images/2026/06/ChatGPT-Image-13-de-jun.-de-2026--23_19_48.png" class="kg-image" alt="" loading="lazy" width="943" height="1667" srcset="https://pablomurad.com/content/images/size/w600/2026/06/ChatGPT-Image-13-de-jun.-de-2026--23_19_48.png 600w, https://pablomurad.com/content/images/2026/06/ChatGPT-Image-13-de-jun.-de-2026--23_19_48.png 943w" sizes="(min-width: 720px) 720px"></figure><p>Kind of terrifying, right?</p><p>But that’s it.</p><p>I’m very tired today. I’d like to talk more about it, but I’ll leave that for sometime next week.</p>]]></content>
    </entry>
    <entry>
        <title>The Night We Put Forgejo Behind the Tailnet</title>
        <link href="https://pablomurad.com/the-night-we-put-forgejo-behind-the-tailnet/"/>
        <id>https://pablomurad.com/the-night-we-put-forgejo-behind-the-tailnet/</id>
        <published>2026-06-05T23:51:21-03:00</published>
        <updated>2026-06-05T23:51:21-03:00</updated>
        <author><name>Pablo Murad</name></author>
        <category term="papers"/>
        <summary>It started, as all good server adventures do, with a suspiciously high load average and that sinking feeling that something somewhere was chewing through CPU like it had been personally wronged.

At first glance, the usual suspects appeared: Nginx workers, database processes, Docker containers, and a few background services minding</summary>
        <content type="html"><![CDATA[<p>It started, as all good server adventures do, with a suspiciously high load average and that sinking feeling that something somewhere was chewing through CPU like it had been personally wronged.</p><p>At first glance, the usual suspects appeared: Nginx workers, database processes, Docker containers, and a few background services minding their own business. But one process kept standing out from the crowd: Forgejo, along with several Git-related commands. The server was not merely busy; it was being worked hard.</p><p>The first move was blunt but effective: bring the Forgejo stack down with Docker Compose. Almost immediately, the load began to fall. That was the smoking gun. Forgejo was not just involved; Forgejo was the center of gravity.</p><p>From there, the investigation split into two questions:</p><ol><li>Why was Forgejo consuming so much CPU?</li><li>How could it be locked down without making it useless?</li></ol><p>The answer to the second question shaped the whole plan: Forgejo should not be public. There was only one real user, so leaving a Git service exposed to the entire internet made no sense. Bots, crawlers, scanners, and opportunistic traffic were all invited to knock on the door, and some of them were clearly doing more than knocking.</p><p>The goal became simple:</p><blockquote>Keep Forgejo fully usable, but only inside the Tailscale network.</blockquote><p>First, the Docker Compose configuration was inspected. The web interface was already bound safely to localhost, but the SSH Git port was still exposed publicly. That was a problem. The SSH mapping was changed so it listened only on the server’s Tailscale IP. After recreating the containers, the public Git SSH port was gone, and SSH access was limited to the Tailnet.</p><p>Then came the Nginx side of the story.</p><p>At first, the wrong Nginx site file was edited. The configuration looked right, but requests still returned <code>200 OK</code> from the public internet. That was the clue: Nginx was not using the file that had just been changed. A full configuration dump revealed the real active virtual host, generated under a different filename. Once the correct file was found, the fix was applied properly:</p><pre><code>allow 100.64.0.0/10;
deny all;</code></pre><p>That single rule changed everything. Public requests to the Forgejo domain began returning:</p><pre><code>HTTP/2 403</code></pre><p>Perfect. The internet was locked out.</p><p>But access through Tailscale still worked. A forced test resolving the Forgejo domain to the server’s Tailscale address returned:</p><pre><code>HTTP/2 200</code></pre><p>That proved the architecture was correct:</p><pre><code>Public internet -&gt; blocked
Tailscale network -&gt; allowed</code></pre><p>The CPU confirmed it too. Forgejo dropped from hundreds of percent of CPU usage to almost nothing. The server began to breathe again.</p><p>The last challenge was DNS.</p><p>Editing every client’s hosts file would have worked, but it was ugly. Nobody wants to maintain a pile of manual host overrides across several machines. The better solution was split DNS through Tailscale.</p><p>A small <code>dnsmasq</code> service was installed on the server and configured to listen only on the Tailscale interface. It answered one important question:</p><pre><code>Forgejo domain -&gt; server Tailscale IP</code></pre><p>Testing directly against that DNS server worked. Then the Tailscale admin console was configured to use the server as a custom nameserver for the Forgejo domain. After refreshing the Tailscale clients, machines inside the Tailnet could resolve the Forgejo domain internally, without touching local hosts files.</p><p>At the end of the adventure, the setup looked like this:</p><pre><code>Forgejo web:
  available only through Tailscale

Forgejo SSH:
  bound only to the server’s Tailscale IP

Public domain:
  blocked by Nginx unless the client comes from the Tailnet

Internal DNS:
  resolves the Forgejo domain to the Tailscale IP

Git clone, pull, and push:
  work normally from Tailnet devices

Random internet bots:
  get a 403 and go bother someone else</code></pre><p>The important lesson was simple: exposing a private Git server to the public internet is usually unnecessary risk. Even if registration is disabled and there is only one user, bots can still hammer expensive Git endpoints, trigger pack generation, request archives, crawl diffs, and generally waste CPU.</p><p>The clean solution was not to harden the public door forever.</p><p>It was to remove the public door.</p><p>Forgejo stayed fully functional, but became private to the Tailnet. The server load dropped, the attack surface shrank, and Git access became exactly what it should have been from the start: available to trusted machines only.</p>]]></content>
    </entry>
    <entry>
        <title>O dicionário errado no bolso: por que instalar o Webster de 1913 no Android</title>
        <link href="https://pablomurad.com/o-dicionario-errado-no-bolso-por-que-instalar-o-webster-de-1913-no-android/"/>
        <id>https://pablomurad.com/o-dicionario-errado-no-bolso-por-que-instalar-o-webster-de-1913-no-android/</id>
        <published>2026-06-03T21:18:25-03:00</published>
        <updated>2026-06-03T21:18:25-03:00</updated>
        <author><name>Pablo Murad</name></author>
        <category term="letters"/>
        <summary>Há ferramentas que usamos como quem usa uma chave de fenda: abrimos, resolvemos um problema e fechamos. O dicionário, para muita gente, virou isso. Uma palavra aparece no livro, no artigo ou na tela; você toca nela, recebe uma definição curta, quase clínica, e segue adiante. Resolveu? Talvez. Mas alguma</summary>
        <content type="html"><![CDATA[<p>Há ferramentas que usamos como quem usa uma chave de fenda: abrimos, resolvemos um problema e fechamos. O dicionário, para muita gente, virou isso. Uma palavra aparece no livro, no artigo ou na tela; você toca nela, recebe uma definição curta, quase clínica, e segue adiante. Resolveu? Talvez. Mas alguma coisa se perdeu no caminho.</p>
<p>Um bom dicionário não deveria apenas matar uma dúvida. Ele deveria abrir uma porta.</p>
<p>Foi essa a provocação que tornou tão interessante o ensaio de James Somers, <strong>“You’re probably using the wrong dictionary”</strong>. O texto, publicado em seu site, parte de uma reclamação simples e poderosa: boa parte dos dicionários modernos ficou pobre demais para quem se importa de verdade com linguagem. Eles cumprem a tarefa mínima de explicar uma palavra, mas frequentemente falham em mostrar sua vida interior: sua história, seu peso, seus vizinhos, suas diferenças sutis, seu brilho.</p>
<p>Este artigo nasce dessa inspiração. Não é uma tradução do texto de Somers, nem pretende copiá-lo. O crédito, porém, é claro: foi o artigo dele que reacendeu a questão. A partir dali, vale olhar com mais calma para uma ideia estranhamente prática: talvez o melhor dicionário de inglês para carregar no Android seja um livro de 1913.</p>
<h2 id="o-problema-dos-dicion%C3%A1rios-que-apenas-%E2%80%9Cfuncionam%E2%80%9D">O problema dos dicionários que apenas “funcionam”</h2>
<p>O dicionário comum de hoje é eficiente. E eficiência, neste caso, é parte do problema.</p>
<p>Quando você procura uma palavra em muitos dicionários digitais, recebe uma definição compacta, direta, desenhada para velocidade. Isso é útil quando o objetivo é apenas decodificar uma frase. Mas escrever bem, ler melhor ou pensar com mais precisão exige outra coisa. Exige nuance.</p>
<p>Há uma diferença enorme entre saber mais ou menos o que uma palavra significa e entender quando ela deve ser usada. Uma definição pode dizer que duas palavras são sinônimas. A linguagem real, porém, raramente respeita sinônimos perfeitos. “Coragem”, “bravura”, “audácia”, “ousadia” e “temeridade” moram na mesma vizinhança, mas não são a mesma casa. O mesmo acontece em inglês com centenas de palavras que os dicionários rápidos tratam como se fossem peças intercambiáveis.</p>
<p>Somers chama atenção para isso usando exemplos memoráveis. A força do Webster antigo está menos em listar equivalências e mais em discriminar sentidos. Ele mostra a palavra em movimento, em contexto, com exemplos, citações e uma espécie de inteligência literária embutida. Em vez de reduzir a palavra a uma etiqueta, ele tenta cercá-la por todos os lados.</p>
<p>Esse é o ponto essencial: um dicionário ruim empobrece a relação com a língua sem que você perceba. Ele não apenas oferece respostas fracas. Ele treina o leitor a esperar pouco das palavras.</p>
<h2 id="john-mcphee-e-o-dicion%C3%A1rio-como-ferramenta-de-escrita">John McPhee e o dicionário como ferramenta de escrita</h2>
<p>Um dos personagens importantes do ensaio de Somers é John McPhee, mestre americano da não ficção literária. McPhee não usava o dicionário só para procurar termos desconhecidos. Ele o usava no processo de revisão, quando queria trocar palavras gastas por expressões mais exatas, mais vivas, mais surpreendentes.</p>
<p>Essa mudança de uso é decisiva.</p>
<p>O leitor comum consulta o dicionário quando tropeça em uma palavra difícil. O escritor sério consulta o dicionário também quando está diante de uma palavra fácil. Na verdade, talvez principalmente aí. Palavras comuns são perigosas porque parecem transparentes. “Flash”, “intention”, “sport”, “power”, “sense”, “field”: termos assim dão a ilusão de já estarem resolvidos. Mas um dicionário bom mostra que não estão.</p>
<p>A utilidade real do Webster de 1913 aparece nesse ponto. Ele transforma o dicionário em uma oficina de precisão. Você vai procurar uma palavra que já conhece e volta com três alternativas melhores, uma distinção que não tinha percebido, uma origem inesperada ou uma formulação que muda o ritmo da frase.</p>
<p>Esse tipo de consulta não é nostalgia. É técnica.</p>
<h2 id="por-que-o-webster-de-1913-continua-importante">Por que o Webster de 1913 continua importante</h2>
<p>O <strong>Webster’s Revised Unabridged Dictionary</strong>, edição de 1913, pertence a uma linhagem que remonta ao trabalho monumental de Noah Webster. A edição de 1828 do <strong>American Dictionary of the English Language</strong> foi um marco da lexicografia americana. Webster começou esse projeto no início do século XIX e produziu uma obra com cerca de 70 mil palavras, incluindo milhares que ainda não tinham aparecido em dicionários anteriores. Fontes históricas registram também o esforço de Webster com etimologias e línguas antigas e modernas, algo que ajuda a explicar o caráter ambicioso de sua obra.</p>
<p>A edição de 1913, revisada e expandida dentro da tradição Webster, acabou se tornando especialmente valiosa por outro motivo: está em domínio público. Isso permitiu que fosse digitalizada, redistribuída, adaptada e transformada em formatos úteis para computadores, leitores digitais e celulares.</p>
<p>O Project Gutenberg disponibiliza versões do Webster 1913 sem custo, e há também cópias pesquisáveis na web, como projetos dedicados exclusivamente a tornar esse dicionário mais acessível. Em outras palavras: não estamos falando de uma relíquia trancada numa biblioteca. Estamos falando de uma obra antiga, sim, mas surpreendentemente portátil.</p>
<p>E aqui vem o detalhe curioso: justamente por ser antigo, o Webster 1913 muitas vezes parece mais vivo do que seus concorrentes modernos.</p>
<p>Isso não significa que ele seja perfeito. Não é. Ele é datado em vários aspectos. Algumas palavras mudaram de sentido; outras surgiram depois; certos exemplos carregam marcas de época; e qualquer dicionário do início do século XX precisa ser lido com consciência histórica. Usá-lo como única autoridade para vocabulário contemporâneo seria burrice. Mas usá-lo como instrumento de leitura, escrita e exploração vocabular é outra conversa. Aí ele brilha.</p>
<h2 id="um-dicion%C3%A1rio-antigo-para-uma-mente-moderna">Um dicionário antigo para uma mente moderna</h2>
<p>A maior virtude do Webster 1913 é que ele não trata a definição como um recibo. Ele a trata como uma pequena peça de pensamento.</p>
<p>Muitos verbetes são longos, discriminativos, cheios de gradações. Ele separa usos, distingue sentidos próximos, oferece exemplos e, às vezes, entrega formulações tão boas que fazem o leitor parar. É um dicionário que parece ter sido escrito por alguém que acreditava que definir bem uma palavra era uma tarefa intelectual séria.</p>
<p>Hoje estamos cercados por ferramentas que respondem rápido. Mas velocidade não é profundidade. O problema de uma definição curta demais é que ela pode ser correta e ainda assim ser insuficiente. Quem escreve precisa de algo mais do que “significa X”. Precisa saber se a palavra carrega ironia, solenidade, aspereza, delicadeza, pedantismo, vulgaridade, antiguidade, energia, precisão técnica ou sabor literário.</p>
<p>Um exemplo simples: quando uma palavra é definida apenas como “pomposo”, você aprende pouco. Quando ela é definida como linguagem inflada além da dignidade do assunto, você aprende uma relação. Aprende que o problema não está só na grandiloquência, mas na desproporção entre estilo e matéria. A palavra deixa de ser uma etiqueta e vira uma ferramenta crítica.</p>
<p>Esse é o tipo de diferença que muda a escrita.</p>
<h2 id="por-que-instalar-no-android">Por que instalar no Android?</h2>
<p>Porque o melhor dicionário é aquele que está perto quando você está lendo.</p>
<p>No computador, é fácil abrir uma aba, pesquisar, comparar fontes. No celular, a preguiça vence. Se a consulta exige muitos passos, você simplesmente não consulta. Por isso, ter o Webster 1913 instalado offline no Android muda o jogo. Ele deixa de ser uma curiosidade de navegador e passa a virar uma ferramenta de leitura diária.</p>
<p>Imagine três situações.</p>
<p>Você está lendo um romance em inglês e encontra uma palavra que conhece vagamente. O dicionário moderno te dá uma equivalência apressada. O Webster antigo te mostra a família de sentidos e talvez uma citação literária. Você volta ao parágrafo entendendo melhor a frase.</p>
<p>Você está escrevendo um artigo, uma legenda, uma análise ou um texto acadêmico em inglês. Quer trocar uma palavra frouxa por outra mais precisa. Em vez de pedir a uma ferramenta qualquer um sinônimo genérico, você consulta o dicionário como quem afia uma lâmina.</p>
<p>Você está estudando inglês em nível intermediário ou avançado. Já não precisa apenas traduzir palavras. Precisa perceber diferença de registro, intenção, uso. Nesse estágio, um dicionário rico vale mais do que uma lista infinita de flashcards.</p>
<p>Instalar o Webster 1913 no Android é menos sobre fetiche vintage e mais sobre criar um atrito produtivo. Você passa a conviver com definições melhores.</p>
<h2 id="o-caminho-recomendado-colordict-3-e-formato-stardict">O caminho recomendado: ColorDict 3 e formato StarDict</h2>
<p>A recomendação prática inspirada no artigo de James Somers é usar o <strong>ColorDict 3</strong> no Android, porque ele aceita dicionários no formato <strong>StarDict</strong>. O StarDict é um formato bastante usado para dicionários offline e compatível com diferentes programas em computadores e dispositivos móveis. O próprio ColorDict se apresenta como um aplicativo compatível com StarDict, com busca em múltiplas fontes, histórico e dados armazenados localmente.</p>
<p>O procedimento geral é este:</p>
<ol>
<li>Instalar o <strong>ColorDict 3</strong> no Android.</li>
<li>Baixar uma versão do <strong>Webster 1913 em formato StarDict</strong>.</li>
<li>Extrair corretamente os arquivos do pacote.</li>
<li>Copiar os arquivos do dicionário para a pasta usada pelo ColorDict.</li>
<li>Abrir o aplicativo e conferir se o dicionário foi reconhecido.</li>
</ol>
<p>O ponto crítico é a extração. Em pacotes StarDict, é comum encontrar arquivos com extensões como <code>.dict</code>, <code>.idx</code> e <code>.ifo</code>. Às vezes eles vêm comprimidos em camadas diferentes, como <code>.tar</code>, <code>.bz2</code>, <code>.dz</code> ou <code>.tgz</code>. Não adianta jogar o arquivo compactado na pasta e esperar milagre. O aplicativo precisa enxergar os arquivos finais do dicionário.</p>
<p>Em instalações tradicionais, esses arquivos ficam em uma pasta chamada:</p>
<pre><code class="language-text">/dictdata
</code></pre>
<p>Dependendo da versão do Android, essa pasta pode ficar no armazenamento interno ou no cartão SD. Mas há uma pegadinha importante: no Android 11 e versões superiores, as regras de acesso a arquivos ficaram mais restritivas, e a própria página do ColorDict na Google Play avisa que a localização dos dados de dicionário mudou por causa de limitações do sistema operacional. Ou seja: se o método antigo não funcionar, provavelmente o problema não é o dicionário. É o Android moderno fechando portas que antes ficavam abertas.</p>
<p>Nesse caso, vale abrir o ColorDict, verificar as instruções internas do próprio aplicativo e observar onde ele espera encontrar os dados. Outra saída prática é usar um gerenciador de arquivos mais competente, como ZArchiver ou similares, para extrair o pacote e mover os arquivos para o local correto.</p>
<h2 id="usando-com-leitores-de-e-book">Usando com leitores de e-book</h2>
<p>O uso mais interessante não é abrir o ColorDict isoladamente. É integrá-lo ao ato de leitura.</p>
<p>A recomendação original menciona o <strong>FBReader</strong>, que permite configurar um dicionário externo. A lógica é simples: você lê um livro, segura uma palavra, e o leitor chama o ColorDict. Assim, o Webster 1913 aparece no fluxo natural da leitura.</p>
<p>A experiência ideal é esta:</p>
<ul>
<li>abrir um livro em inglês;</li>
<li>tocar ou pressionar uma palavra;</li>
<li>consultar o Webster 1913 sem sair mentalmente do texto;</li>
<li>voltar à frase com uma compreensão mais fina.</li>
</ul>
<p>Esse detalhe parece pequeno, mas é decisivo. Ferramentas boas são aquelas que aparecem no momento certo. Um dicionário excelente que fica escondido em uma aba abandonada é menos útil do que um dicionário razoável acessível com um toque. O objetivo é colocar um dicionário excelente onde ele possa ser usado sem cerimônia.</p>
<h2 id="alternativas-e-observa%C3%A7%C3%B5es-pr%C3%A1ticas">Alternativas e observações práticas</h2>
<p>ColorDict 3 é uma recomendação tradicional, mas não é a única possibilidade. Há outros aplicativos Android que lidam com formatos de dicionário offline, como GoldenDict, Aard2 e leitores compatíveis com StarDict ou SLOB. O melhor aplicativo pode variar conforme a versão do Android, o aparelho e a tolerância do usuário a anúncios, limitações ou interfaces antigas.</p>
<p>O importante é entender o princípio:</p>
<ul>
<li>você precisa de um aplicativo de dicionário offline;</li>
<li>esse aplicativo precisa aceitar o formato em que o Webster 1913 foi empacotado;</li>
<li>os arquivos precisam estar extraídos corretamente;</li>
<li>o leitor de e-book, se usado, precisa conseguir chamar esse aplicativo.</li>
</ul>
<p>Se alguma dessas quatro etapas falhar, a instalação vira frustração.</p>
<p>Também é bom dizer o óbvio: o Webster 1913 é um dicionário de inglês. Ele não substitui um bom dicionário português-inglês para quem ainda está aprendendo vocabulário básico. Ele também não substitui dicionários contemporâneos quando o assunto é tecnologia, gíria recente, cultura pop, ciência atual ou termos que ganharam sentido novo depois do século XX.</p>
<p>A escolha inteligente não é abandonar todos os outros dicionários. É adicionar o Webster 1913 como uma camada mais profunda.</p>
<p>Use um dicionário moderno para o inglês vivo de hoje. Use o Webster 1913 para mergulhar no corpo das palavras.</p>
<h2 id="o-que-se-ganha-com-isso">O que se ganha com isso</h2>
<p>A resposta curta: precisão.</p>
<p>A resposta longa: você começa a perceber que palavras não são blocos de Lego. Elas têm temperatura, idade, gravidade e direção. Algumas são secas. Outras são cerimoniosas. Algumas servem à conversa. Outras pertencem ao ensaio, ao sermão, ao poema, ao relatório técnico, à piada ou à acusação. Um bom dicionário mostra essas diferenças.</p>
<p>Para quem escreve, isso é ouro.</p>
<p>Escrever mal nem sempre é errar gramática. Muitas vezes é escolher palavras que funcionam, mas não ferem o ponto certo. A frase passa, mas não acerta. O leitor entende, mas não sente a precisão. O Webster 1913 ajuda porque força uma convivência mais exigente com os termos. Ele convida você a trocar aproximação por escolha.</p>
<p>Para quem lê, o ganho é outro: densidade. Textos bons costumam carregar palavras em camadas. Um dicionário pobre nivela essas camadas por baixo. Um dicionário rico devolve profundidade ao texto.</p>
<p>Para quem estuda inglês, o ganho é maturidade. Há uma fase em que o estudante quer saber “o que significa”. Depois vem uma fase mais importante: “por que essa palavra, e não outra?”. O Webster 1913 é especialmente útil nessa segunda fase.</p>
<h2 id="a-cr%C3%ADtica-necess%C3%A1ria-n%C3%A3o-romantize-demais">A crítica necessária: não romantize demais</h2>
<p>Aqui é preciso ser honesto. Nem tudo que é antigo é melhor. Há muita porcaria velha no mundo, assim como há muita ferramenta moderna excelente. O Webster 1913 não é bom porque é antigo. Ele é bom porque foi feito com um tipo de ambição lexicográfica que hoje nem sempre aparece em produtos digitais rápidos.</p>
<p>Também não convém transformar James Somers, John McPhee ou Noah Webster em santos de altar. A lição não é “volte ao passado”. A lição é mais dura e mais útil: escolha ferramentas que aumentem sua percepção, não apenas sua velocidade.</p>
<p>O melhor arranjo é híbrido. Um bom leitor pode usar:</p>
<ul>
<li>um dicionário contemporâneo para usos atuais;</li>
<li>o Webster 1913 para nuance e profundidade;</li>
<li>o Wiktionary para etimologia colaborativa e variações;</li>
<li>corpora e exemplos reais para verificar uso moderno;</li>
<li>bons tradutores apenas como apoio, nunca como autoridade final.</li>
</ul>
<p>A inteligência está em saber qual ferramenta responde a qual pergunta.</p>
<h2 id="instala%C3%A7%C3%A3o-resumida-no-android">Instalação resumida no Android</h2>
<p>Para quem quer apenas o roteiro prático, fica assim:</p>
<ol>
<li>Instale o <strong>ColorDict 3</strong>.</li>
<li>Baixe o <strong>Webster 1913 em formato StarDict</strong>.</li>
<li>Extraia o pacote até chegar aos arquivos finais, normalmente <code>.dict</code>, <code>.idx</code> e <code>.ifo</code>.</li>
<li>Copie esses arquivos para a pasta de dicionários usada pelo ColorDict, tradicionalmente chamada <code>dictdata</code>.</li>
<li>Abra o ColorDict e veja se o Webster aparece na lista de dicionários.</li>
<li>Coloque o Webster 1913 como prioridade, se o aplicativo permitir.</li>
<li>Configure um leitor como <strong>FBReader</strong> para usar o ColorDict como dicionário externo.</li>
<li>Teste com uma palavra simples, não com uma palavra rara. Se palavras comuns funcionarem, o dicionário foi indexado corretamente.</li>
</ol>
<p>Se não funcionar, verifique nesta ordem:</p>
<ul>
<li>os arquivos ainda estão compactados?</li>
<li>os arquivos <code>.dict</code>, <code>.idx</code> e <code>.ifo</code> estão na mesma pasta?</li>
<li>a pasta é realmente a pasta que o ColorDict está lendo?</li>
<li>o Android bloqueou acesso ao diretório?</li>
<li>o aplicativo precisa de permissão de armazenamento?</li>
<li>a versão do Android mudou a localização dos dados?</li>
</ul>
<p>Não complique antes de checar isso. Na maioria das vezes, o erro está em pasta errada ou arquivo não extraído.</p>
<h2 id="conclus%C3%A3o-um-dicion%C3%A1rio-como-instrumento-de-aten%C3%A7%C3%A3o">Conclusão: um dicionário como instrumento de atenção</h2>
<p>Instalar o Webster 1913 no Android parece uma pequena excentricidade. Não é. É uma decisão sobre o tipo de relação que você quer ter com as palavras.</p>
<p>A internet nos acostumou a respostas rápidas, e respostas rápidas são úteis. Mas nem toda pergunta merece uma resposta mínima. Às vezes, procurar uma palavra deveria nos fazer demorar um pouco. Não por nostalgia, mas porque a demora certa educa o olhar.</p>
<p>O artigo de James Somers acerta porque nos lembra que dicionários não são apenas ferramentas escolares. São instrumentos de atenção. Um dicionário fraco resolve dúvidas. Um dicionário forte melhora o leitor.</p>
<p>Carregar o Webster 1913 no Android é carregar uma pequena biblioteca de precisão no bolso. Ele não vai escrever por você, não vai substituir leitura séria, não vai transformar ninguém automaticamente em estilista da língua. Mas vai fazer algo talvez mais importante: vai colocar definições melhores no caminho das suas perguntas.</p>
<p>E isso, para quem lê e escreve, já é muita coisa.</p>
<h2 id="cr%C3%A9ditos-e-refer%C3%AAncias">Créditos e referências</h2>
<p>Este texto foi inspirado pelo ensaio de James Somers, <strong>“You’re probably using the wrong dictionary”</strong>, publicado em seu site pessoal: <a href="https://jsomers.net/blog/dictionary">https://jsomers.net/blog/dictionary</a>.</p>
<p>Fontes consultadas e úteis para aprofundamento:</p>
<ul>
<li>James Somers, “You’re probably using the wrong dictionary”: <a href="https://jsomers.net/blog/dictionary">https://jsomers.net/blog/dictionary</a></li>
<li>Instruções de James Somers para usar o Webster 1913 no Android: <a href="https://gist.github.com/jsomers/9dd78c8dc7fab071993c">https://gist.github.com/jsomers/9dd78c8dc7fab071993c</a></li>
<li>Project Gutenberg, Webster’s Unabridged Dictionary 1913: <a href="https://www.gutenberg.org/files/669/669-h/669-h.htm">https://www.gutenberg.org/files/669/669-h/669-h.htm</a></li>
<li>Webster’s Revised Unabridged Dictionary 1913 pesquisável: <a href="https://websters1913.timcieplowski.com/">https://websters1913.timcieplowski.com/</a></li>
<li>ColorDict, site do desenvolvedor: <a href="https://www.socialnmobile.com/colordict.html">https://www.socialnmobile.com/colordict.html</a></li>
<li>ColorDict na Google Play: <a href="https://play.google.com/store/apps/details?id=com.socialnmobile.colordict">https://play.google.com/store/apps/details?id=com.socialnmobile.colordict</a></li>
<li>FreeDict sobre formatos e aplicativos de dicionário: <a href="https://freedict.org/downloads/">https://freedict.org/downloads/</a></li>
<li>Britannica sobre o American Dictionary de Noah Webster: <a href="https://www.britannica.com/topic/An-American-Dictionary-of-the-English-Language">https://www.britannica.com/topic/An-American-Dictionary-of-the-English-Language</a></li>
<li>JSTOR Daily sobre o Webster de 1828: <a href="https://daily.jstor.org/websters-dictionary-1828-annotated/">https://daily.jstor.org/websters-dictionary-1828-annotated/</a></li>
<li>Wiktionary sobre o Webster 1913 em domínio público: <a href="https://en.wiktionary.org/wiki/Wiktionary:Webster%27s_Dictionary,_1913">https://en.wiktionary.org/wiki/Wiktionary:Webster's_Dictionary,_1913</a></li>
</ul>
]]></content>
    </entry>
    <entry>
        <title>SOLED/2 — The Soledade Publishing Protocol</title>
        <link href="https://pablomurad.com/soled-2-the-soledade-publishing-protocol/"/>
        <id>https://pablomurad.com/soled-2-the-soledade-publishing-protocol/</id>
        <published>2026-06-03T07:02:43-03:00</published>
        <updated>2026-06-03T07:02:43-03:00</updated>
        <author><name>Pablo Murad</name></author>
        <category term="tech"/>
        <summary>Technical-narrative document | soledade.city | port 1915



SOLED/2



The Soledade publishing protocol


Plain text over TCP for citizenship, Markdown, and editorial autonomy




Executive summary


SOLED/2 is a plain-text application protocol that runs over TCP on port 1915. It was created so that citizens of Soledade can register</summary>
        <content type="html"><![CDATA[<p><strong>Technical-narrative document | soledade.city | port 1915</strong></p>
<h2 id="soled2">SOLED/2</h2>
<h3 id="the-soledade-publishing-protocol">The Soledade publishing protocol</h3>
<p><strong>Plain text over TCP for citizenship, Markdown, and editorial autonomy</strong></p>
<hr>
<h2 id="executive-summary">Executive summary</h2>
<p><strong>SOLED/2</strong> is a plain-text application protocol that runs over TCP on port <strong>1915</strong>. It was created so that citizens of Soledade can register an identity, publish Markdown, validate drafts, list their files, delete content, and recover access without depending on a web dashboard, REST API, WordPress, or a relational database.</p>
<p>This document presents SOLED/2 as a deliberate architectural component: simple enough to fit inside a terminal, strict enough to preserve operational safety, and symbolic enough to turn remote publishing into an act of citizenship inside <strong>soledade.city</strong>.</p>
<hr>
<h2 id="1-what-soled2-is">1. What SOLED/2 is</h2>
<p>SOLED/2 is the official write contract between simple clients — such as <code>ncat</code>, automation scripts, and terminals — and the <code>soledade.post_server</code> server.</p>
<p>It does not try to be an IETF standard. It is not a public RFC. It does not compete with HTTP. Its role is narrower and more honest: it transports commands and Markdown into Soledade's <code>content/</code> tree, where the static site is rebuilt after validation.</p>
<p>The name <strong>SOLED</strong> comes from <strong>Soledade</strong>. The <code>/2</code> suffix marks the second generation of the posting protocol. Before it, there was only the legacy mode: an administrative token, a file path, and a body terminated by a single dot. That worked, but only for a city governed by one key.</p>
<hr>
<h2 id="2-why-it-was-created">2. Why it was created</h2>
<p>The original problem was simple: publish remotely without a web dashboard.</p>
<p>For a single administrator, a secret token was enough. But Soledade stopped being a personal blog and started adopting a more ambitious metaphor: a textual city with citizens, houses, districts, documents, and rules of coexistence.</p>
<p>In that new scenario, a single shared token became both a bottleneck and a risk. It does not separate authorship. It does not create individual identity. It does not provide a solid basis for moderation. Worst of all, it turns every authorized client into something too powerful.</p>
<p>Bluntly: a shared secret is acceptable for personal automation; it is bad architecture for a community.</p>
<p>SOLED/2 was created to solve that scaling problem without betraying the philosophy of the project. The answer was not to add a dashboard, a heavy web stack, or a JSON API. The answer was to design a small, textual, explicit protocol in which each operation says who is speaking, what they want to do, and which file they are acting on.</p>
<hr>
<h2 id="3-the-real-need-citizenship-not-just-login">3. The real need: citizenship, not just login</h2>
<p>The protocol had to solve something larger than authentication. It had to allow entry into Soledade's citizenship model.</p>
<p>That means allowing a person to:</p>
<ul>
<li>register a handle;</li>
<li>receive a secret identity;</li>
<li>write only inside their own file territory;</li>
<li>validate drafts before publication;</li>
<li>list their own Markdown files;</li>
<li>delete authorized content;</li>
<li>recover access if their identity is lost.</li>
</ul>
<p>The choice of plain text is part of the need itself. Anyone with <code>ncat</code> can understand, assemble, and send a request. The packet is readable. The error is readable. The response is readable. That reduces dependence on SDKs, libraries, or official clients. The city accepts humble tools.</p>
<p>But simplicity is not naivety. SOLED/2 defines boundaries: allowed paths, citizen status, maximum payload size, preview without writing, recovery with invalidation of the previous identity, IP-based rate limiting, and synchronized build execution to keep the site coherent.</p>
<hr>
<h2 id="4-design-principles">4. Design principles</h2>
<table>
<thead>
<tr>
<th>Principle</th>
<th>Practical consequence</th>
</tr>
</thead>
<tbody>
<tr>
<td>Terminal first</td>
<td>The protocol must be usable by a person in a shell, without a browser or a special client.</td>
</tr>
<tr>
<td>Text before abstraction</td>
<td>Headers and responses are readable lines, not mandatory JSON or opaque binary.</td>
</tr>
<tr>
<td>One connection, one operation</td>
<td>Each TCP packet represents a clear intention: register, publish, list, preview, or recover.</td>
</tr>
<tr>
<td>Symbolic civil identity</td>
<td><code>CITIZEN</code> and <code>IDENTITY</code> are not merely username and password; they are how the city recognizes an inhabitant.</td>
</tr>
<tr>
<td>Disk as the editorial source of truth</td>
<td>The protocol does not serve HTML; it changes Markdown in <code>content/</code> and triggers the static build.</td>
</tr>
<tr>
<td>Pragmatic compatibility</td>
<td>Legacy mode remains available for older administrative automation.</td>
</tr>
</tbody>
</table>
<hr>
<h2 id="5-how-the-protocol-works">5. How the protocol works</h2>
<p>Every SOLED/2 message begins with a fixed first line:</p>
<pre><code class="language-text">SOLED/2
</code></pre>
<p>That line is the boundary between the second-generation parser and the legacy parser. If the first line is not <code>SOLED/2</code>, the server interprets the packet as legacy mode.</p>
<p>After that, headers follow the format:</p>
<pre><code class="language-text">KEY value
</code></pre>
<p>A blank line ends the headers and, when applicable, begins the Markdown body. The packet ends with a line containing only a single dot:</p>
<pre><code class="language-text">.
</code></pre>
<p>This convention makes the protocol easy to transmit manually from a terminal and easy to parse on the server.</p>
<p>Example preview request:</p>
<pre><code class="language-text">SOLED/2
ACTION PREVIEW
CITIZEN maria
IDENTITY &lt;citizen-secret&gt;
PATH citizens/maria/posts/note.md

---
title: "Note"
district: centro
---
Markdown publication text.
.
</code></pre>
<p>The operation above validates a draft without writing it to disk. Replacing <code>ACTION PREVIEW</code> with a normal publication action — or omitting <code>ACTION</code> when publication is treated as the default — changes the intention: the Markdown becomes a candidate for real writing into <code>content/</code>.</p>
<hr>
<h2 id="6-main-actions">6. Main actions</h2>
<table>
<thead>
<tr>
<th>Action</th>
<th>Purpose</th>
<th>Controlled risk</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>REGISTER</code></td>
<td>Creates citizenship, handle, identity, and recovery code. The server stores only the identity hash.</td>
<td>Unauthorized entry through reserved or duplicate handles.</td>
</tr>
<tr>
<td>Publish</td>
<td>Writes or updates an authorized Markdown file inside the citizen's tree.</td>
<td>Writing outside the citizen's territory or sending invalid front matter.</td>
</tr>
<tr>
<td><code>PREVIEW</code></td>
<td>Runs full validation without altering the disk. It is the rehearsal before publication.</td>
<td>Accidentally publishing broken content.</td>
</tr>
<tr>
<td><code>LIST</code></td>
<td>Lists Markdown files inside the citizen's permitted territory.</td>
<td>Leaking file structure outside the citizen's scope.</td>
</tr>
<tr>
<td><code>RECOVER</code></td>
<td>Uses the recovery code to issue a new identity and invalidate the old one.</td>
<td>Permanent loss of access.</td>
</tr>
<tr>
<td><code>DELETE</code></td>
<td>As a special body, removes an authorized Markdown file.</td>
<td>Unauthorized deletion.</td>
</tr>
</tbody>
</table>
<hr>
<h2 id="7-citizenship-registration">7. Citizenship registration</h2>
<p>Registration is the symbolic gate of the city. The client sends <code>ACTION REGISTER</code>, a <code>HANDLE</code>, and optionally a name. The server decides whether the handle is valid, whether it is reserved, and whether it has already been taken.</p>
<p>On success, the server responds with a secret identity and a recovery code.</p>
<pre><code class="language-text">SOLED/2
ACTION REGISTER
HANDLE maria
NAME Maria Silva

.
</code></pre>
<p>The response includes <code>IDENTITY</code> and <code>RECOVERY</code>. The identity should be stored locally. The recovery code should be treated as an emergency document.</p>
<p>The server does not store the identity in clear text. It stores a hash and compares future proofs against that hash.</p>
<hr>
<h2 id="8-publishing-markdown">8. Publishing Markdown</h2>
<p>To publish, the citizen provides <code>CITIZEN</code>, <code>IDENTITY</code>, and <code>PATH</code>.</p>
<p>The <code>PATH</code> is not arbitrary. It must remain inside the permitted space, usually something like:</p>
<pre><code class="language-text">citizens/&lt;handle&gt;/
</code></pre>
<p>Validation blocks directory escapes, forbidden paths, oversized payloads, nonexistent districts, and inconsistent front matter.</p>
<p>When everything passes, the server writes the file into <code>content/</code> and runs the site build. The final visitor does not talk to SOLED/2. They see HTML served later over HTTPS from the regenerated public folder.</p>
<p>Example publication:</p>
<pre><code class="language-text">SOLED/2
CITIZEN maria
IDENTITY &lt;citizen-secret&gt;
PATH citizens/maria/posts/first-post.md

---
title: "My first post"
district: centro
---
This is a publication sent directly from the terminal.
.
</code></pre>
<hr>
<h2 id="9-preview-the-brake-that-prevents-disaster">9. Preview: the brake that prevents disaster</h2>
<p><code>PREVIEW</code> exists because direct publishing to production is too powerful to be blind.</p>
<p>It runs validation, reports warnings or errors, and writes nothing. In practice, it is the safe mode for scripts, assistants, and humans to check whether a packet is publishable before touching the disk.</p>
<p>This action also separates transport from editorial judgment. The protocol carries the file; the validation layer decides whether that file respects the city's model.</p>
<hr>
<h2 id="10-identity-recovery">10. Identity recovery</h2>
<p><code>RECOVER</code> solves an unavoidable failure in simple systems: people lose secrets.</p>
<p>Instead of relying on email, passwords, OAuth, or an administrative dashboard, Soledade issues a recovery code during registration. Whoever holds that code can request a new identity. The previous identity stops working.</p>
<p>This keeps the design coherent: the city remains textual, the terminal remains sufficient, and the server does not need to store a reversible password.</p>
<hr>
<h2 id="11-operational-security">11. Operational security</h2>
<p>SOLED/2 is not transport encryption. If port 1915 is exposed, the operation needs appropriate external protection: firewall rules, an SSH tunnel, IP policy, or an explicit decision to make the port public.</p>
<p>The protocol authenticates identity at the application level. It does not promise connection secrecy by itself.</p>
<p>Operational safeguards include:</p>
<ul>
<li>IP-based rate limiting to contain registration and publishing spam;</li>
<li>citizen status such as <code>active</code>, <code>suspended</code>, or <code>banned</code>;</li>
<li>blocking dangerous paths, including attempts to escape <code>content/</code>;</li>
<li>build locking to avoid regeneration races;</li>
<li>logs containing protocol, citizen, action, and IP for basic auditing;</li>
<li>preservation of legacy mode without mixing administrative privilege with ordinary citizenship.</li>
</ul>
<hr>
<h2 id="12-relationship-with-legacy-mode">12. Relationship with legacy mode</h2>
<p>The first generation does not disappear. It remains available on the same port as legacy mode, identified by the absence of the <code>SOLED/2</code> first line.</p>
<p>Its format is direct:</p>
<pre><code class="language-text">&lt;path&gt;
&lt;administrative-token&gt;
&lt;body&gt;
.
</code></pre>
<p>This preserves older scripts and allows mayor-level operations without forcing immediate migration.</p>
<p>But legacy mode is not citizenship. It is administration. That distinction matters: SOLED/2 was born for many authors with their own scope; legacy mode exists for centralized trusted automation.</p>
<hr>
<h2 id="13-what-soled2-is-not">13. What SOLED/2 is not</h2>
<p>SOLED/2 is not:</p>
<ul>
<li>REST, JSON-RPC, or GraphQL;</li>
<li>a universal standard;</li>
<li>a replacement for Git;</li>
<li>an HTML-serving protocol;</li>
<li>the read protocol on port 1900;</li>
<li>a substitute for firewall policy, exposure decisions, or operational hygiene.</li>
</ul>
<p>It is the official write protocol of Soledade.</p>
<hr>
<h2 id="14-why-this-choice-makes-sense">14. Why this choice makes sense</h2>
<p>The choice behind SOLED/2 is stubbornly simple, and that is its virtue.</p>
<p>For a project like Soledade, adopting a conventional web stack would solve publishing, but it would erase part of the system's identity. The protocol makes the form match the content: a textual city, governed by files, accessible through a terminal, and understandable by direct reading.</p>
<p>The gain is not only technical. It is cultural.</p>
<p>Publishing stops being a click inside an invisible form and becomes the act of sending a formal letter to the city. The server reads it, validates it, records it, and rebuilds the public showcase. That minimal friction creates ritual without becoming bureaucracy.</p>
<p>In short: SOLED/2 exists because Soledade needed to grow from blog to city without losing its plain-text soul.</p>
<hr>
<h2 id="15-quick-specification">15. Quick specification</h2>
<table>
<thead>
<tr>
<th>Item</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>Transport</td>
<td>TCP</td>
</tr>
<tr>
<td>Default port</td>
<td><code>1915</code></td>
</tr>
<tr>
<td>Encoding</td>
<td>UTF-8</td>
</tr>
<tr>
<td>First line</td>
<td><code>SOLED/2</code></td>
</tr>
<tr>
<td>Header format</td>
<td><code>KEY value</code></td>
</tr>
<tr>
<td>Separator</td>
<td>Blank line between headers and body</td>
</tr>
<tr>
<td>Packet terminator</td>
<td>A line containing only <code>.</code></td>
</tr>
<tr>
<td>Editorial unit</td>
<td>Markdown file inside <code>content/</code></td>
</tr>
<tr>
<td>Canonical implementation</td>
<td><code>soledade/post_server.py</code></td>
</tr>
<tr>
<td>Citizenship state</td>
<td><code>data/citizens.json</code> with identity hashes</td>
</tr>
</tbody>
</table>
<hr>
<h2 id="16-one-sentence-pitch">16. One-sentence pitch</h2>
<p>I created SOLED/2 so that Soledade could publish Markdown through port 1915 as a textual city: each person registers a handle, receives a secret identity, sends terminal-readable packets, and the server validates, writes, and rebuilds the site without WordPress, without a web dashboard, and without abandoning the simplicity of TCP.</p>
<hr>
<h2 id="17-future-evolution">17. Future evolution</h2>
<p>If SOLED/3 ever exists, the correct path is to preserve predictability: a new first line, temporary compatibility with SOLED/2, updated documentation, new tests, and old responses preserved whenever possible.</p>
<p>A protocol change without a clear contract is just a bug with marketing.</p>
<p>Until then, SOLED/2 can evolve through new <code>ACTION</code>s and optional headers, as long as it does not break existing clients or change the meaning of fundamental responses.</p>
<hr>
<h2 id="appendix-a-%E2%80%94-mental-model-of-the-flow">Appendix A — Mental model of the flow</h2>
<pre><code class="language-text">ncat/script client
 |
 | TCP :1915, UTF-8, packet terminated by dot
 v
soledade.post_server
 |
 |-- first line == SOLED/2 -&gt; SOLED/2 parser
 |   |-- REGISTER / RECOVER -&gt; citizens and identity
 |   |-- LIST -&gt; permitted files
 |   |-- PREVIEW -&gt; validation without writing
 |   `-- publication -&gt; validation, writing, and build
 |
 `-- otherwise -&gt; legacy parser with administrative token

content/*.md -&gt; build_site() -&gt; public/*.html -&gt; Caddy/HTTPS
</code></pre>
<hr>
<h2 id="appendix-b-%E2%80%94-common-errors">Appendix B — Common errors</h2>
<table>
<thead>
<tr>
<th>Response</th>
<th>Meaning</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>400 bad request</code></td>
<td>Malformed message or missing required header.</td>
</tr>
<tr>
<td><code>401 invalid identity</code></td>
<td>Handle and identity do not match.</td>
</tr>
<tr>
<td><code>401 invalid recovery</code></td>
<td>Invalid recovery code.</td>
</tr>
<tr>
<td><code>403 forbidden</code></td>
<td>Operation not allowed for that citizen or path.</td>
</tr>
<tr>
<td><code>403 handle reserved</code></td>
<td>Handle reserved for the system or mayor.</td>
</tr>
<tr>
<td><code>409 handle taken</code></td>
<td>Handle already registered.</td>
</tr>
<tr>
<td><code>413 payload too large</code></td>
<td>Packet exceeds the allowed limit.</td>
</tr>
<tr>
<td><code>429 rate limited</code></td>
<td>Too many operations in a short interval.</td>
</tr>
</tbody>
</table>
<hr>
<h2 id="final-note">Final note</h2>
<p>SOLED/2 is intentionally small. Its purpose is not to impress through complexity, but to create a clear, auditable contract that fits Soledade's technical aesthetic.</p>
]]></content>
    </entry>
    <entry>
        <title>Building a Little City of Text</title>
        <link href="https://pablomurad.com/building-a-little-city-of-text/"/>
        <id>https://pablomurad.com/building-a-little-city-of-text/</id>
        <published>2026-06-01T13:55:52-03:00</published>
        <updated>2026-06-01T13:55:52-03:00</updated>
        <author><name>Pablo Murad</name></author>
        <category term="updates"/>
        <summary>The other day, while wandering through the stranger, quieter corners of the internet, I came across a curious place called The Midnight Pub. It felt less like a website and more like a door half-open in an alley: dimly lit, quiet, full of small voices and forgotten rooms. From</summary>
        <content type="html"><![CDATA[<p>The other day, while wandering through the stranger, quieter corners of the internet, I came across a curious place called <a href="https://midnight.pub/">The Midnight Pub</a>. It felt less like a website and more like a door half-open in an alley: dimly lit, quiet, full of small voices and forgotten rooms. From there, I eventually found my way to <a href="https://nightfall.city/">Nightfall City</a>, and I have to admit, I was immediately taken by it.</p><p>There was something deeply pleasant about the whole thing. The design, the layout, the restraint — everything seemed to belong to another internet, one that had not yet been buried under dashboards, feeds, pop-ups, infinite scrolling, and algorithmic noise. It reminded me a little of Pico, another place that once made me want to participate simply because it felt human.</p><p>But what really caught my attention was not only the visual style. It was the way the blog itself worked.</p><p>Well, well.</p><p>It was a Nex/NPS-style blog.</p><p>And honestly, I got ridiculously excited.</p><p>I had not seen something like that in years: a blog that could be read like a small textual place, where paths mattered, where the terminal felt welcome, where publishing did not require a bloated interface or a database pretending to be necessary. It felt old, but not obsolete. Small, but not poor. Technical, but strangely warm.</p><p>I even sent a few emails to the creator, just to say congratulations. I never received a reply — which, somehow, only made the whole thing more charming. So I thought: why not build one for myself?</p><p>And that was how <a href="https://soledade.city/">soledade.city</a> began.</p><p>The name <em>Soledade</em> is an old Portuguese word meaning “solitude” or “loneliness.” It has a quiet, archaic beauty to it. But it is also the name of my own little city, the place where I was born, in the south of Minas Gerais, Brazil. A small town with fewer than seven thousand people, but one that has my heart completely. Soledade is not just a word to me. It is a memory, a landscape, a place of origin.</p><p>So I created a Python virtual environment and started building.</p><p>The idea was simple: no CMS, no database, no admin panel, no framework, no unnecessary machinery. Just text, files, and a small set of Python scripts doing exactly what they needed to do.</p><p>At its core, soledade.city is a tiny Python system built almost entirely on the standard library. <code>pathlib</code> keeps the project’s paths clean and readable, separating <code>content/</code>, <code>public/</code>, <code>templates/</code>, <code>secrets/</code>, and <code>logs/</code> without turning the code into a mess of string manipulation. The only meaningful external dependency is <code>markdown</code>, used by <code>build.py</code> to transform <code>.md</code> files into static HTML pages. I enabled small comforts like <code>extra</code>, <code>toc</code>, and <code>sane_lists</code>, enough to make writing pleasant without letting the project become a full-blown publishing platform.</p><p>The generated site lives in <code>public/</code>. That is the whole public face of the project: static HTML, RSS, sitemap, health check, and a custom 404 page. Caddy takes care of serving those files on the web and handling HTTPS. Python does not pretend to be a web server here. It writes files, rebuilds the site, and steps aside.</p><p>For safety and correctness, the code uses <code>html</code> when escaping HTML content and <code>xml.sax.saxutils</code> when generating XML for the feed and sitemap. <code>datetime</code> and <code>email.utils</code> handle proper dates for <code>health.txt</code>, RSS, and sitemap entries. The project is small, but I wanted the boring details to be right.</p><p>The more peculiar part is outside the static generator.</p><p>Soledade also runs two small TCP services using Python’s <code>socketserver</code>. One listens on port <code>1900</code> and behaves like a minimal reading protocol: the client sends a path, and the server returns the raw Markdown. The other listens on port <code>1915</code> and handles publication: send a path, a token, the Markdown body, and a final line containing only a dot. If the token is valid, the server writes the file into <code>content/</code> and rebuilds the site.</p><p>For the publishing token, I used <code>secrets.compare_digest</code>, because even a tiny system should not be careless when checking secrets. The publication service is still intentionally plain — no login screen, no session, no browser interface — but it respects the basics: path validation, token validation, size limits, logs, and firewall rules.</p><p>That is the strange little heart of the project: internally, it is just a static site generator; externally, it behaves like a small textual city. You can visit it in a browser like a normal website, subscribe to the RSS feed like it is 2005, or talk to it from the terminal with <code>ncat</code>.</p><p>Reading is asking for a path.</p><p>Publishing is sending a path, a token, a body, and a dot.</p><p>That is all.</p><p>And that is exactly the point.</p><p>Soledade is not trying to become WordPress, Medium, Substack, or yet another “content platform.” It is intentionally small. It is a place for Markdown files, static pages, terminal rituals, and quiet publishing. A little city of text, named after another little city, built against the obesity of the modern web.</p><p>The project is already alive at <a href="https://soledade.city/">soledade.city</a>, though there is still plenty to improve. But that feels right. Small places should grow slowly.</p>]]></content>
    </entry>
</feed>
