<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://aioue.net/feed.xml" rel="self" type="application/atom+xml" /><link href="https://aioue.net/" rel="alternate" type="text/html" /><updated>2026-04-25T12:35:02+00:00</updated><id>https://aioue.net/feed.xml</id><title type="html">aioue.net</title><subtitle>Technical notes. Nondescript.</subtitle><entry><title type="html">Recovering a lost Cursor chat draft with SQLite and Lexical JSON</title><link href="https://aioue.net/2026/04/24/recovering-a-lost-cursor-draft/" rel="alternate" type="text/html" title="Recovering a lost Cursor chat draft with SQLite and Lexical JSON" /><published>2026-04-24T12:00:00+00:00</published><updated>2026-04-24T12:00:00+00:00</updated><id>https://aioue.net/2026/04/24/recovering-a-lost-cursor-draft</id><content type="html" xml:base="https://aioue.net/2026/04/24/recovering-a-lost-cursor-draft/"><![CDATA[<p>I use <a href="https://www.cursor.com/">Cursor</a> heavily to manage a personal knowledge base and job search tracker. Tasks, notes, checklists - the Cursor chat box acts like a command interface. I type a block of items, hit send, the agent processes them.</p>

<p>Except this time I hit the wrong key and wiped the message before sending.</p>

<p>I’d been adding to that draft over the course of the morning. I knew some of what was in it, but not all of it. I retyped what I could remember and submitted that, but I had a nagging feeling I’d lost something. So I asked a Cursor agent in the same repo to try to recover the original.</p>

<p>What followed was a pretty good piece of detective work through Cursor’s internals.</p>

<h2 id="the-search">The search</h2>

<p>The agent started with the obvious places. It scanned the agent transcript <code class="language-plaintext highlighter-rouge">.jsonl</code> files and the submitted chat bubble history. It found the re-typed version I’d submitted from memory - but not the original draft.</p>

<p>Next it went looking in Cursor’s SQLite databases on disk. macOS stores Cursor’s app data under <code class="language-plaintext highlighter-rouge">~/Library/Application Support/Cursor/</code>. There are several:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">Session Storage/000003.log</code> - empty, not useful</li>
  <li><code class="language-plaintext highlighter-rouge">Local Storage/leveldb/000003.log</code> - only DevTools and PDF viewer state</li>
  <li><code class="language-plaintext highlighter-rouge">User/globalStorage/state.vscdb</code> - this is the main one; it stores submitted chat bubbles under keys like <code class="language-plaintext highlighter-rouge">bubbleId:&lt;composerId&gt;:&lt;bubbleId&gt;</code></li>
  <li><code class="language-plaintext highlighter-rouge">User/workspaceStorage/&lt;workspaceId&gt;/state.vscdb</code> - workspace-scoped state</li>
</ul>

<p>The submitted bubbles were all there in <code class="language-plaintext highlighter-rouge">globalStorage/state.vscdb</code>, but that’s only the sent messages. The unsent draft wasn’t among them - which makes sense, since I never submitted it.</p>

<h2 id="the-key-insight">The key insight</h2>

<p>At this point I mentioned I had hour-old backups of the Cursor app support folder. The agent asked for two specific files from the backup. I restored them to <code class="language-plaintext highlighter-rouge">Cursor_0906/</code> and <code class="language-plaintext highlighter-rouge">Cursor_1006/</code> (the timestamps refer to when the backups were taken).</p>

<p>That’s when it found something interesting: a key called <code class="language-plaintext highlighter-rouge">composerData:&lt;composerId&gt;</code> in <code class="language-plaintext highlighter-rouge">globalStorage/state.vscdb</code>.</p>

<p>This key stores the <strong>unsent rich text draft</strong> of the Cursor chat input box as a <a href="https://lexical.dev/">Lexical</a> editor JSON blob. Lexical is the rich text framework Cursor uses for the chat input. It persists the draft state across quits and restarts - so if you close Cursor mid-thought and reopen it, your unsent message is still there.</p>

<p>The structure looks roughly like this:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"composerData"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"richText"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"root"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"children"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
          </span><span class="p">{</span><span class="w">
            </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"paragraph"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"children"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
              </span><span class="p">{</span><span class="w"> </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"text"</span><span class="p">,</span><span class="w"> </span><span class="nl">"text"</span><span class="p">:</span><span class="w"> </span><span class="s2">"your unsent message here"</span><span class="w"> </span><span class="p">}</span><span class="w">
            </span><span class="p">]</span><span class="w">
          </span><span class="p">}</span><span class="w">
        </span><span class="p">]</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">text</code> fields in the Lexical node tree contained the full original message. Compared to what I’d retyped from memory, there were four extra items I’d completely forgotten about and were meaningful thing to have back.</p>

<h2 id="what-to-check-if-this-happens-to-you">What to check if this happens to you</h2>

<p>If you clear an unsent Cursor message and want to recover it:</p>

<ol>
  <li>
    <p><strong>Check your backups first.</strong> Time Machine, rsync, whatever you use - the window where the draft still exists in the SQLite database is before Cursor overwrites it (which happens when you next open the composer or quit cleanly, I think).</p>
  </li>
  <li>
    <p><strong>Open <code class="language-plaintext highlighter-rouge">~/Library/Application Support/Cursor/User/globalStorage/state.vscdb</code></strong> with any SQLite browser (<code class="language-plaintext highlighter-rouge">sqlite3</code>, <a href="https://sqlitebrowser.org/">DB Browser for SQLite</a>, etc.).</p>
  </li>
  <li>
    <p><strong>Look for keys matching <code class="language-plaintext highlighter-rouge">composerData:&lt;composerId&gt;</code></strong> in the <code class="language-plaintext highlighter-rouge">ItemTable</code>. The composerId is a UUID - there may be one per chat window/composer.</p>
  </li>
  <li>
    <p><strong>Parse the value as JSON</strong> and look for <code class="language-plaintext highlighter-rouge">text</code> nodes in the Lexical tree. The draft content is in there.</p>
  </li>
</ol>

<p>The composerId you want corresponds to the chat composer where you were typing. If you know the chat session, you can cross-reference the submitted bubble keys (<code class="language-plaintext highlighter-rouge">bubbleId:&lt;composerId&gt;:...</code>) to confirm which composerId is the right one.</p>

<h2 id="luck-required">Luck required</h2>

<p>This only worked because I had a recent backup. If Cursor had already overwritten the <code class="language-plaintext highlighter-rouge">composerData</code> key - which presumably happens once you open a new composer or send a new message - the draft would be gone from disk too.</p>

<p>The lesson: if you accidentally wipe an important Cursor draft, <strong>stop using Cursor immediately</strong> and back up <code class="language-plaintext highlighter-rouge">~/Library/Application Support/Cursor/</code> before doing anything else. The draft might still be in <code class="language-plaintext highlighter-rouge">state.vscdb</code>.</p>

<p>Cursor agent doing forensics on its own app’s storage files is a slightly strange loop, but it worked.</p>]]></content><author><name></name></author><category term="cursor" /><category term="sqlite" /><category term="debugging" /><category term="productivity" /><summary type="html"><![CDATA[I use Cursor heavily to manage a personal knowledge base and job search tracker. Tasks, notes, checklists - the Cursor chat box acts like a command interface. I type a block of items, hit send, the agent processes them.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://aioue.net/og-image.png" /><media:content medium="image" url="https://aioue.net/og-image.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Backing up Any.do tasks with any.down</title><link href="https://aioue.net/2026/03/22/anydown-backup-any-do-tasks/" rel="alternate" type="text/html" title="Backing up Any.do tasks with any.down" /><published>2026-03-22T18:00:00+00:00</published><updated>2026-03-22T18:00:00+00:00</updated><id>https://aioue.net/2026/03/22/anydown-backup-any-do-tasks</id><content type="html" xml:base="https://aioue.net/2026/03/22/anydown-backup-any-do-tasks/"><![CDATA[<p>I’ve used <a href="https://www.any.do/">Any.do</a> for task management for years. It’s simple and stays out of the way, which is what I want. But it has no export or backup feature - <a href="https://support.any.do/en/articles/8635961-printing-and-exporting-items">their own support page confirms it</a>. If the service disappeared tomorrow, or I accidentally deleted a list, everything would be gone.</p>

<p><a href="https://github.com/aioue/any.down">any.down</a> is a small Python CLI I wrote to fix that. It logs into Any.do’s web API, pulls your tasks, and saves them as timestamped JSON and Markdown files. Run it once a day (or let Docker do it for you) and you’ve got a local paper trail of everything.</p>

<h2 id="how-it-works">How it works</h2>

<p>First run, any.down asks for your Any.do email and password, then sends a 2FA code to your inbox. After that it saves the session so you don’t have to re-authenticate each time. It only writes new files when your tasks have actually changed, so you don’t end up with hundreds of identical exports.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/aioue/any.down.git
<span class="nb">cd </span>any.down
uv <span class="nb">sync
</span>uv run anydown
</code></pre></div></div>

<p>The output lands in <code class="language-plaintext highlighter-rouge">outputs/</code> - raw JSON for archival and Markdown tables that are easy to read or grep through.</p>

<h2 id="unattended-backups-with-docker">Unattended backups with Docker</h2>

<p>The main reason I built this was to run it on a schedule without thinking about it. A <code class="language-plaintext highlighter-rouge">docker compose up -d</code> gives you hourly syncs via <a href="https://github.com/aptible/supercronic">supercronic</a>, with session state in a Docker volume so it survives container rebuilds:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose up <span class="nt">-d</span>
</code></pre></div></div>

<p>There’s also a <code class="language-plaintext highlighter-rouge">--watch</code> flag if you’d rather run the process directly without Docker - it syncs every 90 minutes or so with some random jitter.</p>

<h2 id="bonus-duplicate-cleaner">Bonus: duplicate cleaner</h2>

<p>Any.do occasionally creates duplicate tasks - maybe from sync conflicts across devices, maybe from the API being weird. any.down ships with a separate <code class="language-plaintext highlighter-rouge">anydown-dupes</code> command that finds exact duplicates (matching title, list, note, and subtasks) and lets you clean them up:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>uv run anydown-dupes               <span class="c"># dry run</span>
uv run anydown-dupes <span class="nt">--delete</span>      <span class="c"># prompt before deleting</span>
</code></pre></div></div>

<h2 id="why-not-just-use-the-app">Why not just use the app?</h2>

<p>Mostly peace of mind. I don’t distrust Any.do, but I’ve been burned before by services that shut down or lose data. Having a local copy of my tasks - in plain text formats I can read without any special tooling - means I’m not locked in. If I ever move to a different system, the data is already there.</p>

<p>Source: <a href="https://github.com/aioue/any.down">aioue/any.down</a></p>]]></content><author><name></name></author><category term="python" /><category term="any.do" /><category term="backup" /><category term="docker" /><summary type="html"><![CDATA[I’ve used Any.do for task management for years. It’s simple and stays out of the way, which is what I want. But it has no export or backup feature - their own support page confirms it. If the service disappeared tomorrow, or I accidentally deleted a list, everything would be gone.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://aioue.net/og-image.png" /><media:content medium="image" url="https://aioue.net/og-image.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Dynamic Ansible inventory from a UniFi controller</title><link href="https://aioue.net/2026/03/22/ansible-unifi-inventory-plugin/" rel="alternate" type="text/html" title="Dynamic Ansible inventory from a UniFi controller" /><published>2026-03-22T16:00:00+00:00</published><updated>2026-03-22T16:00:00+00:00</updated><id>https://aioue.net/2026/03/22/ansible-unifi-inventory-plugin</id><content type="html" xml:base="https://aioue.net/2026/03/22/ansible-unifi-inventory-plugin/"><![CDATA[<p><a href="https://github.com/aioue/ansible-unifi-inventory">aioue.network</a> is an Ansible collection that turns your UniFi OS controller into a dynamic inventory source. It queries clients (and optionally devices) from a UDM, UCG, or similar controller and makes them available as Ansible hosts, grouped by connection type, VLAN, SSID, and network name.</p>

<h2 id="install">Install</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ansible-galaxy collection <span class="nb">install </span>git+https://github.com/aioue/ansible-unifi-inventory.git
</code></pre></div></div>

<p>Or in <code class="language-plaintext highlighter-rouge">requirements.yml</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">collections</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">aioue.network</span>
    <span class="na">source</span><span class="pi">:</span> <span class="s">https://github.com/aioue/ansible-unifi-inventory.git</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">git</span>
</code></pre></div></div>

<h2 id="inventory-file">Inventory file</h2>

<p>Create <code class="language-plaintext highlighter-rouge">unifi.yaml</code> (or whatever you like):</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">plugin</span><span class="pi">:</span> <span class="s">aioue.network.unifi</span>
<span class="na">url</span><span class="pi">:</span> <span class="s2">"</span><span class="s">https://192.168.1.1"</span>
<span class="na">token</span><span class="pi">:</span> <span class="s2">"</span><span class="s">your-api-token"</span>
<span class="na">site</span><span class="pi">:</span> <span class="s2">"</span><span class="s">default"</span>
<span class="na">verify_ssl</span><span class="pi">:</span> <span class="no">false</span>
<span class="na">last_seen_minutes</span><span class="pi">:</span> <span class="m">30</span>
</code></pre></div></div>

<p>Supports API token auth (preferred) or username/password for a local admin account with 2FA disabled. All settings can also come from environment variables (<code class="language-plaintext highlighter-rouge">UNIFI_URL</code>, <code class="language-plaintext highlighter-rouge">UNIFI_TOKEN</code>, etc.).</p>

<h2 id="what-you-get">What you get</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ ansible-inventory -i unifi.yaml all --graph
@all:
  |--@unifi_clients:
  |  |--phone
  |  |--laptop
  |  |--Kitchen Echo
  |  |--pc
  |  |--nas
  |--@unifi_wireless_clients:
  |  |--phone
  |--@unifi_wired_clients:
  |  |--pc
  |  |--nas
  |--@network_default:
  |  |--laptop
  |--@network_iot:
  |  |--Kitchen Echo
  |--@vlan_30:
  |  |--Kitchen Echo
  |--@ssid_iot:
  |  |--Kitchen Echo
</code></pre></div></div>

<p>Groups are created automatically:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">unifi_clients</code>, <code class="language-plaintext highlighter-rouge">unifi_wireless_clients</code>, <code class="language-plaintext highlighter-rouge">unifi_wired_clients</code> - by connection type</li>
  <li><code class="language-plaintext highlighter-rouge">network_&lt;name&gt;</code> - by UniFi network name</li>
  <li><code class="language-plaintext highlighter-rouge">vlan_&lt;id&gt;</code>, <code class="language-plaintext highlighter-rouge">vlan_&lt;name&gt;</code> - by VLAN</li>
  <li><code class="language-plaintext highlighter-rouge">ssid_&lt;name&gt;</code> - by wireless SSID</li>
</ul>

<p>Each host gets <code class="language-plaintext highlighter-rouge">ansible_host</code> set to its IP (IPv4 preferred, IPv6 fallback), plus variables like <code class="language-plaintext highlighter-rouge">mac</code>, <code class="language-plaintext highlighter-rouge">vlan</code>, <code class="language-plaintext highlighter-rouge">network</code>, <code class="language-plaintext highlighter-rouge">ssid</code>, <code class="language-plaintext highlighter-rouge">is_wired</code>, <code class="language-plaintext highlighter-rouge">last_seen_iso</code>, switch port, AP MAC, and manufacturer OUI.</p>

<p>With <code class="language-plaintext highlighter-rouge">include_devices: true</code>, UniFi infrastructure (APs, switches, gateways) appears too, grouped by device type (<code class="language-plaintext highlighter-rouge">unifi_uap</code>, <code class="language-plaintext highlighter-rouge">unifi_usw</code>, etc.) with firmware version and adoption status.</p>

<h2 id="caching">Caching</h2>

<p>API results are cached locally for 30 seconds by default (<code class="language-plaintext highlighter-rouge">cache_ttl</code>). First run takes 2-10 seconds; subsequent runs within the TTL window are near-instant. Set <code class="language-plaintext highlighter-rouge">cache_ttl: 0</code> to disable.</p>

<h2 id="use-case">Use case</h2>

<p>I use this alongside the Proxmox dynamic inventory to manage everything on my LAN. The UniFi inventory gives me ad-hoc access to any client that’s shown up on the network - useful for deploying SSH keys to new machines, checking which devices are on which VLAN, or targeting a group of hosts by network segment without maintaining a static inventory file.</p>

<p>Source: <a href="https://github.com/aioue/ansible-unifi-inventory">aioue/ansible-unifi-inventory</a></p>]]></content><author><name></name></author><category term="ansible" /><category term="unifi" /><category term="inventory" /><category term="networking" /><summary type="html"><![CDATA[aioue.network is an Ansible collection that turns your UniFi OS controller into a dynamic inventory source. It queries clients (and optionally devices) from a UDM, UCG, or similar controller and makes them available as Ansible hosts, grouped by connection type, VLAN, SSID, and network name.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://aioue.net/og-image.png" /><media:content medium="image" url="https://aioue.net/og-image.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Keeping a Canon PIXMA MG6250 alive on modern macOS</title><link href="https://aioue.net/2026/03/21/canon-mg6250-macos-driver-bundle/" rel="alternate" type="text/html" title="Keeping a Canon PIXMA MG6250 alive on modern macOS" /><published>2026-03-21T12:00:00+00:00</published><updated>2026-03-21T12:00:00+00:00</updated><id>https://aioue.net/2026/03/21/canon-mg6250-macos-driver-bundle</id><content type="html" xml:base="https://aioue.net/2026/03/21/canon-mg6250-macos-driver-bundle/"><![CDATA[<p>Canon dropped macOS support for the PIXMA MG6250 after High Sierra (10.13). The hardware still works fine, but there are no official drivers for anything newer. I put together <a href="https://github.com/aioue/canon-mg6250-mac-driver-bundle">canon-mg6250-mac-driver-bundle</a> - a set of shell scripts that extract and install Canon’s last official drivers on current macOS, including Apple Silicon via Rosetta 2.</p>

<h2 id="the-problem">The problem</h2>

<p>Canon’s High Sierra <code class="language-plaintext highlighter-rouge">.dmg</code> installers ship unsigned <code class="language-plaintext highlighter-rouge">.pkg</code> files that Gatekeeper blocks on modern macOS. Even if you force-install them, the printer filters are x86_64 binaries that won’t run on Apple Silicon without Rosetta. The scanner driver needs its ICA bundle placed in the right location and a USB re-plug cycle before Image Capture will recognise it. None of this is documented anywhere obvious.</p>

<h2 id="what-the-bundle-does">What the bundle does</h2>

<p>The repo contains Canon’s original DMGs alongside install scripts that handle the extraction and placement:</p>

<ul>
  <li>
    <p><strong>Printer</strong> - <code class="language-plaintext highlighter-rouge">deploy_printer_canon_full.sh</code> mounts the DMG, extracts the full <code class="language-plaintext highlighter-rouge">BJPrinter</code> tree and the official gzipped PPD, and copies them into <code class="language-plaintext highlighter-rouge">/Library</code>. A separate script creates a CUPS queue using Bonjour/IPP discovery via <code class="language-plaintext highlighter-rouge">ippfind</code>, so you don’t need to know the printer’s IP address.</p>
  </li>
  <li>
    <p><strong>Scanner</strong> - <code class="language-plaintext highlighter-rouge">deploy_canon_scanner.sh</code> does the equivalent for the ICA scanner bundle: mount, extract, codesign removal (the old signatures fail validation on newer macOS), and install to <code class="language-plaintext highlighter-rouge">/Library/Image Capture/Devices</code>.</p>
  </li>
</ul>

<p>Everything runs through <code class="language-plaintext highlighter-rouge">hdiutil</code>, <code class="language-plaintext highlighter-rouge">pkgutil</code>, and <code class="language-plaintext highlighter-rouge">lpadmin</code> - no third-party dependencies.</p>

<h2 id="quick-start">Quick start</h2>

<p>Clone the repo and run two scripts:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Printer (network/Bonjour)</span>
<span class="nb">cd </span>printer-driver
./install_canon_mg6250_bonjour_network.sh

<span class="c"># Scanner (USB)</span>
<span class="nb">cd</span> ../scanner-driver
./deploy_canon_scanner.sh
</code></pre></div></div>

<p>The printer script finds the device on the network automatically. The scanner requires a USB unplug/replug after install so that Image Capture picks up the new ICA bundle.</p>

<h2 id="tested-on">Tested on</h2>

<p>This has been verified on macOS Tahoe 26.3 (Apple Silicon) for both printing and scanning. The printer filters run under Rosetta 2 - if you haven’t installed it yet, macOS will prompt you.</p>

<h2 id="caveats">Caveats</h2>

<p>Classic PPD-based drivers are living on borrowed time. Apple and the CUPS project are moving toward driverless IPP Everywhere, and a future macOS release could drop PPD support entirely. For now, though, this gets a perfectly good multifunction printer back in service without buying new hardware.</p>

<p>The Canon DMGs are included in the repo for convenience but are Canon’s software under Canon’s license terms, not MIT. If you prefer, you can download them directly from <a href="https://www.canon-europe.com/support/consumer/products/printers/pixma/mg-series/pixma-mg6250.html?type=drivers&amp;language=EN&amp;os=macOS%2010.13%20(High%20Sierra)">Canon’s support site</a> and point the scripts at your copies.</p>

<p>Source: <a href="https://github.com/aioue/canon-mg6250-mac-driver-bundle">aioue/canon-mg6250-mac-driver-bundle</a></p>]]></content><author><name></name></author><category term="macos" /><category term="printing" /><category term="scanning" /><category term="canon" /><category term="drivers" /><summary type="html"><![CDATA[Canon dropped macOS support for the PIXMA MG6250 after High Sierra (10.13). The hardware still works fine, but there are no official drivers for anything newer. I put together canon-mg6250-mac-driver-bundle - a set of shell scripts that extract and install Canon’s last official drivers on current macOS, including Apple Silicon via Rosetta 2.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://aioue.net/og-image.png" /><media:content medium="image" url="https://aioue.net/og-image.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Moving blog comments from Utterances to Giscus</title><link href="https://aioue.net/2026/02/27/utterances-to-giscus/" rel="alternate" type="text/html" title="Moving blog comments from Utterances to Giscus" /><published>2026-02-27T21:00:00+00:00</published><updated>2026-02-27T21:00:00+00:00</updated><id>https://aioue.net/2026/02/27/utterances-to-giscus</id><content type="html" xml:base="https://aioue.net/2026/02/27/utterances-to-giscus/"><![CDATA[<p>This blog used <a href="https://utteranc.es/">Utterances</a> for comments, which stores them as GitHub Issues. It worked well, but I’ve switched to <a href="https://giscus.app/">Giscus</a>, which uses GitHub Discussions instead. The main reason is security.</p>

<h2 id="the-permission-problem">The permission problem</h2>

<p>Utterances is a GitHub OAuth App. When you sign in to leave a comment, it requests the <code class="language-plaintext highlighter-rouge">public_repo</code> scope. That’s a broad permission — it grants read and write access to <strong>all</strong> of your public repositories, not just the one you’re commenting on. If the Utterances token were ever compromised, an attacker could create, modify, or delete issues and code across any of your public repos.</p>

<p>Giscus is a GitHub App, which uses a fundamentally different permission model. It can only access repositories where the owner has explicitly installed it. When you sign in to comment on this blog, Giscus can only interact with this blog’s repository. It has no access to your personal repositories unless you’ve specifically installed Giscus there yourself.</p>

<p>In short: Utterances gets keys to every public room in the building. Giscus only gets keys to the rooms it’s been invited into.</p>

<h2 id="the-migration">The migration</h2>

<p>Straightforward. Enable GitHub Discussions on the repository, install the Giscus app, swap the script tag in the page template, and convert the existing comment threads from Issues to Discussions. Existing comments carried over without any trouble.</p>]]></content><author><name></name></author><category term="github-pages" /><category term="giscus" /><category term="utterances" /><category term="security" /><summary type="html"><![CDATA[This blog used Utterances for comments, which stores them as GitHub Issues. It worked well, but I’ve switched to Giscus, which uses GitHub Discussions instead. The main reason is security.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://aioue.net/og-image.png" /><media:content medium="image" url="https://aioue.net/og-image.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Ansible Proxmox Inventory and DHCP VMs</title><link href="https://aioue.net/2026/02/26/ansible-proxmox-inventory-dhcp-vms/" rel="alternate" type="text/html" title="Ansible Proxmox Inventory and DHCP VMs" /><published>2026-02-26T23:00:00+00:00</published><updated>2026-02-26T23:00:00+00:00</updated><id>https://aioue.net/2026/02/26/ansible-proxmox-inventory-dhcp-vms</id><content type="html" xml:base="https://aioue.net/2026/02/26/ansible-proxmox-inventory-dhcp-vms/"><![CDATA[<p>Fix for <code class="language-plaintext highlighter-rouge">community.general.proxmox</code> (now <code class="language-plaintext highlighter-rouge">community.proxmox.proxmox</code>) dynamic inventory not setting <code class="language-plaintext highlighter-rouge">ansible_host</code> for QEMU VMs with DHCP networking. Without this, you need <code class="language-plaintext highlighter-rouge">-e ansible_host=192.168.1.x</code> every time you target the VM.</p>

<h2 id="the-problem">The Problem</h2>

<p>The Proxmox inventory plugin has <code class="language-plaintext highlighter-rouge">want_proxmox_nodes_ansible_host</code> for nodes, but nothing equivalent for QEMU guests. For VMs using cloud-init with <code class="language-plaintext highlighter-rouge">ip=dhcp</code>, the plugin sets <code class="language-plaintext highlighter-rouge">proxmox_ipconfig0</code> to:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"ip"</span><span class="p">:</span><span class="w"> </span><span class="s2">"dhcp"</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Not an actual address. The documented <code class="language-plaintext highlighter-rouge">compose</code> example from the plugin docs:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">compose</span><span class="pi">:</span>
  <span class="na">ansible_host</span><span class="pi">:</span> <span class="s">proxmox_ipconfig0.ip | default(proxmox_net0.ip) | ipaddr('address')</span>
</code></pre></div></div>

<p>doesn’t help — <code class="language-plaintext highlighter-rouge">"dhcp"</code> isn’t an IP, and <code class="language-plaintext highlighter-rouge">proxmox_net0</code> only contains the MAC address and bridge config, not an IP.</p>

<h2 id="the-solution">The Solution</h2>

<p>If the QEMU guest agent is running (<code class="language-plaintext highlighter-rouge">agent: 1</code> in the VM config), the inventory plugin populates <code class="language-plaintext highlighter-rouge">proxmox_agent_interfaces</code> with live network data from inside the VM. This includes actual DHCP-assigned addresses.</p>

<p>The tricky part: the agent data uses hyphenated keys (<code class="language-plaintext highlighter-rouge">ip-addresses</code>, <code class="language-plaintext highlighter-rouge">mac-address</code>) which Jinja2 interprets as subtraction. You can’t use <code class="language-plaintext highlighter-rouge">map(attribute='ip-addresses')</code> — it parses as <code class="language-plaintext highlighter-rouge">attribute='ip' - addresses</code>. Use <code class="language-plaintext highlighter-rouge">.get('ip-addresses', [])</code> instead.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">compose</span><span class="pi">:</span>
  <span class="na">ansible_host</span><span class="pi">:</span> <span class="pi">&gt;-</span>
    <span class="s">((proxmox_agent_interfaces | default([])</span>
    <span class="s">| selectattr('name', 'equalto', 'eth0') | list | first | default({}))</span>
    <span class="s">.get('ip-addresses', [])</span>
    <span class="s">| ansible.utils.ipv4 | first | default(''))</span>
    <span class="s">| ansible.utils.ipaddr('address')</span>
    <span class="s">| default(ansible_host | default(inventory_hostname, true), true)</span>
</code></pre></div></div>

<p>What this does:</p>

<ol>
  <li>Finds the <code class="language-plaintext highlighter-rouge">eth0</code> interface in <code class="language-plaintext highlighter-rouge">proxmox_agent_interfaces</code></li>
  <li>Extracts its <code class="language-plaintext highlighter-rouge">ip-addresses</code> list (working around the hyphenated key)</li>
  <li>Filters to IPv4 only with <code class="language-plaintext highlighter-rouge">ansible.utils.ipv4</code></li>
  <li>Strips the CIDR suffix (<code class="language-plaintext highlighter-rouge">192.168.1.83/24</code> → <code class="language-plaintext highlighter-rouge">192.168.1.83</code>)</li>
  <li>Falls back to existing <code class="language-plaintext highlighter-rouge">ansible_host</code> or <code class="language-plaintext highlighter-rouge">inventory_hostname</code> for hosts without agent data</li>
</ol>

<p>The <code class="language-plaintext highlighter-rouge">default(..., true)</code> at the end is important — without the second argument, Jinja2’s <code class="language-plaintext highlighter-rouge">default</code> only triggers on undefined variables, not on <code class="language-plaintext highlighter-rouge">None</code> or <code class="language-plaintext highlighter-rouge">False</code>. The <code class="language-plaintext highlighter-rouge">ipaddr</code> filter returns <code class="language-plaintext highlighter-rouge">False</code> for invalid input, and <code class="language-plaintext highlighter-rouge">want_proxmox_nodes_ansible_host</code> can set <code class="language-plaintext highlighter-rouge">ansible_host</code> to <code class="language-plaintext highlighter-rouge">None</code>.</p>

<h2 id="requirements">Requirements</h2>

<ul>
  <li>QEMU guest agent installed and running in the VM</li>
  <li><code class="language-plaintext highlighter-rouge">agent: 1</code> in the VM config (so Proxmox queries the agent)</li>
  <li><code class="language-plaintext highlighter-rouge">want_facts: true</code> in the inventory plugin config</li>
  <li><code class="language-plaintext highlighter-rouge">ansible.utils</code> collection (included with the full <code class="language-plaintext highlighter-rouge">ansible</code> package)</li>
  <li><code class="language-plaintext highlighter-rouge">netaddr</code> Python library (pulled in as a dependency of <code class="language-plaintext highlighter-rouge">ansible.utils</code>)</li>
</ul>

<h2 id="verification">Verification</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ansible-inventory <span class="nt">-i</span> inventory/pve.proxmox.yaml <span class="nt">--list</span> 2&gt;/dev/null <span class="se">\</span>
  | python3 <span class="nt">-c</span> <span class="s2">"
import json, sys
data = json.load(sys.stdin)
for host, vars in data['_meta']['hostvars'].items():
    print(f</span><span class="se">\"</span><span class="s2">{host}: {vars.get('ansible_host', 'NOT SET')}</span><span class="se">\"</span><span class="s2">)"</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>homeassistant: homeassistant
pve: pve
tank: tank
ubuntu-cloud: 192.168.1.83
</code></pre></div></div>]]></content><author><name></name></author><category term="ansible" /><category term="proxmox" /><category term="inventory" /><category term="qemu" /><category term="dhcp" /><summary type="html"><![CDATA[Fix for community.general.proxmox (now community.proxmox.proxmox) dynamic inventory not setting ansible_host for QEMU VMs with DHCP networking. Without this, you need -e ansible_host=192.168.1.x every time you target the VM.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://aioue.net/og-image.png" /><media:content medium="image" url="https://aioue.net/og-image.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Cambodia Travel Log</title><link href="https://aioue.net/2026/01/29/cambodia-travel-log/" rel="alternate" type="text/html" title="Cambodia Travel Log" /><published>2026-01-29T08:55:00+00:00</published><updated>2026-01-29T08:55:00+00:00</updated><id>https://aioue.net/2026/01/29/cambodia-travel-log</id><content type="html" xml:base="https://aioue.net/2026/01/29/cambodia-travel-log/"><![CDATA[<h2 id="2026-01-29-1555-ict-indochina-time">2026-01-29 15:55 [ICT] (Indochina Time)</h2>

<p>Travel here not as hard work as expected. Singapore Air flights were comfortable and the 12+ hour flight to Singapore went surprisingly quickly. Food was particularly tasty. Entertainment system worked with the airline adapter cable provided by my Sennheiser headphones. Noise cancelling made the audio for media easy to hear without boosting the volume. No discernible impact on 🦻T.</p>

<p>M and C very welcoming hosts. They have a big house with a pool. 3 ginger cats called John Cena, John Malkovich (Meowkovich?), and Captain Nibbles the Third.</p>

<p>Little excursion to a bar last night, C and I excused ourselves relatively early and I settled in for a big jet-lag sleep until 11:00 this morning.</p>

<p>Noodles and some marmite on toast (brought on request) for brekkie. Coffee via ‘Grab’ (like Uber+Uber Eats combined).</p>

<p>We went for a sauna and swim on the scooter before work for $4.50. The roads are ‘exciting’ but not crazy busy.</p>

<p>It’s sunny and warm here. I’m sitting on my bed with fan on and the shutters open doing some work. I can hear bird tweeting outside and a cockerel just doodle-do’d! My mood has improved enormously.</p>

<p>Photo album link available on request.</p>

<h2 id="2026-01-30-1041-ict-indochina-time">2026-01-30 10:41 [ICT] (Indochina Time)</h2>

<p>M &amp; C went to a birthday early evening while I was working. There is quite a big ex-pat community that they are friends with. Tied to drinking culture, although there is gym, golf, bouldering etc that brings people together. They host poker and board games at their house.</p>

<p>We had a swim in the pool and hung out when they got back. Everyone packed up around midnight. I didn’t sleep super well, possibly due to the different ADHD meds I’m taking here. I was concerned about bringing potentially controlled items across a border - it was difficult to work out what was ok or not. Anyway, nothing a good few laps of the pool won’t sort out.</p>

<p>Ordered some breakfast on the local Grab app. Apparently everything comes with rice so we might have a bit of a surplus! Ordered 6 items (some for lunch while I’m working - £11.50 including delivery). Would probably be pushing £40 in the UK.</p>

<p>I’m being a conscientious, clean, and tidy house guest. I’m hoping to visit the shops with them today/tomorrow to pick up some essentials like bread (apparently the bread is good due to French colonial influences) and other bits so we’re not ordering every day.</p>

<p>Am quite content to just work and rest. Much the same as the UK but warm and bright out. The work day did get a little tricky finishing at ~2100-2200 but I suspect a good portion of that was jet lag. I didn’t really need to wake up so early, and in fact would probably fall back to my normal UK mode of 10AM starts alarms, if I wasn’t with company.</p>

<p>–Resumed ~15:00</p>

<p>We drove motorcycles to the city to get some coffee, have a walk, and do some shopping. I’m not insured for driving even a scooter since I don’t hold a full license, but the speeds are low enough I accepted the risk. The roads are somewhat chaotic, but ultimately relatively chill. The only dangerous person I saw was a white guy pulling out of a bar in front of someone.</p>

<p>We got coffees (and a lychee tea for myself) at a clean air-conditioned place. Then I went with C to a newly opened shopping place that might as well have been Marks and Spencer. Eggs, bread, and some cheeky Korean chocolate ice cream later, C took a tuk tuk back with the shopping and I rode home.</p>

<p>Time for work. I’m going to hopefully sleep well this evening - fading a little and it’s only the ‘start’ of the work day at 3PM.</p>

<h2 id="2026-02-02-1649-ict">2026-02-02 16:49 [ICT]</h2>

<p>It’s Monday and I’m working again. Today we went to a different spa type place for sauna and plunge pool. It wasn’t very busy, half full with ex-pats who I assume are Cambodian or Chinese businessmen. I guess this is how rich people live in the UK - going to places where everything is quite cozy and not super busy.</p>

<p>The sauna and steam room had herbs into colander things over the rocks making them smell alternately like lemongrass and the curry I had for breakfast.</p>

<p>You could get a coconut opened for you with a straw in. It was very nice.</p>

<p>We then went to a ‘healthy’ restaurant for some lunch. Turmeric in everything.</p>

<p>–</p>

<p>The Saturday was fun, we started off with a massage. You change into a tshirt and Cambodian pantaloons - very wide waisted which I attempted to tie a knot into. The lady ended up doing it for me, you fold it over then roll it down, much like a towel in a gym.</p>

<p>The massage options were Thai and Khmer (“kam-eye”), and possibly both have stretch and no-stretch options. You can also have oil or no-oil. I initially opted for no-oil no-stretch which seemed to be Thai, but the lady seemed disappointed to do no-stretch so we swapped to stretch. It was moderate intensity, and a full body treatment with arms and legs being stretched - like a lay-down exercise warm-up, interleaved with ‘normal’ massage. At some points the person sat in the middle of my legs and held a foot while pressing against my thigh - quite physical! As someone who doesn’t get touched very much in my life it was surprisingly easy to relax and go with the flow. I sometimes get a massage in Norwich, maybe twice a year, and this felt less “trained”, but nonetheless very nice.</p>

<p>After that we drove on the motorcycles to a dumpling place. Very good dumplings, big portions for like $6. We met up with some ex-pats there and then all went to play minigolf on the edge of town. Good music at the golf, lot of classic rock. Then went to a bar owned by a friend of theirs for drinks and snacks, then another bar. Good chats and some pool - American sized table, but UK sized balls and cues which made it quite tricky.</p>

<p>Then we went home and chilled out before bed.</p>

<p>Sunday was a very lazy day. I had paced myself quite well - only had 6 beers or so the whole day but I still needed 10 hours kip to recharge. M and I ordered hearty UK style pies for dinner. C had a curry. M played a bit of Arc Raiders on the playstation and we then retired to Bedfordshire.</p>]]></content><author><name></name></author><category term="personal" /><category term="travel" /><category term="holiday" /><summary type="html"><![CDATA[2026-01-29 15:55 [ICT] (Indochina Time)]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://aioue.net/og-image.png" /><media:content medium="image" url="https://aioue.net/og-image.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Ubuntu Desktop VM for RDP on OpenNebula</title><link href="https://aioue.net/2026/01/21/wayland-gnome-remote-desktop-under-opennebula/" rel="alternate" type="text/html" title="Ubuntu Desktop VM for RDP on OpenNebula" /><published>2026-01-21T17:00:00+00:00</published><updated>2026-01-21T17:00:00+00:00</updated><id>https://aioue.net/2026/01/21/wayland-gnome-remote-desktop-under-opennebula</id><content type="html" xml:base="https://aioue.net/2026/01/21/wayland-gnome-remote-desktop-under-opennebula/"><![CDATA[<p>Transform the <a href="https://marketplace.opennebula.io/">OpenNebula Marketplace</a> Ubuntu Server 24.04 image into an RDP-capable desktop VM. This is part 1 of getting RDP working — see <a href="/2026/01/21/gnome-remote-desktop-rdp-ubuntu-24.04/">part 2 for GNOME Remote Desktop configuration</a>.</p>

<h2 id="vm-template-configuration">VM Template Configuration</h2>

<h3 id="videographics-support">Video/Graphics Support</h3>

<p>Add to your OpenNebula template before starting the VM:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>VIDEO = [
  TYPE = "virtio",
  VRAM = "187500" ]
</code></pre></div></div>

<p>VirtIO video adapter with ~183 MB VRAM for desktop environments and RDP sessions.</p>

<h3 id="network-configuration-critical-for-rdp">Network Configuration (Critical for RDP)</h3>

<p><strong>This solved RDP connecting but showing only a black screen.</strong></p>

<p>Switch to NetworkManager instead of systemd-networkd:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CONTEXT = [
  ...
  NETCFG_TYPE = "nm",
  ... ]
</code></pre></div></div>

<p>Without this, RDP connections establish but the desktop session fails to render.</p>

<h2 id="software-changes">Software Changes</h2>

<h3 id="desktop-environment">Desktop Environment</h3>

<p>Install the minimal GNOME desktop:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>apt <span class="nb">install</span> <span class="nt">--no-install-recommends</span> ubuntu-desktop-minimal
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">--no-install-recommends</code> flag significantly reduces install time.</p>

<h3 id="remove-cloud-init">Remove cloud-init</h3>

<p>Prevent conflicts with OpenNebula contextualisation:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>apt remove cloud-init
apt-mark hold cloud-init
</code></pre></div></div>

<h3 id="set-default-boot-target">Set Default Boot Target</h3>

<p>Change from CLI to graphical boot:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>systemctl set-default graphical.target
</code></pre></div></div>

<p>Required when converting a server install to desktop.</p>

<p>Shutdown and save your VM as a new desktop template, <a href="/2026/01/21/gnome-remote-desktop-rdp-ubuntu-24.04/">start up a new VM with it, and proceed with Part 2</a></p>

<h2 id="troubleshooting-black-screen">Troubleshooting: Black Screen</h2>

<p>If RDP connects but shows only black:</p>

<ol>
  <li>Verify <code class="language-plaintext highlighter-rouge">NETCFG_TYPE = "nm"</code> in the VM template CONTEXT section</li>
  <li>Check NetworkManager: <code class="language-plaintext highlighter-rouge">systemctl status NetworkManager</code></li>
  <li>Verify boot target: <code class="language-plaintext highlighter-rouge">systemctl get-default</code> should return <code class="language-plaintext highlighter-rouge">graphical.target</code></li>
</ol>]]></content><author><name></name></author><category term="opennebula" /><category term="ubuntu" /><category term="rdp" /><category term="gnome-remote-desktop" /><category term="virtualization" /><summary type="html"><![CDATA[Transform the OpenNebula Marketplace Ubuntu Server 24.04 image into an RDP-capable desktop VM. This is part 1 of getting RDP working — see part 2 for GNOME Remote Desktop configuration.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://aioue.net/og-image.png" /><media:content medium="image" url="https://aioue.net/og-image.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Jekyll Minima remote_theme CSS not loading on GitHub Pages</title><link href="https://aioue.net/2026/01/21/jekyll-minima-remote-theme-css-not-loading/" rel="alternate" type="text/html" title="Jekyll Minima remote_theme CSS not loading on GitHub Pages" /><published>2026-01-21T16:00:00+00:00</published><updated>2026-01-21T16:00:00+00:00</updated><id>https://aioue.net/2026/01/21/jekyll-minima-remote-theme-css-not-loading</id><content type="html" xml:base="https://aioue.net/2026/01/21/jekyll-minima-remote-theme-css-not-loading/"><![CDATA[<p>Fix for Jekyll sites using <code class="language-plaintext highlighter-rouge">remote_theme: jekyll/minima</code> where CSS loads on the homepage but not on post pages.</p>

<h2 id="the-problem">The Problem</h2>

<p>Homepage styled correctly. Post pages completely bare — no CSS at all. The <code class="language-plaintext highlighter-rouge">&lt;head&gt;</code> section was empty except for Utterances styles injected by JavaScript.</p>

<h2 id="the-cause">The Cause</h2>

<p>Custom <code class="language-plaintext highlighter-rouge">_includes/head.html</code> wasn’t being included because the remote theme’s <code class="language-plaintext highlighter-rouge">default.html</code> layout wasn’t being overridden locally.</p>

<p>When you use <code class="language-plaintext highlighter-rouge">remote_theme</code>, Jekyll pulls the theme files from GitHub. But if you have a custom <code class="language-plaintext highlighter-rouge">_includes/head.html</code>, you also need a local <code class="language-plaintext highlighter-rouge">_layouts/default.html</code> that explicitly includes it.</p>

<h2 id="the-fix">The Fix</h2>

<p>Create <code class="language-plaintext highlighter-rouge">_layouts/default.html</code>:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;!DOCTYPE html&gt;</span>
<span class="nt">&lt;html</span> <span class="na">lang=</span><span class="s">"{{ page.lang | default: site.lang | default: "</span><span class="na">en</span><span class="err">"</span> <span class="err">}}"</span><span class="nt">&gt;</span>
  {%- include head.html -%}
  <span class="nt">&lt;body&gt;</span>
    {%- include header.html -%}
    <span class="nt">&lt;main</span> <span class="na">class=</span><span class="s">"page-content"</span> <span class="na">aria-label=</span><span class="s">"Content"</span><span class="nt">&gt;</span>
      <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"wrapper"</span><span class="nt">&gt;</span>
        {{ content }}
      <span class="nt">&lt;/div&gt;</span>
    <span class="nt">&lt;/main&gt;</span>
    {%- include footer.html -%}
  <span class="nt">&lt;/body&gt;</span>
<span class="nt">&lt;/html&gt;</span>
</code></pre></div></div>

<p>Create <code class="language-plaintext highlighter-rouge">assets/main.scss</code>:</p>

<div class="language-scss highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">---</span>
<span class="nt">---</span>

<span class="o">@</span><span class="nt">import</span>
  <span class="s2">"minima/skins/{{ site.minima.skin | default: 'classic' }}"</span><span class="o">,</span>
  <span class="s2">"minima/initialize"</span>
<span class="p">;</span>
</code></pre></div></div>

<p>Your <code class="language-plaintext highlighter-rouge">_includes/head.html</code> should link to <code class="language-plaintext highlighter-rouge">/assets/main.css</code>:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;link</span> <span class="na">rel=</span><span class="s">"stylesheet"</span> <span class="na">href=</span><span class="s">"{{ "</span><span class="err">/</span><span class="na">assets</span><span class="err">/</span><span class="na">main.css</span><span class="err">"</span> <span class="err">|</span> <span class="na">relative_url</span> <span class="err">}}"</span><span class="nt">&gt;</span>
</code></pre></div></div>

<h2 id="what-not-to-do">What NOT to Do</h2>

<p>Don’t create <code class="language-plaintext highlighter-rouge">assets/css/style.scss</code> with <code class="language-plaintext highlighter-rouge">@import "minima"</code> — this causes build failures because <code class="language-plaintext highlighter-rouge">minima</code> isn’t in the Sass load path when using <code class="language-plaintext highlighter-rouge">remote_theme</code>.</p>

<h2 id="debugging-tips">Debugging Tips</h2>

<ul>
  <li>Check GitHub Actions for build failures</li>
  <li>Add <code class="language-plaintext highlighter-rouge">?nocache=123</code> to URLs to bypass browser cache</li>
  <li>View page source to verify <code class="language-plaintext highlighter-rouge">&lt;head&gt;</code> contains stylesheet links</li>
  <li>Check <code class="language-plaintext highlighter-rouge">/assets/main.css</code> returns actual CSS, not a 404 page</li>
</ul>]]></content><author><name></name></author><category term="jekyll" /><category term="github-pages" /><category term="minima" /><category term="css" /><summary type="html"><![CDATA[Fix for Jekyll sites using remote_theme: jekyll/minima where CSS loads on the homepage but not on post pages.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://aioue.net/og-image.png" /><media:content medium="image" url="https://aioue.net/og-image.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Headless Wayland RDP with GNOME Remote Desktop on Ubuntu 24.04</title><link href="https://aioue.net/2026/01/21/gnome-remote-desktop-rdp-ubuntu-24.04/" rel="alternate" type="text/html" title="Headless Wayland RDP with GNOME Remote Desktop on Ubuntu 24.04" /><published>2026-01-21T15:00:00+00:00</published><updated>2026-01-23T00:00:00+00:00</updated><id>https://aioue.net/2026/01/21/gnome-remote-desktop-rdp-ubuntu-24.04</id><content type="html" xml:base="https://aioue.net/2026/01/21/gnome-remote-desktop-rdp-ubuntu-24.04/"><![CDATA[<p>Set up headless multi-user RDP access on Ubuntu 24.04 using GNOME Remote Desktop, following <a href="https://gitlab.gnome.org/GNOME/gnome-remote-desktop">official GNOME documentation</a>. Ansible configuration below.</p>

<p>If you’re working on OpenNebula <a href="/2026/01/21/wayland-gnome-remote-desktop-under-opennebula/">you’ll need to modify the template first</a>.</p>

<h2 id="overview">Overview</h2>

<p>GNOME Remote Desktop provides native RDP support in Ubuntu, making it ideal for:</p>

<ul>
  <li>Headless VM access via Apache Guacamole</li>
  <li>Multi-user remote login to a graphical desktop</li>
  <li>Hardware-accelerated graphics with virtio-gpu</li>
</ul>

<h2 id="ansible-config">Ansible Config</h2>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Configure Ubuntu desktop for Guacamole RDP access</span>
<span class="c1"># This role sets up a secure RDP connection via GNOME Remote Desktop service</span>
<span class="c1"># for seamless integration with Apache Guacamole HTML5 remote desktop gateway.</span>

<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Install packages</span>
  <span class="na">become</span><span class="pi">:</span> <span class="no">true</span>
  <span class="na">ansible.builtin.apt</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s2">"</span><span class="s">{{</span><span class="nv"> </span><span class="s">package_name</span><span class="nv"> </span><span class="s">}}"</span>
    <span class="na">autoremove</span><span class="pi">:</span> <span class="no">true</span>
    <span class="na">state</span><span class="pi">:</span> <span class="s">present</span>
  <span class="na">loop</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">winpr-utils</span> <span class="c1"># Required for TLS certificate generation</span>
    <span class="pi">-</span> <span class="s">gnome-remote-desktop</span> <span class="c1"># Provides the remote desktop service</span>
    <span class="c1"># Packages for hardware-accelerated graphics with virtio-gpu</span>
    <span class="pi">-</span> <span class="s">mesa-utils</span>
    <span class="pi">-</span> <span class="s">mesa-vulkan-drivers</span>
    <span class="pi">-</span> <span class="s">libgl1-mesa-dri</span>
  <span class="na">loop_control</span><span class="pi">:</span>
    <span class="na">loop_var</span><span class="pi">:</span> <span class="s">package_name</span>
  <span class="na">notify</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">Reboot if needed</span>

<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Allow client to pass locale environment variables and timezone</span>
  <span class="na">become</span><span class="pi">:</span> <span class="no">true</span>
  <span class="na">lineinfile</span><span class="pi">:</span>
    <span class="na">path</span><span class="pi">:</span> <span class="s">/etc/ssh/sshd_config</span>
    <span class="na">regexp</span><span class="pi">:</span> <span class="s1">'</span><span class="s">^#?AcceptEnv'</span>
    <span class="na">line</span><span class="pi">:</span> <span class="s1">'</span><span class="s">AcceptEnv</span><span class="nv"> </span><span class="s">LANG</span><span class="nv"> </span><span class="s">LC_*</span><span class="nv"> </span><span class="s">TZ'</span>
    <span class="na">state</span><span class="pi">:</span> <span class="s">present</span>
    <span class="na">create</span><span class="pi">:</span> <span class="no">false</span>
  <span class="na">notify</span><span class="pi">:</span> <span class="s">restart SSH</span>

<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Enable PasswordAuthentication for SSH</span>
  <span class="na">become</span><span class="pi">:</span> <span class="no">true</span>
  <span class="na">lineinfile</span><span class="pi">:</span>
    <span class="na">path</span><span class="pi">:</span> <span class="s">/etc/ssh/sshd_config</span>
    <span class="na">regexp</span><span class="pi">:</span> <span class="s1">'</span><span class="s">^#?PasswordAuthentication'</span>
    <span class="na">line</span><span class="pi">:</span> <span class="s1">'</span><span class="s">PasswordAuthentication</span><span class="nv"> </span><span class="s">yes'</span>
    <span class="na">state</span><span class="pi">:</span> <span class="s">present</span>
    <span class="na">create</span><span class="pi">:</span> <span class="no">false</span>
  <span class="na">notify</span><span class="pi">:</span> <span class="s">restart SSH</span>

<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Enable linger for admin user to maintain graphical session after reboot</span>
  <span class="na">become</span><span class="pi">:</span> <span class="no">true</span>
  <span class="na">command</span><span class="pi">:</span> <span class="s">loginctl enable-linger {{ guacamole_guest_admin_user }}</span>
  <span class="na">changed_when</span><span class="pi">:</span> <span class="no">false</span>

<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Set graphical target as default boot target</span>
  <span class="na">become</span><span class="pi">:</span> <span class="no">true</span>
  <span class="na">systemd</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">graphical.target</span>
    <span class="na">enabled</span><span class="pi">:</span> <span class="no">true</span>

<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Remove nomodeset from GRUB to enable graphics driver detection</span>
  <span class="na">become</span><span class="pi">:</span> <span class="no">true</span>
  <span class="na">lineinfile</span><span class="pi">:</span>
    <span class="na">path</span><span class="pi">:</span> <span class="s">/etc/default/grub</span>
    <span class="na">regexp</span><span class="pi">:</span> <span class="s1">'</span><span class="s">^GRUB_CMDLINE_LINUX_DEFAULT='</span>
    <span class="na">line</span><span class="pi">:</span> <span class="s1">'</span><span class="s">GRUB_CMDLINE_LINUX_DEFAULT="quiet</span><span class="nv"> </span><span class="s">text"'</span>
    <span class="na">backrefs</span><span class="pi">:</span> <span class="no">true</span>

<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Update GRUB configuration to apply boot parameter changes</span>
  <span class="na">become</span><span class="pi">:</span> <span class="no">true</span>
  <span class="na">command</span><span class="pi">:</span> <span class="s">update-grub</span>
  <span class="na">notify</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">Reboot if needed</span>

<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Apply immediate reboot if required by configuration changes</span>
  <span class="na">meta</span><span class="pi">:</span> <span class="s">flush_handlers</span>

<span class="c1"># TLS certificate generation for secure RDP connections</span>
<span class="c1"># https://gitlab.gnome.org/GNOME/gnome-remote-desktop#tls-key-and-certificate-generation</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Generate TLS certificate and key for GNOME Remote Desktop</span>
  <span class="na">become</span><span class="pi">:</span> <span class="no">true</span>
  <span class="na">command</span><span class="pi">:</span> <span class="s">sudo -u gnome-remote-desktop sh -c 'winpr-makecert -silent -rdp -path ~/.local/share/gnome-remote-desktop tls'</span>

<span class="c1"># Configure GNOME Remote Desktop for headless multi-user remote login</span>
<span class="c1"># https://gitlab.gnome.org/GNOME/gnome-remote-desktop#headless-multi-user-remote-login</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Configure TLS key for RDP connections</span>
  <span class="na">become</span><span class="pi">:</span> <span class="no">true</span>
  <span class="na">command</span><span class="pi">:</span> <span class="s">sudo grdctl --system rdp set-tls-key ~gnome-remote-desktop/.local/share/gnome-remote-desktop/tls.key</span>

<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Configure TLS certificate for RDP connections</span>
  <span class="na">become</span><span class="pi">:</span> <span class="no">true</span>
  <span class="na">command</span><span class="pi">:</span> <span class="s">sudo grdctl --system rdp set-tls-cert ~gnome-remote-desktop/.local/share/gnome-remote-desktop/tls.crt</span>

<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Set RDP access credentials</span>
  <span class="na">become</span><span class="pi">:</span> <span class="no">true</span>
  <span class="na">command</span><span class="pi">:</span> <span class="s">sudo grdctl --system rdp set-credentials rdpuser rdppass</span>

<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Enable interactive input control for RDP sessions</span>
  <span class="na">become</span><span class="pi">:</span> <span class="no">true</span>
  <span class="na">command</span><span class="pi">:</span> <span class="s">sudo grdctl --system rdp disable-view-only</span>

<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Enable RDP protocol support in GNOME Remote Desktop</span>
  <span class="na">become</span><span class="pi">:</span> <span class="no">true</span>
  <span class="na">command</span><span class="pi">:</span> <span class="s">sudo grdctl --system rdp enable</span>

<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Enable GNOME Remote Desktop service to start at boot</span>
  <span class="na">become</span><span class="pi">:</span> <span class="no">true</span>
  <span class="na">command</span><span class="pi">:</span> <span class="s">sudo systemctl enable --now gnome-remote-desktop.service</span>

<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Restart GNOME Remote Desktop service to apply all configurations</span>
  <span class="na">become</span><span class="pi">:</span> <span class="no">true</span>
  <span class="na">command</span><span class="pi">:</span> <span class="s">sudo systemctl restart --now gnome-remote-desktop.service</span>
</code></pre></div></div>

<h2 id="key-steps-explained">Key Steps Explained</h2>

<h3 id="1-package-installation">1. Package Installation</h3>

<p>The <code class="language-plaintext highlighter-rouge">winpr-utils</code> package provides <code class="language-plaintext highlighter-rouge">winpr-makecert</code> for generating TLS certificates. The Mesa packages enable hardware-accelerated graphics when running on VMs with virtio-gpu.</p>

<h3 id="2-session-persistence">2. Session Persistence</h3>

<p><code class="language-plaintext highlighter-rouge">loginctl enable-linger</code> ensures user sessions survive after logout, which is essential for headless RDP access.</p>

<h3 id="3-boot-configuration">3. Boot Configuration</h3>

<p>Setting <code class="language-plaintext highlighter-rouge">graphical.target</code> and removing <code class="language-plaintext highlighter-rouge">nomodeset</code> from GRUB ensures the system boots into a graphical environment with proper driver detection. Not needed if you started off with a Desktop system rather than installing <code class="language-plaintext highlighter-rouge">ubuntu-desktop-minimal</code> <a href="/2026/01/21/wayland-gnome-remote-desktop-under-opennebula/">over a server install</a>.</p>

<h3 id="4-tls-certificates">4. TLS Certificates</h3>

<p>Certificates are generated using <code class="language-plaintext highlighter-rouge">winpr-makecert</code> and stored in <code class="language-plaintext highlighter-rouge">~gnome-remote-desktop/.local/share/gnome-remote-desktop/</code>. Running as the <code class="language-plaintext highlighter-rouge">gnome-remote-desktop</code> user ensures correct ownership.</p>

<h3 id="5-system-wide-rdp">5. System-Wide RDP</h3>

<p>Using <code class="language-plaintext highlighter-rouge">grdctl --system</code> configures the system-wide RDP service for multi-user headless access, as opposed to per-user desktop sharing.</p>

<h2 id="verifying-the-setup">Verifying the Setup</h2>

<p>After running the playbook, verify the configuration:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>grdctl <span class="nt">--system</span> status <span class="nt">--show-credentials</span>
</code></pre></div></div>

<p>You should see:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Overall:
    Unit status: active
RDP:
    Status: enabled
    Port: 3389
    TLS certificate: /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop/tls.crt
    TLS key: /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop/tls.key
    Username: rdpuser
    Password: rdppass
</code></pre></div></div>

<h2 id="macos-rdp-client-fix">macOS RDP Client Fix</h2>

<p>If using Windows App (formerly Microsoft Remote Desktop) on macOS, edit your <code class="language-plaintext highlighter-rouge">.rdp</code> file:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>use redirection server name:i:1
</code></pre></div></div>

<p>See: <a href="https://www.reddit.com/r/Ubuntu/comments/1n8pq1e/rdp_to_ubuntu_from_the_windows_app_on_macos/">https://www.reddit.com/r/Ubuntu/comments/1n8pq1e/rdp_to_ubuntu_from_the_windows_app_on_macos/</a></p>

<h2 id="ubuntu-2510-considerations">Ubuntu 25.10 Considerations</h2>

<p>A <a href="https://ezone.co.uk/blog/working-headless-rdp-with-gnome-remote-desktop-on-ubuntu-25-10.html">detailed troubleshooting guide for Ubuntu 25.10</a> documents some differences that may require changes:</p>

<table>
  <thead>
    <tr>
      <th>Aspect</th>
      <th>Ubuntu 24.04</th>
      <th>Ubuntu 25.10</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Certificate tool</td>
      <td><code class="language-plaintext highlighter-rouge">winpr-makecert</code></td>
      <td>OpenSSL (alternative)</td>
    </tr>
    <tr>
      <td>Certificate path</td>
      <td><code class="language-plaintext highlighter-rouge">~gnome-remote-desktop/.local/share/gnome-remote-desktop/</code></td>
      <td><code class="language-plaintext highlighter-rouge">/var/lib/gnome-remote-desktop/</code></td>
    </tr>
    <tr>
      <td>Permissions</td>
      <td>Implicit (runs as gnome-remote-desktop user)</td>
      <td>May need explicit <code class="language-plaintext highlighter-rouge">chmod 600</code>/<code class="language-plaintext highlighter-rouge">644</code></td>
    </tr>
  </tbody>
</table>

<h3 id="openssl-alternative-for-certificates">OpenSSL Alternative for Certificates</h3>

<p>If <code class="language-plaintext highlighter-rouge">winpr-utils</code> becomes unavailable or problematic, use OpenSSL instead:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>openssl req <span class="nt">-newkey</span> rsa:2048 <span class="nt">-nodes</span> <span class="se">\</span>
    <span class="nt">-keyout</span> /var/lib/gnome-remote-desktop/rdp-tls.key <span class="se">\</span>
    <span class="nt">-x509</span> <span class="nt">-days</span> 365 <span class="se">\</span>
    <span class="nt">-out</span> /var/lib/gnome-remote-desktop/rdp-tls.crt <span class="se">\</span>
    <span class="nt">-subj</span> <span class="s2">"/CN=</span><span class="si">$(</span><span class="nb">hostname</span><span class="si">)</span><span class="s2">"</span>

<span class="nb">sudo chown </span>gnome-remote-desktop:gnome-remote-desktop /var/lib/gnome-remote-desktop/rdp-tls.<span class="k">*</span>
<span class="nb">sudo chmod </span>600 /var/lib/gnome-remote-desktop/rdp-tls.key
<span class="nb">sudo chmod </span>644 /var/lib/gnome-remote-desktop/rdp-tls.crt
</code></pre></div></div>

<p>Approach is fundamentally the same across versions. Test on 25.10 before upgrading production systems.</p>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://gitlab.gnome.org/GNOME/gnome-remote-desktop">GNOME Remote Desktop GitLab</a></li>
  <li><a href="https://ezone.co.uk/blog/working-headless-rdp-with-gnome-remote-desktop-on-ubuntu-25-10.html">Ubuntu 25.10 RDP Guide</a></li>
</ul>]]></content><author><name></name></author><category term="gnome-remote-desktop" /><category term="ansible" /><category term="guacamole" /><category term="rdp" /><summary type="html"><![CDATA[Set up headless multi-user RDP access on Ubuntu 24.04 using GNOME Remote Desktop, following official GNOME documentation. Ansible configuration below.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://aioue.net/og-image.png" /><media:content medium="image" url="https://aioue.net/og-image.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>