<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="/assets/feed-content.xsl?v=537700020e"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
    <title>Pablo Murad</title>
    <link>https://pablomurad.com</link>
    <atom:link href="https://pablomurad.com/feed-full/" rel="self" type="application/rss+xml" />
    <atom:link href="https://websubhub.com/hub" rel="hub" />
    <description>I build things that put education within reach and keep the web open.</description>
    <language>en</language>
    <generator>pablawn-v1</generator>
    
    <item>
        <title>Copyparty Is the File Server I Did Not Know I Needed</title>
        <link>https://pablomurad.com/copyparty-is-the-file-server-i-did-not-know-i-needed/</link>
        <guid isPermaLink="true">https://pablomurad.com/copyparty-is-the-file-server-i-did-not-know-i-needed/</guid>
        <pubDate>Sat, 18 Jul 2026 22:11:42 -0300</pubDate>
        <dc:creator>Pablo Murad</dc:creator>
        <category>loveletter</category>
        <description>A practical love letter to a portable file server that keeps surprising me.

You know that rare kind of software that solves the problem you installed it for, then quietly reveals that it can solve another five?

Well, I think I found one.

I have been using copyparty, and I</description>
        <content:encoded><![CDATA[<p><em>A practical love letter to a portable file server that keeps surprising me.</em></p><p>You know that rare kind of software that solves the problem you installed it for, then quietly reveals that it can solve another five?</p><p>Well, I think I found one.</p><p>I have been using <a href="https://github.com/9001/copyparty">copyparty</a>, and I am honestly loving it. At first, the idea sounded almost too simple: run a program, point it at a directory, open a browser, and there are your files. But copyparty is one of those projects where “simple” describes the entrance, not the building.</p><p>Behind that browser window is a remarkably capable file server. It can handle resumable uploads, searchable indexes, duplicate detection, user accounts, per-folder permissions, media previews, WebDAV, SFTP, FTP and several other useful things. It runs on an absurd variety of systems. It can be a quick file drop, a personal web drive, a media shelf, a bridge between old machines or the front end of a much larger storage setup.</p><p>And somehow it still feels light.</p><h2 id="the-first-thing-i-loved-almost-no-ceremony">The first thing I loved: almost no ceremony</h2><p>There is a particular exhaustion that comes with self-hosted software. Sometimes you want one useful service and receive, as a bonus, a database, a cache, an identity platform, six containers and a small career in YAML maintenance.</p><p>Copyparty goes in the opposite direction.</p><p>The server itself only needs Python; its additional dependencies are optional and unlock extra features. The official project provides a self-contained Python version, a Python zipapp, a Windows executable and a Docker image. It can run on Linux, Windows, macOS, Android, iOS and a rather entertaining list of less common architectures and operating systems.</p><p>The fastest test is almost suspiciously easy:</p><pre><code class="language-bash">python copyparty-sfx.py
</code></pre><p>That is enough to turn the current directory into a browser-accessible file server. For a quick transfer on a trusted network, a temporary lab or an emergency involving two machines that refuse to cooperate, this is wonderful.</p><p>There is an important warning, however: running copyparty without arguments gives everyone who can reach it read and write access to the current folder. That default is convenient for testing, not a production security policy. Before exposing it beyond a trusted network, configure accounts, volumes and permissions, and place it behind properly configured HTTPS when appropriate.</p><p>Easy to start does not mean safe to forget.</p><h2 id="it-is-much-more-than-a-page-with-files">It is much more than a page with files</h2><p>The web interface is where copyparty stopped feeling like a clever transfer utility and started feeling like infrastructure I could actually keep.</p><p>From the browser, I can navigate directories, upload and download files, create folders, rename items, move or copy them and undo accidental uploads when the server allows it. Folders can be downloaded as ZIP or TAR archives. There is a navigation pane, keyboard shortcuts, thumbnails, an image gallery, a Markdown viewer and even media-oriented features such as audio playback, playlists and server-side transcoding when the optional tools are available.</p><p>This matters because a file server should not require every person to understand a file server.</p><p>Give someone a clean web address and the interaction is already familiar. They do not need to mount a network share. They do not need an FTP client. They do not need to learn which operating system is running on the other side. A browser is enough.</p><p>But if a browser is not enough for your workflow, copyparty also speaks WebDAV, SFTP, FTP, TFTP and SMB/CIFS, depending on configuration and platform support. It can announce services on a local network through Zeroconf, mDNS and SSDP. The same collection of files can therefore meet modern browsers, desktop file managers, command-line tools and old machines without forcing everything through one narrow door.</p><p>That flexibility is not decorative. It is useful.</p><h2 id="resumable-uploads-are-the-feature-you-appreciate-after-something-fails">Resumable uploads are the feature you appreciate after something fails</h2><p>Large uploads are easy to advertise when the connection is perfect. Real networks are less polite.</p><p>Copyparty's main browser uploader, called <strong>up2k</strong>, supports resumable and multithreaded transfers. If an upload is interrupted, the useful response is not to punish the user by starting again from zero. The server and browser can continue the work.</p><p>This is one of those features that sounds technical until you need it. Then it becomes the entire reason you trust the software.</p><p>It also supports write-only folders, filename randomization, self-destructing uploads and content-based duplicate handling. A write-only volume is especially interesting for collecting files: people can submit material without receiving permission to browse everything that other people have uploaded. With the right volume permissions, copyparty can behave like a private drop box instead of a public cupboard.</p><h2 id="search-indexing-and-the-quiet-intelligence-of-deduplication">Search, indexing and the quiet intelligence of deduplication</h2><p>Enable file indexing with <code>-e2dsa</code>, and copyparty can scan the shared volumes and make them searchable from the web interface. Searches can use names, paths, dates and sizes. With media indexing enabled, metadata such as audio tags can also become part of the search.</p><p>The clever part is that indexing is connected to duplicate detection. Copyparty can compare content and avoid storing the same upload repeatedly. You can even drag a local file into the search interface to check whether identical content already exists somewhere on the server.</p><p>This changes how the server feels as a collection grows. It is no longer only a directory exposed over HTTP. It begins to understand enough about the collection to help you navigate it.</p><p>Not everything needs artificial intelligence.</p><p>Sometimes a good index is the intelligent thing.</p><h2 id="accounts-and-volumes-make-it-practical">Accounts and volumes make it practical</h2><p>The permission model is another reason I can imagine copyparty serving very different roles without becoming a mess.</p><p>A <strong>volume</strong> maps a real directory to a location in the web interface. Each volume can have its own access rules, and permissions can be assigned to users. One folder may be public and read-only. Another may allow uploads but hide its contents. A private directory may be visible only to one account, while an administrator receives broader file-management permissions.</p><p>This separation is simple enough to understand and flexible enough to matter. I can think in terms of actual use:</p><ul><li>a public download area;</li><li>a private family archive;</li><li>an upload-only inbox;</li><li>a music library with browser playback;</li><li>a working directory available through WebDAV;</li><li>a temporary share with limited permissions.</li></ul><p>One service. Different doors. Different keys.</p><p>For longer-lived installations, the project recommends using a configuration file rather than accumulating a heroic command line. The official example shows global options, accounts and volumes in one readable file, and account or volume changes can be reloaded without restarting the entire service in supported setups.</p><h2 id="it-respects-small-machines">It respects small machines</h2><p>Perhaps this is the part I like most.</p><p>Copyparty does not begin with the assumption that every useful service deserves a new server. Its project philosophy is wonderfully direct: run anywhere, support everything, require little preparation and keep dependencies minimal.</p><p>That makes it useful on a home server, naturally. But it also makes it interesting on an old laptop, a small single-board computer, a temporary virtual machine or some forgotten device that still has a disk, a network connection and enough life left to be useful.</p><p>Software like this gives hardware a second purpose.</p><p>And there is something deeply satisfying about that. We are surrounded by machines that are declared obsolete long before they become incapable. A portable file server will not solve electronic waste, naturally, but it can turn an idle computer into a useful point of exchange again. Sometimes the difference between junk and infrastructure is one good piece of software.</p><h2 id="docker-when-i-want-it-plain-python-when-i-do-not">Docker when I want it, plain Python when I do not</h2><p>I also appreciate that copyparty does not turn deployment preference into a religion.</p><p>If I want the direct route, I can run the standalone Python package. If I want a container, the project maintains the <code>copyparty/ac</code> image and provides Compose examples. If I am on Windows, there is an executable. If I want to integrate it with a service manager, reverse proxy or a more carefully designed storage layout, the configuration is there.</p><p>This is how portable software should behave. It offers choices without making every choice mandatory.</p><p>For a serious deployment, I would still treat it like any other internet-facing service: use a dedicated account, expose only the directories that are necessary, grant the smallest useful permissions, keep the software updated, use HTTPS, understand the reverse-proxy headers and review the project's hardening guidance. Anonymous uploads deserve additional care, particularly around browser-rendered HTML, SVG and Markdown content.</p><p>Copyparty makes the first ten minutes easy. Security remains our job after that.</p><h2 id="why-i-am-enjoying-it-so-much">Why I am enjoying it so much</h2><p>There are larger platforms. There are more polished cloud suites. There are systems with calendars, collaborative documents, contact synchronization and enough plugins to recreate an office building inside a browser.</p><p>Copyparty is not trying to become all of that.</p><p>It is trying to move, organize, expose, search and play files extremely well across an unreasonable number of environments. Its own documentation jokes about following an “inverse Unix philosophy”: do all the things, and do an okay job. The joke is fair, but it hides the impressive part. The features do not feel like a random pile. They orbit the same practical question:</p><p>How can I make these files useful from another device?</p><p>That question appears simple. It is not. Different users, unreliable uploads, old protocols, browsers, media, permissions, duplicate data, reverse proxies and strange operating systems all arrive eventually. Copyparty has apparently met most of them already.</p><p>And this is why I am loving it.</p><p>It does not demand that I build my life around the software. It arrives, points at the files and becomes useful. Then, when I need more, there is usually another switch, another protocol or another carefully strange feature waiting in the documentation.</p><p>Some software wants to be a platform.</p><p>Copyparty wants to be invited to the party, carry every box through the door and make sure nothing gets lost on the way.</p><p>Honestly, it can stay.</p><h2 id="useful-official-links">Useful official links</h2><ul><li><a href="https://github.com/9001/copyparty">Copyparty on GitHub</a></li><li><a href="https://copyparty.eu/">Official project website and downloads</a></li><li><a href="https://github.com/9001/copyparty/blob/hovudstraum/docs/example.conf">Example configuration</a></li><li><a href="https://github.com/9001/copyparty/blob/hovudstraum/docs/examples/docker/basic-docker-compose/docker-compose.yml">Basic Docker Compose example</a></li></ul>]]></content:encoded>
    </item>
    <item>
        <title>How I built a public fax wall</title>
        <link>https://pablomurad.com/how-i-built-a-public-fax-wall/</link>
        <guid isPermaLink="true">https://pablomurad.com/how-i-built-a-public-fax-wall/</guid>
        <pubDate>Sat, 18 Jul 2026 15:47:00 -0300</pubDate>
        <dc:creator>Pablo Murad</dc:creator>
        <category>papers</category>
        <description>A real fax number, a static website, and no application server. Here’s how I built an automated public fax wall using Gmail, Apps Script, Drive, Sheets, Cloudinary, cron, and nginx.</description>
        <content:encoded><![CDATA[<p><a href="https://fax.1208.pro"><strong>https://fax.1208.pro</strong></a></p><p>There is a working fax number out in the world and it belongs to me. If you send a page to it, that page may end up hanging on a web page, in black and white, for any stranger to look at. No signup, no login, no app. Just a phone number and an old machine on the other end of an imaginary line.</p><p>The idea came out of a small grudge. "Just send the link" has become the beige wallpaper of modern life. Everything is instant, everything is editable, everything disappears. Fax isn't like that. Fax is stubborn, far too physical for 2026, and somehow still alive — running like a haunted appliance with union protection. I wanted to see what would happen if I left that channel open to the world and published whatever came through without much curation.</p><p>This is about how the thing was built. I'll walk through each piece in the order a sheet of paper travels through the system.</p><h2 id="the-rule-that-shaped-the-architecture">The rule that shaped the architecture</h2><p>Before the first file existed, I had one personal constraint: <strong>no application server</strong>.</p><p>No backend running around the clock, no database to back up, no container to patch at three in the morning because somebody published a CVE. This is a toy project. If it demands maintenance, it dies in three months. They all do.</p><p>So the decision was: the final site is <strong>static HTML</strong>. One file. Nginx serves it and that's the end of it. Everything dynamic happens far away, on infrastructure somebody else maintains for free, and the result lands as a dumb JSON file on disk.</p><p>That sounds like laziness. It is, but it's laziness with a design behind it. Every other choice in the system falls out of this rule.</p><h2 id="piece-1-getting-a-real-fax-number">Piece 1: getting a real fax number</h2><p>I don't have a phone line and I'm not buying a machine. I used a fax-to-email service — fax.plus, in this case. You rent a number, and when somebody transmits to it, the service answers the call, decodes the signal, and emails you the document as an attachment, PDF or TIFF.</p><p>The number is published on the site, because that's the entire point: <strong>+1 762 475 9826</strong>.</p><p>Here's the trick that made the project viable: the moment a fax becomes an email with an attachment, it stops being telephony and becomes a Gmail inbox. And a Gmail inbox is something I know how to automate without paying anyone.</p><h2 id="piece-2-google-apps-script-playing-the-part-of-a-backend">Piece 2: Google Apps Script playing the part of a backend</h2><p>The whole core of the system lives in a single <code>Código.gs</code> file running on Google Apps Script. It's JavaScript hosted by Google with native access to Gmail, Drive, and Sheets, time-based triggers, and a public HTTP endpoint if you want one. It costs nothing and I administer none of it.</p><p>It has two main functions that run in sequence.</p><h3 id="ingestfaxes-%E2%80%94-fishing-out-the-new-faxes"><code>ingestFaxes()</code> — fishing out the new faxes</h3><p>The function sweeps Gmail with a specific query: messages from the fax service's notification sender, within the last 30 days. The search is paginated 100 threads at a time in a loop, because the Apps Script API hands results back in batches, and ignoring that is the classic way to silently lose older messages once volume grows.</p><p>For each message, three decisions:</p><p><strong>Have I seen this one?</strong> Before anything else, I compare the Gmail message ID against the IDs already on record. This is the heart of the system's idempotency. The trigger fires periodically and always looks at a rolling 30-day window, which means it reprocesses the same messages dozens of times. Without a reliable dedup key, the wall would turn into a wall of duplicates. Using the message ID rather than the subject or the date was the right call: subjects repeat, dates collide, IDs don't.</p><p><strong>Which attachment is the fax?</strong> A notification email arrives with a logo, a footer, an inline image, sometimes a receipt. I wrote a function that filters for plausible candidates — <code>.pdf</code>, <code>.tif</code>, <code>.tiff</code>, or the matching content types — and then scores the survivors: PDF is worth 3, TIFF is worth 2, and file size enters as a tiebreaker divided by ten million. Highest score wins.</p><p>There's one ugly case in there that I left on purpose: if an attachment shows up as <code>application/octet-stream</code> and is larger than 1 KB, I accept it. Fax gateways are notorious for sending generic content types. Rejecting on technical purity would mean dropping legitimate faxes, and the cost of the opposite error is low — worst case, Cloudinary refuses the file downstream and the row simply ends up without an image.</p><p><strong>Keep the original.</strong> The chosen attachment is copied into a Drive folder under a deterministic name: <code>fax_20260410_143022_&lt;messageId&gt;</code>. That's the raw file, untouched. If I break every other stage of the pipeline, the source material is still sitting there.</p><p>Then a row goes into the spreadsheet with its own UUID, the received date, the sender, the subject, the Drive file ID, the attachment name and type, and a short public slug derived from the first eight characters of the UUID.</p><h3 id="the-spreadsheet-as-a-database">The spreadsheet as a database</h3><p>Yes, the database is a fifteen-column Google Sheet. I recommend this far more often than people expect for projects this size. It comes with a free admin interface, revision history, permissions, and export, and I can fix a bad record from my phone on the bus without touching SSH.</p><p>One thing I did that saved me pain: a function that <strong>checks and repairs the sheet's header row every single time the script runs</strong>. It verifies the fifteen columns are where they should be, inserts columns if any are missing, and rewrites the header row if it doesn't match. A spreadsheet is human-editable, and humans drag columns around. Assuming the schema is intact is optimism. The code never reads a column by fixed position — it always builds a name-to-index map from row 1.</p><p>Every row is born with status <code>approved</code>. The schema supports moderation — the field is there, and the publishing function only looks at approved rows — but in practice the gate is wide open. That was a deliberate choice about the spirit of the site, not an oversight. The switch exists for the day I need it.</p><h3 id="publishapproved-%E2%80%94-turning-a-pdf-into-a-publishable-image"><code>publishApproved()</code> — turning a PDF into a publishable image</h3><p>Second function. It looks for approved rows that don't have a published image yet, pulls the original file from Drive, and pushes it to Cloudinary under a predictable public ID.</p><p>And this is where my favorite part of the whole project lives, because it's work I <strong>didn't</strong> have to do.</p><p>The real problem was: how do you turn a fax PDF into an image that fits on a web page? The obvious answer involves rasterizing PDF, which means ImageMagick or Ghostscript, which means a server, which breaks rule number one.</p><p>Cloudinary does it in the URL. After the upload, the public image is assembled from four transformations chained into the path:</p><ul><li><code>pg_1</code> — take only the first page of the PDF</li><li><code>dn_200</code> — rasterize at 200 DPI, fax density</li><li><code>e_blackwhite</code> — force pure black and white, no halftone</li><li><code>q_auto</code> — let Cloudinary pick the compression</li></ul><p>No processing happens on my side at all. I upload the raw PDF and request an image by URL. The conversion runs on their CDN, on demand, and gets cached. The result looks exactly the way I wanted — fax grain, high contrast, none of that tasteful gray.</p><p>The image URL and the public ID go back into the spreadsheet, and the row is live.</p><h2 id="piece-3-privacy-or-at-least-the-facade-of-it">Piece 3: privacy, or at least the facade of it</h2><p>The wall displays pages that strangers sent me. I have no idea what's going to arrive. So there's a hygiene layer working at two levels.</p><p>On the server side, the sender runs through a function that tries to extract a human name from the email's <code>From</code> field. It handles the <code>"Some Person" &lt;person@domain.com&gt;</code> format, strips outer quotes, and then applies two rejection tests: if what's left looks like an email address, discard it; if it looks like a phone or fax number — seven or more digits, nothing but phone characters — discard it. Anything suspicious becomes <code>Unknown sender</code>.</p><p>This matters more than it looks. Fax carries the originating number in its header, and the service frequently drops that number straight into the sender field. Publishing it would mean leaking the phone number of whoever sent me a joke.</p><p>On the client side I went further: <strong>the page doesn't render the sender name at all</strong>. The public JSON carries the field, the front-end simply never draws it. Every entry on the wall shows the date, the image, and an optional note. That's it. As the site's own FAQ puts it — the machine knows, obviously, but it is not a snitch.</p><h2 id="piece-4-how-the-data-leaves-google-and-reaches-my-vps">Piece 4: how the data leaves Google and reaches my VPS</h2><p>The Apps Script publishes a Web App with three output formats, selected by URL parameter: a rough HTML gallery, <code>format=json</code>, and <code>format=rss</code>. The last two are the ones that matter. Both accept a per-item filter and a count limit.</p><p>Now, I could have just pointed the site at Google's endpoint and called it done. I didn't, for three reasons: the URL is ugly and advertises the implementation, Apps Script has execution quotas, and I don't want my site's availability to depend on a Google redirect resolving during a visitor's request.</p><p>So there's an eighty-line shell script running hourly on the VPS via cron. It's small, but there are four deliberate decisions inside it.</p><p><strong>Cascading configuration.</strong> The script loads config files in order — repository first, then <code>~/.config</code> — and environment variables override everything. That gives me versioned defaults plus machine-local overrides without a single <code>if</code>.</p><p><strong>Explicit failure.</strong> It opens with <code>set -euo pipefail</code> and aborts with a clear message if the Web App URL isn't configured. A silent cron job is the worst category of bug: the site just freezes in time and nobody notices for three weeks.</p><p><strong>URL rewriting.</strong> Every link pointing at Google's domain inside the payload gets swapped for the public domain, using <code>perl</code> with both ends passed in through environment variables instead of interpolated into the regex. That keeps a slash or a question mark in the URL from turning into a metacharacter and destroying the file.</p><p><strong>Atomic writes.</strong> This is the most important one and the easiest to forget. <code>curl</code> downloads to <code>faxes.json.tmp</code>, and only then does <code>mv</code> move it over the real file. <code>mv</code> within the same filesystem is an atomic rename, which means nginx never — not for a single instant — serves a half-written JSON. If you download straight over the file being served, sooner or later a visitor catches the transfer mid-flight and gets a parse error.</p><p>Nginx serves both files with <code>Cache-Control: max-age=3600</code>, deliberately aligned to the cron interval. There's no point caching for longer than the refresh, or for shorter.</p><h2 id="piece-5-the-page">Piece 5: the page</h2><p>The front-end is a single HTML file. No build step, no framework, no dependencies, no <code>npm install</code>. It loads the JSON with <code>fetch</code> and assembles the list.</p><p>The look is monospace, light gray background, blue underlined links in the browser's original default. That's not aesthetic laziness — that <em>is</em> the aesthetic. A fax wall shouldn't look like a SaaS product.</p><p>A few details worth mentioning:</p><p>The favicon is a fax emoji embedded as an SVG data URL. Zero extra requests, no <code>.ico</code> file.</p><p>The image containers have <code>aspect-ratio: 210 / 297</code> locked in before any image loads. That's the A4 ratio. The space is reserved at the correct size up front, so nothing jumps around when the images arrive.</p><p>The first image loads with <code>loading="eager"</code> and <code>fetchpriority="high"</code>; every other one is <code>lazy</code>. There's a <code>preconnect</code> to Cloudinary's domain in the <code>head</code>, so the TLS handshake is already underway before the JSON finishes downloading.</p><p>The JSON parser is intentionally forgiving: if the response is an array, use it; if it's an object, look for <code>items</code>, <code>faxes</code>, <code>records</code>, <code>data</code>, or <code>entries</code>. It cost six lines and lets me change the backend's shape without breaking the live page.</p><p>And everything coming out of the JSON gets HTML-escaped before it touches the DOM. I am rendering content that anonymous strangers transmitted over a phone line. Concatenating that straight into <code>innerHTML</code> would be an open invitation for the first clever visitor to write the rest of my site for me.</p><h2 id="piece-6-the-doorman">Piece 6: the doorman</h2><p>Sitting in front of all of it is Anubis, a proxy that demands proof-of-work from the browser before it lets the page through. It's there because AI training crawlers started burning through small-VPS bandwidth as if it were infinite.</p><p>I found out it was stricter than I remembered when I tried to fetch my own site with an automated tool and got an "access denied" to the face. Working as designed, I suppose.</p><h2 id="the-whole-flow-in-one-line">The whole flow, in one line</h2><p>Somebody dials the number → the service converts the call into an email with an attachment → Apps Script finds the email, dedupes on the message ID, picks the right attachment, archives the original in Drive, and writes a row to the spreadsheet → it uploads the PDF to Cloudinary and requests page one in black and white at 200 DPI → it publishes a JSON feed → the VPS cron pulls that JSON hourly and atomically swaps it over the old one → nginx serves it → static HTML draws it.</p><p>Not one process of mine runs continuously. The most complex part of the system — rasterizing PDF — is a parameter in a URL. The part that most resembles a database is a spreadsheet I can open on my phone.</p><h2 id="what-id-do-differently">What I'd do differently</h2><p>Two things bother me when I reread the repository.</p><p>First: the Web App URL is committed to the environment file inside the repo, and the README itself says it should live outside of it. It's a read-only endpoint, so it isn't catastrophic, but it's exactly the kind of thing you swear you'll fix later and never do. The right move is to purge it from history, publish a new Web App version, and keep the value only in <code>~/.config</code> on the VPS.</p><p>Second: the <code>data/faxes.json</code> sitting in the repo is empty. It's a placeholder — the real file is generated on the VPS by cron — but a committed empty file is a trap waiting for someone to deploy it over the good one.</p><p>Beyond that, I'd keep all of it. Especially the part where there's no server to maintain.</p>]]></content:encoded>
    </item>
    <item>
        <title>Movie: A Serious Man</title>
        <link>https://pablomurad.com/movie-a-serious-man/</link>
        <guid isPermaLink="true">https://pablomurad.com/movie-a-serious-man/</guid>
        <pubDate>Sat, 18 Jul 2026 09:15:11 -0300</pubDate>
        <dc:creator>Pablo Murad</dc:creator>
        <category>movies</category>
        <description>A Serious Man turns spiritual confusion, domestic collapse, and cosmic indifference into razor-sharp black comedy. The Coens offer no answers—only the terrifying possibility that meaning exists, but remains beyond our reach.</description>
        <content:encoded><![CDATA[<p><em>A Serious Man</em> is one of the funniest films I have ever watched, although “funny” feels like an inadequate word for what the Coen brothers are doing here. This is not comedy designed to make you comfortable. It is comedy extracted from humiliation, spiritual confusion, domestic collapse and the quiet suspicion that the universe may be operating according to rules no one bothered to explain to us.</p><p>I loved it.</p><p>The film follows Larry Gopnik, a physics professor living in Minnesota in 1967, whose life begins to fall apart with almost mathematical precision. His wife wants a divorce so she can marry Sy Ableman, a man so calm, reasonable and unbearably sympathetic that his politeness becomes a form of violence. Larry’s brother is sleeping on the couch, draining a cyst in the bathroom and working obsessively on a mysterious notebook. His children treat him like an inconvenient financial institution. A student tries to bribe him and then threatens to sue him. His tenure is uncertain. His roof needs repairing.</p><p>And Larry, poor bastard, keeps asking the same question:</p><p>Why?</p><p>That is the real joke.</p><p>Larry is a man of science. He teaches uncertainty, probability and the limitations of human knowledge, but he cannot tolerate uncertainty when it enters his own house. He can explain Schrödinger’s cat on a blackboard, yet he cannot understand why his wife suddenly prefers Sy Ableman. He wants the universe to present its calculations. He wants suffering to show its work.</p><p>But the universe does not care about intellectual consistency.</p>
<!--kg-card-begin: html-->
<iframe title="A Serious Man" width="560" height="315" src="https://pablo.tube/videos/embed/gB3fiKaM1kACUzaFBQLpyH" style="border: 0px;" allow="fullscreen" sandbox="allow-same-origin allow-scripts allow-popups allow-forms"></iframe>
<!--kg-card-end: html-->
<p><em>A Serious Man</em> is essentially the Book of Job relocated to a Jewish suburb, except that God never arrives to explain Himself. There are rabbis, lawyers, doctors, neighbors and academic committees, but no authority capable of giving Larry a useful answer. Every person he consults offers either a meaningless story, a bureaucratic procedure or some variation of “accept the mystery.”</p><p>The rabbis are especially brilliant. Larry approaches them expecting ancient wisdom and receives anecdotes, platitudes and administrative delays. One rabbi tells him a strange story about a dentist who discovered Hebrew letters carved into a patient’s teeth. The story sounds as though it must contain a profound revelation. Naturally, it leads nowhere.</p><p>That is exactly the point.</p><p>Human beings are addicted to the idea that everything means something. We cannot accept random suffering, so we invent structures around it. Religion, mathematics, superstition, philosophy, bureaucracy — all of them become methods of drawing borders around the incomprehensible. We ask for signs, and when a sign appears, we immediately ask what the sign means.</p><p>Maybe it means nothing.</p><p>Maybe the teeth are just teeth.</p><p>The humor in <em>A Serious Man</em> is brutally precise. The Coens never beg for laughter. They simply allow situations to become so uncomfortable, so absurdly unfair, that laughter becomes the only honest reaction. Sy Ableman embracing Larry and telling him how difficult the divorce must be is funnier than any conventional joke. He steals the man’s wife and then comforts him about it. It is an almost perfect portrait of passive aggression disguised as compassion.</p><p>Fred Melamed plays Sy with a kind of majestic softness. Every word sounds therapeutic. Every gesture feels murderous.</p><p>Michael Stuhlbarg is equally extraordinary as Larry. His performance is built from confusion, suppressed panic and the exhausted decency of a man who still believes that behaving correctly should protect him from catastrophe. Larry is not heroic, but he is recognizably human. He does not demand happiness. He merely wants an explanation.</p><p>He does not get one.</p><p>What makes the film even better is that it refuses to turn Larry into a saint. He is passive, indecisive and frequently blind to the people around him. He spends so much time asking what God wants from him that he rarely considers what anyone else might need. His suffering is real, but so is his self-absorption.</p><p>This prevents the film from becoming a simple story about an innocent man punished by fate. Larry may not deserve what happens to him, but the universe has never been particularly interested in proportional punishment.</p><p>The cinematography is controlled, clean and almost clinical. Everything in Larry’s world appears organized: suburban houses, synagogue rituals, university offices, mathematical formulas. Yet beneath that order is complete chaos. The visual neatness makes the existential disorder even funnier. The furniture is aligned. The lawns are trimmed. God remains unavailable.</p><p>Then there is the ending.</p><p>No sentimental resolution. No final rabbinical wisdom. No comforting proof that suffering produces growth. Larry makes one small moral compromise, the doctor calls with troubling news, and a tornado approaches his son’s school while Jefferson Airplane plays.</p><p>The film ends not with an answer, but with an interruption.</p><p>It is perfect.</p><p>The tornado is not merely punishment. That interpretation would be too easy, and <em>A Serious Man</em> distrusts easy interpretations. It is uncertainty made visible — enormous, indifferent and moving directly toward everyone. The children stare at it because there is nothing else to do.</p><p>That final image stayed with me because it expresses the whole film without pretending to solve it. We live inside systems we barely understand. We make plans, study sacred texts, calculate probabilities, repair antennas and worry about television reception while something immense forms on the horizon.</p><p>And still, somehow, it is hilarious.</p><p><em>A Serious Man</em> is not nihilistic, although it frequently looks in that direction. Nihilism would be simpler. The film’s position is more disturbing: meaning may exist, but we may be fundamentally incapable of accessing it. God may have a plan. God may be absent. God may simply have a very strange sense of humor.</p><p>The Coen brothers do not answer the question.</p><p>They understand that the question is funnier.</p>]]></content:encoded>
    </item>
    <item>
        <title>No Legacy, Just Curiosity</title>
        <link>https://pablomurad.com/no-legacy-just-curiosity/</link>
        <guid isPermaLink="true">https://pablomurad.com/no-legacy-just-curiosity/</guid>
        <pubDate>Sat, 18 Jul 2026 00:43:35 -0300</pubDate>
        <dc:creator>Pablo Murad</dc:creator>
        <category>life</category>
        <description>A reflection on learning without fame, driven by curiosity, persistence, and the joy of understanding—while building friendships and a world where everyone’s strangeness and beauty can belong.</description>
        <content:encoded><![CDATA[<p>I do not want fame, nor do I care about leaving a legacy. It makes little difference to me whether I am known or not. I simply want to learn and understand.</p><p>Many things are driven by hype, while others make us feel less excluded—or, more accurately, more included but I am not looking for any of that. In truth, I do not care what you are, as long as you have something to teach. I am here to learn from you, and I hope you can learn something from me as well, whether in programming or in management.</p><p>I am, by far, the worst programmer I know, despite having “played around” with Delphi since 1997. On the other hand, it would be equally true to say that I am good at management. But none of that really matters. What matters is that, in this space, we are driven by curiosity, by that sudden urge to keep going without giving up, and by the desire to see things work.</p><p>Curiosity is the force that drives us. It makes us dig deeper, leaving behind beautiful memories and nurturing new friendships (perhaps lifelong ones) each person with their own strangeness and beauty, exactly as they are. Together, we are building a world that adapts to us.</p>]]></content:encoded>
    </item>
    <item>
        <title>Movie: Napoleon Dynamite</title>
        <link>https://pablomurad.com/movie-napoleon-dynamite/</link>
        <guid isPermaLink="true">https://pablomurad.com/movie-napoleon-dynamite/</guid>
        <pubDate>Sat, 18 Jul 2026 00:26:23 -0300</pubDate>
        <dc:creator>Pablo Murad</dc:creator>
        <category>movies</category>
        <description>A gloriously awkward comedy where tater tots, bad dancing, Uncle Rico’s delusions, and small-town weirdness become cinematic perfection. Napoleon Dynamite is deadpan, ridiculous, and impossible not to love.</description>
        <content:encoded><![CDATA[
<!--kg-card-begin: html-->
<iframe title="Napoleon Dynamite" width="560" height="315" src="https://pablo.tube/videos/embed/rSczyLKwquumEsY9FiyDva" style="border: 0px;" allow="fullscreen" sandbox="allow-same-origin allow-scripts allow-popups allow-forms"></iframe>
<!--kg-card-end: html-->
<p><strong>The Art of Absolutely Nothing Happening</strong></p><p>Napoleon Dynamite is a movie in which almost nothing happens - and, somehow, everything happens. There is no proper villain, no grand mission, nobody has to save the world, and no character stops the story to explain their trauma while staring through a rain-soaked window. There is just a teenager in moon boots walking around Preston, Idaho, looking like he was dragged out of a bad dream and told he has to present a group project.</p><p>That is exactly why I loved it. The film has the nerve to exist on its own frequency, a kind of small-town fever dream where every silence lasts half a second longer than it should, people talk with absolutely no sense of social rhythm, and a spoonful of mashed potatoes can carry more dramatic tension than the entire third act of most blockbusters. The humor does not come from traditional punchlines. It comes from the pause, the blank stare, the wrong outfit, the even-worse response, and the certainty that nobody in that town has the faintest idea how a human interaction is supposed to work.</p><p>It is deadpan elevated to a religion. The movie never begs for laughs, never uses the soundtrack to announce that something is funny, and never tries to win us over with artificially adorable characters. It simply shows Napoleon drawing a liger - "pretty much my favorite animal" - and moves on as though this were perfectly ordinary information. And maybe it is. After a few minutes, the movie's logic takes over. You stop wondering why its universe is so strange and begin to suspect that the real world is simply too conventional.</p><p>Napoleon is a walking masterpiece of awkward energy. He is irritating, arrogant, vulnerable, loyal, and completely incapable of understanding how other people see him. In a lazier high-school comedy, he would be the nerd who gets a makeover, learns how to use hair gel, and earns the approval of everyone who used to despise him. Not here. Napoleon remains Napoleon. There is no glow-up, no makeover montage, no speech about believing in yourself. There is only a boy who desperately wants everyone to know he has "nunchuck skills, bow hunting skills, computer hacking skills" - although the available evidence is, at best, inconclusive.</p><p>That is one of the sweetest things about the movie, even though it hides every trace of sentiment behind a bored expression. Napoleon does not need to become cool. Pedro does not need to become a charismatic candidate coached by consultants. Deb does not need to stop being shy. They only need to find some small way to exist together. The film looks at the weird kids without turning them into cute mascots, misunderstood geniuses, or moral lessons. They are strange, occasionally annoying, and frequently lost - and they still deserve friendship, dignity, and a place on the stage.</p><p>Pedro, by the way, is the true calm king of American cinema. While everyone else seems permanently one social interaction away from collapse, he moves through the film with the serenity of a man who has already realized that reality is far too ridiculous to justify anxiety. His campaign for class president is basically an anti-marketing performance: very few words, zero manufactured enthusiasm, and a slogan that entered pop culture because it cannot be improved. Vote for Pedro. That is it. That is the platform.</p><p><strong>Uncle Rico and the Multiverse of 1982</strong></p><p>If Napoleon is the teenager who has not yet found his place in the world, Uncle Rico is the adult who found his place in 1982 and refused to leave. He does not merely live in the past: he rented the past, furnished the past, and is probably trying to sell plastic containers to the past. Every time he appears, the movie gains another layer of discomfort. Rico has the confidence of a motivational speaker and the credibility of a man who records himself throwing a football beside a van.</p><p>His dream of traveling back in time to win a high-school football game is funny because it is absurd, but also because it is painfully recognizable. Everyone knows someone who turned one specific year of their youth into an entire personality. Uncle Rico is the patron saint of men who say, "I could have gone pro," while holding a warm beer at a barbecue. He permanently radiates "peaked in high school" energy, except for the minor detail that he may never have peaked at all. It is tragic, pathetic, and wonderful.</p><p>Kip, meanwhile, managed to become terminally online before it was recognized as a social diagnosis. He spends his days talking to women in chat rooms, discusses technology with the solemnity of a Silicon Valley pioneer, and somehow ends up in a genuinely sweet romance with LaFawnduh. The film could easily have used their relationship as nothing more than a cruel joke. Instead, it gives Kip perhaps the most sincere romantic arc in the entire story. In a universe where almost everyone seems emotionally frozen, the man who sings about technology finds somebody. Good for him, honestly.</p><p><strong>Tater Tots, Glamour Shots, and an Aesthetic You Cannot Fake</strong></p><p>Visually, Napoleon Dynamite looks like it was discovered inside a forgotten box in a basement, somewhere between a corporate training VHS and a 1987 school catalog. I mean that as a huge compliment. The faded colors, empty spaces, furniture, hairstyles, clothes, and endless Idaho sky create a place that seems trapped outside time. The film came out in 2004, but it could take place in 1986, 1994, or an alternate dimension where every store still sells binders with horses on the cover.</p><p>The direction understands that objects can tell jokes too. The tater tots hidden in Napoleon's pocket are not merely food; they are a manifesto. Deb's glamour shots, Uncle Rico's tapes, Pedro's bicycle, Tina the llama, the "delicious bass" - everything seems to have been selected by someone with an advanced degree in the science of embarrassment. Some movies spend millions building universes. Napoleon Dynamite needs only a corded telephone, a beige sofa, and a wolf T-shirt to establish an entire cosmology.</p><p>And then the dance happens. By that point, the movie has already proved it obeys none of the usual rules. But when Napoleon walks onto the stage and dances to Jamiroquai's "Canned Heat," the comedy becomes a strange little miracle. It is not technically perfect, nor should it be. It is awkward, intense, unexpected, and completely free. For the first time, Napoleon stops explaining his alleged skills and simply demonstrates one. The boy understood the assignment.</p><p>There is a huge difference between a comedy that humiliates its characters and a comedy that understands being human is already humiliating enough. Napoleon Dynamite almost always stays on the right side of that line. The movie laughs at its characters' social failures, of course, but rarely treats them with contempt. Even its most ridiculous people want things we can understand: Napoleon wants respect, Pedro wants to belong, Deb wants to be seen, Kip wants love, and Uncle Rico wants a time machine because accepting the present would be much harder.</p><p>Not everything will work for everyone. The story feels deliberately assembled from pieces somebody found scattered on the floor, the pace is so slow that some viewers will feel as though they are watching paint dry in Idaho, and several scenes end without the payoff a conventional comedy would promise. Anyone who needs plot twists, perfectly engineered character arcs, and a joke every fifteen seconds will probably hate it. Fair enough. But demanding conventional structure from this movie is like complaining that a liger does not exist in nature. You have completely missed the point.</p><p>For me, the pace was not an obstacle; it was the mechanism of the joke. I laughed so much because the film gives its strangeness room to breathe. Every silence becomes a trap. Every conversation sounds as though it was written by aliens who learned about humanity from high-school yearbooks. There is enormous precision behind the appearance of casual improvisation. The comedy looks effortless, but the timing is surgical.</p><p><strong>Vote for Pedro - and for the Right to Stay Weird</strong></p><p>In the end, Napoleon Dynamite is a comedy about people who do not know how to communicate, dress, flirt, or, in several cases, what they are doing at all. In other words, it is a movie about people. Its genius lies in turning small lives, monotonous routines, and social failures into something almost epic without ever abandoning the tone of someone answering, "Whatever I feel like I wanna do, gosh," when asked what they plan to do that day.</p><figure class="kg-card kg-image-card"><img src="https://pablomurad.com/content/images/2026/07/images.jpg" class="kg-image" alt="" loading="lazy" width="447" height="447"></figure><p>I finished the movie with that rare feeling of having visited an entire place. Not just a collection of characters, but a town, a temperature, a particular kind of carpet, an imaginary smell of a school cafeteria. It is extremely funny because it trusts the absurdity of everyday life and understands that the most memorable moments do not always arrive with a grand musical score. Sometimes they involve a student election, a drawing of a hybrid animal, a plate of nachos, or someone shouting at a llama to eat.</p><p>Napoleon Dynamite is weird cinema in its purest form: too specific to have been manufactured by committee, too sincere to be only ironic, and too funny to depend on nostalgia. More than twenty years later, it still feels like a creature nobody could reproduce in a laboratory. Thank goodness. Some things should remain unique.</p><p><strong>VERDICT</strong></p><p><strong>9/10 tater tots hidden in a pocket</strong></p><p><em>Gosh!</em></p>]]></content:encoded>
    </item>
    <item>
        <title>How I Built Pablo&#x27;s Room</title>
        <link>https://pablomurad.com/how-i-built-pablos-room/</link>
        <guid isPermaLink="true">https://pablomurad.com/how-i-built-pablos-room/</guid>
        <pubDate>Fri, 17 Jul 2026 22:19:18 -0300</pubDate>
        <dc:creator>Pablo Murad</dc:creator>
        <category>papers</category>
        <description>A behind-the-scenes look at Pablo’s Room, an interactive pixel-art portfolio built with React, TypeScript, SVG hotspots, a visual editor, serverless storage, accessibility in mind, and one unforgettable cat named Netuno.</description>
        <content:encoded><![CDATA[<p>I wanted a portfolio that felt like a place instead of a page. So I turned my workspace into a pixel-art scene: a cozy isometric office full of monitors, books, gadgets, a window with weather, and my cat, Netuno, napping somewhere in the frame. Every object in the room is clickable. Clicking opens a panel that tells a story — about a project, a tool I use, or just a detail of the room. The whole site behaves like a point-and-click adventure game, but underneath it is a modern, fully typed web application.</p><h1 id="the-stack">The Stack</h1><p>•&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <strong>React 19 + Next-style App Router</strong>, built and served through a Vite-based toolchain that compiles the app into a single edge-style worker bundle.</p><p>•&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <strong>TypeScript everywhere</strong> — every data structure in the project has an explicit contract.</p><p>•&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <strong>Tailwind CSS 4 plus hand-written CSS</strong> for the retro CRT/neon look, with two bundled terminal fonts (VT323 and IBM Plex Mono).</p><p>•&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <strong>Framer Motion</strong> for motion, always gated behind reduced-motion preferences.</p><p>•&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <strong>A serverless-style runtime</strong>: the production build runs as a worker on a lightweight local engine, with a SQL database for published content and an object store for uploaded media.</p><p>•&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <strong>Docker</strong> for self-hosting, sitting behind my own reverse proxy and HTTPS domain.</p><p>There are no paid services in the loop. The same build can be deployed to an edge platform or run entirely on my own server.</p><h1 id="one-coordinate-system-to-rule-everything">One Coordinate System to Rule Everything</h1><p>The heart of the project is a simple decision: the scene image and the interactive layer share the exact same coordinate space. The room illustration has a fixed logical size, and an SVG overlay uses that same size as its viewBox. Every clickable region — I call them <strong>hotspots</strong> — is defined in those image coordinates. Because the SVG scales with the image, hotspots stay perfectly aligned at any window size, on any device, in any orientation. No math at render time, no drift.</p><p>A hotspot is a small typed record: an id, a slug, a title, a shape (polygon, rectangle, circle or ellipse), its geometry, visibility flags, a category, tags, a z-index, and a content block. The whole room is one versioned JSON document with a schemaVersion field so future migrations stay possible.</p><h1 id="the-content-system">The Content System</h1><p>Each hotspot's content block can carry a short description, long-form Markdown, an image or gallery, video, audio, external links, metadata pairs, references to a small library of fictional books, and even custom commands for a fake in-page terminal. The terminal is a toy — it understands a handful of friendly commands and never touches the real system. Longer texts live as Markdown files, ready to be moved into a CMS later without changing any component.</p><p>Because content is data, not code, redecorating the room never requires touching a component. I edit JSON (or use the visual editor) and the site follows.</p><h1 id="the-public-experience">The Public Experience</h1><p>The public page is composed of a few focused components:</p><p>•&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <strong>RoomExperience</strong> — the orchestrator. It renders the scene, fetches the latest published content from the API at load time (falling back to the bundled document if the network fails), and manages which hotspot is active or open.</p><p>•&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <strong>HotspotLayer</strong> — the SVG overlay. It draws each hotspot as a focusable, keyboard-operable shape with an accessible name, hover/focus highlighting, and an on-demand object list for people who prefer picking from a menu instead of hunting with a pointer.</p><p>•&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <strong>InfoPanel</strong> — the storytelling surface. It renders the hotspot's Markdown and media, traps focus while open, closes on Escape or outside click, and becomes a bottom drawer on small screens.</p><p>•&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <strong>Netuno</strong> — my cat, as a component. He is positioned in the same coordinate system as his hotspot, breathes, flicks his tail, and reacts when you pay attention to him. His animations can be disabled independently of everything else.</p><p>•&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <strong>AmbientRoom</strong> — subtle life for the scene itself: glows, flickers and drifting effects anchored to the room's geometry, all disabled automatically when the visitor prefers reduced motion.</p><p>The title banner is a neon terminal prompt: a CRT-style font, a blinking cursor, layered neon glow, and an occasional sign-flicker — all pure CSS, all switched off for reduced-motion users.</p><h1 id="the-visual-editor">The Visual Editor</h1><p>Editing polygon coordinates by hand gets old fast, so the project includes a full visual editor. I can draw polygons vertex by vertex, drag out rectangles, circles and ellipses, move shapes, drag individual vertices, add or remove points, and edit every content field in a side panel. It supports undo/redo history, duplicating hotspots, copying and pasting hotspot JSON, previewing the public panel in place, and hiding, disabling or deleting areas with confirmation. Keyboard shortcuts cover the usual verbs — save, undo, duplicate, nudge by pixel.</p><p>Draft work autosaves locally in the browser. Import and export round-trip the same JSON document the site ships with, and imports are validated structurally before they are accepted — version, image dimensions, unique ids, shape geometry. A backup of the previous state is kept before any import.</p><h1 id="publishing-and-administration">Publishing and Administration</h1><p>The public site is read-only. Writing goes through a small admin area with a login form. Authentication is intentionally boring and robust: credentials are checked server-side with constant-time comparison, and a successful login issues a signed, HTTP-only, strict-same-site session cookie with a short lifetime. The signature uses HMAC with a server-held secret, so sessions cannot be forged or extended from the outside. All secrets and credentials live in environment variables — none of them are in the repository.</p><p>Publishing takes the editor's current document, validates it again on the server (never trust the client, even when the client is me), and stores it in the database as the single published revision. Uploaded media — images, audio, the background-music tape — goes to the object store and is served back through a small asset route with proper content types and range support.</p><figure class="kg-card kg-image-card"><img src="https://pablomurad.com/content/images/2026/07/room1.png" class="kg-image" alt="" loading="lazy" width="1319" height="928" srcset="https://pablomurad.com/content/images/size/w600/2026/07/room1.png 600w, https://pablomurad.com/content/images/size/w1000/2026/07/room1.png 1000w, https://pablomurad.com/content/images/2026/07/room1.png 1319w" sizes="(min-width: 720px) 720px"></figure><h1 id="storage">Storage</h1><p>Published content lives in a tiny SQL schema: one table for the published site document (as JSON, with a timestamp) and one for upload metadata. The media files themselves live in an object store keyed by upload. The schema is created on demand, so a fresh volume boots into a working site with the bundled default content — the database is an overlay on top of the built-in room, never a requirement.</p><h1 id="keeping-it-light">Keeping It Light</h1><p>The production container is deliberately minimal. The application compiles into a self-contained bundle of about ten megabytes, so the runtime image installs only the worker engine — none of the build-time dependencies ship to production. The container starts a single small supervisor process plus the worker engine itself, idles at roughly 130 MB of RAM, and is capped with hard memory and CPU limits plus log rotation. Persistence uses the same on-disk layout as the standard tooling, so data survives upgrades and container rebuilds.</p><h1 id="accessibility">Accessibility</h1><p>•&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; Every hotspot is a real focusable control with an accessible name, operable by Enter or Space.</p><p>•&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; An object list offers a precision-free way to open any item without pointer accuracy.</p><p>•&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; The info panel traps focus, restores it on close, and closes by Escape, button, or outside click.</p><p>•&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; Reduced motion is respected at three levels: the OS preference, a global animation toggle, and a separate toggle just for the cat.</p><p>•&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; Exploration progress (which objects you have visited) is stored locally and can be reset or turned off.</p><h1 id="quality">Quality</h1><p>The project ships with a unit and component test suite covering the geometry utilities, document validation, editor history, authentication logic and the key components, plus strict TypeScript checking and linting. Production validation is a single ritual: build, test, lint, type-check. I also smoke-test the container itself — routes, login, static assets and storage layout — before calling a change done.</p><h1 id="serving-it-on-my-domain">Serving It on My Domain</h1><p>In production, the container binds only to the loopback interface and my reverse proxy terminates HTTPS for the public domain and forwards requests to it. The app is proxy-friendly by construction: no hardcoded hosts anywhere, relative URLs throughout, secure cookies in production, and absolute social-preview URLs generated from the canonical domain so shared links unfurl correctly.</p><figure class="kg-card kg-image-card"><img src="https://pablomurad.com/content/images/2026/07/room2.png" class="kg-image" alt="" loading="lazy" width="470" height="756"></figure><h1 id="what-i-would-tell-past-me">What I Would Tell Past Me</h1><p>•&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; Lock the coordinate system first. Every feature after that became easier because geometry was settled.</p><p>•&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; Make content data from day one. The editor, the API and the CMS-shaped future all fell out of that decision.</p><p>•&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; Validate at every boundary — imports, API writes, stored documents. Each validator earned its keep.</p><p>•&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; Treat the runtime as part of the design. Cutting the production container down to the essentials was as satisfying as any visual feature.</p><p>•&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; Put the cat in. People remember the cat.</p>]]></content:encoded>
    </item>
    <item>
        <title>The Black Cat</title>
        <link>https://pablomurad.com/the-black-cat/</link>
        <guid isPermaLink="true">https://pablomurad.com/the-black-cat/</guid>
        <pubDate>Tue, 14 Jul 2026 01:58:55 -0300</pubDate>
        <dc:creator>Pablo Murad</dc:creator>
        <category>papers</category>
        <description>I turned copyparty into a shell-free webtilde: a small community where members edit personal sites securely, publish to /~username/ pages, and build a handmade corner of the web without touching the server.</description>
        <content:encoded><![CDATA[<h2 id="how-i-turned-copyparty-into-a-tilde-without-shell-access">How I Turned copyparty into a Tilde Without Shell Access</h2>
<h3 id="the-idea-the-architecture-and-the-building-of-a-personal-web-community-on-a-server-that-was-already-alive">The idea, the architecture, and the building of a personal-web community on a server that was already alive</h3>
<hr>
<h2 id="first-things-first-asking-the-right-question">First Things First: Asking the Right Question</h2>
<p>The idea began with a small, almost casual question: if copyparty can serve files, deliver an <code>index.html</code>, and apply different permissions per user and per directory, why couldn't I use it as the foundation for a tilde?</p>
<p>At first, the connection seemed slightly crooked. A traditional tilde is a shared Unix machine. A user receives an account, logs in over SSH, gets a home directory, works inside <code>public_html</code>, learns commands, runs programs, talks to other people, and gradually starts inhabiting the server rather than merely publishing through it. Tilde.club describes that tradition as access to a shared Unix computer where people create web pages, learn, and share knowledge.</p>
<p>copyparty, by contrast, presents itself as a portable file server: a browser interface, resumable uploads, WebDAV, SFTP, indexing, accounts, volumes, and directory-level permissions. It would have been easy to look at it and see nothing more than a strange, unusually capable Dropbox.</p>
<p>I saw something else: a publishing panel for the personal web.</p>
<p>The trick was not pretending that copyparty was a multi-user Unix system. It is not. The trick was separating tilde culture from one specific implementation. I wanted to preserve the parts that mattered to me: personal pages, <code>/~username/</code> URLs, a small community, quotas, rules, a member directory, the freedom to edit HTML, and that old feeling that the web can still be made by hand.</p>
<p>I was willing to give up shell access in exchange for a much friendlier front door.</p>
<blockquote>
<h2 id="the-projects-thesis">The Project's Thesis</h2>
<p>A tilde does not have to lose its soul merely because access to <code>public_html</code> happens through a browser. I was not building a complete pubnix. I was building a deliberately limited webtilde: safer, more approachable, and centered on publishing.</p>
</blockquote>
<hr>
<h2 id="what-i-wanted-to-preserve-%E2%80%94-and-what-i-chose-not-to-imitate">What I Wanted to Preserve — and What I Chose Not to Imitate</h2>
<p>Before installing anything, I had to be honest about the idea. If I called any public folder containing HTML a tilde, the word would become decoration. So I defined a minimum cultural core.</p>
<table>
<thead>
<tr>
<th>I wanted to preserve</th>
<th>I would not try to reproduce</th>
</tr>
</thead>
<tbody>
<tr>
<td>Personal pages at <code>/~username/</code></td>
<td>Unix shell access for members</td>
</tr>
<tr>
<td>A small, recognizable community</td>
<td>Persistent user processes</td>
</tr>
<tr>
<td>HTML, CSS, images, text, and small experiments</td>
<td>Arbitrary compilers and runtimes</td>
</tr>
<tr>
<td>Quotas and human approval</td>
<td>Open, automatic registration</td>
</tr>
<tr>
<td>A member directory and public rules</td>
<td>Personal services and custom ports</td>
</tr>
<tr>
<td>Real files outside a CMS</td>
<td>PHP, databases, or WordPress per member</td>
</tr>
</tbody>
</table>
<p>That limitation was not a hidden defect. It was the product.</p>
<p>A member would receive static space, an editor account, a quota, and a URL. They would not receive access to Debian, Docker, Pangolin, Traefik, or any other part of the infrastructure.</p>
<p>That drastically reduced the attack surface and removed much of the thankless work involved in operating an open pubnix: fork bombs, cryptomining, abandoned processes, arbitrary listening ports, compilers, Unix permission puzzles, quota evasion, and CPU abuse.</p>
<hr>
<h2 id="why-copyparty-fit-better-than-it-first-appeared">Why copyparty Fit Better Than It First Appeared</h2>
<p>copyparty had three characteristics that changed the entire discussion.</p>
<p>The first was its volume model: a URL can be mapped to a real directory, and each volume can receive permissions per user.</p>
<p>The second was the web interface. It could upload, rename, move, delete, and edit files without requiring members to understand SSH, SCP, Unix ownership, or command-line editors.</p>
<p>The third was the ability to treat a directory as a traditional website, serving <code>index.html</code> instead of exposing a file listing.</p>
<p>On top of that, copyparty already provided the unglamorous pieces I did not want to rebuild:</p>
<ul>
<li>authentication;</li>
<li>password hashing;</li>
<li>resumable uploads;</li>
<li>per-volume storage limits;</li>
<li>maximum file counts;</li>
<li>maximum individual file sizes;</li>
<li>minimum free-disk reserves;</li>
<li>indexing;</li>
<li>account and volume reloads without bringing down the entire process.</li>
</ul>
<p>The detail that made the project genuinely possible was realizing that the <strong>same files could be presented by two separate copyparty instances</strong>.</p>
<p>One instance would be an authenticated editor with write access.</p>
<p>The other would be an anonymous, read-only public server.</p>
<p>That split looks obvious after the fact. Before it, the project was merely a charming idea. After it, it became architecture.</p>
<hr>
<h2 id="the-server-was-not-a-blank-slate">The Server Was Not a Blank Slate</h2>
<p>I did not begin with an empty VPS. The machine already mattered.</p>
<p>It was a Debian 13 virtual machine running under QEMU and serving as one of the central points in my infrastructure. Before the project, it had roughly 8 GiB of RAM and 150 GB of storage. I upgraded it to around 17 GiB of RAM, 1 TB of disk, and added 4 GiB of swap.</p>
<p>Storage stopped being the problem. The real risk became breaking what was already working.</p>
<p>The server already hosted several components:</p>
<ul>
<li>Pangolin, Gerbil, Traefik, and CrowdSec under Docker Compose;</li>
<li>a Keila installation with PostgreSQL;</li>
<li>an FRP server running directly on the host;</li>
<li>Tailscale, SSH, SNMP, Exim, cron, and the usual Debian services;</li>
<li>ports 80 and 443 already owned by the Gerbil/Traefik path;</li>
<li>an external Docker network named <code>pangolin</code>, used by local services that needed to reach the proxy.</li>
</ul>
<p>That imposed a rule that guided the entire build:</p>
<blockquote>
<p>copyparty would not receive its own public host port.</p>
</blockquote>
<p>There would be no <code>3923:3923</code> mapping followed by crossed fingers. The containers would remain invisible on the host and would be reachable only through the existing proxy on the existing Docker network.</p>
<hr>
<h2 id="the-first-real-step-was-installing-nothing">The First Real Step Was Installing Nothing</h2>
<p>Before creating the project, I audited the server.</p>
<p>I inventoried:</p>
<ul>
<li>CPU;</li>
<li>memory and swap;</li>
<li>disks and filesystems;</li>
<li>mounts;</li>
<li>listening TCP and UDP ports;</li>
<li>systemd services and timers;</li>
<li>local users;</li>
<li>cron jobs;</li>
<li>firewall state;</li>
<li>Docker containers;</li>
<li>Docker networks;</li>
<li>volumes and bind mounts;</li>
<li>Compose projects;</li>
<li>logs;</li>
<li>the directories consuming the most storage.</li>
</ul>
<p>That may sound excessively cautious, but it was the opposite. It was the fastest way to stop guessing.</p>
<p>The audit exposed an important topology. Traefik did not publish ports directly; it shared Gerbil's network namespace. Gerbil was the component actually holding ports 80 and 443.</p>
<p>It also showed that the existing projects relied on bind mounts rather than named Docker volumes, and that <code>/srv</code> was almost empty. That was where I decided to create <code>/srv/theblack-cat</code> as the isolated root of the experiment.</p>
<blockquote>
<h2 id="a-rule-i-kept-afterwards">A Rule I Kept Afterwards</h2>
<p>On a server with history, installing first and understanding later is the wrong order. An inventory is not bureaucracy. It tells you where a new service can exist without competing for ports, networks, storage, or authority with production systems.</p>
</blockquote>
<hr>
<h2 id="the-architecture-that-solved-the-problem">The Architecture That Solved the Problem</h2>
<p>The final design used two persistent copyparty instances, two web origins, and one content tree.</p>
<pre><code class="language-text">Internet
   |
   v
Traefik / HTTPS
   |
   +--&gt; theblack.cat
   |       |
   |       +--&gt; copyparty-public
   |               /w/sites      (read-only)
   |               /w/community  (read-only)
   |
   +--&gt; edit.theblack.cat
           |
           +--&gt; copyparty-editor
                   /w/sites      (read-write)
                   /w/community  (read-write)
</code></pre>
<p>The main domain became the display window. The <code>edit</code> subdomain became the file-management panel.</p>
<p>When I changed <code>/community/index.html</code> through the editor, that same file was already being read by the public instance. There was no deployment step, no publish button, no build pipeline, and no intermediate copy.</p>
<p>Saving was publishing.</p>
<p>At the same time, the public container mounted the shared directories with <code>:ro</code>. Even if an application flaw attempted a write, Docker's mount policy would block it.</p>
<p>That redundancy was intentional:</p>
<ul>
<li>copyparty permissions at the application layer;</li>
<li>read-only mounts at the container/filesystem layer.</li>
</ul>
<hr>
<h2 id="foundation-a-service-identity-and-a-predictable-tree">Foundation: A Service Identity and a Predictable Tree</h2>
<p>I created a system user named <code>theblackcat</code>, with no password and <code>/usr/sbin/nologin</code> as its shell. It did not join the <code>docker</code> group and received no SSH access.</p>
<p>The <code>pablo</code> account would exist inside copyparty. It did not need to become another human Linux account.</p>
<pre><code class="language-text">/srv/theblack-cat/
├── config/
│   ├── editor/
│   └── public/
├── sites/
├── community/
├── members/
├── secrets/
├── state/
├── hists/
├── administration/
├── policies/
├── audit/
├── backup/
├── scripts/
└── release/
</code></pre>
<p>From the start, I treated configuration, content, runtime state, and secrets as different categories.</p>
<ul>
<li>Configuration could be read by containers.</li>
<li>Session state needed to be writable, but isolated per instance.</li>
<li>Member sites were writable only in the editor.</li>
<li>Secrets used mode <code>0600</code>.</li>
<li>The public service never received the authentication file.</li>
</ul>
<p>That separation made later decisions much easier because each path already had a clear purpose and trust level.</p>
<hr>
<h2 id="governance-before-registration">Governance Before Registration</h2>
<p>The system began as a closed alpha.</p>
<p>I deliberately did not start with a signup form, because receiving applications was not the difficult problem. The difficult problem was deciding what happened after someone applied.</p>
<p>So governance came before automation.</p>
<p>I established that every account and every quota required my explicit approval. The administrative account received 10 GiB because it would also be used for testing and for the community website.</p>
<p>Ordinary members could receive one of four approved allocations:</p>
<ul>
<li>100 MiB;</li>
<li>250 MiB;</li>
<li>500 MiB;</li>
<li>1024 MiB.</li>
</ul>
<p>The requested value would never become the approved value automatically.</p>
<p>I also set limits that made sense for personal websites:</p>
<ul>
<li>5,000 files;</li>
<li>50 MiB per individual file;</li>
<li>a global reserve of 100 GiB free on the server.</li>
</ul>
<p>The goal was never to host video libraries. The goal was to prevent a webtilde from accidentally becoming a file locker.</p>
<hr>
<h2 id="the-editor-configuration">The Editor Configuration</h2>
<p>Inside the editor instance, each member receives a dedicated volume.</p>
<p>In my case, <code>/~pablo</code> pointed to <code>/w/sites/pablo</code>. The administrative <code>A</code> permission remained exclusive to me. Ordinary members would receive <code>rwmd</code>, enough to read, upload, move, and delete their own files without receiving application-level administrative power.</p>
<pre><code class="language-ini">[/~example]
  /w/sites/example

  accs:
    rwmd: example

  flags:
    e2ds
    nohtml
    xvol
    vmaxb: 250m
    vmaxn: 5k
    sz: 1-50m
    df: 100g
</code></pre>
<p>The <code>nohtml</code> flag was crucial.</p>
<p>The editor needed to manipulate HTML without executing a member's HTML inside the authenticated origin. With <code>nohtml</code>, HTML and SVG are returned as text, and related rendering behavior is restricted.</p>
<p>That distinction is subtle but important:</p>
<blockquote>
<p>The place where I log in should not behave like the website a member is building.</p>
</blockquote>
<p>The quotas use <code>vmaxb</code> and <code>vmaxn</code>. Because those controls depend on volume indexing, each volume also enables <code>e2ds</code>.</p>
<p>copyparty performs the accounting. I did not need to build another layer just to count files and bytes.</p>
<hr>
<h2 id="the-public-configuration">The Public Configuration</h2>
<p>The public instance is almost the inverse.</p>
<p>It knows nothing about accounts and never receives the authentication secret. Each personal site receives only the <code>h</code> permission, which serves the directory as a traditional website and delivers <code>index.html</code> without turning the domain into a file browser.</p>
<pre><code class="language-ini">[/~example]
  /w/sites/example

  accs:
    h: *

  flags:
    noscript
    xvol
    norobots
    cachectl: no-cache
</code></pre>
<p>During the alpha, I kept <code>noscript</code> enabled. That was not a religious position against JavaScript. It was a way to reduce variables while I validated the model. HTML and CSS were more than enough to prove the concept.</p>
<p>Later, the institutional community website received the root volume <code>/</code>, pointing to <code>/w/community</code>. The <code>/~username</code> volumes remained as child volumes and shadowed the root where appropriate.</p>
<p>The result was simple:</p>
<ul>
<li>the community homepage lived at <code>theblack.cat/</code>;</li>
<li>personal pages remained at <code>theblack.cat/~username/</code>.</li>
</ul>
<hr>
<h2 id="containers-what-i-explicitly-refused-to-give-them">Containers: What I Explicitly Refused to Give Them</h2>
<p>The image was pinned by digest rather than <code>latest</code>.</p>
<p>Both containers run as the UID and GID of the technical service account, with:</p>
<ul>
<li>a read-only root filesystem;</li>
<li>all Linux capabilities dropped;</li>
<li><code>no-new-privileges</code> enabled;</li>
<li>memory limits;</li>
<li>PID limits;</li>
<li>log rotation;</li>
<li>health checks;</li>
<li>no Docker socket;</li>
<li>no privileged mode;</li>
<li>no host networking;</li>
<li>no published host ports.</li>
</ul>
<p>An abbreviated Compose example looks like this:</p>
<pre><code class="language-yaml">services:
  editor:
    image: copyparty/ac@sha256:EXAMPLE_DIGEST
    user: "999:986"
    read_only: true
    restart: unless-stopped
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    pids_limit: 256
    mem_limit: 1g
    volumes:
      - ./sites:/w/sites:rw
      - ./community:/w/community:rw

  public:
    image: copyparty/ac@sha256:EXAMPLE_DIGEST
    user: "999:986"
    read_only: true
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    volumes:
      - ./sites:/w/sites:ro
      - ./community:/w/community:ro

networks:
  pangolin:
    external: true
</code></pre>
<p>The external network became the bridge between separate Compose projects.</p>
<p>Services could be resolved by name inside the <code>pangolin</code> network while the host continued to expose no listener on port 3923. That avoided creating a second public entry point or duplicating the proxy stack.</p>
<hr>
<h2 id="the-session-state-problem-that-appeared-only-because-i-did-things-properly">The Session-State Problem That Appeared Only Because I Did Things Properly</h2>
<p>During isolated tests, copyparty warned that it had no safe writable location for session state.</p>
<p><code>/cfg</code> was mounted read-only, and the service account did not have a useful writable home directory. The easy answer would have been enabling an unsafe option.</p>
<p>I chose to fix the cause instead.</p>
<p>I created separate state directories for the editor and public instances, mounted each one at <code>/state</code>, and set:</p>
<pre><code class="language-text">HOME=/state
XDG_CONFIG_HOME=/state/xdg
</code></pre>
<p>After that, the editor created a real session database. The warning disappeared without weakening the container.</p>
<p>This was a useful reminder that hardening is not simply a list of restrictions. A service still needs explicit writable locations for the state it legitimately owns.</p>
<hr>
<h2 id="putting-the-project-behind-the-infrastructure-that-already-existed">Putting the Project Behind the Infrastructure That Already Existed</h2>
<p>The first plan was to register both resources directly in Pangolin.</p>
<p>Discovery revealed an important detail: the available Pangolin sites represented remote tunnels. There was no local site capable of resolving containers on that same VPS.</p>
<p>Forcing the model would have produced a resource that looked elegant in the dashboard and failed in practice.</p>
<p>The solution was to use Traefik's file provider, already the established pattern for other local services on that machine.</p>
<p>I added two routers and two services while preserving:</p>
<ul>
<li>HTTP-to-HTTPS redirection;</li>
<li>security headers;</li>
<li>automatic certificate issuance.</li>
</ul>
<p>A simplified example:</p>
<pre><code class="language-yaml">http:
  routers:
    blackcat-public:
      rule: Host(`theblack.cat`)
      entryPoints:
        - websecure
      service: blackcat-public
      tls:
        certResolver: letsencrypt

    blackcat-editor:
      rule: Host(`edit.theblack.cat`)
      entryPoints:
        - websecure
      service: blackcat-editor
      tls:
        certResolver: letsencrypt

  services:
    blackcat-public:
      loadBalancer:
        servers:
          - url: http://theblackcat-public:3923

    blackcat-editor:
      loadBalancer:
        servers:
          - url: http://theblackcat-editor:3923
</code></pre>
<p>Public traffic reached Traefik, but copyparty remained invisible to the host. TLS terminated at the proxy, and the upstreams were addressed by Docker service name rather than by an exposed port.</p>
<hr>
<h2 id="the-real-ip-trap">The Real-IP Trap</h2>
<p>That was when a bug appeared that exists only when security is partially correct.</p>
<p>Traefik sent <code>X-Forwarded-For</code> containing the visitor's real address, but copyparty did not trust the proxy. Because every request seemed to originate from the same private Docker address, the anti-abuse protection eventually banned the proxy itself.</p>
<p>The result was a <code>403</code> for everyone and the message:</p>
<pre><code class="language-text">thank you for playing
</code></pre>
<p>The investigation showed that Traefik shared Gerbil's network namespace.</p>
<p>Instead of trusting the entire Docker subnet, I allowed only the exact <code>/32</code> of the observed proxy address. For illustration, imagine Gerbil at <code>172.20.0.4</code>:</p>
<pre><code class="language-ini">[global]
  xff-hdr: x-forwarded-for
  xff-src: 172.20.0.4/32
  rproxy: 1
</code></pre>
<p><code>rproxy: 1</code> documented that there was exactly one trusted proxy hop.</p>
<p>After restarting only the two copyparty containers, the proxy-wide ban disappeared. Logs began associating requests with the real visitor IP rather than with the Docker proxy address.</p>
<p>That fix came with an operational warning: the private address was dynamically assigned.</p>
<p>After recreating Gerbil or the Pangolin stack, I would need to verify the address again. The correct answer was not to trust <code>172.16.0.0/12</code> or the entire Docker network. The correct answer was to update the exact <code>/32</code> to the newly observed proxy address.</p>
<hr>
<h2 id="i-did-not-want-to-manage-users-by-editing-files-by-hand">I Did Not Want to Manage Users by Editing Files by Hand</h2>
<p>At that point, the system worked, but it still depended on manual changes to YAML, authentication fragments, and volume configuration files.</p>
<p>That would have been tolerable for me alone and terrible for a community.</p>
<p>The next step was building an administrative CLI: <code>theblackcat-admin</code>.</p>
<p>The tool implemented an explicit lifecycle:</p>
<pre><code class="language-text">pending
  ├── approved
  │     └── active
  │           ├── suspended ──&gt; active
  │           └── archived
  └── rejected
</code></pre>
<p>Each mutating operation uses:</p>
<ul>
<li>a lock;</li>
<li>a backup;</li>
<li>atomic writes;</li>
<li>validation;</li>
<li>rollback;</li>
<li>an audit event written as JSON Lines.</li>
</ul>
<p>The tool never accepts a plaintext password. The administrator generates a hash interactively with copyparty's own utility and installs only the resulting value.</p>
<p>A normal onboarding sequence looks like this:</p>
<pre><code class="language-bash">theblackcat-admin candidate-add alice \
  --display-name "Alice" \
  --email "alice@example.net" \
  --requested-quota 250

theblackcat-admin approve alice \
  --quota 250 \
  --approved-by pablo

theblackcat-admin credential-instructions alice

theblackcat-admin credential-install alice

theblackcat-admin activate alice \
  --approved-by pablo
</code></pre>
<p>Activating a member creates:</p>
<ul>
<li>the member directory;</li>
<li>the initial homepage;</li>
<li>the editor volume;</li>
<li>the public volume;</li>
<li>the authentication entry;</li>
<li>the public directory listing entry.</li>
</ul>
<p>It then reloads accounts and volumes and validates the result.</p>
<p>Suspending a member performs the inverse without deleting anything. Archiving removes publication and authentication while preserving files.</p>
<p>I deliberately did not implement deletion. Removing data should be a separate, reviewed decision, not an accidental side effect of moderation.</p>
<hr>
<h2 id="reloading-without-restarting-the-service">Reloading Without Restarting the Service</h2>
<p>Accounts and volumes are reloaded with <code>USR1</code>:</p>
<pre><code class="language-bash">docker kill --signal=USR1 theblackcat-editor
docker kill --signal=USR1 theblackcat-public
</code></pre>
<p>This mattered because a member could be activated or suspended without recreating containers and without interrupting existing pages.</p>
<p>The CLI records <code>StartedAt</code>, sends the signal, checks container health, and confirms that the process did not restart.</p>
<p>Changes to the global configuration still require a restart, and I treated them as a separate class of operation. The ordinary member lifecycle does not touch global settings.</p>
<hr>
<h2 id="the-community-became-more-than-a-single-pablo">The Community Became More Than a Single <code>/~pablo/</code></h2>
<p>The final public layer was the community website itself.</p>
<p>I created:</p>
<ul>
<li>a homepage;</li>
<li>an About page;</li>
<li>a Members page;</li>
<li>a Rules page;</li>
<li>an Alpha Status page.</li>
</ul>
<p>Everything uses local HTML and CSS, with no JavaScript, remote fonts, analytics, or trackers. <code>robots.txt</code> blocks indexing, and responses also carry <code>noindex</code> instructions.</p>
<p>The member page is not maintained by hand. The CLI generates it from protected member records and exposes only:</p>
<ul>
<li>username;</li>
<li>public display name;</li>
<li>public URL;</li>
<li>a safe role label.</li>
</ul>
<p>Email, quota, internal notes, suspension reasons, and timestamps never enter the public HTML.</p>
<p>Pablo appears as the founding administrator. An ordinary member appears only when their status is exactly <code>active</code>. Suspending or archiving someone regenerates the directory within the same transaction.</p>
<hr>
<h2 id="backup-the-part-that-never-looks-impressive-in-a-screenshot">Backup: The Part That Never Looks Impressive in a Screenshot</h2>
<p>I used Restic to create encrypted, deduplicated, testable snapshots.</p>
<p>The initial repository remained on the same virtual disk, outside the project tree. That provides rollback and recovery from human mistakes, but it is not disaster recovery.</p>
<p>I wrote that limitation in large letters because calling a same-disk copy an offsite backup would be dishonest.</p>
<p>The system received:</p>
<ul>
<li>daily snapshots;</li>
<li>weekly maintenance;</li>
<li>7 daily snapshots retained;</li>
<li>4 weekly snapshots retained;</li>
<li>6 monthly snapshots retained;</li>
<li>1 yearly snapshot retained.</li>
</ul>
<p>Maintenance runs <code>forget</code> with <code>prune</code>, followed by a repository check:</p>
<pre><code class="language-bash">restic forget \
  --keep-daily 7 \
  --keep-weekly 4 \
  --keep-monthly 6 \
  --keep-yearly 1 \
  --prune

restic check --read-data-subset=10%
</code></pre>
<p>I also wrote a real restore test. It restores the latest snapshot into a temporary directory, validates critical files, YAML, JSONL, Compose configuration, and permissions, securely removes restored secret files, and deletes the temporary tree.</p>
<p>The repository password lives outside the project in a root-only file.</p>
<p>Restic's encryption is valuable precisely because the password matters. Losing both the original machine and that password means losing access to the repository. That is why two operational tasks remain important:</p>
<ol>
<li>export the recovery key to a secure offline location;</li>
<li>configure a genuinely independent offsite destination.</li>
</ol>
<hr>
<h2 id="what-the-system-became">What the System Became</h2>
<p>At the end of the ten-part build, I had something that did not exist as an off-the-shelf product: a copyparty-based webtilde with a community homepage, personal pages, an authenticated editor, read-only publication, quotas, moderation, auditing, backup, and a tested restore procedure.</p>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Function</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>theblack.cat</code></td>
<td>Public site, community pages, and <code>/~username/</code></td>
</tr>
<tr>
<td><code>edit.theblack.cat</code></td>
<td>Authenticated file management</td>
</tr>
<tr>
<td><code>copyparty-editor</code></td>
<td>Write access, quotas, <code>nohtml</code>, and account volumes</td>
</tr>
<tr>
<td><code>copyparty-public</code></td>
<td><code>index.html</code>, <code>h: *</code>, <code>noscript</code>, and read-only mounts</td>
</tr>
<tr>
<td>Traefik</td>
<td>HTTPS, redirection, and routing</td>
</tr>
<tr>
<td><code>theblackcat-admin</code></td>
<td>Approval, activation, suspension, quota management, and auditing</td>
</tr>
<tr>
<td>Restic + systemd</td>
<td>Snapshots, retention, integrity checks, and restore tests</td>
</tr>
</tbody>
</table>
<p>The result is not a pubnix.</p>
<p>It does not offer SSH to members, personal cron jobs, compilers, or long-running processes. I do not hide that.</p>
<p>At the same time, calling it simple static hosting would also miss the point. It has a community, an identity, a URL structure, rules, limits, lifecycle management, and a shared publishing experience.</p>
<hr>
<h2 id="where-the-real-cleverness-was">Where the Real Cleverness Was</h2>
<p>The merit of the idea is not hidden in an obscure configuration line.</p>
<p>The clever part was realizing that a file server could occupy the role of a publishing panel, as long as I did not ask one single instance to act as both editor and public server.</p>
<p>Separating those responsibilities solved almost everything:</p>
<ul>
<li>the editor could require authentication and refuse to execute member HTML;</li>
<li>the public service could deliver <code>index.html</code> without knowing any credentials;</li>
<li>the same files appeared on both sides without a deployment stage;</li>
<li>Docker enforced read-only publication in addition to application permissions;</li>
<li>copyparty's volume model naturally became a <code>/~username/</code> directory structure;</li>
<li>native quotas removed the need for a separate accounting system.</li>
</ul>
<p>From that point on, the rest was edge engineering: integrating the proxy, trusting the correct client address, creating an administrative workflow, testing rollback, and documenting limitations honestly.</p>
<p>The idea opened the door. Rigor kept it from becoming a fragile toy.</p>
<hr>
<h2 id="the-decisions-i-do-not-romanticize">The Decisions I Do Not Romanticize</h2>
<p>Some choices remain compromises.</p>
<p>The local backup is on the same disk and still needs an offsite copy.</p>
<p>The administrative credential used during the build was exposed in conversation and must be rotated before opening invitations to outsiders.</p>
<p>The trusted private proxy address is dynamically assigned and must be revalidated after the stack is recreated.</p>
<p>Those pending items do not invalidate the project. They define its real state.</p>
<p>The Black Cat became operational in owner-only mode. I could edit, publish, test, and administer it. I still should not invite third parties until the credential is rotated.</p>
<p>That operational honesty is worth more than an artificial green badge.</p>
<hr>
<h2 id="what-i-learned-while-building-it">What I Learned While Building It</h2>
<ol>
<li>The best architecture appeared when I stopped trying to make one instance perform contradictory roles.</li>
<li>On existing infrastructure, the initial audit saves more time than any automatic installer.</li>
<li>Read-only at the application layer is not the same as read-only at the container layer; using both is better.</li>
<li>Quotas without governance merely limit bytes. Human approval limits purpose.</li>
<li>A badly configured proxy can turn anti-abuse protection into global downtime.</li>
<li>Administrative automation needs transactions, locks, validation, and rollback even when the project is small.</li>
<li>A backup deserves trust only after a restore has been tested.</li>
<li>A tilde is as much a culture of publishing as it is a Unix machine. Part of that culture can survive without pretending shell access exists.</li>
</ol>
<hr>
<h2 id="conclusion">Conclusion</h2>
<p>I began by asking whether copyparty could host simple websites.</p>
<p>I ended with a closed community for personal pages, built on top of a server that already did many important things and could not be treated as a disposable laboratory.</p>
<p>The most satisfying part was seeing that the idea remained simple even after it gained serious security and operational layers.</p>
<p>For a member, the experience is straightforward:</p>
<ol>
<li>log in to the editor;</li>
<li>open their directory;</li>
<li>change <code>index.html</code>;</li>
<li>see the page at <code>/~username/</code>.</li>
</ol>
<p>Underneath that simple experience are separate containers, permissions, quotas, a reverse proxy, TLS, real-client-IP handling, auditing, backups, and restore tests.</p>
<p>The complexity ended up on the correct side of the door.</p>
<p>The Black Cat does not try to replace a traditional Unix tilde. It is another interpretation: a webtilde for people who want to build their own corner of the internet without beginning with SSH.</p>
<p>It was a genuinely good idea because it used exactly what copyparty does best — files, accounts, and volumes — to create something the project never explicitly promised to be, but for which it was surprisingly well prepared.</p>
]]></content:encoded>
    </item>
    <item>
        <title>How I built NyxPE</title>
        <link>https://pablomurad.com/how-i-built-nyxpe/</link>
        <guid isPermaLink="true">https://pablomurad.com/how-i-built-nyxpe/</guid>
        <pubDate>Sun, 12 Jul 2026 11:08:25 -0300</pubDate>
        <dc:creator>Pablo Murad</dc:creator>
        <category>tech</category>
        <description>NyxPE is my custom WinPE rescue environment, built with PhoenixPE and PEBakery. This article covers its design, toolset, offline package system, stubborn bugs, and the work behind turning a personal idea into a bootable release.</description>
        <content:encoded><![CDATA[<p>I do a lot of tech work. Reinstalls, data recovery, dead boot loaders, the usual. And for years I did all of it from other people's rescue disks, mostly Sergei Strelec's WinPE. Nothing wrong with it, honestly, it's a fantastic piece of work. But every single boot I'd be re-fixing the keyboard layout, hunting for a tool three submenus deep, staring at branding that wasn't mine. Small itch, never went away. So I finally sat down and built the thing I actually wanted, and that's NyxPE. Nyx Rescue Environment.</p><p>The base was an easy call: PhoenixPE plus PEBakery. PhoenixPE gives you a clean, scriptable WinPE project, and PEBakery is the engine that turns a pile of scripts into a bootable image. The one rule I set for myself on day one was to never touch the official PhoenixPE scripts. Everything I add lives in my own folder. That way, if I ever want to pull a newer PhoenixPE, I just drop it in and my stuff still sits on top. A little discipline now, a lot of sanity later.</p><h2 id="making-it-feel-like-mine">Making it feel like mine</h2><p>First thing was identity, because if it doesn't look like NyxPE it's just PhoenixPE with extra steps. Dark wallpaper, a moon logo, the "NYX PE" wordmark, a matching splash while it boots. OEM info so the System Properties page says NyxPE instead of the default. And the computer name, which turned out to be weirdly annoying. WinPE loves to call the machine something like PHOENIXPE-VF23 with a random suffix, and after some digging I found the culprit was PENetwork, generating the name from a profile template. So I nailed it in two places: offline in the registry hives during the build, and then again at runtime with a tiny script that fires after the network comes up, because the network stack will happily rename the box out from under you if you let it.</p><p>PENetwork itself needed to shut up. Out of the box it throws a window in your face on boot, runs a countdown, asks questions. I just want ethernet up on DHCP and the icon sitting quietly in the tray for later. So I went through the actual INI keys the bundled version uses (I refused to copy-paste settings off some 2011 forum post and hope for the best), and now it starts silent, grabs an address, and gets out of the way.</p><p>Little things after that. Firefox with exactly one bookmark, my GitHub, nothing else. Killing the Windows boot logo so you get a clean black screen instead of the spinning dots. I did that one with <code>bootuxdisabled</code> in the BCD, no patching of signed files, nothing sketchy with Secure Boot.</p><h2 id="the-part-that-ate-my-weekend">The part that ate my weekend</h2><p>Then I went big. I wanted a real toolbox: backup, imaging, partitioning, file recovery, read-only forensics, malware scanners, network tools, hardware diagnostics. Dozens of programs.</p><p>The catch is the build has to stay offline. I didn't want scripts phoning home in the middle of a build. So I wrapped a little package system around it. A manifest of every app with its official source, a downloader that pulls each one straight from the vendor or the GitHub release, checks the SHA-256 and the Authenticode signature, and stages it locally. Then the PEBakery build just copies from the local stash. If a tool goes missing, the build warns and keeps going instead of blowing up.</p><p>The audit had a nice surprise: PhoenixPE already ships scripts for a ton of apps. So the smart move wasn't to reinvent those, it was to keep the good official ones and only write my own for what was missing. It also forced a decision I feel pretty strongly about. PhoenixPE bundles a whole family of credential-dumping tools, browser password grabbers, LSA secrets, all of that. I pulled every single one out of my profiles. A rescue disk is for fixing your own machines, not lifting passwords off them. The only account tool I kept is a proper local admin reset that warns you, backs up the registry hives first, and never runs silently.</p><h2 id="bugs-so-many-bugs">Bugs. So many bugs.</h2><p>This is where it got real. A few that stuck with me.</p><p>The one that made me feel dumb: my downloader kept staging exactly one app and marking every other one "no resolver defined". Turns out PowerShell variable names are case-insensitive, so <code>$res</code> (my per-app result) and <code>$RES</code> (my big lookup table) were literally the same variable. The first loop clobbered the table and the rest just found nothing. Renamed it, fixed instantly, said a few words out loud.</p><p>Then there was a helper function I named <code>H</code>. PowerShell has a built-in alias <code>h</code> for Get-History, and aliases win over functions, so every call to my <code>H</code> was quietly running Get-History and choking on a category name it was never meant to see. Renamed that too.</p><p>The nasty one: every app installed fine, and every single shortcut was dead. "The item has been changed or moved." Took me a while to trace. I was using DirCopy with a wildcard to drop each program into place, and it turns out DirCopy only grabs the subfolders, not the files sitting in the root. So the <code>mappings</code> folder made it onto the media and the actual <code>.exe</code> never did. Every shortcut pointed at a file that wasn't there. Swapped in FileCopy and DirCopy together and suddenly everything opened.</p><p>And the one that nearly ended me. The build would finish "successfully" and produce no ISO. The imaging tool was dying instantly. When I finally ran it by hand it told me the truth: one of the Eric Zimmerman forensic tools has map files with names over 120 characters, and oscdimg in the default ISO9660 mode caps paths around 110. GSmartControl's GTK icons blew past it too. I renamed files for a while, playing whack-a-mole, before doing the real fix, which was to switch the image to pure UDF. No path limit, and it's what every modern WinPE build uses anyway. Booted BIOS and UEFI on the first try after that.</p><p>Smaller ones too. A .NET tool with no version resource killed the whole build, because I was reading its file version for a marker file. Wrapped it so a missing version is just "n/a" instead of a hard stop.</p><p>Once it actually worked, I started caring about the feel. Command-line tools were the worst. You click one in the Start Menu and a console flashes for a tenth of a second and vanishes, because it's waiting for arguments. So now the CLI tools open a prompt already parked in their own folder, wearing the tool's own icon, ready to type. GUI apps open normally. Sysinternals gets split the same way, since half of it is windows and half is command line.</p><p>I also dropped a usage guide right on the desktop. An HTML page that lists every tool, tells you whether it's a window or a terminal thing, and gives a quick example for the command-line ones. Because future me, at 3am with a dead laptop, does not remember the exact chainsaw syntax.</p><p>All of the testing has been in VMware, watching the splash come up, the desktop load, the network grab an address, the tray behave itself. Seeing NYXPE on that wallpaper for the first time, after all the fighting, was genuinely a good moment.</p><p>It lives at nyxpe.xyz now. The ISO, the guide, a checksum, and a short honest note about licensing, because it's built on Windows PE and a mix of third-party tools, and I'd rather be upfront that this is a personal, for-study project than pretend it's something it isn't. Next I want to swap in a real graphic boot splash and start wiring up separate rescue ISOs for iVentoy, but that's a problem for another weekend.</p>]]></content:encoded>
    </item>
    <item>
        <title>The Terminal Is a Door</title>
        <link>https://pablomurad.com/the-terminal-is-a-door/</link>
        <guid isPermaLink="true">https://pablomurad.com/the-terminal-is-a-door/</guid>
        <pubDate>Wed, 08 Jul 2026 10:05:55 -0300</pubDate>
        <dc:creator>Pablo Murad</dc:creator>
        <category>papers</category>
        <description>A new door into my writing archive: a small BBS served from the same plain-text source as my Web, Gemini, and Gopher posts. Web for reach. Terminal for ritual. ANSI for soul. Come knock.</description>
        <content:encoded><![CDATA[<p>For a while now my writing has lived in more than one place. There are posts on <strong>Prose</strong>. There is a <strong>Gemini</strong> capsule at pablomurad.com. There is a <strong>Gopher</strong> hole for whoever still remembers how to knock. Same words, different doors — I have always liked the idea that a single text can be reached in many ways, each with its own texture, its own weather.</p><p>This week I added another door. Not a new post, not a new feed: a whole new way in. I built a <strong>BBS</strong>.</p><p>If you were online in the right decade, the word carries a smell — dial tones, ANSI art, the patience of a modem. A Bulletin Board System was a place you <em>called</em>, not a page you scrolled. You arrived. You typed. The screen answered in blocks of colour. For me the appeal was never nostalgia; it was the ritual. The web became fast and smooth and tracked. A terminal asks something of you. It slows you down. It gives texture back.</p><p>So now, alongside Prose, Gemini and Gopher, there is <strong>bbs.pablomurad.com</strong>.</p><p><strong>How you get in</strong></p><p>Two ways, both from a terminal:</p><p>ssh guest@bbs.pablomurad.com -p 2200</p><p>telnet bbs.pablomurad.com 4223</p><p>No password, no account. The user does not matter — guest, your name, anything. You are not given a shell; you are given the archive. Over SSH you get the full thing: colour, ANSI art, a block-letter MURAD sign at the top like a marquee. Over Telnet you get the same in CP437, the old DOS character set, which is the honest way to serve a BBS to a telnet client — and, I will admit, the more beautiful one.</p><p>Inside, you move by typing. ls lists everything. open reads a text. search looks through titles and bodies. random hands you something at random, which is my favourite way to meet an old piece again. Long texts turn into pages you step through. back takes you where you came from. It is small and it is quiet, and that is the point.</p><p><strong>One archive, many doors</strong></p><p>The part I care about most is not the ANSI. It is that none of this is a copy.</p><p>Everything I write lives once, as plain text, in a single place. It is served by one small program written in Go. From that one source the BBS reads its pages; from the same source I generate the Gemini and Gopher versions of the same texts; and the BBS, in turn, reads my <strong>gemlog</strong> — the very posts I write for the capsule — so the blog is legible from inside the terminal too. Write once; appear everywhere. A post on Prose, an entry on Gemini, a menu in Gopher, and now a room in a BBS, all pointing at the same words.</p><p>That is the whole philosophy, and I keep it short enough to fit on a screen:</p><p><em>Web for reach. Terminal for ritual.</em></p><p><em>Markdown for preservation. ANSI for soul.</em></p><p><strong>Why bother</strong></p><p>Because the small web is not a museum. It is a way of publishing that refuses a few things — surveillance, bloat, the assumption that faster is better — and keeps a few others: ownership, permanence, calm. A BBS is the most stubborn form of that refusal I could find. It does not care about your browser. It will not autoplay anything. It waits for you to type.</p><p>There is also a plainer reason. It made me happy to build it. To watch a modem-era screen come up over SSH, to see accented Portuguese survive a trip through CP437, to type open and have my own words page up in colour — that is a small, real pleasure, and I have decided those are worth chasing.</p><p>So: I still post on Prose. The capsule is still on Gemini. The hole is still on Gopher. And now there is a light on in the terminal too.</p><p>Come knock.&nbsp; ssh guest@bbs.pablomurad.com -p 2200</p>]]></content:encoded>
    </item>
    <item>
        <title>Building Runv Club</title>
        <link>https://pablomurad.com/building-runv-club-2/</link>
        <guid isPermaLink="true">https://pablomurad.com/building-runv-club-2/</guid>
        <pubDate>Thu, 02 Jul 2026 17:34:41 -0300</pubDate>
        <dc:creator>Pablo Murad</dc:creator>
        <category>papers</category>
        <description>Building Runv Club, my own pubnix: a small Unix/Linux community server for SSH, personal pages, old web protocols, email, IRC, and a more handmade, independent corner of the internet.</description>
        <content:encoded><![CDATA[<p>After never getting a reply from a few tildes, which honestly made me sad because I tried more than once and waited more than once, I decided to build my own pubnix.</p><p>Just to make things clearer, a pubnix is a shared Unix or Linux server, usually accessed through SSH, where people have their own user accounts. They can host personal pages, learn the terminal, write code, exchange messages, use email, run small services, and take part in a more handmade kind of internet culture. I like to think of it as a small digital condominium, built around community, experimentation, the small web, and autonomy. Very far from the closed, polished, locked-down logic of the big platforms.</p><p>The project slowly became something close to an obsession. From the outside, I started studying every pubnix I could find. I went from SDF to Tilde Town, one by one, looking at their code, their architecture, their habits, and the way they handled administration. Not because I wanted to copy them, but because I wanted to understand how these places actually worked, especially behind the scenes.</p><p>One curious thing I noticed is that many pubnixes are built on the BSD family.</p><p>I could understand the appeal. BSD is stable, elegant, and maybe even a little sophisticated. But beyond that, I did not find a strong reason why I could not use another operating system.</p><p>So I thought: fine, I will use Debian.</p><p>Another thing I noticed is that a lot of their administrative tools are written in C, and some in Go. I decided to go in another direction and build around Python and its ecosystem. That was the path I wanted to follow. Python would help me administer the place, glue things together, automate the boring parts, and keep the whole thing readable enough for me to maintain.</p><p>The basic idea behind Runv is simple: each member can publish personal content through four different protocols, each one mapped to a folder inside their home directory.</p><p>HTTP is served by Apache 2 with <code>mod_userdir</code>, on ports 80 and 443, using <code>~/public_html</code>. Each member gets a URL like <code>/~username/</code>, with TLS handled through Certbot.</p><p>Gopher is served by Gophernicus on port 70, using <code>~/public_gopher</code>, with a <code>gophermap</code> and <code>/var/gopher</code> as the root.</p><p>Gemini is served by Molly Brown on port 1965, using <code>~/public_gemini</code>. Each user directory is exposed through a bind mount at <code>/var/gemini/users/&lt;user&gt;</code>, with TLS certificates from Let's Encrypt.</p><p>Nex is served by my own small <code>nexd</code>, written with the Python standard library, on port 1900, using <code>~/public_nex</code>. No bind mount, no complexity, just plain text and a very small protocol doing exactly what it needs to do.</p><p>The base stack is Debian, Apache 2 with <code>mod_userdir</code>, <code>mod_rewrite</code>, <code>mod_headers</code>, and <code>mod_proxy</code>, OpenSSH, systemd, ext4 quotas with <code>usrquota</code>, Certbot, and Let's Encrypt. Transactional email can go through Mailgun over HTTP or through sendmail/msmtp, while member email is handled through Postfix. For IRC, the default path is WeeChat on <code>irc.tilde.chat</code>, especially the <code>#runv</code> channel.</p><p>After writing the first scripts, I started to see where the project was going. It was no longer just an idea. It was taking shape in front of me. But something important was still missing: the entrance. The welcome. The first contact.</p><p>So I built the onboarding flow around a special account called <code>entre</code>.</p><p>New people do not get automatic invites. Entry is manually approved. A visitor connects through SSH as <code>entre@runv.club</code>, and <code>sshd</code> uses <code>ForceCommand</code> to run <code>entre_app.py</code>. That application only queues the request. It never creates accounts by itself.</p><p>The flow is intentionally simple.</p><p>First, the visitor chooses a language: Portuguese or English. That choice follows the whole session through the i18n system.</p><p>Then Runv shows the ASCII art and the introduction and warning templates.</p><p>After that, <code>entre_core.py</code> validates the request. It checks the username with the pattern <code>^[a-z][a-z0-9_-]{1,31}$</code>, blocks reserved names, validates the email, asks for online presence, and checks the public SSH key. Only allowed key types are accepted, and the fingerprint is generated through <code>ssh-keygen</code>.</p><p>The request is then written as an atomic JSON file, using <code>O_EXCL</code>, into <code>/var/lib/runv/entre-queue/&lt;uuid&gt;.json</code>, including the selected language.</p><p>Finally, the admin is notified through logs and email, and the visitor sees a goodbye screen with the request number.</p><p>The visitor-facing strings live in <code>terminal/i18n.py</code>, with Portuguese and English parity enforced by <code>tests/test_i18n_strings.py</code>. The admin templates are intentionally only in Portuguese, because that part is for me.</p><p>At that point, another question appeared: how would I give people accounts without exposing things they should not see? How could I keep the privacy principle alive while still giving users a real shell environment?</p><p>That led me to the account provisioning system.</p><p>The canonical way to create accounts is <code>scripts/admin/create_runv_user.py</code>. It runs as root and is protected by <code>admin_guard</code>. The operator has to be <code>pmurad-admin</code>, <code>root</code>, or someone listed in <code>RUNV_ADMIN_USERS</code>.</p><p>The pipeline follows a strict order.</p><p>First, the script creates the user with <code>adduser --disabled-password</code>, copying Debian's default skeleton.</p><p>Then it installs the SSH key with the right permissions: <code>~/.ssh</code> as <code>700</code> and <code>authorized_keys</code> as <code>600</code>.</p><p>After that, it creates the public folders: <code>public_html</code> with an <code>index.html</code>, <code>public_gopher</code> with a <code>gophermap</code>, <code>public_gemini</code> with an <code>index.gmi</code> and the Gemini bind mount, and <code>public_nex</code> with an <code>index</code>.</p><p>Then it consolidates permissions: home directories as <code>755</code>, public folders as <code>755</code>, and files as <code>644</code>.</p><p>There is also an optional legacy SSH jail mode through <code>--with-jail</code>, but by default members are not jailed, because I want them to be able to use the global commands.</p><p>The script applies ext4 quotas using <code>setquota</code>, with the default being 450/500 MiB and 10k/12k inodes.</p><p>Then it runs a final permission check, applies the IRC patch that enables the <code>chat</code> command, updates <code>users.json</code> using <code>flock</code>, syncs the landing page with <code>genlanding --sync-public-only</code>, and sends the welcome email and the admin notification.</p><p>If something fails after the user is created, the script rolls back with <code>deluser --remove-home</code>. If only the quota step fails, the account is left in a <code>partial_quota</code> state, so I know exactly what needs attention instead of pretending everything is fine.</p><p>Approving requests from the queue is also straightforward.</p><p>To approve a specific request:</p><pre><code>sudo python3 scripts/admin/create_runv_user.py --request-id &lt;uuid&gt;</code></pre><p>To approve all pending requests:</p><pre><code>sudo python3 scripts/admin/create_runv_user.py --all-pending</code></pre><p>And to create a user manually, without the queue:</p><pre><code>sudo python3 scripts/admin/create_runv_user.py --username maria \
     --email maria@example.org --public-key-file maria.pub</code></pre><p>Building Runv Club has been genuinely fun. Not fake startup fun, not polished product-launch fun, but real fun. The kind where you break things, understand why they broke, fix them, and suddenly realize you learned more in a few nights than you would have learned from weeks of passive reading.</p><p>It is still small, still imperfect, and still becoming what it wants to be. But it already feels alive.</p><p><a href="https://runv.club">https://runv.club</a></p>]]></content:encoded>
    </item>
    <item>
        <title>One Post, Many Webs</title>
        <link>https://pablomurad.com/one-post-many-webs/</link>
        <guid isPermaLink="true">https://pablomurad.com/one-post-many-webs/</guid>
        <pubDate>Thu, 02 Jul 2026 00:48:20 -0300</pubDate>
        <dc:creator>Pablo Murad</dc:creator>
        <category>tech</category>
        <description>A Ghost webhook became a tiny syndication hub, turning one post into prose.sh, Gemini, gopher, Fediverse, Bluesky, and webmentions — stateless, respectful of each medium, and gloriously boring.</description>
        <content:encoded><![CDATA[<p>A while ago I wrote about the little bridge that keeps my two blogs in sync — Ghost as the canonical home, prose.sh as a plain-text mirror, and even a way to publish by sending an email. One post, two blogs, zero copy-paste.</p>
<p>That should have been the end of the story.</p>
<p>It wasn't, because I remembered I also had a Gemini capsule and a gopher hole quietly rotting somewhere, each with content from two eras ago. And once you have a bridge that reacts to "post published" and delivers things over SSH... well. You know how this goes.</p>
<p>This is part two: how one webhook ended up feeding the entire small web, two fediverse accounts, Bluesky, and every site I link to. Same rule as before — one source of truth, one road out.</p>
<h2 id="the-small-web-speaks-differently">The small web speaks differently</h2>
<p>Gemini and gopher aren't just "small HTTP". They have opinions.</p>
<p>Gemtext, Gemini's format, is radical: <strong>no inline links, no inline formatting</strong>. A link gets its own line, starting with <code>=&gt;</code>. So you can't just dump Markdown there — every <code>[link](url)</code> in the middle of a sentence has to go somewhere.</p>
<p>The community convention is to hoist links out of the paragraph and list them right below it. So the converter reads a paragraph, strips the link syntax, remembers what it found, and flushes it after:</p>
<pre><code class="language-text">markdown in:
  Last year I [switched ISPs](https://example.com/isp) and lost my public IP.

gemtext out:
  Last year I switched ISPs and lost my public IP.
  =&gt; https://example.com/isp switched ISPs
</code></pre>
<p>Gopher is even older-school: plain text, and the unwritten rule says keep it under ~67 columns because that's what classic clients render comfortably. Links become numbered footnotes, like a scientific paper written on a napkin:</p>
<pre><code class="language-text">gopher out:
  Last year I switched ISPs [1] and lost my public IP.

  Links:
    [1] https://example.com/isp
</code></pre>
<p>The whole converter is one pass over the Markdown, line by line, with two renderers at the end. No AST, no dependency with 400 transitive packages. Gemtext is simple enough that respecting it is easier than fighting it.</p>
<h2 id="ghost-is-the-manifest">Ghost is the manifest</h2>
<p>Here's the design decision I'm most happy about, and it costs nothing: <strong>the bridge keeps no database of posts.</strong> None.</p>
<p>Every time a post is published, the bridge asks Ghost's Admin API for the full list of published posts and regenerates the indexes — the gemlog index, the gopher menu, the Atom feed — from scratch:</p>
<pre><code class="language-text">on publish:
  1. deliver the new post        (slug.gmi, slug.txt)
  2. fetch ALL published posts   (Ghost Admin API, paginated)
  3. rebuild every index from that list
  4. deliver the indexes
</code></pre>
<p>Stateless. Always correct. If an index ever looks wrong, restarting the service fixes it, because there's nothing to corrupt — Ghost <em>is</em> the manifest.</p>
<p>And you get backfill for free: populating years of existing posts into a freshly wiped capsule is the same code path, just looped. One flag, one restart, the whole archive appears on gemini and gopher. Delete the flag, done.</p>
<h2 id="pagination-because-archives-grow">Pagination, because archives grow</h2>
<p>A gemlog index with the whole archive on one page is fine at 10 posts and absurd at 200. So indexes paginate — twenty per page, with newer/older navigation:</p>
<pre><code class="language-text">gemlog/index.gmi        &lt;- newest twenty
gemlog/page-2.gmi       &lt;- next twenty
gemlog/page-3.gmi       ...

phlog/gophermap         &lt;- newest twenty
phlog/page2/gophermap   &lt;- next twenty
</code></pre>
<p>Since indexes are rebuilt from zero every time, stale pages get deleted before writing. If posts disappear, pages disappear. No ghosts. (Well. Except the canonical one.)</p>
<h2 id="gophers-dirty-little-secret">Gopher's dirty little secret</h2>
<p>Here's a protocol fact that cost me an evening: <strong>gopher requests don't include the hostname.</strong> No Host header, nothing. The client connects to an IP and sends a selector, and that's it.</p>
<p>Which means: if two domains point at the same server, port 70 cannot tell them apart. Name-based virtual hosting, the thing HTTP has had since the 90s, is <em>physically impossible</em> in gopher.</p>
<p>I had an existing gopher site on another domain, same box. The fix is beautifully dumb: the new hole owns the root, and the old site survives as symlinks inside it, so every old deep link still resolves:</p>
<pre><code class="language-text">/var/gopher/
  gophermap            &lt;- new home (the menu controls what's visible)
  phlog/
  oldsite/     -&gt; /home/oldsite/gopher     (branding link)
  archive/     -&gt; /home/oldsite/gopher/archive   (compat: old selectors)
</code></pre>
<p>The menu shows what I want; the symlinks keep two decades of gopher etiquette intact. Everyone's links still work, nobody notices anything changed. That's the best kind of migration.</p>
<h2 id="homepages-worth-the-trip">Homepages worth the trip</h2>
<p>If someone bothers to open a Gemini client or a gopher browser in 2026, the least I can do is greet them properly. Both homepages got rebuilt from scratch: a FIGlet banner, a piece of classic ASCII art (credited — small web, good manners), and short English copy.</p>
<p>The static files are baked into the container image and pushed on startup, so "redeploy the homepage" is just "rebuild the container". The blog sections underneath them are regenerated by the bridge, so the homes never go stale.</p>
<h2 id="retiring-the-feed-robot">Retiring the feed robot</h2>
<p>For social announcements I used to run my posts through a hosted RSS-to-everywhere service. It worked, but it's polling — the announcement shows up whenever the feed gets read next — and it's one more account, one more dashboard, one more thing.</p>
<p>The bridge already knows the <em>instant</em> a post goes live. So it now announces natively:</p>
<ul>
<li><strong>Mastodon-compatible instances</strong> (including GoToSocial): one <code>POST /api/v1/statuses</code> with a Bearer token. The post's feature image gets downloaded and attached as media, with the title as alt text.</li>
<li><strong>Bluesky</strong>: a session, an optional thumbnail blob, and a post whose link lives in an <em>external embed card</em> — so the URL doesn't eat into the 300-character budget, and you get the pretty preview.</li>
</ul>
<p>The announcement text writes itself from what the post already has: an excerpt if I wrote one, otherwise the first lines of the content. And instead of a robotic "New post:", the opener rotates — deterministically, hashed from the slug, so retries don't reroll it:</p>
<pre><code class="language-text">openers = [ "Hot off the press:", "Fresh from the lab:",
            "Straight from the terminal:", "Just shipped:",
            "New transmission:" ]

pick = hash(slug) % len(openers)
</code></pre>
<p>Same post, same opener, forever. Different posts, different flavor. Nobody suspects a robot.</p>
<h2 id="the-double-post-problem">The double-post problem</h2>
<p>Mirrors are forgiving: deliver the same file twice and you've overwritten it with itself. Social is not. Every announcement is a new item in someone's timeline, and re-announcing a typo fix is how you teach people to unfollow you.</p>
<p>Two layers fix it:</p>
<p><strong>One — split the events.</strong> Ghost fires webhooks for "post published" and "published post updated". Give each its own query string:</p>
<pre><code class="language-text">.../hooks/ghost?event=published   -&gt; mirrors + announce + webmentions
.../hooks/ghost?event=updated     -&gt; mirrors + webmentions. NEVER announce.
</code></pre>
<p>Editing a post updates every mirror and stays silent on social. Exactly what you want.</p>
<p><strong>Two — keep a ledger.</strong> A tiny JSON file in a persistent volume records which slugs have been announced:</p>
<pre><code class="language-json">["my-first-post", "that-networking-rant", "one-post-many-webs"]
</code></pre>
<p>Webhook retries, container restarts, full backfills — none of them can announce twice, because the slug is already in the book. The ledger only gets written when at least one network accepted the announcement, so a total outage retries cleanly next time.</p>
<p>Belt, suspenders. Timelines unspammed.</p>
<h2 id="webmentions-closing-the-loop">Webmentions: closing the loop</h2>
<p>The last piece is the most indieweb thing of all. When a post links to someone's site, the polite move is to tell them — that's a webmention. On publish (and update — receivers dedupe, it's in the spec), the bridge:</p>
<ol>
<li>extracts every external link from the post,</li>
<li>asks each target "do you accept webmentions?" — an HTTP <code>Link</code> header or a <code>rel="webmention"</code> tag in the HTML,</li>
<li>and if yes, sends a tiny form: <code>source=my post, target=your page</code>.</li>
</ol>
<pre><code class="language-text">POST https://example.com/webmention-endpoint
Content-Type: application/x-www-form-urlencoded

source=https://myblog.example/one-post-many-webs/
&amp;target=https://example.com/the-page-i-linked
</code></pre>
<p>It runs in the background after the webhook responds, with timeouts, skipping my own domains, capped at a sane number of targets. Most sites don't accept webmentions. The ones that do get a knock on the door.</p>
<h2 id="what-the-flow-looks-like-now">What the flow looks like now</h2>
<pre><code class="language-text"> write in Ghost ── or send an email ──┐
                                       │  one webhook
                                       ▼
                              ┌─────────────────┐
                              │      bridge     │
                              └────────┬────────┘
        ┌──────────┬──────────┬────────┼─────────┬──────────┐
        ▼          ▼          ▼        ▼         ▼          ▼
     prose.sh   gemini://  gopher://  fediverse  Bluesky  webmentions
     (markdown) (gemtext)  (67 cols)  (2 accts)  (card)   (to whoever
                                                            I linked)
</code></pre>
<p>One post. Seven destinations. Zero extra effort at publish time — the effort was spent once, building the pipes.</p>
<h2 id="lessons-from-part-two">Lessons from part two</h2>
<ul>
<li><strong>Statelessness is a feature you choose.</strong> Making the CMS the single manifest deleted a whole class of bugs (and the database that would've hosted them).</li>
<li><strong>Respect each medium's grammar.</strong> Gemtext without inline links, gopher at 67 columns, Bluesky's 300 characters. Converting properly beats mirroring lazily.</li>
<li><strong>Separate "changed" from "new".</strong> Mirrors want both events; announcements want exactly one. A query string was all it took.</li>
<li><strong>Idempotency needs memory only where actions aren't reversible.</strong> Mirrors overwrite; social needs the ledger. Put state where it's mandatory, nowhere else.</li>
<li><strong>Old protocols deserve real engineering.</strong> The gopher hostname problem is unsolvable in-protocol — but a menu plus symlinks made the migration invisible. Constraints breed elegant hacks.</li>
</ul>
<p>The bridge started as "I'm tired of running scp twice." It's now a tiny syndication hub that speaks five protocols, and I still just click Publish.</p>
<p>Boring magic, compounding.</p>
<p>And yes. It's still working.</p>
<p>See you around.</p>
]]></content:encoded>
    </item>
    <item>
        <title>Being the CEO.</title>
        <link>https://pablomurad.com/being-the-ceo/</link>
        <guid isPermaLink="true">https://pablomurad.com/being-the-ceo/</guid>
        <pubDate>Wed, 01 Jul 2026 21:54:08 -0300</pubDate>
        <dc:creator>Pablo Murad</dc:creator>
        <category>updates</category>
        <description>Being a CEO means carrying decisions that can affect dozens of lives. From failed hires to impossible risks, this is a brief reflection on power, responsibility, and the quiet weight no one sees.</description>
        <content:encoded><![CDATA[<p>It’s all part of a process. It’s harder than it looks, and when people look from the outside, they only see the good parts.</p><p>I remember that about two years ago, I had to hire an executive for the company, and it turned out to be an extremely interesting experience with the two candidates. There was something they both had in common, and I suspected that if there had been a third candidate, he probably would have shared the same peculiarity: they both seemed to be deeply obsessed with power.</p><p>Doing a good job didn’t seem to matter, as long as they were able to subjugate others.</p><p>Neither of them lasted more than a few days at the company. They didn’t know how to handle the weight of their decisions, and being an executive is, more than anything, exactly that. It means making decisions that will impact dozens of people, taking on the risk and the responsibility — something most people avoid.</p><p>They had impeccable résumés, but they lacked the maturity required for the role.</p><p>I’m briefly telling this story because this week I had to make a move that was considered impossible and extremely risky, one I don’t want to talk about right now. And if it goes wrong, the only person to blame will be me.</p><p>Sleeping with that responsibility can be difficult, and it already hurts.</p><p>It’s as if you were in control of fate, but you’re not. The only thing you have is faith that everything will work out in the end.</p><p>And that’s it.</p>]]></content:encoded>
    </item>
    <item>
        <title>Amon: how I made Fedora feel right on the MSI Pulse 16 AI</title>
        <link>https://pablomurad.com/amon-how-i-made-fedora-feel-right-on-the-msi-pulse-16-ai/</link>
        <guid isPermaLink="true">https://pablomurad.com/amon-how-i-made-fedora-feel-right-on-the-msi-pulse-16-ai/</guid>
        <pubDate>Tue, 30 Jun 2026 02:16:40 -0300</pubDate>
        <dc:creator>Pablo Murad</dc:creator>
        <category>tech</category>
        <description>A practical write-up of how I configured Fedora KDE on an MSI Pulse 16 AI: NVIDIA, Ghostty, writing tools, cloud access, SSH, Tailscale, power settings and a clean KDE setup.</description>
        <content:encoded><![CDATA[<p>I wiped the notebook and installed Fedora KDE because I wanted a fast, clean and current machine. The idea was not to turn the installation into an eternal lab project. I wanted it ready for work, writing, coding, server access, IRC, cloud storage and full use of the hardware without fighting drivers all day.</p>
<p>The machine was named Amon. The goal was simple: install the system, get the NVIDIA driver right, build a strong writing environment, make SSH and Tailscale work, configure power behavior without annoying surprises and customize KDE without filling the system with questionable themes.</p>
<p>This text does not include passwords, private domains, IP addresses, tokens or sensitive details. Commands only appear when they help document what was actually done.</p>
<h2 id="the-machine">The machine</h2>
<p>The notebook is an MSI Pulse 16 AI C1VGKG. According to the official MSI specification for the Pulse 16 AI C1V line, the configuration includes a 16 inch QHD+ 2560x1600 display at 240 Hz, an NVIDIA GeForce RTX 4070 Laptop GPU with 8 GB GDDR6 in the C1VGKG model, DDR5 5600 memory with two slots and support for up to 96 GB, two M.2 NVMe PCIe Gen4 slots, Wi-Fi 6E with Bluetooth 5.3, a 90 Wh battery, a 240 W power adapter and a weight of 2.5 kg.</p>
<p>Source: MSI Pulse 16 AI C1V Specification, <a href="https://www.msi.com/Laptop/Pulse-16-AI-C1VX/Specification">https://www.msi.com/Laptop/Pulse-16-AI-C1VX/Specification</a></p>
<table>
<thead>
<tr>
<th>Item</th>
<th>Specification</th>
</tr>
</thead>
<tbody>
<tr>
<td>Model</td>
<td>MSI Pulse 16 AI C1VGKG</td>
</tr>
<tr>
<td>Display</td>
<td>16 inch QHD+ 2560x1600, 240 Hz, IPS-Level</td>
</tr>
<tr>
<td>CPU</td>
<td>Intel Core Ultra 9, with Intel AI Boost NPU</td>
</tr>
<tr>
<td>GPU</td>
<td>NVIDIA GeForce RTX 4070 Laptop GPU, 8 GB GDDR6</td>
</tr>
<tr>
<td>Memory</td>
<td>DDR5 5600, two slots, up to 96 GB</td>
</tr>
<tr>
<td>Storage</td>
<td>2 M.2 NVMe PCIe Gen4 slots</td>
</tr>
<tr>
<td>Network</td>
<td>Wi-Fi 6E and Bluetooth 5.3</td>
</tr>
<tr>
<td>Battery and power</td>
<td>90 Wh battery and 240 W adapter</td>
</tr>
<tr>
<td>Weight</td>
<td>2.5 kg</td>
</tr>
</tbody>
</table>
<h2 id="first-step-hostname-and-a-clean-base">First step: hostname and a clean base</h2>
<p>After the installation, the first thing I did was give the machine a proper name. That avoids the usual mess of generic hostnames in the network, terminal, Tailscale and SSH.</p>
<pre><code class="language-bash">sudo hostnamectl set-hostname amon
hostnamectl
sudo dnf upgrade --refresh -y
</code></pre>
<h2 id="rpm-fusion-and-nvidia-without-the-manual-installer">RPM Fusion and NVIDIA without the manual installer</h2>
<p>The sensitive part was NVIDIA. Instead of downloading the <code>.run</code> installer from NVIDIA and creating a maintenance problem for myself, I used RPM Fusion and <code>akmod-nvidia</code>. That is the cleaner path on Fedora because the module follows kernel updates instead of becoming a manual hack.</p>
<p>Before installing the driver, I checked that Secure Boot was disabled. That avoids the whole module signing soap opera. Then I installed the driver, let <code>akmods</code> build the module, regenerated initramfs and validated everything with <code>nvidia-smi</code>.</p>
<pre><code class="language-bash">mokutil --sb-state
sudo dnf install -y akmod-nvidia xorg-x11-drv-nvidia-cuda nvidia-settings kernel-devel-matched
sudo akmods --force
sudo dracut --force
sudo reboot
nvidia-smi
lsmod | grep nvidia
__NV_PRIME_RENDER_OFFLOAD=1 __GLX_VENDOR_LIBRARY_NAME=nvidia glxinfo | grep "OpenGL renderer"
</code></pre>
<h2 id="multimedia-codecs-and-video-acceleration">Multimedia, codecs and video acceleration</h2>
<p>Fedora is more careful with codecs by default. That is nice in theory, but in real use I want video working, media players behaving and hardware acceleration doing its job. So I finished the multimedia setup through RPM Fusion and installed VA-API pieces for Intel and NVIDIA.</p>
<pre><code class="language-bash">sudo dnf swap -y ffmpeg-free ffmpeg --allowerasing
sudo dnf group install -y multimedia
sudo dnf install -y libva libva-utils intel-media-driver libva-nvidia-driver
vainfo
</code></pre>
<h2 id="dnf-and-diagnostic-tools">DNF and diagnostic tools</h2>
<p>I also tuned DNF a little and installed diagnostic tools. Not because I want to stare at pretty graphs all day, but because it is useful when something heats up, freezes or starts eating resources without asking permission.</p>
<pre><code class="language-bash">sudo tee -a /etc/dnf/dnf.conf &gt;/dev/null &lt;&lt;'EOF'
# Amon tuning
max_parallel_downloads=10
fastestmirror=True
defaultyes=True
keepcache=False
EOF

sudo dnf clean all
sudo dnf makecache
sudo dnf install -y btop htop fastfetch inxi lm_sensors pciutils usbutils lshw nvtop powertop tuned tuned-ppd tuned-utils mesa-demos vulkan-tools mangohud goverlay akmods kernel-devel-matched
</code></pre>
<h2 id="power-profile">Power profile</h2>
<p>For performance, I enabled <code>tuned</code> and used <code>throughput-performance</code>. It is great on AC power, but it is not magic. More performance usually means more heat and more power draw. For normal use, <code>balanced</code> is still the smarter default.</p>
<pre><code class="language-bash">sudo systemctl enable --now tuned
sudo tuned-adm profile throughput-performance
tuned-adm active
sudo tuned-adm profile balanced
</code></pre>
<h2 id="ghostty-as-the-default-terminal">Ghostty as the default terminal</h2>
<p>Ghostty became the default terminal. I wanted something modern, fast and good looking. The only small trap was the theme name. On Linux, theme names are literal. <code>catppuccin-latte</code> did not work. <code>Catppuccin Latte</code> did.</p>
<pre><code class="language-bash">mkdir -p ~/.config/ghostty

cat &gt; ~/.config/ghostty/config &lt;&lt;'EOF'
font-family = JetBrains Mono
font-size = 12
theme = light:Catppuccin Latte,dark:Catppuccin Mocha
window-padding-x = 8
window-padding-y = 8
copy-on-select = clipboard
confirm-close-surface = false
EOF

ghostty +list-themes | grep -i cat
</code></pre>
<h2 id="writing-pdf-and-epub">Writing, PDF and EPUB</h2>
<p>The machine was also built as a writing workstation. I installed ghostwriter for Markdown, LibreOffice for traditional documents, Okular for reading and annotating PDFs, PDF Arranger for simple PDF surgery, Calibre for ebook management and conversion, Sigil for proper EPUB editing and Zotero for references.</p>
<p>In the terminal, I installed the tools that actually solve problems: Pandoc for document conversion, qpdf and poppler-utils for PDF work, OCRmyPDF and Tesseract for OCR, ImageMagick for images, ExifTool for metadata and TeX Live for PDF generation through LaTeX.</p>
<pre><code class="language-bash">flatpak install -y flathub org.kde.ghostwriter org.libreoffice.LibreOffice org.kde.okular com.github.jeromerobert.pdfarranger com.calibre_ebook.calibre com.sigil_ebook.Sigil org.zotero.Zotero

sudo dnf install -y pandoc qpdf poppler-utils ocrmypdf tesseract tesseract-langpack-por tesseract-langpack-eng ImageMagick djvulibre perl-Image-ExifTool unpaper texlive-scheme-medium texlive-collection-fontsrecommended texlive-collection-latexrecommended texlive-collection-latexextra
</code></pre>
<h2 id="irc-with-halloy">IRC with Halloy</h2>
<p>For IRC, I installed Halloy through Flatpak and replicated my Windows configuration into <code>config.toml</code>. The important part was not publishing passwords or private server details. Anything with credentials stays local.</p>
<pre><code class="language-bash">mkdir -p ~/.var/app/org.squidowl.halloy/config
nano ~/.var/app/org.squidowl.halloy/config/config.toml
chmod 600 ~/.var/app/org.squidowl.halloy/config/config.toml
</code></pre>
<h2 id="tailscale-and-ssh">Tailscale and SSH</h2>
<p>Remote access is handled through Tailscale and SSH. That lets me work on the machine from Windows without exposing SSH to the whole internet. <code>sshd</code> was enabled, and the machine became reachable through the Tailnet address.</p>
<pre><code class="language-bash">sudo dnf install -y openssh-server
sudo systemctl enable --now sshd
systemctl status sshd --no-pager
tailscale ip -4
tailscale status
</code></pre>
<h2 id="cloud-storage-on-fedora-kde">Cloud storage on Fedora KDE</h2>
<p>I did not want to depend on KDE native Google Drive integration. It exists, but it is not something I would build my whole workflow around. To mount cloud services as a local filesystem, I installed ExpanDrive through its RPM. The installation completed properly and left the binary available on the system.</p>
<pre><code class="language-bash">mkdir -p ~/Downloads
cd ~/Downloads
curl -L -A "Mozilla/5.0" -o ExpanDrive.rpm "https://www.expandrive.com/api/download/expandrive?platform=linux&amp;ext=rpm"
file ExpanDrive.rpm
sudo dnf install -y ./ExpanDrive.rpm
rpm -qa | grep -i expandrive
command -v expandrive
</code></pre>
<h2 id="dark-kde-fonts-and-icons">Dark KDE, fonts and icons</h2>
<p>The visual side was handled carefully because I was doing it remotely over SSH. I did not try to rebuild panels or install random KDE Store themes without seeing the screen. I applied a safe base: Breeze Dark, Papirus Dark, Noto Sans for the interface and JetBrains Mono for fixed-width text.</p>
<pre><code class="language-bash">sudo dnf install -y papirus-icon-theme google-noto-sans-fonts google-noto-serif-fonts jetbrains-mono-fonts kvantum qt6ct qt5ct breeze-gtk

kwriteconfig6 --file kdeglobals --group General --key ColorScheme BreezeDark
kwriteconfig6 --file kdeglobals --group KDE --key LookAndFeelPackage org.kde.breezedark.desktop
kwriteconfig6 --file kdeglobals --group KDE --key widgetStyle Breeze
kwriteconfig6 --file kdeglobals --group Icons --key Theme Papirus-Dark
kwriteconfig6 --file kdeglobals --group General --key fixed "JetBrains Mono,10,-1,5,50,0,0,0,0,0"
kwriteconfig6 --file kdeglobals --group General --key font "Noto Sans,10,-1,5,50,0,0,0,0,0"
</code></pre>
<h2 id="power-plugged-in-no-sleeping-from-idle">Power: plugged in, no sleeping from idle</h2>
<p>The power behavior ended up exactly how I wanted it. When plugged in, the notebook should not turn off the display or suspend by itself because of inactivity. But when I close the lid, it can suspend. That is the correct middle ground for a notebook.</p>
<pre><code class="language-bash">kwriteconfig6 --file powerdevilrc --group AC --group DPMSControl --key idleTime 0
kwriteconfig6 --file powerdevilrc --group AC --group DimDisplay --key idleTime 0
kwriteconfig6 --file powerdevilrc --group AC --group SuspendSession --key idleTime 0
kwriteconfig6 --file powerdevilrc --group AC --group HandleButtonEvents --key lidAction 1
systemctl --user restart plasma-powerdevil.service 2&gt;/dev/null || systemctl --user restart plasma-powerdevil

sudo tee /etc/systemd/logind.conf.d/99-amon-power.conf &gt;/dev/null &lt;&lt;'EOF'
[Login]
HandleLidSwitchExternalPower=suspend
HandleLidSwitchDocked=suspend
IdleAction=ignore
EOF

sudo systemctl restart systemd-logind
</code></pre>
<h2 id="openpgp-development-and-ai-in-the-terminal">OpenPGP, development and AI in the terminal</h2>
<p>For OpenPGP, the natural choice on Fedora KDE was Kleopatra, together with GnuPG and <code>pinentry-qt</code>. For development, I installed a Python and Go base. For AI in the terminal, I installed Claude Code through the official repository, with the rule of running it inside project directories, not across my whole home folder.</p>
<pre><code class="language-bash">sudo dnf install -y kleopatra gnupg2 pinentry-qt kgpg

sudo dnf install -y python3 python3-pip python3-devel python3-virtualenv pipx golang git gcc gcc-c++ make cmake pkgconf-pkg-config openssl-devel libffi-devel zlib-devel bzip2-devel xz-devel sqlite-devel readline-devel

claude doctor
claude --version
</code></pre>
<h2 id="image-work-and-visual-creation">Image work and visual creation</h2>
<p>For images, GIMP, Krita and Inkscape already cover a lot of ground. GIMP is for general editing, Krita is for art and covers, and Inkscape is for vector work. darktable and RawTherapee are next candidates if I want a more serious photography and RAW workflow.</p>
<pre><code class="language-bash">flatpak list --app | grep -Ei "gimp|krita|inkscape"
</code></pre>
<p>Amon now has Fedora KDE, working NVIDIA support, Wayland, RTX 4070 offload, codecs, a complete writing setup, remote access through SSH and Tailscale, cloud mounting prepared with ExpanDrive, Ghostty as the terminal, IRC in Halloy and a clean KDE visual base.</p>
<p>There is still room to tune panels, wallpaper, widgets and small visual details when I am physically in front of the machine. Over SSH, I stopped at the right point. Going further with visuals without looking at the screen would be vanity with a decent chance of breaking something that already works.</p>
<p>The best part is that the installation did not turn into a monster. It has strong tools, but not random packages installed just because they looked nice in a Reddit screenshot.</p>
<p>Source used only for the notebook specification: MSI, Pulse 16 AI C1V Specification, <a href="https://www.msi.com/Laptop/Pulse-16-AI-C1VX/Specification">https://www.msi.com/Laptop/Pulse-16-AI-C1VX/Specification</a></p>
]]></content:encoded>
    </item>
    <item>
        <title>Building the Bridge: from Dify to Chatwoot</title>
        <link>https://pablomurad.com/building-the-bridge-from-dify-to-chatwoot/</link>
        <guid isPermaLink="true">https://pablomurad.com/building-the-bridge-from-dify-to-chatwoot/</guid>
        <pubDate>Sun, 28 Jun 2026 23:54:30 -0300</pubDate>
        <dc:creator>Pablo Murad</dc:creator>
        <category>interesting</category>
        <description>A practical look at building the overlooked middleware between a help desk and an AI bot, covering message routing, async replies, handoffs, failure handling, and the quiet plumbing that makes support automation actually work.</description>
        <content:encoded><![CDATA[<p>I spent a weekend wiring an AI bot into a customer support inbox, and the part that ate most of my time was not the AI and not the inbox. It was the small piece of software that sits between them. Nobody talks about that piece much, which is strange, because it is the piece that makes the whole thing work. Here is how it went.</p><p>The setup is common enough. On one side you have a help desk that collects conversations from a website widget and a messaging channel. On the other you have an AI platform that holds your knowledge base and answers questions. Both are mature products. Both have APIs. You would assume someone has already glued them together in a clean, maintained package.</p><p>They have not, or at least not in a way I would trust in production. What I found instead were a couple of community projects that looked promising for about ten minutes. One was still in beta and had not been touched in a long while. Its companion repository was gone entirely. Another dragged in a heavy stack of background workers and an extra database just to pass a message from one place to another. For a job this simple, that felt wrong.</p><h1 id="what-the-middleware-actually-does">What the middleware actually does</h1><p>Strip away the jargon and the job is almost boring. A customer sends a message. The help desk pokes your service to say a message arrived. Your service reads it, asks the AI for an answer, and posts that answer back into the conversation. That is the entire loop.</p><p>The first instinct is to write the part that calls the AI. Resist it. The first thing your service should be good at is saying no. A help desk fires events for all kinds of reasons, and most of them are not your concern. The bot's own replies generate events too, and if you are not careful your service will answer itself in a loop that is both funny and expensive.</p><p>I settled on a tight filter near the top of the request handler. Act only on genuinely new messages from the customer, and only while the conversation is still in the bot's hands. Everything else gets a polite acknowledgment and nothing more. Getting this filter right early saved me from a whole category of bugs that would have been miserable to chase later.</p><p>There is a timing trap here that is worth naming. The help desk expects a quick reply when it pokes your service. The AI, meanwhile, can take several seconds to think, especially when the answer runs long. If you make the help desk wait for the AI, you risk the request timing out, and timeouts in this kind of plumbing are the worst sort of intermittent problem.</p><p>The fix is to acknowledge the poke immediately and do the real work in the background. Tell the help desk you received the message, close that request, and only then go bother the AI. The customer waits a few seconds for a thoughtful answer, which is fine. Your service never leaves anyone hanging on an open connection.</p><p>An AI answer is much better when it knows what was already said. Most platforms give you a handle, some kind of identifier, that ties a series of messages into one ongoing conversation. Your job is to remember which platform conversation belongs to which help desk conversation, and to hand that identifier back each time.</p><p>You do not need much to store this. A tiny local database file, the kind that lives on disk and needs no server of its own, is plenty. People reach for a full database engine by reflex, but for a lookup table this small that is overkill. Keep the storage as light as the job demands.</p><p>A bot that cannot get out of the way is worse than no bot. Build the exit early. There should be a few clear ways a conversation leaves the bot and reaches a person. The customer asks for a human in plain language. The AI signals that it is out of its depth. Or the AI simply fails, in which case a human should catch the conversation rather than the customer hitting a wall.</p><p>Mechanically the handoff is just flipping the conversation's state so the bot stops paying attention and the team can pick it up. The hard part is not the mechanics, it is remembering to treat failure as a normal path rather than an afterthought. When the AI errors out, the customer should glide to a person, not see a broken silence.</p><p>Even if you start with a single bot on a single channel, leave room for more. The cheap way to do this is a small routing table that maps each inbox to the AI app that should serve it. One channel today, three channels and three different bots tomorrow, and the routing table absorbs the change without you rewriting the core. This costs almost nothing to set up on day one and saves a real rewrite later.</p><p>The part I want to be honest about is that the clean architecture was not what cost me the night. The surprises were. A platform update had quietly added a security rule that refused to deliver messages to internal network addresses, which is exactly where a service like this tends to live. The behavior looked like the bot was broken when in fact the message was being blocked one step upstream, by a protection that was doing its job a little too eagerly.</p><p>None of these were architecture problems. They were the kind of thing you only find by reading logs slowly and refusing to guess. When the bot goes quiet, the logs are already telling you why. Trace the message one hop at a time and let the system show you where it stopped.</p><p>The bridge is small. It is also the difference between two impressive tools that ignore each other and one quiet system that answers your customers at three in the morning. Worth building carefully.</p>]]></content:encoded>
    </item>
    <item>
        <title>Around ereaders</title>
        <link>https://pablomurad.com/around-ereaders/</link>
        <guid isPermaLink="true">https://pablomurad.com/around-ereaders/</guid>
        <pubDate>Sun, 28 Jun 2026 07:21:18 -0300</pubDate>
        <dc:creator>Pablo Murad</dc:creator>
        <category>update</category>
        <description>My journey through several e-readers, from Kindle to Bigme, and my impressions of each one.</description>
        <content:encoded><![CDATA[<p>I am on my third Kindle now. I do not remember the model of the first one well enough to specify it, but it was a very old one, from the early generations. I am an avid reader, and when Amazon decided to launch those e-readers, I got excited right away. It was getting hard to walk around with books inside my backpack, and if I was carrying a folder, it was even worse.</p><p>When the Paperwhite version came out, I did not think twice. I bought it. And my first one, I gave to my cousin (that is why I do not remember the model). I fell in love with the Paperwhite because it was thinner, but in terms of functionality, there was not that much difference. It had a better texture, and the backlight was completely comfortable.</p><p>I used it for about two or three years until one day I saw that Amazon was once again going to innovate with the launch of a color model, the Colorsoft. I got even more excited. I had thousands of comics that I used to read on the PC. What I needed was exactly that.</p><p>I waited one year after the launch to buy it. I wanted people to review it first and talk about their impressions. Then came the Colorsoft Signature, and the time had come. Coincidentally, Amazon, on the day I bought it, was giving a discount (I think it was Amazon week or something like that). I paid R$1,500. I am not sure of the price in dollars, but certainly in reais, like everything in Brazil, it was probably much more expensive than buying it in the United States or anywhere else beyond our borders.</p><p>It was perfect. But something disappointed me. It was the lack of native support for comic books, just that. Fortunately, this could be solved with software developed by the community and available on <a href="https://github.com/ciromattia/kcc" rel="noreferrer">GitHub</a>. </p><p>Around the same time, the “hackers” released the <a href="https://github.com/KindleModding/WinterBreak" rel="noreferrer">jailbreak </a>for the Kindle, but I could not test it. It was compatible with the model I no longer had, which was my older Kindle, but I no longer had it for a good reason.</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://pablomurad.com/content/images/2026/06/WhatsApp-Image-2026-06-28-at-07.19.33--2-.jpeg" class="kg-image" alt="" loading="lazy" width="1280" height="591" srcset="https://pablomurad.com/content/images/size/w600/2026/06/WhatsApp-Image-2026-06-28-at-07.19.33--2-.jpeg 600w, https://pablomurad.com/content/images/size/w1000/2026/06/WhatsApp-Image-2026-06-28-at-07.19.33--2-.jpeg 1000w, https://pablomurad.com/content/images/2026/06/WhatsApp-Image-2026-06-28-at-07.19.33--2-.jpeg 1280w" sizes="(min-width: 720px) 720px"><figcaption><span style="white-space: pre-wrap;">Kindle Colorsoft Signature</span></figcaption></figure><p>Anyway, after filling it with books and delighting myself with the dear Colorsoft Signature, I saw something online about a device that was being produced in China, called XTeink. I thought it was some parallel thing, and I did not pay much attention. But a few days later, the internet, or at least the places where I browsed, was talking about nothing else.</p><p>I was taken over by a lot of curiosity. It was not an expensive device, and everyone spoke well of it. Not to mention that it was extremely portable. I went to AliExpress and, without thinking too much, bought the X4. A joy that did not last long. A few days later, I received an email saying that my purchase had been canceled. So I went back to the website to buy it again and saw the listing this time for the X3, but it was marked as a new release. I did a brief search, because I had found it strange that the X4 had been released before the X3, and in fact this X3 was the newer version.</p><p>Besides the size, apparently nothing had changed. But since it was the most recent one, I bought it.</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://pablomurad.com/content/images/2026/06/WhatsApp-Image-2026-06-28-at-07.19.33--1-.jpeg" class="kg-image" alt="" loading="lazy" width="591" height="1280"><figcaption><span style="white-space: pre-wrap;">XTeink X3</span></figcaption></figure><p>Then I realized that I had bought it in pre-order and that what I had seen about it were only reviews. But that was not a problem. It was January 2026. I received the product at the end of April / beginning of May.</p><p>It arrived in a modest little box, with a screen protector and a case. I was charmed right away. As soon as I turned it on, came the new surprise: it was in Chinese. And the screen displayed a QR Code to download the app. The surprise that should have been good turned into a mix of frustration and the urge to throw it against the wall.</p><p>It was literally the Chinese version, meant to be used in China, and it needed a Chinese phone.</p><p>So I would need to find a way to get a Chinese number or change the firmware. And I researched. The available firmwares I found taught how to change the stock firmware of the X4 and the first-gen X3, but they did not mention the new X3 (the connection methods and USB are a little different). Until I found out how. The process was similar to the others, but with peculiarities I will not talk about here, in this post.</p><p>And I went ahead and changed the stock firmware for another one. My happiness came back. It turned on and, even better, it had Brazilian Portuguese translation. I used it tirelessly for one month. Excellent texture and OPDS support, which allowed me to connect my Calibre to the device.</p><p>Then I started thinking about why I had not bought this before, and I had the idea of checking whether there was something better than the Kindle Colorsoft.</p><p>That is when I discovered Bigme. A wonderful e-reader tablet. I was enchanted after watching some videos on YouTube. It had everything a tablet could have, but it was an e-reader. It had compatibility with Play Store apps, it had its own apps, and it came with a pen. Yes, you can write on it, and the latency is almost zero.</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://pablomurad.com/content/images/2026/06/WhatsApp-Image-2026-06-28-at-07.19.34.jpeg" class="kg-image" alt="" loading="lazy" width="591" height="1280"><figcaption><span style="white-space: pre-wrap;">Bigme B7</span></figcaption></figure><p>I bought it. And after some customs problems, the device arrived. I can say that I was impressed enough after using it for only a few hours, and it has been three days since it arrived. I installed it, configured it, uninstalled things, installed more apps, did everything, prepared my agenda, etc., etc., etc.</p><p>I must confess that the Kindle is a robust device, but it was the others that are making me feel happy.</p>]]></content:encoded>
    </item>
    <item>
        <title>iocaine: poisoning AI&#x27;s</title>
        <link>https://pablomurad.com/iocaine-poisoning-ais/</link>
        <guid isPermaLink="true">https://pablomurad.com/iocaine-poisoning-ais/</guid>
        <pubDate>Sun, 28 Jun 2026 06:32:03 -0300</pubDate>
        <dc:creator>Pablo Murad</dc:creator>
        <category>tech</category>
        <description>Iocaine is not just another anti-bot tool. It is a Rust-built tarpit that traps AI scrapers, feeds them synthetic nonsense, and turns content theft into self-inflicted data poisoning.</description>
        <content:encoded><![CDATA[<p>At first glance, the project looks easy to summarize: a tarpit that serves garbage to bots. That summary is not wrong, but it is too small. After reading through the stable 3.x branch, its workspace layout, dependency choices, and changelog, the more accurate description is sharper: Iocaine has grown into a programmable HTTP decision engine that can classify requests, generate poisoned content, preserve legitimate traffic, export metrics, and, in recent versions, optionally cooperate with nftables.</p><h1 id="the-problem-it-attacks">The Problem It Attacks</h1><p>The web has always had robots. Search engines, RSS readers, uptime monitors, academic crawlers, archive tools, SEO crawlers, and link checkers are not new. The newer pain is the intensity and entitlement of large-scale scraping, especially when public websites, documentation, blogs, and Git forges are treated as raw input for machine-learning pipelines.</p><p>Traditional defenses are imperfect. robots.txt is a polite request. Rate limiting reduces volume but does not necessarily change intent. User-Agent blocking becomes theater the moment the crawler starts lying. Commercial WAF and CDN tools help, but they also add opacity, cost, and dependency. Iocaine chooses a different axis: do not simply reject the crawler; let it keep crawling, but put it in the wrong place.</p><p><em>The trick is not to spend your resources fighting the crawler. The trick is to make the crawler spend its resources on itself.</em></p><p>The basic topology is simple:</p><p>Internet</p><p>&nbsp;&nbsp; ↓</p><p>Reverse proxy: Caddy, Nginx, HAProxy, or similar</p><p>&nbsp;&nbsp; ↓</p><p>Iocaine</p><p>&nbsp;&nbsp; ├── benign visitor → fallback to the real backend</p><p>&nbsp;&nbsp; └── suspicious scraper → poisoned content, fake links, maze behavior, metrics, and possible firewalling</p><p>&nbsp;</p><p>Iocaine is designed to sit between upstream resources and the fronting reverse proxy. The proxy effectively asks Iocaine whether a request should see the real site. If the request looks benign, the proxy falls back to the backend. If it looks suspicious, the visitor receives plausible garbage and links into a maze.</p><p>The economics matter. The project is engineered so that serving garbage is cheap for the operator, close to the cost of serving a static file. Meanwhile the crawler must download, parse, queue, classify, and follow links. That flips the cost asymmetry.</p><p>The 3.x series is where Iocaine becomes more than a toy. The configuration system was replaced, request handlers became mandatory, handlers became responsible for rendering output, and a single instance gained the ability to run multiple servers. Alongside HTTP and metrics servers, the project added an HAProxy SPOA mode. It also ships with a built-in configuration, request handler, and training corpus, so it can function out of the box.</p><p>That design says something important: Iocaine is not trying to be a static blocklist. It is trying to be a programmable traffic policy engine.</p><p>The operator can now express decisions around trusted User-Agents, trusted IPs, trusted paths, ASNs, CIDR ranges, poisoned URLs, headers, query parameters, cookies, and request metadata. It is HTTP triage with a mean streak.</p><h1 id="the-stack-rust-outside-script-inside">The Stack: Rust Outside, Script Inside</h1><p>The stable 3.x branch uses Rust 2024 with a minimum supported Rust version of 1.88. That is a reasonable technical choice for a piece of edge infrastructure: predictable performance, safe concurrency, strong typing, and easy static deployment.</p><p>The workspace is split into four main crates:</p><p>iocaine&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; → the main executable and operational integration</p><p>iocaine-powder&nbsp;&nbsp; → the core library, intended for embedding Iocaine logic elsewhere</p><p>iocaine-label&nbsp;&nbsp;&nbsp; → bundled third-party embedded resources, including Fennel and ai.robots.txt data</p><p>iocaine-table&nbsp;&nbsp;&nbsp; → a wrapper around an optional dependency, keeping the rest of the project cleaner</p><p>&nbsp;</p><p>This split is not cosmetic. It is a separation of responsibilities. iocaine-powder turns the core idea into an embeddable library. iocaine remains the complete application. iocaine-label isolates vendored resources so they can be updated or patched downstream. iocaine-table hides an optional feature-bound dependency.</p><p>The dependency graph tells the rest of the story:</p><p>axum / tower-http&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; → HTTP server and middleware</p><p>Tokio&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; → asynchronous runtime</p><p>tracing&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; → structured logging</p><p>Prometheus&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; → metrics</p><p>Figment + KDL/TOML/YAML/JSON → flexible configuration</p><p>mlua + Fennel&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; → Lua/Fennel scripting</p><p>Roto&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; → scripting and rule logic</p><p>maxminddb&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; → GeoIP/ASN-based decisions</p><p>ipnet / ipnet-trie&nbsp;&nbsp;&nbsp;&nbsp; → networks, CIDRs, efficient matching</p><p>regex / aho-corasick&nbsp;&nbsp; → text matching and multi-pattern scanning</p><p>upon&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; → templating</p><p>minify-html&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; → compact generated output</p><p>mimalloc&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; → custom allocator on x86-64 Linux</p><p>libnftables1-sys&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; → experimental nftables integration</p><p>rust-embed&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; → embedded resources inside the binary</p><p>&nbsp;</p><p>That stack is not accidental. It is built to classify traffic cheaply, not merely block it crudely.</p><p>The nonsense content is part of the weapon. It must be cheap to generate, plausible enough to keep crawlers engaged, and poisonous enough to degrade naive collection pipelines.</p><p>If the response looked like an obvious block page, the crawler could abandon the path, rotate identity, switch to browser automation, or retry more aggressively. If the response looks like content, the crawler continues. This is the difference between a wall and a swamp.</p><p><em>A wall says: do not enter. A swamp says: come a little farther.</em></p><h1 id="poisoned-urls-the-clever-part">Poisoned URLs: The Clever Part</h1><p>One of the strongest ideas in Iocaine is the poisoned URL. A simple collector enters the maze and receives fake links carrying identifiers. Later, if a different agent comes back with one of those URLs — perhaps from another IP, perhaps using a real browser, perhaps with a more convincing User-Agent — Iocaine has a strong signal. A normal human visitor would not have discovered that URL.</p><p>That turns crawler behavior into evidence. The bot carries the poison forward. When it later tries to appear legitimate, it reveals itself by the path it follows.</p><p>Earlier deployments often used HTTP 421 Misdirected Request as the fallback signal: if Iocaine decided a request should see the real site, it returned a status the reverse proxy could intercept and route to the upstream backend. In 3.5.0, the built-in script gained the ability to configure that fallback status instead of hardcoding 421.</p><p>That sounds small, but it matters. Different proxies and environments may prefer different integration contracts.</p><p>With Caddy, the pattern is roughly:</p><p>example.com {</p><p>&nbsp; @read method GET HEAD</p><p>&nbsp;</p><p>&nbsp; reverse_proxy @read 127.0.0.1:42069 {</p><p>&nbsp;&nbsp;&nbsp; @fallback status 421</p><p>&nbsp;&nbsp;&nbsp; handle_response @fallback</p><p>&nbsp; }</p><p>&nbsp;</p><p>&nbsp; reverse_proxy 127.0.0.1:8080</p><p>}</p><p>&nbsp;</p><p>With Nginx, the same idea is to intercept the fallback status and jump to the real backend:</p><p>location / {</p><p>&nbsp;&nbsp;&nbsp; proxy_pass http://127.0.0.1:42069;</p><p>&nbsp;&nbsp;&nbsp; proxy_intercept_errors on;</p><p>&nbsp;&nbsp;&nbsp; error_page 421 = @real_backend;</p><p>}</p><p>&nbsp;</p><p>location @real_backend {</p><p>&nbsp;&nbsp;&nbsp; proxy_pass http://127.0.0.1:8080;</p><p>}</p><p>&nbsp;</p><p>This is also where the danger lives. Misconfigure this layer and you can poison Googlebot, RSS readers, ActivityPub fetchers, uptime monitors, APIs, or real users. Good tools do not protect you from sloppy operations.</p><p>Modern Iocaine is not a fixed list of bot names. It has a scripting engine with Roto and optional Lua/Fennel support. The request handler can reason over headers, cookies, query parameters, source addresses, CIDRs, ASNs, User-Agents, Sec-CH-UA, poisoned URLs, trusted paths, trusted IPs, trusted User-Agents, metrics, and challenge responses.</p><p>That is where the project becomes surgical. It does not need to treat all machine traffic as hostile. A feed reader, a webhook, a monitoring probe, a legitimate search engine, and a hostile scraper can be treated differently.</p><p>A defense mechanism without observability is superstition. Iocaine exposes Prometheus metrics for requests, generated garbage, maze depth, request handler hits, process statistics, and, in recent versions, firewall blocks.</p><p>That allows the operator to ask concrete questions: how many requests reached the real backend, how many were fed poison, which rules are firing, whether the maze is being explored, whether upstream load dropped, and whether firewalling is doing anything useful.</p><p>Starting in the 3.3.0 series, Iocaine gained experimental nftables-based firewalling. It talks to the kernel through netlink without relying on external binaries. A built-in rule can push certain bad actors into firewall blocks, and later versions redesigned this subsystem for simplicity and performance.</p><p>I would be conservative here. The tarpit and metrics are the first tools to use. Kernel-level blocking should come after the operator understands false positives and traffic patterns. A clever firewall is still a foot-gun if pointed at the wrong visitor.</p><h1 id="how-i-would-deploy-it-after-operating-on-the-code">How I Would Deploy It After Operating on the Code</h1><p>I would not throw Iocaine in front of everything immediately. The sane deployment path is layered:</p><p>1. Start with a lab domain, not a commercial site.</p><p>2. Run Iocaine locally behind Caddy or Nginx.</p><p>3. Enable Prometheus metrics from day one.</p><p>4. Define trusted paths for RSS, ActivityPub, APIs, webhooks, and known good bots.</p><p>5. Test with curl, known bad User-Agents, normal browsers, and real feed readers.</p><p>6. Watch logs for several days.</p><p>7. Only then move it in front of real public services.</p><p>&nbsp;</p><p>I would test it first on a personal site, documentation, a public directory, or a Git forge. I would not start with a production education portal, a payment path, a login flow, or anything whose organic search visibility matters.</p><p>Iocaine is strongest around technical blogs, public documentation, wikis, Forgejo/Gitea/cgit instances, static sites, personal sites, and services suffering from abusive crawler traffic. Git forges are an especially good fit because commits, diffs, file trees, raw files, blame pages, and history create a huge surface for crawlers to waste time on.</p><h1 id="where-it-can-hurt-you">Where It Can Hurt You</h1><p>It is risky in commercial SEO-heavy sites, public APIs, e-commerce, educational portals, federated services that were not carefully tested, and environments with aggressive reverse caching. The obvious disaster is caching garbage and serving it to humans. The second disaster is poisoning a legitimate crawler. The third is forgetting that RSS, ActivityPub, webhooks, and monitoring tools can look bot-like from a distance.</p><p>Iocaine is spectacular because it is not a sanitized enterprise appliance. It has attitude. It is technical, sarcastic, and hostile in the right direction. But beneath the attitude there is real engineering: Rust, async IO, scripting, flexible configuration, embedded resources, metrics, network matching, ASN logic, templates, persistent state, configurable fallback, and experimental firewall integration.</p><p>The project changes the question. The old question was: how do I make the bot go away? Iocaine asks: how do I make the bot keep walking into the wrong room, spending its money instead of mine?</p>]]></content:encoded>
    </item>
    <item>
        <title>Newt and OpenBSD</title>
        <link>https://pablomurad.com/newt-and-openbsd/</link>
        <guid isPermaLink="true">https://pablomurad.com/newt-and-openbsd/</guid>
        <pubDate>Sun, 28 Jun 2026 04:54:53 -0300</pubDate>
        <dc:creator>Pablo Murad</dc:creator>
        <category>papers</category>
        <description>A static OpenBSD site, a failed Newt build, and the simple fix: use the existing Linux connector instead of forcing the wrong tool into the wrong place.</description>
        <content:encoded><![CDATA[<h1 id="the-scenario">The scenario</h1><p>The idea was simple: spin up an OpenBSD VM on Proxmox to host a small, static site generated with an SSG. No monstrous stack, no Docker, no panel full of abstractions in the middle. Just a clean and predictable system, with httpd serving static files, the way OpenBSD likes it.</p><p>On the public side, I already had a structure running on a VPS: Pangolin, Traefik, and Gerbil. Their job is to receive internet traffic and forward it to internal services on my network. And the most natural connector for that, inside the Pangolin ecosystem, is Newt.</p><p>The domain would be theskull.org. The OpenBSD VM became mercurio, with the IP 192.168.50.73 on the local network. There was also calisto, an existing machine on the network, at 192.168.50.46. That detail seemed minor at first and ended up being the key to everything.</p><h1 id="the-first-decision-was-openbsd-a-good-idea">The first decision: was OpenBSD a good idea?</h1><p>For Docker, no. For a static site, yes. That distinction matters because a lot of people mix the two things and then blame the operating system. OpenBSD is a poor choice for stacking modern containers, ready-made stacks, and compose files for everything. But for serving a static site with httpd, pf, and a small configuration, it is excellent.</p><p>I created a very modest VM: one CPU, little memory, a simple disk, and VirtIO networking. No X, no frills. The system came up, I left sshd enabled, created the right user, adjusted permissions with doas, and moved on to what seemed to be the main step: installing Newt.</p><h1 id="the-wrong-attempt-installing-newt-directly-on-openbsd">The wrong attempt: installing Newt directly on OpenBSD</h1><p>Pangolin offers a nice and tempting command to install Newt, the kind of command that usually solves everything on Linux in ten seconds:</p><p>curl -fsSL https://static.pangolin.net/get-newt.sh | bash</p><p>But I was on OpenBSD, and that is where things stopped. Newt works very well in supported environments, especially Linux. On OpenBSD, it is not that straightforward. Since the official installer did not solve it, I tried to compile it manually from the repository.</p><p>pkg_add curl git go gmake</p><p>git clone https://github.com/fosrl/newt.git</p><p>cd newt</p><p>go build -o newt .</p><p>The compilation did start. Go downloaded the dependencies, flooded the screen with modules, and almost gave me hope. Then it stopped on a very clear error:</p><p>package github.com/fosrl/newt</p><p>imports github.com/fosrl/newt/clients/permissions: build constraints</p><p>exclude all Go files in /home/skull/newt/clients/permissions</p><p>It was not a missing package or a typo. It was drier than that: in that part of the code, the available Go files were excluded by OpenBSD build constraints. There was no valid implementation being compiled for the platform. It was not the time to force it by shouting at the terminal.</p><p>The failure was not technical. It was architectural. I had gotten stuck on the idea that the connector needed to run inside the OpenBSD VM itself. But Newt does not need to be on the same server as the final service. It only needs to be somewhere that can see the internal service.</p><p>That was when it clicked: on another occasion, I had already connected internal services to Pangolin, and I suspected that this went through a machine called calisto. So I checked.</p><p>hostname</p><p>uname -a</p><p>ip a</p><p>which newt</p><p>ps aux | grep -i newt | grep -v grep</p><p>systemctl status newt --no-pager</p><p>It was all there. Calisto was Debian, IP 192.168.50.46, running Newt as a systemd service. The active process was pointing to the Pangolin endpoint at pango.forsak.ing. The tunnel already existed. I was trying to build a new bridge over a river that already had one.</p><h1 id="the-correct-architecture">The correct architecture</h1><p>The solution became much cleaner when I stopped trying to push Newt into OpenBSD. The right design was this:</p><p>Internet</p><p>&nbsp;-&gt; Pangolin / Traefik / Gerbil on the VPS</p><p>&nbsp;-&gt; Newt running on calisto</p><p>&nbsp;-&gt; http://192.168.50.73:80 on mercurio/OpenBSD</p><p>Calisto was already the network connector and could reach mercurio through the LAN. So Pangolin only needed a resource pointing to mercurio's internal IP, using calisto as the site. Direct, without trying to turn OpenBSD into Linux.</p><p>On mercurio, the OpenBSD side was the easiest part. I created the site directory inside the standard httpd environment:</p><p>mkdir -p /var/www/htdocs/mercurio</p><p>I put in a test index.html and a minimal configuration in /etc/httpd.conf:</p><p>server "default" {</p><p>listen on * port 80</p><p>root "/htdocs/mercurio"</p><p>}</p><p>I validated the configuration:</p><p>httpd -n</p><p>And enabled the service:</p><p>rcctl enable httpd</p><p>rcctl start httpd</p><p>From mercurio, the server was up. From calisto, the test also passed:</p><p>curl -I http://192.168.50.73</p><p>It returned 200 OK, with Server: OpenBSD httpd. That was the sign that the path from calisto to mercurio was working.</p><p>Then came the most irritating part. Opening https://theskull.org in the browser, the page loaded without an error, the certificate was valid, and everything was white. It is the kind of bug that tricks you, because it looks like DNS, looks like proxying, looks like Pangolin, looks like anything except the obvious.</p><p>I tested it with curl for real instead of just staring at the browser:</p><p>curl -v http://192.168.50.73/</p><p>curl -v https://theskull.org/</p><p>In both cases it returned 200 OK. TLS was right, theskull.org pointed to the correct VPS IP (84.247.140.162), and the traffic reached OpenBSD. But one line revealed the crime:</p><p>Content-Length: 0</p><p>It was not DNS, Pangolin, Traefik, Gerbil, or Newt. OpenBSD was serving a valid response, but it was empty. The white page was, literally, a page with no content.</p><h1 id="the-final-fix">The final fix</h1><p>The fix was to recreate index.html properly inside the directory served by httpd:</p><p>&lt;!doctype html&gt;</p><p>&lt;html&gt;</p><p>&lt;head&gt;</p><p>&lt;meta charset="utf-8"&gt;</p><p>&lt;title&gt;The Skull&lt;/title&gt;</p><p>&lt;/head&gt;</p><p>&lt;body&gt;</p><p>&lt;h1&gt;The Skull is alive on OpenBSD&lt;/h1&gt;</p><p>&lt;p&gt;Served by the mercurio VM through OpenBSD httpd.&lt;/p&gt;</p><p>&lt;/body&gt;</p><p>&lt;/html&gt;</p><p>I restarted httpd, repeated the tests, and then the content appeared. theskull.org started loading the HTML served by mercurio, passing through calisto and Pangolin.</p><p>The most obvious lesson: not everything needs to run on the same machine. Newt did not have to be on OpenBSD. It had to be on a machine that could see OpenBSD, and calisto was already doing that job.</p><p>The more important lesson is another one. When a tool does not properly support a platform, insisting on it becomes technical fetishism. Compiling Newt on OpenBSD seemed elegant, but it was unnecessary. The right move was to use OpenBSD for what it does well: serve the site in a simple and clean way.</p><p>And then there is that lesson that always comes back to humiliate me: test layer by layer. DNS, TLS, proxy, connector, internal service, in that order. In the end, the error was in the dumbest possible place: a file served by httpd with zero bytes of useful content.</p><p>Mercurio remained a minimalist OpenBSD VM, serving a static site with httpd. Calisto remained the Newt connector for the network. And Pangolin kept handling public exposure, with Traefik and the certificate working.</p><p>The final architecture stayed small and easy to maintain. OpenBSD did not become the host for a stack that does not suit it, Linux kept the connector that was already working, and theskull.org started coming from the internet, crossing Pangolin, entering through calisto, and ending on a page served by OpenBSD httpd.</p><p>The solution was not to install more things. It was to stop installing things where they did not need to be.</p>]]></content:encoded>
    </item>
    <item>
        <title>OpenBGPD: speaking like a real router</title>
        <link>https://pablomurad.com/openbgpd-speaking-like-a-real-router/</link>
        <guid isPermaLink="true">https://pablomurad.com/openbgpd-speaking-like-a-real-router/</guid>
        <pubDate>Sun, 28 Jun 2026 04:48:15 -0300</pubDate>
        <dc:creator>Pablo Murad</dc:creator>
        <category>papers</category>
        <description>OpenBGPD turns an ordinary machine into a real BGP speaker, showing how routing, prefixes, filters, and network policy quietly shape the internet beneath the surface.</description>
        <content:encoded><![CDATA[<p>There are tools you look at for the first time and think, “okay, this seems important, but important to whom?” OpenBGPD is one of them. The name already gives away half of it: it is the BGP daemon born in OpenBSD. The problem is that this sentence, by itself, says nothing to someone who does not spend the whole day living inside routing, ASN, and peering.</p><p>So I will translate it once and for all: it is software that lets any machine speak BGP, the protocol networks use to exchange routes with each other. It is not for hosting a website, and it is not a reverse proxy, VPN, or firewall with a pretty panel. It lives in a much lower layer. Its job is to tell other routers, “to reach these IPs, the path goes through here.”</p><p>That changes the scale of the conversation. Nginx, Docker, VPS, domain names: all of that is application and service territory. BGP is already the gear that lets the entire internet know where traffic should go. Another floor of the building.</p><h1 id="first-what-is-bgp">First: what is BGP?</h1><p>BGP is the Border Gateway Protocol, the protocol autonomous networks use to exchange routing information with each other. An autonomous network, or AS, is a network large enough to have its own identity on the internet, identified by a number called an ASN. Providers, datacenters, carriers, universities, IXPs, large companies: this is the world they all live in.</p><p>The internet is not a magical cloud; it is a giant agreement between networks. Each one announces the IP blocks it knows, the paths it accepts, the ones it prefers, and the ones it does not want at any price. BGP is the language these networks use to negotiate all of this.</p><p>Think of it as GPS for big routers. Except instead of saying “turn right in 300 meters,” it says “this prefix is reachable through this AS, along this path, under these rules.”</p><p>OpenBGPD is the free implementation of BGP made by the OpenBSD people. It came with the same mindset as the project itself: lean code, configuration you can actually read, privilege separation, and a genuine concern not to become a monster.</p><p>In practice, it turns an ordinary Unix machine into a BGP router. It can run on OpenBSD itself, and there is also a portable version that compiles on FreeBSD and on some Linux distributions.</p><p>That is the good part: you do not need a carrier-grade router that costs a fortune to study or operate BGP in certain scenarios. You can take an ordinary machine, put a serious system on it, and make it participate in the routing conversation.</p><h1 id="what-it-is-not">What it is not</h1><p>It is worth being direct here, because this is where a lot of people get excited for the wrong reason. OpenBGPD will not “make your site faster,” and you also do not install it on a random VPS and receive global redundancy as a free bonus.</p><p>It does not replace Nginx, Traefik, Pangolin, Cloudflare Tunnel, FRP, WireGuard, or pfSense, because those are different problems. OpenBGPD handles announcing and receiving routes by talking to neighboring routers through BGP. If you do not have a BGP neighbor, an ASN, your own IP prefix, or a lab set up, it simply has no one to talk to.</p><p>It is an aviation radio without an airplane. The device may be excellent, but it makes no sense sitting on your desk.</p><p>The first case is learning. If you like infrastructure, OpenBSD, and networks, building a lab with OpenBGPD is one of the best ways to understand how the internet works under the hood. You create private ASNs, simulate providers, announce prefixes, drop a link, and watch the routing reorganize itself.</p><p>The second case is real network operation. Then we are talking about a company with its own ASN, an allocated IPv4 or IPv6 block, transit with a carrier, peering at an IXP, that kind of environment. In that scenario, OpenBGPD can be the daemon holding the BGP sessions and applying routing policies.</p><p>The third is a route server, especially at an internet exchange point. Version 9.0 improved efficiency in handling Adj-RIB-Out, which matters in large route server deployments at IXPs. In practice: when you need to calculate and deliver many routes to many participants, memory and efficiency stop being details and become matters of survival.</p><p>The pieces that matter are few. bgpd is the daemon, the process that runs and maintains the sessions. bgpd.conf is the configuration file, where you define your AS, the router ID, the neighbors, the announced networks, the filters, and the policies. And bgpctl is the command-line tool used to check session status, inspect routes, and administer the service.</p><p>That simplicity is one of the project’s charms. Instead of throwing you into a maze of modes, submodes, and commands that feel inherited from an extinct civilization, OpenBGPD tries to keep the file readable. It is still BGP, so it is not a toy, but it also does not insist on being impenetrable.</p><p>Imagine you have ASN 64512 and the block 203.0.113.0/24. Your provider uses ASN 64500, and its router responds at 198.51.100.1. A conceptual configuration would look roughly like this:</p><p>AS 64512router-id 192.0.2.1network 203.0.113.0/24<br><br>neighbor 198.51.100.1 {&nbsp;&nbsp;&nbsp; remote-as 64500&nbsp;&nbsp;&nbsp; descr "Provider 1"}</p><p>Do not copy this thinking it is a production configuration, because it is not. Those blocks are for documentation, and a real configuration needs filters, authentication when it makes sense, import and export policy, prefix validation, RPKI depending on the case, limits, and common sense. BGP without filters is an open datacenter door with you sleeping beside it.</p><p>BGP is powerful because it changes the path traffic takes, and that is exactly why it is dangerous when used carelessly. A wrong announcement can make your network disappear from the map, send traffic to the wrong place, break connectivity, and, in ugly cases, leak routes to people who should not even see them.</p><p>That is why the heart of a decent BGP configuration is not “the session came up.” Bringing up the session is only the beginning of the story. What truly matters is what you accept, what you announce, to whom you announce it, and under which conditions. Filtering here is basic hygiene. Anyone running BGP without filters is not being an old-school hacker; they are being irresponsible.</p><p>It is the difference between playing in a lab and operating on the real internet. In the lab, making mistakes is learning. In production, mistakes become incidents.</p><p>OpenBSD has a very particular culture. It does not try to please everyone; it prefers to do fewer things, but with clarity and predictability. OpenBGPD inherited that entire personality.</p><p>The configuration tends to be direct, the daemon was designed with privilege separation, and the project takes input validation and lean code seriously. That does not mean it is magical or invulnerable; it means it was made by people who understand that network software spends all day exposed to garbage coming from outside.</p><p>And for routing, that matters a lot. A BGP daemon is not a little program hidden in a corner: it talks to other systems, receives updates, processes attributes, decides routes, and influences real traffic. The attack surface and the possible damage are too large to tolerate hacks.</p><p>Honestly, I would not start putting OpenBGPD on an application VPS just because it looks nice. That is decorating infrastructure with the wrong tool. For GoToSocial, Pixelfed, Lemmy, Nginx, Docker, reverse proxy, and web services in general, it does not enter the conversation.</p><p>Now, to study real routing, yes, I would absolutely use it. I would set up two or three OpenBSD VMs, each with a private ASN, create fictional prefixes, bring up BGP sessions between them, and test filtering. Then I would drop one link, bring up another, play with local-pref to prefer a route, manipulate AS path, and apply communities. That is where things get fun.</p><p>And it would make sense again if one day the operation had its own ASN, an IPv6 block, more than one provider, or a presence at an exchange point. At that moment, OpenBGPD stops being a curiosity and becomes a real candidate for the network edge.</p><h1 id="openbgpd-against-the-fetish-of-complexity">OpenBGPD against the fetish of complexity</h1><p>One thing I like about this kind of project is how it breaks the idea that serious infrastructure needs to be noisy, full of dashboards, buttons, plugins, and marketing. Sometimes the right tool is a small daemon, a clear configuration file, and a command that shows the state of things.</p><p>That is very OpenBSD. No fireworks, no “revolutionary AI-powered routing platform.” It is serious software doing a serious job, and honestly, that is a relief. Not everything needs to become a SaaS with a mascot and an Enterprise plan.</p><p>OpenBGPD seems boring until it clicks that it is handling one of the most important parts of the internet: the decision of where packets go.</p><h1 id="the-no-nonsense-summary">The no-nonsense summary</h1><p>OpenBGPD is a free implementation of BGP-4 born in the OpenBSD ecosystem. It lets an ordinary machine exchange routes with other systems that speak BGP, and it is used for edge routing, multihoming, peering, IXPs, route servers, and network labs.</p><p>It is not for hosting a website, speeding up WordPress, replacing a reverse proxy, or solving common VPS headaches. If you do not have an ASN, a prefix, a BGP neighbor, or a lab set up, it will be more of a study object than an immediately useful tool.</p><p>But as study material, it is excellent, because studying OpenBGPD is studying the internet underneath. Not the internet of posts, videos, and pretty panels, but the real one: routes, policy, announcements, filters, and a pile of technical decisions almost nobody sees and everyone uses.</p><p>The smart path is not trying to put this into production; it is building a lab. Three OpenBSD VMs are enough. One is your AS, another is a provider, and the third is a second provider or a customer. You create documentation prefixes, bring up the sessions, announce routes, and use bgpctl to follow what is happening.</p><p>From there, you raise the level: filters, then policy, then route preference, then intentional failure, then IPv6. When you can explain the path of a route without copying from a tutorial, then you have started to truly understand BGP.</p><p>That is the point. OpenBGPD is not a button-clicking tool; it is a tool for understanding and operating an important layer of the network. Anyone who only wants to put a service online can safely ignore it. Anyone who wants to know how the internet breathes from the inside should take a careful look.</p><h1 id="quick-table">Quick table</h1>
<!--kg-card-begin: html-->
<table class="MsoTableGrid" border="1" cellspacing="0" cellpadding="0" style="border-collapse:collapse;border:none;mso-border-alt:solid windowtext .5pt;
 mso-yfti-tbllook:1184;mso-padding-alt:0cm 5.4pt 0cm 5.4pt">
 <tbody><tr style="mso-yfti-irow:0;mso-yfti-firstrow:yes">
  <td width="326" valign="top" style="width:244.8pt;border:solid windowtext 1.0pt;
  mso-border-alt:solid windowtext .5pt;padding:0cm 5.4pt 0cm 5.4pt">
  <p class="MsoNormal" style="margin-bottom:2.0pt"><span lang="EN-US" style="font-size:10.0pt;mso-bidi-font-size:11.0pt;line-height:107%">Question</span><span lang="EN-US"><o:p></o:p></span></p>
  </td>
  <td width="326" valign="top" style="width:244.8pt;border:solid windowtext 1.0pt;
  border-left:none;mso-border-left-alt:solid windowtext .5pt;mso-border-alt:
  solid windowtext .5pt;padding:0cm 5.4pt 0cm 5.4pt">
  <p class="MsoNormal" style="margin-bottom:2.0pt"><span lang="EN-US" style="font-size:10.0pt;mso-bidi-font-size:11.0pt;line-height:107%">Direct
  answer</span><span lang="EN-US"><o:p></o:p></span></p>
  </td>
 </tr>
 <tr style="mso-yfti-irow:1">
  <td width="326" valign="top" style="width:244.8pt;border:solid windowtext 1.0pt;
  border-top:none;mso-border-top-alt:solid windowtext .5pt;mso-border-alt:solid windowtext .5pt;
  padding:0cm 5.4pt 0cm 5.4pt">
  <p class="MsoNormal" style="margin-bottom:2.0pt"><span lang="EN-US" style="font-size:10.0pt;mso-bidi-font-size:11.0pt;line-height:107%">What is
  it?</span><span lang="EN-US"><o:p></o:p></span></p>
  </td>
  <td width="326" valign="top" style="width:244.8pt;border-top:none;border-left:
  none;border-bottom:solid windowtext 1.0pt;border-right:solid windowtext 1.0pt;
  mso-border-top-alt:solid windowtext .5pt;mso-border-left-alt:solid windowtext .5pt;
  mso-border-alt:solid windowtext .5pt;padding:0cm 5.4pt 0cm 5.4pt">
  <p class="MsoNormal" style="margin-bottom:2.0pt"><span lang="EN-US" style="font-size:10.0pt;mso-bidi-font-size:11.0pt;line-height:107%">A free
  BGP daemon originating from OpenBSD.</span><span lang="EN-US"><o:p></o:p></span></p>
  </td>
 </tr>
 <tr style="mso-yfti-irow:2">
  <td width="326" valign="top" style="width:244.8pt;border:solid windowtext 1.0pt;
  border-top:none;mso-border-top-alt:solid windowtext .5pt;mso-border-alt:solid windowtext .5pt;
  padding:0cm 5.4pt 0cm 5.4pt">
  <p class="MsoNormal" style="margin-bottom:2.0pt"><span lang="EN-US" style="font-size:10.0pt;mso-bidi-font-size:11.0pt;line-height:107%">What is
  it for?</span><span lang="EN-US"><o:p></o:p></span></p>
  </td>
  <td width="326" valign="top" style="width:244.8pt;border-top:none;border-left:
  none;border-bottom:solid windowtext 1.0pt;border-right:solid windowtext 1.0pt;
  mso-border-top-alt:solid windowtext .5pt;mso-border-left-alt:solid windowtext .5pt;
  mso-border-alt:solid windowtext .5pt;padding:0cm 5.4pt 0cm 5.4pt">
  <p class="MsoNormal" style="margin-bottom:2.0pt"><span lang="EN-US" style="font-size:10.0pt;mso-bidi-font-size:11.0pt;line-height:107%">Exchanging
  routes with other routers and systems that speak BGP.</span><span lang="EN-US"><o:p></o:p></span></p>
  </td>
 </tr>
 <tr style="mso-yfti-irow:3">
  <td width="326" valign="top" style="width:244.8pt;border:solid windowtext 1.0pt;
  border-top:none;mso-border-top-alt:solid windowtext .5pt;mso-border-alt:solid windowtext .5pt;
  padding:0cm 5.4pt 0cm 5.4pt">
  <p class="MsoNormal" style="margin-bottom:2.0pt"><span lang="EN-US" style="font-size:10.0pt;mso-bidi-font-size:11.0pt;line-height:107%">Is it
  useful for a regular VPS?</span><span lang="EN-US"><o:p></o:p></span></p>
  </td>
  <td width="326" valign="top" style="width:244.8pt;border-top:none;border-left:
  none;border-bottom:solid windowtext 1.0pt;border-right:solid windowtext 1.0pt;
  mso-border-top-alt:solid windowtext .5pt;mso-border-left-alt:solid windowtext .5pt;
  mso-border-alt:solid windowtext .5pt;padding:0cm 5.4pt 0cm 5.4pt">
  <p class="MsoNormal" style="margin-bottom:2.0pt"><span lang="EN-US" style="font-size:10.0pt;mso-bidi-font-size:11.0pt;line-height:107%">Almost
  never.</span><span lang="EN-US"><o:p></o:p></span></p>
  </td>
 </tr>
 <tr style="mso-yfti-irow:4">
  <td width="326" valign="top" style="width:244.8pt;border:solid windowtext 1.0pt;
  border-top:none;mso-border-top-alt:solid windowtext .5pt;mso-border-alt:solid windowtext .5pt;
  padding:0cm 5.4pt 0cm 5.4pt">
  <p class="MsoNormal" style="margin-bottom:2.0pt"><span lang="EN-US" style="font-size:10.0pt;mso-bidi-font-size:11.0pt;line-height:107%">Do I need
  an ASN?</span><span lang="EN-US"><o:p></o:p></span></p>
  </td>
  <td width="326" valign="top" style="width:244.8pt;border-top:none;border-left:
  none;border-bottom:solid windowtext 1.0pt;border-right:solid windowtext 1.0pt;
  mso-border-top-alt:solid windowtext .5pt;mso-border-left-alt:solid windowtext .5pt;
  mso-border-alt:solid windowtext .5pt;padding:0cm 5.4pt 0cm 5.4pt">
  <p class="MsoNormal" style="margin-bottom:2.0pt"><span lang="EN-US" style="font-size:10.0pt;mso-bidi-font-size:11.0pt;line-height:107%">For real
  use on the internet, generally yes.</span><span lang="EN-US"><o:p></o:p></span></p>
  </td>
 </tr>
 <tr style="mso-yfti-irow:5">
  <td width="326" valign="top" style="width:244.8pt;border:solid windowtext 1.0pt;
  border-top:none;mso-border-top-alt:solid windowtext .5pt;mso-border-alt:solid windowtext .5pt;
  padding:0cm 5.4pt 0cm 5.4pt">
  <p class="MsoNormal" style="margin-bottom:2.0pt"><span lang="EN-US" style="font-size:10.0pt;mso-bidi-font-size:11.0pt;line-height:107%">Is it
  good for studying?</span><span lang="EN-US"><o:p></o:p></span></p>
  </td>
  <td width="326" valign="top" style="width:244.8pt;border-top:none;border-left:
  none;border-bottom:solid windowtext 1.0pt;border-right:solid windowtext 1.0pt;
  mso-border-top-alt:solid windowtext .5pt;mso-border-left-alt:solid windowtext .5pt;
  mso-border-alt:solid windowtext .5pt;padding:0cm 5.4pt 0cm 5.4pt">
  <p class="MsoNormal" style="margin-bottom:2.0pt"><span lang="EN-US" style="font-size:10.0pt;mso-bidi-font-size:11.0pt;line-height:107%">Very
  much. It is great for a routing lab.</span><span lang="EN-US"><o:p></o:p></span></p>
  </td>
 </tr>
 <tr style="mso-yfti-irow:6;mso-yfti-lastrow:yes">
  <td width="326" valign="top" style="width:244.8pt;border:solid windowtext 1.0pt;
  border-top:none;mso-border-top-alt:solid windowtext .5pt;mso-border-alt:solid windowtext .5pt;
  padding:0cm 5.4pt 0cm 5.4pt">
  <p class="MsoNormal" style="margin-bottom:2.0pt"><span lang="EN-US" style="font-size:10.0pt;mso-bidi-font-size:11.0pt;line-height:107%">Does it
  replace a firewall, proxy, or VPN?</span><span lang="EN-US"><o:p></o:p></span></p>
  </td>
  <td width="326" valign="top" style="width:244.8pt;border-top:none;border-left:
  none;border-bottom:solid windowtext 1.0pt;border-right:solid windowtext 1.0pt;
  mso-border-top-alt:solid windowtext .5pt;mso-border-left-alt:solid windowtext .5pt;
  mso-border-alt:solid windowtext .5pt;padding:0cm 5.4pt 0cm 5.4pt">
  <p class="MsoNormal" style="margin-bottom:2.0pt"><span lang="EN-US" style="font-size:10.0pt;mso-bidi-font-size:11.0pt;line-height:107%">No. It
  belongs to another layer of the problem.</span><span lang="EN-US"><o:p></o:p></span></p>
  </td>
 </tr>
</tbody></table>
<!--kg-card-end: html-->
<h1 id="sources-consulted">Sources consulted</h1><p>OpenBGPD, official page: https://www.openbgpd.org/</p><p>OpenBGPD, project goals: https://www.openbgpd.org/goals.html</p><p>OpenBGPD, official manuals: https://www.openbgpd.org/manual.html</p><p>RFC 4271, Border Gateway Protocol 4: https://www.rfc-editor.org/rfc/rfc4271</p><p>OpenBGPD 9.0 announcement: https://www.mail-archive.com/announce@openbsd.org/msg00576.html</p><p>Article written in June 2026.</p>]]></content:encoded>
    </item>
    <item>
        <title>One Post, Two Blogs</title>
        <link>https://pablomurad.com/one-post-two-blogs/</link>
        <guid isPermaLink="true">https://pablomurad.com/one-post-two-blogs/</guid>
        <pubDate>Sat, 27 Jun 2026 01:42:16 -0300</pubDate>
        <dc:creator>Pablo Murad</dc:creator>
        <category>tech</category>
        <description>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</description>
        <content:encoded><![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:encoded>
    </item>
    <item>
        <title>Weekly recap</title>
        <link>https://pablomurad.com/weekly-recap/</link>
        <guid isPermaLink="true">https://pablomurad.com/weekly-recap/</guid>
        <pubDate>Fri, 26 Jun 2026 11:13:12 -0300</pubDate>
        <dc:creator>Pablo Murad</dc:creator>
        <category>recap</category>
        <description>I didn’t plan for this week to be about infrastructure. But that’s what it became.</description>
        <content:encoded><![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:encoded>
    </item>
</channel>
</rss>
