<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>News on mig5</title>
    <link>https://mig5.net/news/</link>
    <description>Recent content from News on mig5</description>
    <generator>Hugo -- gohugo.io</generator>
    <language>en-au</language>
    <lastBuildDate>Thu, 08 Oct 2026 00:00:00 +1100</lastBuildDate><atom:link href="https://mig5.net/news/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>KEEN: turn everyday work into evidence of assurance</title>
      <link>https://mig5.net/news/keen-evidence-first/</link>
      <pubDate>Thu, 08 Oct 2026 00:00:00 +1100</pubDate>
      <guid>https://mig5.net/news/keen-evidence-first/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-diagram-3"></i><span>Key Evidence Engine</span></div>
    <h1 class="display-6 fw-semibold mb-2">Evidence first. Assurance connected.</h1>
    <p class="text-muted mb-3"><time datetime="2026-10-08">October 8, 2026</time></p>
    <p class="lead mb-0">Bring the work your organisation does, the requirements it follows and the evidence it produces into one connected workspace.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-10-08T00:00:00&#43;11:00">8 October 2026</time>
        </div>

<figure class="text-center my-4">
  <a href="/news/keen-evidence-first/keen-alligator_hu1585701652004123695.png" class="image-lightbox" title="Click to view larger">
    <img src="/news/keen-evidence-first/keen-alligator_hu12323903436971681633.png" alt="The KEEN Mitigator, a friendly purple alligator holding an auditing checklist" width="380" height="380" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
</figure>

<h2 id="show-me-the-evidence">Show me the evidence!</h2>
<p>An organisation running their operations according to a framework (such as ISO27001, SOC-2 etc) produces evidence every day: software upgrade logs, a reviewed change, the act of someone SSHing in from a VPN with an SSH key, an Ansible playbook run, a successful restore test, an access review, a supplier update.</p>
<p>Making that material useful for assurance takes context. Which requirement does it support? Which service and period does it cover? Who reviewed it? What needs to improve?</p>
<p>It also is recorded 'as it happens', whereas frameworks tend to be structured very strictly through 'Controls' and 'Clauses'. The order of these Controls makes sense to an auditor's brain, but the <em>reality</em> down here in the trenches is different.</p>
<p>I've spent a lot of time preparing for and undergoing audits with organisations. I would assemble all the most relevant procedures and other documents which explain how infrastructure is managed. I'd collect screenshots or hyperlinks to really good examples of CI/CD jobs, Pull Requests, and other records. However, come the actual audit time, I'd find out that the auditor wants to start with a specific Control. Very quickly, we would be jumping all over the place as I scrambled to find the right thing to show.</p>
<p>The frustrating part has always been that I <em>know</em> the evidence is there. But the order in which it made sense to me to show it, was not the way the auditor would see it.</p>
<p>Or, I'd show a document saying what we are <em>supposed</em> to do, but the act of proving that's what we <em>do</em> do, can be trickier.</p>
<p>Wouldn't it be great if all the actual raw evidence was already there <em>and</em> already mapped appropriately to the relevant Control?</p>
<p>Then the auditor could simply pick the Control they're interested in, immediately see evidence, with contextual remarks explaining why that evidence is relevant to the Control (since often evidence is very 'raw' and it needs context). They could then 'sample' that evidence, ideally very safely (e.g with redactions if necessary, as well as headers/footers annotating where it has come from).</p>
<p>You'd also ideally want that evidence to be near 'tamper-proof', e.g in some sort of immutable storage that helps assure its provenance.</p>
<p>And why not also map other 'entities' to Controls, such as the documents themselves, the recognised risks, the assets in your asset tracking system?</p>
<p>To actually <em>ingest</em> that evidence and have it automatically associated with your framework is really demonstrating that you aren't just compliant: instead, your organisation is demonstrating that it <em>lives and breathes</em> its ISMS, day in, day out.</p>
<p>So, I created <strong>KEEN - the Key Evidence Engine</strong> to connect those records with frameworks, risks, audits and the Information Security Management System (ISMS). Start with an event, trace it to a control, inspect the supporting material and record your assessment.</p>
<p>KEEN is <a href="https://github.com/FERCBusters/keen" target="_blank" rel="noopener noreferrer">free and open source software</a>
. You can run it on your own infrastructure, on-premises or in the cloud, and shape its framework definitions and evidence rules around your organisation.</p>
<figure class="text-center my-4">
  <a href="/news/keen-evidence-first/workspace_hu8317076932221745140.jpg" class="image-lightbox" title="Click to view larger">
    <img src="/news/keen-evidence-first/workspace_hu2039741748858366928.jpg" alt="KEEN assurance workspace with evidence coverage and items requiring attention" width="1100" height="768" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">An overview of your selected framework, its evidence coverage and work requiring attention. Screenshots use fictional example records.</figcaption>
</figure>

<h2 id="where-keen-fits-alongside-other-products">Where KEEN fits alongside other products</h2>
<p>A lot of products out there are 'lip service'. You spend a lot of money on a risk management platform. But all you really get to do is tick some boxes saying 'yes, we do that'. You also pay through the nose, usually by headcount, via sales consultants under pressure to meet KPIs.</p>
<p>KEEN offers an open source, self-hostable approach for organisations that want to work directly with the underlying evidence and tailor the system themselves.</p>
<p>The starting question is: <strong>what demonstrates that this practice is operating in our organisation?</strong> In KEEN, you define how your operational events relate to your requirements, retain their context, and use them in a continuing programme of measurement, review and improvement.</p>
<p>That is useful when your evidence lives in bespoke systems, your business practices need specific mapping rules, or you want control over hosting and development. You choose the environment, maintain the source systems that provide the data, and decide how the assurance model evolves.</p>
<h2 id="collect-from-the-systems-you-already-use">Collect from the systems you already use</h2>
<p>Built-in ingesters in KEEN are like presets - they come with the product out of the box. These include <strong>GitHub, GitLab, Forgejo, Gitea, Jenkins, Redmine, Loki, Amazon CloudWatch Logs, Google Workspace, Taiga, BookStack and RSS/Atom feeds</strong>. You can also push data to authenticated KEEN webhooks, and add manual 'diary' evidence.</p>
<p><strong>KEEN Agent</strong> is a complimentary open-source daemon which can be installed on your Linux servers. It collects things like journal syslog messages, nginx requests, package-manager activity, auditd records, and even OSSEC json logs. This is useful if you're not already shipping your logs to a central service like Loki.</p>
<p>For a bespoke service, there is also a <strong>API ingester builder</strong>. This lets you construct completely unique-to-you HTTP requests, credentials, pagination and event extraction in the web UI, which will then call those endpoints of your bespoke services, and attempt to ingest the response. Compatibility depends on the remote API's authentication and response format.</p>
<p>Redaction and masking help limit sensitive data entering evidence workflows. Administrators can pause collection and set evidence retention policies, with protection for records sampled into audits.</p>
<h2 id="map-once-review-the-evidence-in-context">Map once, review the evidence in context</h2>
<p>Mapping the evidence to controls is an act of curation - it often is spiky at the start until you climb the curve of your evidence 'patterns'.</p>
<p>To make it easier, you can pick an existing event and create a mapping rule from it to apply to it and all similar events both retrospectively and into the future. Keep the fields that matter, choose its framework targets, and preview matching and nonmatching records. A software-maintenance rule might select package activity; a more specific web-server rule might select a status code, path and client network.</p>
<figure class="text-center my-4">
  <a href="/news/keen-evidence-first/mapping-controls_hu5872322799269880642.jpg" class="image-lightbox" title="Click to view larger">
    <img src="/news/keen-evidence-first/mapping-controls_hu13064664673795752920.jpg" alt="Evidence mapping editor selecting framework controls for an event rule" width="1100" height="651" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">Build a rule from observed evidence, then select the requirements it supports.</figcaption>
</figure>

<p>Many organisations need to comply with <em>multiple</em> frameworks. KEEN includes many framework catalogues thanks in large part to work performed by others to collate and cross-reference those controls. KEEN offersr a framework editor so that you can edit or add new frameworks as you need. You can enable the frameworks your organisation needs.</p>
<p>For me, a killer feature that exists in KEEN is that you can associate the controls from one framework to another - what this means is that any evidence arriving and mapping to one framework's controls, <em>automatically</em> will map to that other framework's controls, without you needing to do anything at all!</p>
<p>Of course, when writing mapping rules, you can also explicitly map any number of specific controls to that evidence pattern.</p>
<figure class="text-center my-4">
  <a href="/news/keen-evidence-first/evidence-relationships_hu16933012871976968890.png" class="image-lightbox" title="Click to view larger">
    <img src="/news/keen-evidence-first/evidence-relationships_hu3213745437728842993.png" alt="Bidirectional graph linking distinctly coloured evidence sources to framework controls" width="800" height="896" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">Follow a source-to-control relationship into the records behind it. Evidence coverage gives you a starting point for assessment.</figcaption>
</figure>

<h2 id="keep-the-isms-connected">Keep the ISMS connected</h2>
<p>Most certified organisations have a wide range of documents, spreadsheets, matrices and the like in a range of different systems.</p>
<p>KEEN makes it possible to manage assets, people, organisational roles and charts, suppliers, access-control matrices, objectives and meeting minutes - all alongside your other ingested evidence. This means that those entities are <em>also</em> able to be 'related' to Controls directly. Once you start to build those relationships, you can also visualise them in interesting ways.</p>
<p>Write policies and procedures directly in KEEN, retain document revisions, upload existing material or link to external documents. If you are using BookStack, integration with its API can mean you can associate even specific parts of a book or a shelf to your framework.</p>
<figure class="text-center my-4">
  <a href="/news/keen-evidence-first/access-matrix_hu11302525969584005733.png" class="image-lightbox" title="Click to view larger">
    <img src="/news/keen-evidence-first/access-matrix_hu1654797564184937598.png" alt="ISMS access matrix showing roles and recorded access relationships" width="1100" height="233" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">Record and review access relationships alongside the people, roles and assets they concern.</figcaption>
</figure>

<p>Risk registers bring together inherent and residual assessments, 5×5 heatmaps, treatment plans and ownership by a person or organisational role. There is support for CIA (Confidentiality, Integrity and Availability) risk models, as well as PESTLE.</p>
<p>Not sure how to categorise your risks? The <strong>KEEN Mitigator</strong> uses a natural-language (not AI!) analysis of your description to suggest both controls that may apply as mitigation, <em>and</em> whether you or someone else has already defined a similar risk, to help keep your data hygiene nice and healthy.</p>
<p>KEEN also ships with its own informal 'KEEN Assurance Framework', as inspiration for both modelling your organisation against Controls and as a reusable 'risk library' for you to pick and choose which pre-defined risks are relevant to your business.</p>
<figure class="text-center my-4">
  <a href="/news/keen-evidence-first/mitigator_hu9011189226669779622.jpg" class="image-lightbox" title="Click to view larger">
    <img src="/news/keen-evidence-first/mitigator_hu11126616544315613990.jpg" alt="KEEN Mitigator showing a natural-language risk description and suggested risk associations" width="1100" height="1013" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">Use risk-library suggestions to start a context-specific assessment and treatment discussion.</figcaption>
</figure>

<h2 id="measure-whether-the-practice-is-effective">Measure whether the practice is effective</h2>
<p>This is the crunch point for the auditors. It's all well and good to say you try to reach five nines, or that your data recovery procedures hope to reach a Recovery Time Objective of 20 minutes, but <em>do</em> they, in practice?</p>
<p>In KEEN you can define an 'effectiveness measurement', its unit and thresholds (be it time, percentage, a count, greater than or less than or equal to, etc), then record observationsL: either manually (with evidence!), or automatically through an inbound metric webhook from the systems that <em>generate</em> those metrics. KEEN gives you copyable Python and curl payload examples that KEEN expects to receive on those webhook endpoints, to help connect those reporting systems easily.</p>
<p>For example, measure the percentage of critical patches completed within an agreed SLA. Retain the reporting period and supporting evidence, compare the result with its target, and investigate any missed threshold.</p>
<figure class="text-center my-4">
  <a href="/news/keen-evidence-first/effectiveness-table_hu7821236347897904849.png" class="image-lightbox" title="Click to view larger">
    <img src="/news/keen-evidence-first/effectiveness-table_hu12276283480024378218.png" alt="Effectiveness measures table with green and red threshold badges" width="1100" height="695" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">Recorded measurements make targets, observed results and exceptions visible.</figcaption>
</figure>

<p>As always, those effectiveness measurements can be directly mapped to Controls! This means the auditor, when viewing a Control, can easily find those measurements from the same page.</p>
<h2 id="audit-sample-and-improve">Audit, sample and improve</h2>
<p>Schedule recurring audits or begin an on-demand review. Set its scope, sample evidence into the audit, document OFIs or non-conformities, and retain the assessment. Evidence can also be downloaded for review. Scope-gap views help identify areas that still need audit attention.</p>
<figure class="text-center my-4">
  <a href="/news/keen-evidence-first/audit-review_hu1692882549192752720.png" class="image-lightbox" title="Click to view larger">
    <img src="/news/keen-evidence-first/audit-review_hu17950881485554873649.png" alt="KEEN audit workspace with scope and evidence review information" width="1100" height="501" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">Bring the scope, sampled evidence and review work together in an audit.</figcaption>
</figure>

<figure class="text-center my-4">
  <a href="/news/keen-evidence-first/audit-scope-gaps_hu13478309393636142407.png" class="image-lightbox" title="Click to view larger">
    <img src="/news/keen-evidence-first/audit-scope-gaps_hu483678844673791047.png" alt="Audit scope gaps identifying requirements needing coverage" width="1100" height="607" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">Check what still needs to be covered before drawing an audit conclusion.</figcaption>
</figure>

<p>Auditors can questions about an event (they frequently need more context). Those and their answers are kept alongside the evidence. For more formal issues arising out of evidence, incidents can also be declared, which can integrate with your own ticket system for follow-up.</p>
<h2 id="managing-use-of-keen">Managing use of KEEN</h2>
<p>KEEN has its own local user management system with full RBAC for controlling who can do what, as well as support for security keys and TOTP Authenticators to help secure accounts with 2FA. An audit log also exists for actions taken in KEEN, so you can even audit the audit system!</p>
<p>If you don't want to use the local user auth, there is support for SSO (Google, Github, bring-your-own OpenIDConnect, or LDAP).</p>
<h2 id="work-with-the-person-who-built-it">Work with the person who built it</h2>
<p>Like all open source software, the onus can sometimes be on you to keep it running well. But it doesn't have to stop with you.</p>
<p>As KEEN's inventor and developer, I also offer professional services to help your organisation put it to work:</p>
<ul>
<li><strong>Install and operate:</strong> deployment in your chosen hosting environment, configuration, upgrades, maintenance and ongoing operational support.</li>
<li><strong>Connect and adapt:</strong> ingester setup, evidence mapping, additional features and custom development for your services and workflows.</li>
<li><strong>Migrate your ISMS:</strong> I can help bring existing policies, assets, risks, ownership and evidence systems into KEEN, with a practical plan for populating and maintaining the records.</li>
<li><strong>Shape your assurance programme:</strong> If you're new to compliance, I can work through your specific framework requirements and business practices to define useful evidence, measures, mapping rules and audit routines, to help you get closer to that certification!</li>
</ul>
<p>KEEN is a means to an end, but that end is seriously impressive: a populated, maintainable system that reflects how your organisation works - not because you say so, but because it proves it, before the auditor even has to ask.</p>
<p><a href="/contact/">Contact me to discuss your implementation or migration</a>
.</p>
<h2 id="try-keen">Try KEEN</h2>
<p>If you need convincing, you can <strong>start a free 30-day demo</strong> at <a href="https://keen.cloud" target="_blank" rel="noopener noreferrer">keen.cloud</a>
. Allow <strong>5 to 6 minutes</strong> for your private, free instance to be prepared. The demo will be seeded with fictional sample records to help give a sense of the experience.</p>
<p>After 30 days, it will shut down and delete itself automatically. There is no obligation to continue with a paid engagement unless you want to get in touch!</p>
<p>You can also <a href="https://keen.cloud/help/" target="_blank" rel="noopener noreferrer">read the handbooks</a>
 or <a href="keen-brochure.pdf">download the illustrated brochure</a>
 if you want to pass on some succinct detail to your auditor or Q/A team.</p>
<p>If you're 'keen', I'd love to hear from you!</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>The Key Evidence Engine: evidence, frameworks, risks and audits in one workspace</li>
<li>Free and open source, on-premises or in the cloud</li>
<li>Ingesters for GitHub, GitLab, Forgejo, Jenkins, Loki and more</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://keen.cloud" target="_blank" rel="noopener noreferrer">Try the 30-day demo</a>
</li>
<li><a href="https://keen.cloud/help/" target="_blank" rel="noopener noreferrer">Read the handbooks</a>
</li>
<li><a href="keen-brochure.pdf">Illustrated brochure</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Implementing KEEN in your organisation?</div>
  <div class="smallprint mb-3">Work with the person who built it: deployment, evidence mapping, ISMS migration and programme design.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-diagram-3"></i><span>Key Evidence Engine</span></div>
    <h1 class="display-6 fw-semibold mb-2">Evidence first. Assurance connected.</h1>
    <p class="text-muted mb-3"><time datetime="2026-10-08">October 8, 2026</time></p>
    <p class="lead mb-0">Bring the work your organisation does, the requirements it follows and the evidence it produces into one connected workspace.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-10-08T00:00:00&#43;11:00">8 October 2026</time>
        </div>

<figure class="text-center my-4">
  <a href="/news/keen-evidence-first/keen-alligator_hu1585701652004123695.png" class="image-lightbox" title="Click to view larger">
    <img src="/news/keen-evidence-first/keen-alligator_hu12323903436971681633.png" alt="The KEEN Mitigator, a friendly purple alligator holding an auditing checklist" width="380" height="380" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
</figure>

<h2 id="show-me-the-evidence">Show me the evidence!</h2>
<p>An organisation running their operations according to a framework (such as ISO27001, SOC-2 etc) produces evidence every day: software upgrade logs, a reviewed change, the act of someone SSHing in from a VPN with an SSH key, an Ansible playbook run, a successful restore test, an access review, a supplier update.</p>
<p>Making that material useful for assurance takes context. Which requirement does it support? Which service and period does it cover? Who reviewed it? What needs to improve?</p>
<p>It also is recorded 'as it happens', whereas frameworks tend to be structured very strictly through 'Controls' and 'Clauses'. The order of these Controls makes sense to an auditor's brain, but the <em>reality</em> down here in the trenches is different.</p>
<p>I've spent a lot of time preparing for and undergoing audits with organisations. I would assemble all the most relevant procedures and other documents which explain how infrastructure is managed. I'd collect screenshots or hyperlinks to really good examples of CI/CD jobs, Pull Requests, and other records. However, come the actual audit time, I'd find out that the auditor wants to start with a specific Control. Very quickly, we would be jumping all over the place as I scrambled to find the right thing to show.</p>
<p>The frustrating part has always been that I <em>know</em> the evidence is there. But the order in which it made sense to me to show it, was not the way the auditor would see it.</p>
<p>Or, I'd show a document saying what we are <em>supposed</em> to do, but the act of proving that's what we <em>do</em> do, can be trickier.</p>
<p>Wouldn't it be great if all the actual raw evidence was already there <em>and</em> already mapped appropriately to the relevant Control?</p>
<p>Then the auditor could simply pick the Control they're interested in, immediately see evidence, with contextual remarks explaining why that evidence is relevant to the Control (since often evidence is very 'raw' and it needs context). They could then 'sample' that evidence, ideally very safely (e.g with redactions if necessary, as well as headers/footers annotating where it has come from).</p>
<p>You'd also ideally want that evidence to be near 'tamper-proof', e.g in some sort of immutable storage that helps assure its provenance.</p>
<p>And why not also map other 'entities' to Controls, such as the documents themselves, the recognised risks, the assets in your asset tracking system?</p>
<p>To actually <em>ingest</em> that evidence and have it automatically associated with your framework is really demonstrating that you aren't just compliant: instead, your organisation is demonstrating that it <em>lives and breathes</em> its ISMS, day in, day out.</p>
<p>So, I created <strong>KEEN - the Key Evidence Engine</strong> to connect those records with frameworks, risks, audits and the Information Security Management System (ISMS). Start with an event, trace it to a control, inspect the supporting material and record your assessment.</p>
<p>KEEN is <a href="https://github.com/FERCBusters/keen" target="_blank" rel="noopener noreferrer">free and open source software</a>
. You can run it on your own infrastructure, on-premises or in the cloud, and shape its framework definitions and evidence rules around your organisation.</p>
<figure class="text-center my-4">
  <a href="/news/keen-evidence-first/workspace_hu8317076932221745140.jpg" class="image-lightbox" title="Click to view larger">
    <img src="/news/keen-evidence-first/workspace_hu2039741748858366928.jpg" alt="KEEN assurance workspace with evidence coverage and items requiring attention" width="1100" height="768" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">An overview of your selected framework, its evidence coverage and work requiring attention. Screenshots use fictional example records.</figcaption>
</figure>

<h2 id="where-keen-fits-alongside-other-products">Where KEEN fits alongside other products</h2>
<p>A lot of products out there are 'lip service'. You spend a lot of money on a risk management platform. But all you really get to do is tick some boxes saying 'yes, we do that'. You also pay through the nose, usually by headcount, via sales consultants under pressure to meet KPIs.</p>
<p>KEEN offers an open source, self-hostable approach for organisations that want to work directly with the underlying evidence and tailor the system themselves.</p>
<p>The starting question is: <strong>what demonstrates that this practice is operating in our organisation?</strong> In KEEN, you define how your operational events relate to your requirements, retain their context, and use them in a continuing programme of measurement, review and improvement.</p>
<p>That is useful when your evidence lives in bespoke systems, your business practices need specific mapping rules, or you want control over hosting and development. You choose the environment, maintain the source systems that provide the data, and decide how the assurance model evolves.</p>
<h2 id="collect-from-the-systems-you-already-use">Collect from the systems you already use</h2>
<p>Built-in ingesters in KEEN are like presets - they come with the product out of the box. These include <strong>GitHub, GitLab, Forgejo, Gitea, Jenkins, Redmine, Loki, Amazon CloudWatch Logs, Google Workspace, Taiga, BookStack and RSS/Atom feeds</strong>. You can also push data to authenticated KEEN webhooks, and add manual 'diary' evidence.</p>
<p><strong>KEEN Agent</strong> is a complimentary open-source daemon which can be installed on your Linux servers. It collects things like journal syslog messages, nginx requests, package-manager activity, auditd records, and even OSSEC json logs. This is useful if you're not already shipping your logs to a central service like Loki.</p>
<p>For a bespoke service, there is also a <strong>API ingester builder</strong>. This lets you construct completely unique-to-you HTTP requests, credentials, pagination and event extraction in the web UI, which will then call those endpoints of your bespoke services, and attempt to ingest the response. Compatibility depends on the remote API's authentication and response format.</p>
<p>Redaction and masking help limit sensitive data entering evidence workflows. Administrators can pause collection and set evidence retention policies, with protection for records sampled into audits.</p>
<h2 id="map-once-review-the-evidence-in-context">Map once, review the evidence in context</h2>
<p>Mapping the evidence to controls is an act of curation - it often is spiky at the start until you climb the curve of your evidence 'patterns'.</p>
<p>To make it easier, you can pick an existing event and create a mapping rule from it to apply to it and all similar events both retrospectively and into the future. Keep the fields that matter, choose its framework targets, and preview matching and nonmatching records. A software-maintenance rule might select package activity; a more specific web-server rule might select a status code, path and client network.</p>
<figure class="text-center my-4">
  <a href="/news/keen-evidence-first/mapping-controls_hu5872322799269880642.jpg" class="image-lightbox" title="Click to view larger">
    <img src="/news/keen-evidence-first/mapping-controls_hu13064664673795752920.jpg" alt="Evidence mapping editor selecting framework controls for an event rule" width="1100" height="651" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">Build a rule from observed evidence, then select the requirements it supports.</figcaption>
</figure>

<p>Many organisations need to comply with <em>multiple</em> frameworks. KEEN includes many framework catalogues thanks in large part to work performed by others to collate and cross-reference those controls. KEEN offersr a framework editor so that you can edit or add new frameworks as you need. You can enable the frameworks your organisation needs.</p>
<p>For me, a killer feature that exists in KEEN is that you can associate the controls from one framework to another - what this means is that any evidence arriving and mapping to one framework's controls, <em>automatically</em> will map to that other framework's controls, without you needing to do anything at all!</p>
<p>Of course, when writing mapping rules, you can also explicitly map any number of specific controls to that evidence pattern.</p>
<figure class="text-center my-4">
  <a href="/news/keen-evidence-first/evidence-relationships_hu16933012871976968890.png" class="image-lightbox" title="Click to view larger">
    <img src="/news/keen-evidence-first/evidence-relationships_hu3213745437728842993.png" alt="Bidirectional graph linking distinctly coloured evidence sources to framework controls" width="800" height="896" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">Follow a source-to-control relationship into the records behind it. Evidence coverage gives you a starting point for assessment.</figcaption>
</figure>

<h2 id="keep-the-isms-connected">Keep the ISMS connected</h2>
<p>Most certified organisations have a wide range of documents, spreadsheets, matrices and the like in a range of different systems.</p>
<p>KEEN makes it possible to manage assets, people, organisational roles and charts, suppliers, access-control matrices, objectives and meeting minutes - all alongside your other ingested evidence. This means that those entities are <em>also</em> able to be 'related' to Controls directly. Once you start to build those relationships, you can also visualise them in interesting ways.</p>
<p>Write policies and procedures directly in KEEN, retain document revisions, upload existing material or link to external documents. If you are using BookStack, integration with its API can mean you can associate even specific parts of a book or a shelf to your framework.</p>
<figure class="text-center my-4">
  <a href="/news/keen-evidence-first/access-matrix_hu11302525969584005733.png" class="image-lightbox" title="Click to view larger">
    <img src="/news/keen-evidence-first/access-matrix_hu1654797564184937598.png" alt="ISMS access matrix showing roles and recorded access relationships" width="1100" height="233" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">Record and review access relationships alongside the people, roles and assets they concern.</figcaption>
</figure>

<p>Risk registers bring together inherent and residual assessments, 5×5 heatmaps, treatment plans and ownership by a person or organisational role. There is support for CIA (Confidentiality, Integrity and Availability) risk models, as well as PESTLE.</p>
<p>Not sure how to categorise your risks? The <strong>KEEN Mitigator</strong> uses a natural-language (not AI!) analysis of your description to suggest both controls that may apply as mitigation, <em>and</em> whether you or someone else has already defined a similar risk, to help keep your data hygiene nice and healthy.</p>
<p>KEEN also ships with its own informal 'KEEN Assurance Framework', as inspiration for both modelling your organisation against Controls and as a reusable 'risk library' for you to pick and choose which pre-defined risks are relevant to your business.</p>
<figure class="text-center my-4">
  <a href="/news/keen-evidence-first/mitigator_hu9011189226669779622.jpg" class="image-lightbox" title="Click to view larger">
    <img src="/news/keen-evidence-first/mitigator_hu11126616544315613990.jpg" alt="KEEN Mitigator showing a natural-language risk description and suggested risk associations" width="1100" height="1013" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">Use risk-library suggestions to start a context-specific assessment and treatment discussion.</figcaption>
</figure>

<h2 id="measure-whether-the-practice-is-effective">Measure whether the practice is effective</h2>
<p>This is the crunch point for the auditors. It's all well and good to say you try to reach five nines, or that your data recovery procedures hope to reach a Recovery Time Objective of 20 minutes, but <em>do</em> they, in practice?</p>
<p>In KEEN you can define an 'effectiveness measurement', its unit and thresholds (be it time, percentage, a count, greater than or less than or equal to, etc), then record observationsL: either manually (with evidence!), or automatically through an inbound metric webhook from the systems that <em>generate</em> those metrics. KEEN gives you copyable Python and curl payload examples that KEEN expects to receive on those webhook endpoints, to help connect those reporting systems easily.</p>
<p>For example, measure the percentage of critical patches completed within an agreed SLA. Retain the reporting period and supporting evidence, compare the result with its target, and investigate any missed threshold.</p>
<figure class="text-center my-4">
  <a href="/news/keen-evidence-first/effectiveness-table_hu7821236347897904849.png" class="image-lightbox" title="Click to view larger">
    <img src="/news/keen-evidence-first/effectiveness-table_hu12276283480024378218.png" alt="Effectiveness measures table with green and red threshold badges" width="1100" height="695" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">Recorded measurements make targets, observed results and exceptions visible.</figcaption>
</figure>

<p>As always, those effectiveness measurements can be directly mapped to Controls! This means the auditor, when viewing a Control, can easily find those measurements from the same page.</p>
<h2 id="audit-sample-and-improve">Audit, sample and improve</h2>
<p>Schedule recurring audits or begin an on-demand review. Set its scope, sample evidence into the audit, document OFIs or non-conformities, and retain the assessment. Evidence can also be downloaded for review. Scope-gap views help identify areas that still need audit attention.</p>
<figure class="text-center my-4">
  <a href="/news/keen-evidence-first/audit-review_hu1692882549192752720.png" class="image-lightbox" title="Click to view larger">
    <img src="/news/keen-evidence-first/audit-review_hu17950881485554873649.png" alt="KEEN audit workspace with scope and evidence review information" width="1100" height="501" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">Bring the scope, sampled evidence and review work together in an audit.</figcaption>
</figure>

<figure class="text-center my-4">
  <a href="/news/keen-evidence-first/audit-scope-gaps_hu13478309393636142407.png" class="image-lightbox" title="Click to view larger">
    <img src="/news/keen-evidence-first/audit-scope-gaps_hu483678844673791047.png" alt="Audit scope gaps identifying requirements needing coverage" width="1100" height="607" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">Check what still needs to be covered before drawing an audit conclusion.</figcaption>
</figure>

<p>Auditors can questions about an event (they frequently need more context). Those and their answers are kept alongside the evidence. For more formal issues arising out of evidence, incidents can also be declared, which can integrate with your own ticket system for follow-up.</p>
<h2 id="managing-use-of-keen">Managing use of KEEN</h2>
<p>KEEN has its own local user management system with full RBAC for controlling who can do what, as well as support for security keys and TOTP Authenticators to help secure accounts with 2FA. An audit log also exists for actions taken in KEEN, so you can even audit the audit system!</p>
<p>If you don't want to use the local user auth, there is support for SSO (Google, Github, bring-your-own OpenIDConnect, or LDAP).</p>
<h2 id="work-with-the-person-who-built-it">Work with the person who built it</h2>
<p>Like all open source software, the onus can sometimes be on you to keep it running well. But it doesn't have to stop with you.</p>
<p>As KEEN's inventor and developer, I also offer professional services to help your organisation put it to work:</p>
<ul>
<li><strong>Install and operate:</strong> deployment in your chosen hosting environment, configuration, upgrades, maintenance and ongoing operational support.</li>
<li><strong>Connect and adapt:</strong> ingester setup, evidence mapping, additional features and custom development for your services and workflows.</li>
<li><strong>Migrate your ISMS:</strong> I can help bring existing policies, assets, risks, ownership and evidence systems into KEEN, with a practical plan for populating and maintaining the records.</li>
<li><strong>Shape your assurance programme:</strong> If you're new to compliance, I can work through your specific framework requirements and business practices to define useful evidence, measures, mapping rules and audit routines, to help you get closer to that certification!</li>
</ul>
<p>KEEN is a means to an end, but that end is seriously impressive: a populated, maintainable system that reflects how your organisation works - not because you say so, but because it proves it, before the auditor even has to ask.</p>
<p><a href="/contact/">Contact me to discuss your implementation or migration</a>
.</p>
<h2 id="try-keen">Try KEEN</h2>
<p>If you need convincing, you can <strong>start a free 30-day demo</strong> at <a href="https://keen.cloud" target="_blank" rel="noopener noreferrer">keen.cloud</a>
. Allow <strong>5 to 6 minutes</strong> for your private, free instance to be prepared. The demo will be seeded with fictional sample records to help give a sense of the experience.</p>
<p>After 30 days, it will shut down and delete itself automatically. There is no obligation to continue with a paid engagement unless you want to get in touch!</p>
<p>You can also <a href="https://keen.cloud/help/" target="_blank" rel="noopener noreferrer">read the handbooks</a>
 or <a href="keen-brochure.pdf">download the illustrated brochure</a>
 if you want to pass on some succinct detail to your auditor or Q/A team.</p>
<p>If you're 'keen', I'd love to hear from you!</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>The Key Evidence Engine: evidence, frameworks, risks and audits in one workspace</li>
<li>Free and open source, on-premises or in the cloud</li>
<li>Ingesters for GitHub, GitLab, Forgejo, Jenkins, Loki and more</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://keen.cloud" target="_blank" rel="noopener noreferrer">Try the 30-day demo</a>
</li>
<li><a href="https://keen.cloud/help/" target="_blank" rel="noopener noreferrer">Read the handbooks</a>
</li>
<li><a href="keen-brochure.pdf">Illustrated brochure</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Implementing KEEN in your organisation?</div>
  <div class="smallprint mb-3">Work with the person who built it: deployment, evidence mapping, ISMS migration and programme design.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>A slower boat</title>
      <link>https://mig5.net/news/the-slower-boat/</link>
      <pubDate>Sun, 04 Oct 2026 00:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/the-slower-boat/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-water"></i><span></span></div>
    <h1 class="display-6 fw-semibold mb-2">A slower boat</h1>
    <p class="text-muted mb-3"><time datetime="2026-10-04">October 4, 2026</time></p>
    <p class="lead mb-0">Notes on business, reputation, anxiety, and coming back into the room.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-10-04T00:00:00&#43;10:00">4 October 2026</time>
        </div>

<h2 id="tracing-a-very-particular-voyage">Tracing a very particular voyage</h2>
<p>A cormorant glides past and settles on a port-side cardinal mark, where the Western Port channel starts. Water slaps gently against the hull of my father's boat as I reach for the laptop, running OpenCPN - great open source nautical chart software, by the way.</p>
<p>I curse under my breath as my e-reader slips off my lap, its screen showing a close up image of <a href="https://en.wikipedia.org/wiki/Jean_Le_Cam" target="_blank" rel="noopener noreferrer">Jean Le Cam</a>
's keen, inquisitive face - a Breton yachtsman and competitive sailor whose name is very well known, especially in the French sailing community. He's a cheeky devil, known to speaking bluntly, but always honestly. This book of his I'm reading is a delight - actually a sort of transcribed long-form series of interviews.</p>
<p>A point he keeps returning to is that he is not prone to nostalgia: he always looks ahead, to what's next. With a wry laugh, he also admits he is prone to making the same mistakes incessantly.</p>
<p>I wish I was one of those people who can just look ahead, but I'm quite nostalgic, and I often feel very viscerally shaped by the experiences or decisions that led me to the present moment. I look backward, inwardly, all the time. Not to wistfully wish for change, but to better understand why things happened the way they did.</p>
<p>It's a particular time at the moment in that regard - probably to a large degree because I have reached my early 40s. But especially professionally, I am thinking about where it all began for me, and what's coming next. So where did, in fact, 'mig5 system administration' start for me?</p>
<h2 id="golden-years">Golden years</h2>
<p>In 2010, I was working at a web agency that was going through a dry spell. They wanted to save money by sacking my junior sysadmin. Angered by the idea of this solution, I argued that this junior was earning one of the lowest salaries at the org, but was doing a great job. If anyone ought to leave, it, in my opinion, should have been the director of New Business - because where <em>was</em> it? What was he doing while my junior continued to get his work done, meeting his own and my expectations?</p>
<p>Le Cam probably could not have been blunter. It went down about as well as you'd expect. After a few days of arguing and generally being a pain in my boss' backside, I negotiated myself to go down to three days a week, and my junior to four. Between the two of us, we reduced roughly the equivalent of his original wage, for the organisation to stay afloat.</p>
<p>On those two days off per week, I started taking sysadmin consulting work on the side. It took less than a year before I was reliably earning more money on those two days a week than I was at the 'day job'. By October 2011, freelancing became my whole living, and it has stayed that way, almost by accident.</p>
<p>How did I find the work? Rather incredibly, it found me. From around 2009 I had become a core developer of <a href="https://www.drupal.org/docs/getting-started/drupal-distributions/distribution-documentation/aegir-hosting-system" target="_blank" rel="noopener noreferrer">Aegir</a>
, a site management and hosting system for Drupal, and somewhere along the way I found myself pioneering ideas that the wider industry had yet to name: using Aegir's clone features to move builds through <a href="https://old.mig5.net/content/drupal-deployments-workflows-version-control-drushmake-and-aegir.html" target="_blank" rel="noopener noreferrer">dev, stage and production</a>
, and leaning on Drush Make to pin and reproduce an entire application from a manifest. Looking back, it was configuration management 'avant la lettre'. Puppet was still finding its feet. Ansible barely existed. Containers were a curiosity at best, and the cloud itself was only a couple of practical years old. Even Github was pretty new - I noticed the other day that I registered there in <a href="https://github.com/mig5?tab=overview&amp;from=2009-06-01&amp;to=2009-06-30" target="_blank" rel="noopener noreferrer">June 2009</a>
 (boy, it took me a while to get to grips with Git, coming from Subversion and CVS world!).</p>
<p>Because I was one of the louder evangelists for that automation workflow, work came to me easily. (I also don't think, at the younger age I was, how much being a 'white guy in tech' was already working in my favour.) I was promoting tech solutions through my website via Twitter (at a time when that corner of the internet was still fun). Agencies running Drupal and Magento needed someone who understood the whole lifecycle, and for the better part of a decade, from 2011 to 2020, that was my world. It would be dishonest if I claimed it never went to my head. It was a fine place to be: respected, in demand, and shaping how a small corner of the industry thought about deployments. People literally were writing about having <a href="https://perlucida.co.uk/blog/2010-02/four-tips-for-developing-drupal-under-aegir/" target="_blank" rel="noopener noreferrer">&#39;fallen under my spell&#39;</a>
.</p>
<h2 id="then-the-music-stopped">Then the music stopped</h2>
<p>There was a bill to pay for all that visibility, and I paid it in anxiety. The Drupal and Magento ecosystems went through some ugly security incidents in those years, and when you are the infrastructure person behind an agency, every emergency belongs to somebody else's client, and somehow still to you. Some difficult clients, difficult clients of clients, difficult clients of clients of clients. Problems that always seemed to beep the monitoring alarm on my phone the moment I stepped out the door with my wife on a Saturday morning. The endless chain of liability, one step removed from anything you control. I found myself lying awake doing arithmetic about risk that, in retrospect, was never really mine to carry.</p>
<p>Years of <a href="https://en.wikipedia.org/wiki/Cognitive_behavioral_therapy" target="_blank" rel="noopener noreferrer">CBT</a>
 helped, but only to a degree (honestly? Escitalopram has done more). I learned a lot, including that one of the reasons I probably do systems engineering is due to an attraction to being 'in control' as a means to overcome the anxiety. Unfortunately, it's also an oroborus, in that wielding a lot of power brings, as they say in the comics, great responsibility, and it can be a big weight on the shoulders when you are easily 'triggered' by certain conditions.</p>
<p>So during the COVID lockdowns I made a clean break. I stepped away from agency work entirely and went looking for organisations with a single mission I could believe in: civil society, human rights, privacy, public-interest technology. Places where the security of the systems genuinely matters because real people are affected by it. It suited me, and it still does. Strangely enough, nothing about these missions make them less stressful - in fact, many of them are even scarier than before! But I've somehow found a way to make it emotionally resonate with me at a level that cuts out that other type of fear that, even as I try to write this article, is tricky to put into words.</p>
<p>The switch away from my 'community' that was Drupal, had another cost though. I stopped speaking at (or even attending) conferences, stopped going to meetups, stopped writing and making videos. Perhaps COVID didn't help either, but I don't think it was much of that. I had been one of those people who explained or even introduced ideas to an industry before the word 'influencer' existed, and then I simply went quiet. I became something of a hermit, and I let my community ties lapse. In our line of work, that is close to vanishing.</p>
<h2 id="what-we-do-in-the-shadows">What we do in the shadows</h2>
<p>The strange thing about the hermit years is how much of value got built there. Post-Drupal, my work settled into infrastructure as code, automation, monitoring, effective incident response, security compliance, and the general craft the industry now calls DevSecOps. I learned an <em>enormous</em> amount.</p>
<p>I also did what I have always done, which is, 'build the tool I need' and release it. <a href="/projects/enroll">Enroll</a>
, which reverse-engineers existing production servers into honest, reproducible Ansible; more recently, others such as <a href="/projects/heckle">Heckle</a>
, which pulls a whole git forge into Terraform or OpenTofu (<em>notice this trend of trying to get to 'being back in control'?</em>); a decade of packaging <a href="https://git.mig5.net/mig5/php-sqlcipher" target="_blank" rel="noopener noreferrer">SQLCipher for PHP</a>
.</p>
<p>I also became a core developer of <a href="https://onionshare.org" target="_blank" rel="noopener noreferrer">OnionShare</a>
 - that project, in particular led to a bunch of paid gigs still to this day, in the Tor/privacy/security space. For a time, I was the sysadmin of the onion services of <a href="https://qubes-os.org" target="_blank" rel="noopener noreferrer">Qubes OS</a>
' package repositories through consulting to Whonix. Qubes remains my daily driver. The OnionShare opportunity has echoes of my 'Drupal journey', with word of mouth working to my advantage, but without quite the same level of visibility.</p>
<p>Open source gave me my career in the first place, and it has kept giving, even during the years I gave the least of myself back.</p>
<h2 id="let-me-digress">Let me digress!</h2>
<p>Some of the work I am proudest of is still yet to be released, but is coming soon! Let me go on a brief and total tangent because I'm excited by it. Over the course of many ISO27001 audits, I gradually learned how auditors think, and became conscious of a frequent divide between 'on the ground operations' and what a compliance framework looks like on paper. Well-run orgs often tick most or even all of the boxes! but the order they go about it is frustrating for auditors, who think about risk and process information differently. This often leads to auditors asking absurd, <em>often already-answered-but-in-a-different-way</em> questions that lead to frustration back for those in the trenches too.</p>
<p>Tired of jumping in all directions to provide the evidence the auditor was seeking, I have built a compliance platform that does something the market largely skips: ingesting real raw data from logs and other sources, and <em>automatically</em> mapping those to relevant compliance framework controls and clauses (such as ISO27001:2022, Cyber Essentials, SOC-2 and so on) based on established pattern matching and rule definitions. Most compliance products ask you to fill in questionnaires about your controls. Mine starts at the other end. The auditor has an interface to 'snapshot' evidence, there is automatic data masking/redactions where necessary, and full ISMS and risk management tools. My ultimate goal: not needing the auditor to even ask me questions.</p>
<p>Several auditors have urged me not to open source that work, because they think it is a true 'competition killer' and one that should be locked away and monetised ASAP. Nothing could convince me better that making it FOSS is therefore the <em>right</em> move, and so it's going to be. I'm on the cusp of developing a free demo system for it. More on this to come soon!</p>
<p>Anyway, on to how things look for me in October 2026.</p>
<h2 id="the-ship-that-carried-me">The ship that carried me</h2>
<p>One client has been with me for fourteen of these nineteen years. That's a long time for a freelance contract. It has been quite a high stakes client with major security needs, and so working there has not been easy for me and my anxiety. But it has been, in financial terms, a keel: the steady weight under the waterline that keeps the whole boat upright. Recently, funding and cash-flow problems has made the organisation non-viable, and it is now winding up. There is no fault to assign. It is simply a ship that has reached the end of its voyage, and I am grateful beyond measure for how long it carried me.</p>
<p>It does mean a steep annual drop in funds for me. For the first time in my career I am out in the market looking for work, without the old privilege of it landing in my lap. That is a peculiar feeling at my stage of life. My ego was never enormous, but I would be pretending if I said I am indifferent to being respected. I am at the age where I still need to work, and recognition and reputation still matter in ways both practical and personal. The awkward truth is that my reputation faded, yet it was while my skills <em>increased</em>. The Drupal-era work that made me known has been largely forgotten, and the work that replaced it happened in private, away from the room where reputations are maintained.</p>
<p>I've started a <a href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer">Buy me a Coffee</a>
 for those who might benefit from my open source, volunteer time and feel they can give back. Similarly, I set up the <a href="https://github.com/sponsors/mig5" target="_blank" rel="noopener noreferrer">Github &#39;Sponsor&#39; feature</a>
. But honestly? I don't expect to see anything there. Times are tough already for people.</p>
<h2 id="jean-le-cams-new-boat">Jean Le Cam's new boat</h2>
<p>Jean Le Cam spent years racing the Vendée Globe in IMOCA vessels at the raw, punishing edge of offshore sailing, then grew disillusioned with what that kind of racing had become. For the upcoming Route du Rhum he has chosen a much slower, far more comfortable boat. He knows he is probably not going to win. He has done the hard yards, many times over, and now he wants to enjoy the sea again. It is a return to the roots of why he sails.</p>
<p>I read my own situation in that. The IMOCA years, the record-chasing, the solo punch-outs in the Southern Ocean: that was agency work at scale, security incidents at 3am, reputation maintenance, the tech talks. The slower boat is this: working with the organisations I believe in, building tools that solve real problems, and returning to something I have genuinely missed, which is helping shape best practices in the Dev, Sec and Ops space, in public, with the community I drifted away from. In particular, making it easier to demonstratably prove strong security safeguards in workflows and business processes, without just lip service.</p>
<p>I, too, would like the sailing to be good again.</p>
<h2 id="coming-back-into-the-room">Coming back into the room</h2>
<p>This has been a proper ramble! What am I on about, anyway? I guess this essay is, in part, a reintroduction. I am Miguel Jacq. I have worked on Linux servers since 2007, consulted since 2010, and been fully remote from Melbourne for that whole time, carving out a niche working the 'night shift' for orgs mostly located in the US, the UK, and Europe. I help small and specialist teams, often with key missions that I personally happen to believe in, untangle poorly-managed Linux estates, automate change to reduce time without skimping on checks and balances, harden security all the way through the org's processes, keep monitoring useful without alert fatigue, and make recovery feasible.</p>
<p>I join teams that don't need a full-time sysadmin, or need mentoring of existing ones, and try to leave behind lasting changes, playbooks and documentation so the system stops depending on any one person's memory, mine included. For a number of years, I've specialised in demonstrating 'the art of the possible' for technical problems that, for any number of reasons, have felt 'too hard'.</p>
<p>I am once again enjoying the work for a multitude of reasons, be it my health and wellbeing, a certain middle-aged reflection, an accomplished skill set and wide range of experience. I am older, more pragmatic, and here to help. I don't need to be a 'thought leader', but I also don't want to be forgotten. It's a hard thing to write. Google 'Australian tall poppy syndrome', it maybe partly explains why. (I am also half Australian, half French, and the 'mixed identities/cultures' phemonenon is also a big area of interest and part of who defines me - perhaps a blog post for another time.)</p>
<p>If you knew my name from Aegir and my Drupal days, thank you for that era and helping me be successful. I hope I inspired you or helped you run your tools too. I would love to show you what I build now. More is coming soon. I am open to new engagements, and to collaboration on the open-source work, and to simply being part of the conversation again.</p>
<p>Le Cam will be racing in November for the love of the sea, <a href="https://www.youtube.com/watch?v=mLantA4tCEs" target="_blank" rel="noopener noreferrer">on a boat</a>
 that finally suits the man sailing it. Funnily enough, I got my own marine license just recently, following a <a href="/spm2026">trip</a>
 to <a href="https://en.wikipedia.org/wiki/Saint_Pierre_and_Miquelon" target="_blank" rel="noopener noreferrer">Saint-Pierre et Miquelon</a>
 which helped reconnect me to the water and my ancestral roots. It's also why OpenCPN's on the laptop now. My Dad is still sailing, still has plenty to teach me in that regard. (He happens to also be a long-time Linux guy who also liked arguing with his boss. My sysadmin adventure really began with a stack of his O'Reilly books, at a time when there was no Stack Overflow, let alone AI.)</p>
<p>Le Cam has renovated his boat for comfort and for fun. In some ways, I've been doing the same with my business: in public, somewhat the way it used to look, but better, and for who I am now. The conditions are a bit dicey at the moment. Tech turbulence. But I'm out there, like the cormorant. Trying to be a lighthouse of logic, in the storm, for traffic seeking safe passage.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-1">Does this resonate with you?</div>
  <div class="smallprint mb-3">Come say hi or spend a bit more time here looking over the services I provide, and the projects I&#39;m working on.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-water"></i><span></span></div>
    <h1 class="display-6 fw-semibold mb-2">A slower boat</h1>
    <p class="text-muted mb-3"><time datetime="2026-10-04">October 4, 2026</time></p>
    <p class="lead mb-0">Notes on business, reputation, anxiety, and coming back into the room.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-10-04T00:00:00&#43;10:00">4 October 2026</time>
        </div>

<h2 id="tracing-a-very-particular-voyage">Tracing a very particular voyage</h2>
<p>A cormorant glides past and settles on a port-side cardinal mark, where the Western Port channel starts. Water slaps gently against the hull of my father's boat as I reach for the laptop, running OpenCPN - great open source nautical chart software, by the way.</p>
<p>I curse under my breath as my e-reader slips off my lap, its screen showing a close up image of <a href="https://en.wikipedia.org/wiki/Jean_Le_Cam" target="_blank" rel="noopener noreferrer">Jean Le Cam</a>
's keen, inquisitive face - a Breton yachtsman and competitive sailor whose name is very well known, especially in the French sailing community. He's a cheeky devil, known to speaking bluntly, but always honestly. This book of his I'm reading is a delight - actually a sort of transcribed long-form series of interviews.</p>
<p>A point he keeps returning to is that he is not prone to nostalgia: he always looks ahead, to what's next. With a wry laugh, he also admits he is prone to making the same mistakes incessantly.</p>
<p>I wish I was one of those people who can just look ahead, but I'm quite nostalgic, and I often feel very viscerally shaped by the experiences or decisions that led me to the present moment. I look backward, inwardly, all the time. Not to wistfully wish for change, but to better understand why things happened the way they did.</p>
<p>It's a particular time at the moment in that regard - probably to a large degree because I have reached my early 40s. But especially professionally, I am thinking about where it all began for me, and what's coming next. So where did, in fact, 'mig5 system administration' start for me?</p>
<h2 id="golden-years">Golden years</h2>
<p>In 2010, I was working at a web agency that was going through a dry spell. They wanted to save money by sacking my junior sysadmin. Angered by the idea of this solution, I argued that this junior was earning one of the lowest salaries at the org, but was doing a great job. If anyone ought to leave, it, in my opinion, should have been the director of New Business - because where <em>was</em> it? What was he doing while my junior continued to get his work done, meeting his own and my expectations?</p>
<p>Le Cam probably could not have been blunter. It went down about as well as you'd expect. After a few days of arguing and generally being a pain in my boss' backside, I negotiated myself to go down to three days a week, and my junior to four. Between the two of us, we reduced roughly the equivalent of his original wage, for the organisation to stay afloat.</p>
<p>On those two days off per week, I started taking sysadmin consulting work on the side. It took less than a year before I was reliably earning more money on those two days a week than I was at the 'day job'. By October 2011, freelancing became my whole living, and it has stayed that way, almost by accident.</p>
<p>How did I find the work? Rather incredibly, it found me. From around 2009 I had become a core developer of <a href="https://www.drupal.org/docs/getting-started/drupal-distributions/distribution-documentation/aegir-hosting-system" target="_blank" rel="noopener noreferrer">Aegir</a>
, a site management and hosting system for Drupal, and somewhere along the way I found myself pioneering ideas that the wider industry had yet to name: using Aegir's clone features to move builds through <a href="https://old.mig5.net/content/drupal-deployments-workflows-version-control-drushmake-and-aegir.html" target="_blank" rel="noopener noreferrer">dev, stage and production</a>
, and leaning on Drush Make to pin and reproduce an entire application from a manifest. Looking back, it was configuration management 'avant la lettre'. Puppet was still finding its feet. Ansible barely existed. Containers were a curiosity at best, and the cloud itself was only a couple of practical years old. Even Github was pretty new - I noticed the other day that I registered there in <a href="https://github.com/mig5?tab=overview&amp;from=2009-06-01&amp;to=2009-06-30" target="_blank" rel="noopener noreferrer">June 2009</a>
 (boy, it took me a while to get to grips with Git, coming from Subversion and CVS world!).</p>
<p>Because I was one of the louder evangelists for that automation workflow, work came to me easily. (I also don't think, at the younger age I was, how much being a 'white guy in tech' was already working in my favour.) I was promoting tech solutions through my website via Twitter (at a time when that corner of the internet was still fun). Agencies running Drupal and Magento needed someone who understood the whole lifecycle, and for the better part of a decade, from 2011 to 2020, that was my world. It would be dishonest if I claimed it never went to my head. It was a fine place to be: respected, in demand, and shaping how a small corner of the industry thought about deployments. People literally were writing about having <a href="https://perlucida.co.uk/blog/2010-02/four-tips-for-developing-drupal-under-aegir/" target="_blank" rel="noopener noreferrer">&#39;fallen under my spell&#39;</a>
.</p>
<h2 id="then-the-music-stopped">Then the music stopped</h2>
<p>There was a bill to pay for all that visibility, and I paid it in anxiety. The Drupal and Magento ecosystems went through some ugly security incidents in those years, and when you are the infrastructure person behind an agency, every emergency belongs to somebody else's client, and somehow still to you. Some difficult clients, difficult clients of clients, difficult clients of clients of clients. Problems that always seemed to beep the monitoring alarm on my phone the moment I stepped out the door with my wife on a Saturday morning. The endless chain of liability, one step removed from anything you control. I found myself lying awake doing arithmetic about risk that, in retrospect, was never really mine to carry.</p>
<p>Years of <a href="https://en.wikipedia.org/wiki/Cognitive_behavioral_therapy" target="_blank" rel="noopener noreferrer">CBT</a>
 helped, but only to a degree (honestly? Escitalopram has done more). I learned a lot, including that one of the reasons I probably do systems engineering is due to an attraction to being 'in control' as a means to overcome the anxiety. Unfortunately, it's also an oroborus, in that wielding a lot of power brings, as they say in the comics, great responsibility, and it can be a big weight on the shoulders when you are easily 'triggered' by certain conditions.</p>
<p>So during the COVID lockdowns I made a clean break. I stepped away from agency work entirely and went looking for organisations with a single mission I could believe in: civil society, human rights, privacy, public-interest technology. Places where the security of the systems genuinely matters because real people are affected by it. It suited me, and it still does. Strangely enough, nothing about these missions make them less stressful - in fact, many of them are even scarier than before! But I've somehow found a way to make it emotionally resonate with me at a level that cuts out that other type of fear that, even as I try to write this article, is tricky to put into words.</p>
<p>The switch away from my 'community' that was Drupal, had another cost though. I stopped speaking at (or even attending) conferences, stopped going to meetups, stopped writing and making videos. Perhaps COVID didn't help either, but I don't think it was much of that. I had been one of those people who explained or even introduced ideas to an industry before the word 'influencer' existed, and then I simply went quiet. I became something of a hermit, and I let my community ties lapse. In our line of work, that is close to vanishing.</p>
<h2 id="what-we-do-in-the-shadows">What we do in the shadows</h2>
<p>The strange thing about the hermit years is how much of value got built there. Post-Drupal, my work settled into infrastructure as code, automation, monitoring, effective incident response, security compliance, and the general craft the industry now calls DevSecOps. I learned an <em>enormous</em> amount.</p>
<p>I also did what I have always done, which is, 'build the tool I need' and release it. <a href="/projects/enroll">Enroll</a>
, which reverse-engineers existing production servers into honest, reproducible Ansible; more recently, others such as <a href="/projects/heckle">Heckle</a>
, which pulls a whole git forge into Terraform or OpenTofu (<em>notice this trend of trying to get to 'being back in control'?</em>); a decade of packaging <a href="https://git.mig5.net/mig5/php-sqlcipher" target="_blank" rel="noopener noreferrer">SQLCipher for PHP</a>
.</p>
<p>I also became a core developer of <a href="https://onionshare.org" target="_blank" rel="noopener noreferrer">OnionShare</a>
 - that project, in particular led to a bunch of paid gigs still to this day, in the Tor/privacy/security space. For a time, I was the sysadmin of the onion services of <a href="https://qubes-os.org" target="_blank" rel="noopener noreferrer">Qubes OS</a>
' package repositories through consulting to Whonix. Qubes remains my daily driver. The OnionShare opportunity has echoes of my 'Drupal journey', with word of mouth working to my advantage, but without quite the same level of visibility.</p>
<p>Open source gave me my career in the first place, and it has kept giving, even during the years I gave the least of myself back.</p>
<h2 id="let-me-digress">Let me digress!</h2>
<p>Some of the work I am proudest of is still yet to be released, but is coming soon! Let me go on a brief and total tangent because I'm excited by it. Over the course of many ISO27001 audits, I gradually learned how auditors think, and became conscious of a frequent divide between 'on the ground operations' and what a compliance framework looks like on paper. Well-run orgs often tick most or even all of the boxes! but the order they go about it is frustrating for auditors, who think about risk and process information differently. This often leads to auditors asking absurd, <em>often already-answered-but-in-a-different-way</em> questions that lead to frustration back for those in the trenches too.</p>
<p>Tired of jumping in all directions to provide the evidence the auditor was seeking, I have built a compliance platform that does something the market largely skips: ingesting real raw data from logs and other sources, and <em>automatically</em> mapping those to relevant compliance framework controls and clauses (such as ISO27001:2022, Cyber Essentials, SOC-2 and so on) based on established pattern matching and rule definitions. Most compliance products ask you to fill in questionnaires about your controls. Mine starts at the other end. The auditor has an interface to 'snapshot' evidence, there is automatic data masking/redactions where necessary, and full ISMS and risk management tools. My ultimate goal: not needing the auditor to even ask me questions.</p>
<p>Several auditors have urged me not to open source that work, because they think it is a true 'competition killer' and one that should be locked away and monetised ASAP. Nothing could convince me better that making it FOSS is therefore the <em>right</em> move, and so it's going to be. I'm on the cusp of developing a free demo system for it. More on this to come soon!</p>
<p>Anyway, on to how things look for me in October 2026.</p>
<h2 id="the-ship-that-carried-me">The ship that carried me</h2>
<p>One client has been with me for fourteen of these nineteen years. That's a long time for a freelance contract. It has been quite a high stakes client with major security needs, and so working there has not been easy for me and my anxiety. But it has been, in financial terms, a keel: the steady weight under the waterline that keeps the whole boat upright. Recently, funding and cash-flow problems has made the organisation non-viable, and it is now winding up. There is no fault to assign. It is simply a ship that has reached the end of its voyage, and I am grateful beyond measure for how long it carried me.</p>
<p>It does mean a steep annual drop in funds for me. For the first time in my career I am out in the market looking for work, without the old privilege of it landing in my lap. That is a peculiar feeling at my stage of life. My ego was never enormous, but I would be pretending if I said I am indifferent to being respected. I am at the age where I still need to work, and recognition and reputation still matter in ways both practical and personal. The awkward truth is that my reputation faded, yet it was while my skills <em>increased</em>. The Drupal-era work that made me known has been largely forgotten, and the work that replaced it happened in private, away from the room where reputations are maintained.</p>
<p>I've started a <a href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer">Buy me a Coffee</a>
 for those who might benefit from my open source, volunteer time and feel they can give back. Similarly, I set up the <a href="https://github.com/sponsors/mig5" target="_blank" rel="noopener noreferrer">Github &#39;Sponsor&#39; feature</a>
. But honestly? I don't expect to see anything there. Times are tough already for people.</p>
<h2 id="jean-le-cams-new-boat">Jean Le Cam's new boat</h2>
<p>Jean Le Cam spent years racing the Vendée Globe in IMOCA vessels at the raw, punishing edge of offshore sailing, then grew disillusioned with what that kind of racing had become. For the upcoming Route du Rhum he has chosen a much slower, far more comfortable boat. He knows he is probably not going to win. He has done the hard yards, many times over, and now he wants to enjoy the sea again. It is a return to the roots of why he sails.</p>
<p>I read my own situation in that. The IMOCA years, the record-chasing, the solo punch-outs in the Southern Ocean: that was agency work at scale, security incidents at 3am, reputation maintenance, the tech talks. The slower boat is this: working with the organisations I believe in, building tools that solve real problems, and returning to something I have genuinely missed, which is helping shape best practices in the Dev, Sec and Ops space, in public, with the community I drifted away from. In particular, making it easier to demonstratably prove strong security safeguards in workflows and business processes, without just lip service.</p>
<p>I, too, would like the sailing to be good again.</p>
<h2 id="coming-back-into-the-room">Coming back into the room</h2>
<p>This has been a proper ramble! What am I on about, anyway? I guess this essay is, in part, a reintroduction. I am Miguel Jacq. I have worked on Linux servers since 2007, consulted since 2010, and been fully remote from Melbourne for that whole time, carving out a niche working the 'night shift' for orgs mostly located in the US, the UK, and Europe. I help small and specialist teams, often with key missions that I personally happen to believe in, untangle poorly-managed Linux estates, automate change to reduce time without skimping on checks and balances, harden security all the way through the org's processes, keep monitoring useful without alert fatigue, and make recovery feasible.</p>
<p>I join teams that don't need a full-time sysadmin, or need mentoring of existing ones, and try to leave behind lasting changes, playbooks and documentation so the system stops depending on any one person's memory, mine included. For a number of years, I've specialised in demonstrating 'the art of the possible' for technical problems that, for any number of reasons, have felt 'too hard'.</p>
<p>I am once again enjoying the work for a multitude of reasons, be it my health and wellbeing, a certain middle-aged reflection, an accomplished skill set and wide range of experience. I am older, more pragmatic, and here to help. I don't need to be a 'thought leader', but I also don't want to be forgotten. It's a hard thing to write. Google 'Australian tall poppy syndrome', it maybe partly explains why. (I am also half Australian, half French, and the 'mixed identities/cultures' phemonenon is also a big area of interest and part of who defines me - perhaps a blog post for another time.)</p>
<p>If you knew my name from Aegir and my Drupal days, thank you for that era and helping me be successful. I hope I inspired you or helped you run your tools too. I would love to show you what I build now. More is coming soon. I am open to new engagements, and to collaboration on the open-source work, and to simply being part of the conversation again.</p>
<p>Le Cam will be racing in November for the love of the sea, <a href="https://www.youtube.com/watch?v=mLantA4tCEs" target="_blank" rel="noopener noreferrer">on a boat</a>
 that finally suits the man sailing it. Funnily enough, I got my own marine license just recently, following a <a href="/spm2026">trip</a>
 to <a href="https://en.wikipedia.org/wiki/Saint_Pierre_and_Miquelon" target="_blank" rel="noopener noreferrer">Saint-Pierre et Miquelon</a>
 which helped reconnect me to the water and my ancestral roots. It's also why OpenCPN's on the laptop now. My Dad is still sailing, still has plenty to teach me in that regard. (He happens to also be a long-time Linux guy who also liked arguing with his boss. My sysadmin adventure really began with a stack of his O'Reilly books, at a time when there was no Stack Overflow, let alone AI.)</p>
<p>Le Cam has renovated his boat for comfort and for fun. In some ways, I've been doing the same with my business: in public, somewhat the way it used to look, but better, and for who I am now. The conditions are a bit dicey at the moment. Tech turbulence. But I'm out there, like the cormorant. Trying to be a lighthouse of logic, in the storm, for traffic seeking safe passage.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-1">Does this resonate with you?</div>
  <div class="smallprint mb-3">Come say hi or spend a bit more time here looking over the services I provide, and the projects I&#39;m working on.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>Heckle: no joke, your git forge in HCL</title>
      <link>https://mig5.net/news/heckle/</link>
      <pubDate>Fri, 02 Oct 2026 00:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/heckle/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-diagram-3"></i><span>Heckle</span></div>
    <h1 class="display-6 fw-semibold mb-2">No joke: your git forge in HCL</h1>
    <p class="text-muted mb-3"><time datetime="2026-10-02">October 2, 2026</time></p>
    <p class="lead mb-0">Terraformer is end of life and archived. Heckle picks up its job for git forges: turning an
existing GitHub, GitLab, Gitea or Forgejo namespace into maintainable Terraform or OpenTofu.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-10-02T00:00:00&#43;10:00">2 October 2026</time>
        </div>

<figure class="text-center my-4">
  <a href="/news/heckle/heckle-mascot_hu2740279982092530362.png" class="image-lightbox" title="Click to view larger">
    <img src="/news/heckle/heckle-mascot_hu18056854674388929532.png" alt="The Heckle mascot: a cheeky bird holding a wrench" width="320" height="320" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
</figure>

<h2 id="terraformer-is-gone">Terraformer is gone</h2>
<p>For years, <a href="https://github.com/googlecloudplatform/terraformer" target="_blank" rel="noopener noreferrer">Terraformer</a>
 from
GoogleCloudPlatform was the go-to tool for &quot;reverse Terraform&quot;: pointing it at existing
infrastructure, having it read the live state, and generating <code>.tf</code> files
so you could bring something you had built by hand under Infrastructure as Code.
It covered dozens of providers, and for many it was the pragmatic answer to the
&quot;we already have this thing running, how do we codify it?&quot; question.</p>
<p>Unfortunately, it seems that since March 2026, the repository is now read-only, and
states that no further updates, security patches or support will be provided. It's dead, Jim.</p>
<h2 id="enter-heckle-a-focused-alternative-for-git-forges">Enter Heckle: a focused alternative for git forges</h2>
<p>I had a specific scenario that required getting a Github org with <em>many</em> repositories, teams,
members, branch protection rules etc into OpenTofu quick smart.</p>
<p>After a few crusty scripts that did the job, I turned my attention to another client's Gitlab.</p>
<p>Then I decided to get my <em>own</em> Forgejo under control, otherwise, whose dog food am I eating
in this industry? Not my own!</p>
<p>In the end, I abstracted the logic, made a lot of robustness and provider-specific guardrail
fixes, and the result is <a href="https://heckle.tf" target="_blank" rel="noopener noreferrer">Heckle</a>
.</p>
<p>Heckle inventories a namespace on GitHub, GitLab, Gitea or Forgejo - an organisation,
a GitLab group (including nested subgroups), or a personal account - maps the supported
objects to the selected provider's import contracts, and emits a standalone Terraform or
OpenTofu project of ordinary <code>.tf</code> files and local modules.</p>
<p>Then it steps out of the way: after adoption, Heckle is not required. You just
edit and run the generated project like any other Terraform or OpenTofu code.</p>
<h2 id="what-makes-it-safe-to-adopt">What makes it safe to adopt</h2>
<p>The hard part of reverse Terraform was dealing with provider differences - when I say provider,
I mean literally the Terraform/Tofu provider for those forges. Frequently, they seem to work
differently to how you use them in the web UI. Either there is contradictory stuff in the APIs,
or missing bits, or the developers of the TF providers simply did it a 'different' way, or haven't
yet got to adding certain features yet.</p>
<p>In any case, here's some info about Heckle:</p>
<ul>
<li><strong>It never runs <code>apply</code>.</strong> Heckle reads your forge, generates HCL, and rehearses the
adoption path in disposable local state. It never applies anything, and it never
persists that temporary state.</li>
<li><strong>Inspect before you generate.</strong> <code>heckle inventory</code> saves a discovery snapshot and
coverage report without generating any HCL, so you can check what the token can see
and what the provider covers before committing to anything. <code>heckle coverage</code> reads
a saved report without network access.</li>
<li><strong>Rehearsed adoption.</strong> Heckle rehearses the import privately, classifies
narrowly-defined, version-scoped <em>known provider behaviour</em>, and stops on anything
unrecognised - create, delete, replace or unexpected updates halt generation rather
than reaching you as a surprise.</li>
<li><strong>Guarded by default.</strong> Generated resources carry <code>prevent_destroy</code> guards unless you
explicitly opt out, and provider versions are pinned to exact, audited releases
(<code>heckle compatibility</code> shows what this release has audited).</li>
</ul>
<p>That said, the same caveat Terraformer users knew applies here: Heckle helps you
understand a plan; it does not guarantee one is safe. Provider behaviour, forge APIs and
upstream bugs can produce unintended changes. Review every plan yourself - the decision
to apply, and responsibility for its effects, remains yours. And I'll state it bluntly:
Heckle is also currently alpha software. Living on the edge!</p>
<h2 id="running-it">Running it</h2>
<p>Install with pipx, apt or dnf, or grab an AppImage - see the
<a href="https://heckle.tf/docs.html#install" target="_blank" rel="noopener noreferrer">installation docs</a>
. Credentials go through
environment variables, never command line options, and are never rendered into provider
configuration.</p>
<p>One organisation on GitHub, using the default OpenTofu:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">GITHUB_TOKEN</span><span class="o">=</span><span class="s1">&#39;...&#39;</span>
</span></span><span class="line"><span class="cl">heckle generate github --org example --out ./github-iac
</span></span></code></pre></div><p>A nested GitLab group on a self-hosted instance, with Terraform instead of OpenTofu:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">GITLAB_TOKEN</span><span class="o">=</span><span class="s1">&#39;...&#39;</span>
</span></span><span class="line"><span class="cl">heckle generate gitlab --group example/platform --url https://gitlab.example.org --tf terraform
</span></span></code></pre></div><p>Personal accounts work across all four forges with the same selectors:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">FORGEJO_API_TOKEN</span><span class="o">=</span><span class="s1">&#39;...&#39;</span>
</span></span><span class="line"><span class="cl">heckle generate forgejo --me --url https://code.example.org
</span></span></code></pre></div><p>And the inspect-first workflow, if you want to see coverage before generating anything:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">heckle inventory gitea --org example --url https://git.example.org --out ./snapshot
</span></span><span class="line"><span class="cl">heckle coverage ./snapshot
</span></span><span class="line"><span class="cl">heckle generate gitea --org example --url https://git.example.org --from-inventory ./snapshot
</span></span></code></pre></div><p>Out of the project directory, the workflow is the ordinary one:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="nb">cd</span> ./github-iac
</span></span><span class="line"><span class="cl">tofu init
</span></span><span class="line"><span class="cl">tofu plan -out<span class="o">=</span>import.tfplan
</span></span><span class="line"><span class="cl">tofu show import.tfplan
</span></span><span class="line"><span class="cl"><span class="c1"># Review every change before running this yourself:</span>
</span></span><span class="line"><span class="cl">tofu apply import.tfplan
</span></span></code></pre></div><p>From there, the <code>.tf</code> files and modules are yours to maintain directly, and Heckle is no
longer part of the picture.</p>
<h2 id="learn-more">Learn more</h2>
<p><a href="https://heckle.tf" target="_blank" rel="noopener noreferrer">heckle.tf</a>
 has the full documentation, <a href="https://heckle.tf/compatibility.html" target="_blank" rel="noopener noreferrer">provider compatibility</a>
 details and
the adoption flow. As always, the source code is in my Forgejo at
<a href="https://git.mig5.net/mig5/heckle" target="_blank" rel="noopener noreferrer">git.mig5.net/mig5/heckle</a>
.</p>
<p>If you give it a spin on your own forge, let me know how it goes!</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Forges: GitHub, GitLab, Gitea, Forgejo - organisations, groups and personal accounts</li>
<li>Output: a standalone Terraform or OpenTofu project of <code>.tf</code> files and local modules</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://heckle.tf" target="_blank" rel="noopener noreferrer">Heckle website and docs</a>
</li>
<li><a href="https://git.mig5.net/mig5/heckle" target="_blank" rel="noopener noreferrer">Source repository</a>
</li>
<li><a href="https://heckle.tf/compatibility.html" target="_blank" rel="noopener noreferrer">Provider compatibility</a>
</li>
<li><a href="https://github.com/googlecloudplatform/terraformer" target="_blank" rel="noopener noreferrer">Terraformer (archived)</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Bringing a forge under IaC?</div>
  <div class="smallprint mb-3">I do contract sysadmin and DevSecOps work and can help you take an existing forge - or the rest of your estate - safely into Terraform or OpenTofu.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-diagram-3"></i><span>Heckle</span></div>
    <h1 class="display-6 fw-semibold mb-2">No joke: your git forge in HCL</h1>
    <p class="text-muted mb-3"><time datetime="2026-10-02">October 2, 2026</time></p>
    <p class="lead mb-0">Terraformer is end of life and archived. Heckle picks up its job for git forges: turning an
existing GitHub, GitLab, Gitea or Forgejo namespace into maintainable Terraform or OpenTofu.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-10-02T00:00:00&#43;10:00">2 October 2026</time>
        </div>

<figure class="text-center my-4">
  <a href="/news/heckle/heckle-mascot_hu2740279982092530362.png" class="image-lightbox" title="Click to view larger">
    <img src="/news/heckle/heckle-mascot_hu18056854674388929532.png" alt="The Heckle mascot: a cheeky bird holding a wrench" width="320" height="320" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
</figure>

<h2 id="terraformer-is-gone">Terraformer is gone</h2>
<p>For years, <a href="https://github.com/googlecloudplatform/terraformer" target="_blank" rel="noopener noreferrer">Terraformer</a>
 from
GoogleCloudPlatform was the go-to tool for &quot;reverse Terraform&quot;: pointing it at existing
infrastructure, having it read the live state, and generating <code>.tf</code> files
so you could bring something you had built by hand under Infrastructure as Code.
It covered dozens of providers, and for many it was the pragmatic answer to the
&quot;we already have this thing running, how do we codify it?&quot; question.</p>
<p>Unfortunately, it seems that since March 2026, the repository is now read-only, and
states that no further updates, security patches or support will be provided. It's dead, Jim.</p>
<h2 id="enter-heckle-a-focused-alternative-for-git-forges">Enter Heckle: a focused alternative for git forges</h2>
<p>I had a specific scenario that required getting a Github org with <em>many</em> repositories, teams,
members, branch protection rules etc into OpenTofu quick smart.</p>
<p>After a few crusty scripts that did the job, I turned my attention to another client's Gitlab.</p>
<p>Then I decided to get my <em>own</em> Forgejo under control, otherwise, whose dog food am I eating
in this industry? Not my own!</p>
<p>In the end, I abstracted the logic, made a lot of robustness and provider-specific guardrail
fixes, and the result is <a href="https://heckle.tf" target="_blank" rel="noopener noreferrer">Heckle</a>
.</p>
<p>Heckle inventories a namespace on GitHub, GitLab, Gitea or Forgejo - an organisation,
a GitLab group (including nested subgroups), or a personal account - maps the supported
objects to the selected provider's import contracts, and emits a standalone Terraform or
OpenTofu project of ordinary <code>.tf</code> files and local modules.</p>
<p>Then it steps out of the way: after adoption, Heckle is not required. You just
edit and run the generated project like any other Terraform or OpenTofu code.</p>
<h2 id="what-makes-it-safe-to-adopt">What makes it safe to adopt</h2>
<p>The hard part of reverse Terraform was dealing with provider differences - when I say provider,
I mean literally the Terraform/Tofu provider for those forges. Frequently, they seem to work
differently to how you use them in the web UI. Either there is contradictory stuff in the APIs,
or missing bits, or the developers of the TF providers simply did it a 'different' way, or haven't
yet got to adding certain features yet.</p>
<p>In any case, here's some info about Heckle:</p>
<ul>
<li><strong>It never runs <code>apply</code>.</strong> Heckle reads your forge, generates HCL, and rehearses the
adoption path in disposable local state. It never applies anything, and it never
persists that temporary state.</li>
<li><strong>Inspect before you generate.</strong> <code>heckle inventory</code> saves a discovery snapshot and
coverage report without generating any HCL, so you can check what the token can see
and what the provider covers before committing to anything. <code>heckle coverage</code> reads
a saved report without network access.</li>
<li><strong>Rehearsed adoption.</strong> Heckle rehearses the import privately, classifies
narrowly-defined, version-scoped <em>known provider behaviour</em>, and stops on anything
unrecognised - create, delete, replace or unexpected updates halt generation rather
than reaching you as a surprise.</li>
<li><strong>Guarded by default.</strong> Generated resources carry <code>prevent_destroy</code> guards unless you
explicitly opt out, and provider versions are pinned to exact, audited releases
(<code>heckle compatibility</code> shows what this release has audited).</li>
</ul>
<p>That said, the same caveat Terraformer users knew applies here: Heckle helps you
understand a plan; it does not guarantee one is safe. Provider behaviour, forge APIs and
upstream bugs can produce unintended changes. Review every plan yourself - the decision
to apply, and responsibility for its effects, remains yours. And I'll state it bluntly:
Heckle is also currently alpha software. Living on the edge!</p>
<h2 id="running-it">Running it</h2>
<p>Install with pipx, apt or dnf, or grab an AppImage - see the
<a href="https://heckle.tf/docs.html#install" target="_blank" rel="noopener noreferrer">installation docs</a>
. Credentials go through
environment variables, never command line options, and are never rendered into provider
configuration.</p>
<p>One organisation on GitHub, using the default OpenTofu:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">GITHUB_TOKEN</span><span class="o">=</span><span class="s1">&#39;...&#39;</span>
</span></span><span class="line"><span class="cl">heckle generate github --org example --out ./github-iac
</span></span></code></pre></div><p>A nested GitLab group on a self-hosted instance, with Terraform instead of OpenTofu:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">GITLAB_TOKEN</span><span class="o">=</span><span class="s1">&#39;...&#39;</span>
</span></span><span class="line"><span class="cl">heckle generate gitlab --group example/platform --url https://gitlab.example.org --tf terraform
</span></span></code></pre></div><p>Personal accounts work across all four forges with the same selectors:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">FORGEJO_API_TOKEN</span><span class="o">=</span><span class="s1">&#39;...&#39;</span>
</span></span><span class="line"><span class="cl">heckle generate forgejo --me --url https://code.example.org
</span></span></code></pre></div><p>And the inspect-first workflow, if you want to see coverage before generating anything:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">heckle inventory gitea --org example --url https://git.example.org --out ./snapshot
</span></span><span class="line"><span class="cl">heckle coverage ./snapshot
</span></span><span class="line"><span class="cl">heckle generate gitea --org example --url https://git.example.org --from-inventory ./snapshot
</span></span></code></pre></div><p>Out of the project directory, the workflow is the ordinary one:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="nb">cd</span> ./github-iac
</span></span><span class="line"><span class="cl">tofu init
</span></span><span class="line"><span class="cl">tofu plan -out<span class="o">=</span>import.tfplan
</span></span><span class="line"><span class="cl">tofu show import.tfplan
</span></span><span class="line"><span class="cl"><span class="c1"># Review every change before running this yourself:</span>
</span></span><span class="line"><span class="cl">tofu apply import.tfplan
</span></span></code></pre></div><p>From there, the <code>.tf</code> files and modules are yours to maintain directly, and Heckle is no
longer part of the picture.</p>
<h2 id="learn-more">Learn more</h2>
<p><a href="https://heckle.tf" target="_blank" rel="noopener noreferrer">heckle.tf</a>
 has the full documentation, <a href="https://heckle.tf/compatibility.html" target="_blank" rel="noopener noreferrer">provider compatibility</a>
 details and
the adoption flow. As always, the source code is in my Forgejo at
<a href="https://git.mig5.net/mig5/heckle" target="_blank" rel="noopener noreferrer">git.mig5.net/mig5/heckle</a>
.</p>
<p>If you give it a spin on your own forge, let me know how it goes!</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Forges: GitHub, GitLab, Gitea, Forgejo - organisations, groups and personal accounts</li>
<li>Output: a standalone Terraform or OpenTofu project of <code>.tf</code> files and local modules</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://heckle.tf" target="_blank" rel="noopener noreferrer">Heckle website and docs</a>
</li>
<li><a href="https://git.mig5.net/mig5/heckle" target="_blank" rel="noopener noreferrer">Source repository</a>
</li>
<li><a href="https://heckle.tf/compatibility.html" target="_blank" rel="noopener noreferrer">Provider compatibility</a>
</li>
<li><a href="https://github.com/googlecloudplatform/terraformer" target="_blank" rel="noopener noreferrer">Terraformer (archived)</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Bringing a forge under IaC?</div>
  <div class="smallprint mb-3">I do contract sysadmin and DevSecOps work and can help you take an existing forge - or the rest of your estate - safely into Terraform or OpenTofu.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>Introducing stef: grep that sees through SOPS, and remembers what it saw</title>
      <link>https://mig5.net/news/stef-sops-grep/</link>
      <pubDate>Thu, 01 Oct 2026 00:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/stef-sops-grep/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-search"></i><span>stef</span></div>
    <h1 class="display-6 fw-semibold mb-2">Introducing stef: grep that sees through SOPS, and remembers what it saw</h1>
    <p class="text-muted mb-3"><time datetime="2026-10-01">October 1, 2026</time></p>
    <p class="lead mb-0">A new native Rust search tool for ordinary files <em>and</em> SOPS-encrypted files, built on ripgrep's
own search crates - with an encrypted match history that answers a second question grep normally
cannot: <strong>what used to match, but does not match now?</strong></p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-10-01T00:00:00&#43;10:00">1 October 2026</time>
        </div>

<h2 id="uncovering-secrets">Uncovering secrets</h2>
<p>If you run infrastructure the way I do, a large slice of your configuration tree is
<a href="https://github.com/getsops/sops" target="_blank" rel="noopener noreferrer">SOPS</a>
-encrypted: <code>host_vars/*.sops.yml</code>, <code>.env</code> files, JSON
blobs. The plaintext values are never on disk. Exactly what you want! But it also
means that every ordinary text tool (like a search tool) is blind to them. <code>grep -r password inventory/</code> happily reports
that your secrets files contain nothing of interest, because what they actually contain is
AES256-GCM ciphertext.</p>
<p>In fact, it's worse than that: I even get false positive matches because of coincidental gibberish
in the SOPS files that match the pattern I searched for. Not helpful...</p>
<p>The standard workaround, of course, is to decrypt those sops files by hand:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">sops -d inventory/host_vars/someserver/forgejo.sops.yml <span class="p">|</span> grep password
</span></span></code></pre></div><p>For one file, that's fine. For a real, recursive mass search, it falls over quickly:</p>
<ul>
<li>your tree contains <em>hundreds</em> of ordinary files plus a handful of encrypted ones, and <code>grep</code>
has no idea which is which;</li>
<li>you lose ignore rules, globs, file types, context lines and colouring the moment you pipe;</li>
<li>each file needs its own <code>sops</code> invocation, so it's slow and easy to get wrong in a loop;</li>
</ul>
<p>One other quirk that I've always wanted: a grep match is stateless. It can tell you what matches <strong>now</strong>,
but it can never tell you what <em>used to</em> match.</p>
<p>There's ways around that! So long as your files are in git, you can <code>git blame</code> and find the
associated commit for when a line changed. Then, you delve into new hell decrypting the contents
of the git commit to understand what changed in that encrypted file. Only to find that the
<code>git blame</code> is just masking an earlier change in an earlier commit that you wanted..</p>
<p>So I wrote <a href="https://git.mig5.net/mig5/stef" target="_blank" rel="noopener noreferrer">stef</a>
 to close that gap.</p>
<h2 id="what-stef-does">What stef does</h2>
<p>stef is a standalone grep-style search tool. The normal muscle memory works:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">stef forgejo README.md
</span></span><span class="line"><span class="cl">stef -r forgejo ~/git/mig5-devops/
</span></span><span class="line"><span class="cl">stef -r <span class="s1">&#39;password|token&#39;</span> inventory/
</span></span><span class="line"><span class="cl">stef -r -C <span class="m">3</span> <span class="s1">&#39;failed login&#39;</span> /var/log/
</span></span></code></pre></div><p>Directory recursion is opt-in like GNU grep (<code>-r</code> without following symlinks, <code>-R</code> following
them), patterns can be regexes or fixed strings (<code>-F</code>), and the familiar <code>-i</code>, <code>-w</code>, <code>-v</code>, <code>-A/ -B/-C</code>, <code>-m</code>, glob (<code>-g</code>), file-type (<code>-t</code>), ignore-file, <code>--json</code>, <code>--vimgrep</code>, <code>-c</code>, <code>-l</code>, <code>-q</code>
controls are all there. Exit codes follow grep convention too: 0 found, 1 not found, 2 trouble.</p>
<p>The difference is what happens when stef meets a SOPS file. It first narrows candidates by
likely configuration formats (<code>*.yaml</code>, <code>*.yml</code>, <code>*.json</code>, <code>*.env</code>, <code>*.ini</code>, <code>*.sops*</code> - extend
with <code>--sops-glob</code>, disable entirely with <code>--no-sops</code>), then byte-sniffs the candidate for the
<code>ENC[AES256_GCM</code> marker <strong>and</strong> SOPS metadata. Both signals must be present, so a README that
merely <em>mentions</em> <code>ENC[AES256_GCM</code> is not mistaken for an encrypted file.</p>
<p>If the file is genuinely SOPS-encrypted, stef runs the <code>sops</code> binary you already have and trust,
as the most compatible interface there is:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">sops --decrypt FILE
</span></span></code></pre></div><p>and pipes the decrypted stdout straight into the same search engine used for plaintext files.
Decrypted content is never written to a temporary file - it exists only in the stream. The
original on disk stays encrypted, and your existing SOPS key setup (GPG, age, KMS) is used
exactly as-is, because stef does not reimplement any of it.</p>
<p>That means this &quot;just works&quot; across a mixed tree:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">stef -r password infrastructure/
</span></span></code></pre></div><p>with plaintext files searched directly and encrypted files decrypted and searched transparently,
all in one pass, with one binary.</p>
<h2 id="built-from-ripgreps-own-engine-without-ripgrep">Built from ripgrep's own engine, without ripgrep</h2>
<p>The first stef prototype I built shelled out to <code>rg</code>. That worked, but it meant asking users to install
a second search tool. Worse, it created flag conflicts - GNU grep's <code>-r</code> means <em>recursive</em> while
ripgrep's <code>-r</code> means <em>replacement</em>. Later, I realised that ripgrep is actually a Rust project that
splits its functionality into a sort of 'modular' series of crates that can be used independently.</p>
<p>So, stef is now a single native Rust binary that embeds the reusable crates from the ripgrep project itself -
<code>grep-regex</code>, <code>grep-matcher</code>, <code>grep-searcher</code> and <code>ignore</code> - for the regex engine, line-oriented
search, and directory traversal with <code>.gitignore</code>/<code>.ignore</code>/<code>.stefignore</code> support, globs and file
types.</p>
<p>So stef is not a half-finished reimplementation of a regex engine; it searches with the same
machinery ripgrep does. It is also deliberately <em>not</em> the full <code>rg</code> CLI: there is no PCRE2,
no replacement output, no <code>-z</code> compressed-file search, no <code>--pre</code> preprocessors. Those omissions
are intentional. In the case of preprocessing, SOPS is a first-class integration rather
than something you bolt on with <code>--pre</code>.</p>
<h2 id="the-part-grep-cant-do-remember-previous-matches">The part grep can't do: remember previous matches</h2>
<p>Every ordinary search is remembered (unless you pass <code>--no-history</code>). Not file copies - only the
match records the search produced, plus enough metadata to replay the search. Then:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">stef -p -r database_password inventory/
</span></span></code></pre></div><p>compares today's matches against the most recent <em>distinct</em> earlier result for that same search,
diff-style:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-diff" data-lang="diff"><span class="line"><span class="cl"><span class="gu">@@ inventory/group_vars/all/secrets.sops.yml @@
</span></span></span><span class="line"><span class="cl"><span class="gu"></span><span class="gd">-     12 │ database_password: alpha
</span></span></span><span class="line"><span class="cl"><span class="gd"></span><span class="gi">+     12 │ database_password: beta
</span></span></span></code></pre></div><p>Red is what disappeared, green is what appeared. Changed values show up as a removal plus an
addition, because the comparison is over decrypted line text. Line movement is not a change -
identity is <code>path + matched line text</code>, so a secret moving from line 12 to line 97 doesn't
produce a false alarm. And <code>stef -p</code> with no other arguments replays your last remembered search
automatically, including returning to the directory where it originally ran.</p>
<p>There's also a time window:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">stef -p 7d -r forgejo inventory/
</span></span></code></pre></div><p>That considers every remembered snapshot of that exact search over the last seven days and
compares the union of what was seen with what exists now. If a value went <code>alpha → beta → gamma</code>
during the week and is <code>delta</code> today, the old values show as removals and <code>delta</code> as an
addition - rather than quietly vanishing because no single earlier snapshot matches &quot;the last
one&quot; cleanly.</p>
<p>I'll admit, this is not yet a perfect system. The only way for matches to enter the historical
search is, well, to search for them. I think there's still an uncomfortable gap there, where
contents might change independently of a git commit or a previous <code>stef</code> search, perhaps even
several times over - and that change history will still be lost. (As I write this, it occurs to
me that readers might be interested in looking at my article about <a href="/news/guardutils/">GuardUtils</a>
,
because that sort of thing feels like it could be solved with <code>mirro</code>! Hey Marco, can we get mirro to
'backup' SOPS files as they get edited?)</p>
<h2 id="encrypting-history">Encrypting history</h2>
<p>In my world, the matches themselves, if we are going to store those in state, <em>are secrets</em> too.
A tool that persisted secret lines in a plaintext SQLite database next to your dotfiles would be a
liability, not a feature. So the history store is encrypted at rest as well.</p>
<p>I thought about using SQLCipher, but decided we have a cheap way out: to be 'grepping' SOPS files
encrypted with GPG, and therefore decrypting them, this also means we can leverage the user's GPG
key for the history too. So:</p>
<ul>
<li>match records, paths, working directories and commands are serialised to JSON, then encrypted
with ChaCha20-Poly1305 under a random 256-bit master key;</li>
<li>search identities in the database are keyed BLAKE3 hashes rather than plaintext pattern
columns, so even the <em>query</em> isn't sitting there in the clear;</li>
<li>the state directory is created <code>0700</code> and files <code>0600</code>, and stef refuses to use a history key
whose permissions are looser;</li>
<li>when you search PGP-backed SOPS files, stef reads the PGP fingerprints from the encrypted SOPS
metadata without decrypting anything, checks them against your local GPG secret keyring, and
if one matches, wraps the master key to it as <code>history.key.gpg</code> - so no plaintext history key
is left on disk at all. age-only SOPS users get the local-key mode instead;</li>
<li><code>stef --clear-history</code> deletes and then <code>VACUUM</code>s the database, because a secrets database
that leaves the deleted rows in freed SQLite pages is not really deleted;</li>
<li><code>stef --history</code> shows run counts and commands, but <strong>never</strong> stored match text.</li>
</ul>
<h2 id="getting-it">Getting it</h2>
<p>Install from my APT repository:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo mkdir -p /usr/share/keyrings
</span></span><span class="line"><span class="cl">curl -fsSL https://mig5.net/static/mig5.asc <span class="p">|</span> sudo gpg --dearmor -o /usr/share/keyrings/mig5.gpg
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;deb [arch=amd64 signed-by=/usr/share/keyrings/mig5.gpg] https://apt.mig5.net </span><span class="k">$(</span>lsb_release -cs<span class="k">)</span><span class="s2"> main&#34;</span> <span class="p">|</span> sudo tee /etc/apt/sources.list.d/mig5.list
</span></span><span class="line"><span class="cl">sudo apt update
</span></span><span class="line"><span class="cl">sudo apt install stef
</span></span></code></pre></div><p>or <code>cargo install stef</code>, or build from source (Rust 1.88+). Plaintext searching needs nothing
else, but searching SOPS files still needs <code>sops</code> installed separately, and configured as you
already have it (and you probably already have it, else you wouldn't have the need for <code>stef</code>
in the first place).</p>
<p>The source code, as usual, is <a href="https://git.mig5.net/stef" target="_blank" rel="noopener noreferrer">in my Forgejo</a>
.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Language: Rust</li>
<li>Engine: ripgrep's <code>grep-*</code> and <code>ignore</code> crates, embedded</li>
<li>SOPS: transparent detection + <code>sops --decrypt</code> streaming, no temp files</li>
<li>History: ChaCha20-Poly1305 SQLite, keyed BLAKE3 identities, optional GPG-wrapped key</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://git.mig5.net/mig5/stef" target="_blank" rel="noopener noreferrer">stef source and issue tracker</a>
</li>
<li><a href="https://github.com/getsops/sops" target="_blank" rel="noopener noreferrer">SOPS</a>
</li>
<li><a href="https://github.com/BurntSushi/ripgrep" target="_blank" rel="noopener noreferrer">ripgrep</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Secrets sprawl in your config tree?</div>
  <div class="smallprint mb-3">I do contract sysadmin and DevSecOps work and can help you design SOPS workflows, secret rotation and the pipelines around them.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-search"></i><span>stef</span></div>
    <h1 class="display-6 fw-semibold mb-2">Introducing stef: grep that sees through SOPS, and remembers what it saw</h1>
    <p class="text-muted mb-3"><time datetime="2026-10-01">October 1, 2026</time></p>
    <p class="lead mb-0">A new native Rust search tool for ordinary files <em>and</em> SOPS-encrypted files, built on ripgrep's
own search crates - with an encrypted match history that answers a second question grep normally
cannot: <strong>what used to match, but does not match now?</strong></p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-10-01T00:00:00&#43;10:00">1 October 2026</time>
        </div>

<h2 id="uncovering-secrets">Uncovering secrets</h2>
<p>If you run infrastructure the way I do, a large slice of your configuration tree is
<a href="https://github.com/getsops/sops" target="_blank" rel="noopener noreferrer">SOPS</a>
-encrypted: <code>host_vars/*.sops.yml</code>, <code>.env</code> files, JSON
blobs. The plaintext values are never on disk. Exactly what you want! But it also
means that every ordinary text tool (like a search tool) is blind to them. <code>grep -r password inventory/</code> happily reports
that your secrets files contain nothing of interest, because what they actually contain is
AES256-GCM ciphertext.</p>
<p>In fact, it's worse than that: I even get false positive matches because of coincidental gibberish
in the SOPS files that match the pattern I searched for. Not helpful...</p>
<p>The standard workaround, of course, is to decrypt those sops files by hand:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">sops -d inventory/host_vars/someserver/forgejo.sops.yml <span class="p">|</span> grep password
</span></span></code></pre></div><p>For one file, that's fine. For a real, recursive mass search, it falls over quickly:</p>
<ul>
<li>your tree contains <em>hundreds</em> of ordinary files plus a handful of encrypted ones, and <code>grep</code>
has no idea which is which;</li>
<li>you lose ignore rules, globs, file types, context lines and colouring the moment you pipe;</li>
<li>each file needs its own <code>sops</code> invocation, so it's slow and easy to get wrong in a loop;</li>
</ul>
<p>One other quirk that I've always wanted: a grep match is stateless. It can tell you what matches <strong>now</strong>,
but it can never tell you what <em>used to</em> match.</p>
<p>There's ways around that! So long as your files are in git, you can <code>git blame</code> and find the
associated commit for when a line changed. Then, you delve into new hell decrypting the contents
of the git commit to understand what changed in that encrypted file. Only to find that the
<code>git blame</code> is just masking an earlier change in an earlier commit that you wanted..</p>
<p>So I wrote <a href="https://git.mig5.net/mig5/stef" target="_blank" rel="noopener noreferrer">stef</a>
 to close that gap.</p>
<h2 id="what-stef-does">What stef does</h2>
<p>stef is a standalone grep-style search tool. The normal muscle memory works:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">stef forgejo README.md
</span></span><span class="line"><span class="cl">stef -r forgejo ~/git/mig5-devops/
</span></span><span class="line"><span class="cl">stef -r <span class="s1">&#39;password|token&#39;</span> inventory/
</span></span><span class="line"><span class="cl">stef -r -C <span class="m">3</span> <span class="s1">&#39;failed login&#39;</span> /var/log/
</span></span></code></pre></div><p>Directory recursion is opt-in like GNU grep (<code>-r</code> without following symlinks, <code>-R</code> following
them), patterns can be regexes or fixed strings (<code>-F</code>), and the familiar <code>-i</code>, <code>-w</code>, <code>-v</code>, <code>-A/ -B/-C</code>, <code>-m</code>, glob (<code>-g</code>), file-type (<code>-t</code>), ignore-file, <code>--json</code>, <code>--vimgrep</code>, <code>-c</code>, <code>-l</code>, <code>-q</code>
controls are all there. Exit codes follow grep convention too: 0 found, 1 not found, 2 trouble.</p>
<p>The difference is what happens when stef meets a SOPS file. It first narrows candidates by
likely configuration formats (<code>*.yaml</code>, <code>*.yml</code>, <code>*.json</code>, <code>*.env</code>, <code>*.ini</code>, <code>*.sops*</code> - extend
with <code>--sops-glob</code>, disable entirely with <code>--no-sops</code>), then byte-sniffs the candidate for the
<code>ENC[AES256_GCM</code> marker <strong>and</strong> SOPS metadata. Both signals must be present, so a README that
merely <em>mentions</em> <code>ENC[AES256_GCM</code> is not mistaken for an encrypted file.</p>
<p>If the file is genuinely SOPS-encrypted, stef runs the <code>sops</code> binary you already have and trust,
as the most compatible interface there is:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">sops --decrypt FILE
</span></span></code></pre></div><p>and pipes the decrypted stdout straight into the same search engine used for plaintext files.
Decrypted content is never written to a temporary file - it exists only in the stream. The
original on disk stays encrypted, and your existing SOPS key setup (GPG, age, KMS) is used
exactly as-is, because stef does not reimplement any of it.</p>
<p>That means this &quot;just works&quot; across a mixed tree:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">stef -r password infrastructure/
</span></span></code></pre></div><p>with plaintext files searched directly and encrypted files decrypted and searched transparently,
all in one pass, with one binary.</p>
<h2 id="built-from-ripgreps-own-engine-without-ripgrep">Built from ripgrep's own engine, without ripgrep</h2>
<p>The first stef prototype I built shelled out to <code>rg</code>. That worked, but it meant asking users to install
a second search tool. Worse, it created flag conflicts - GNU grep's <code>-r</code> means <em>recursive</em> while
ripgrep's <code>-r</code> means <em>replacement</em>. Later, I realised that ripgrep is actually a Rust project that
splits its functionality into a sort of 'modular' series of crates that can be used independently.</p>
<p>So, stef is now a single native Rust binary that embeds the reusable crates from the ripgrep project itself -
<code>grep-regex</code>, <code>grep-matcher</code>, <code>grep-searcher</code> and <code>ignore</code> - for the regex engine, line-oriented
search, and directory traversal with <code>.gitignore</code>/<code>.ignore</code>/<code>.stefignore</code> support, globs and file
types.</p>
<p>So stef is not a half-finished reimplementation of a regex engine; it searches with the same
machinery ripgrep does. It is also deliberately <em>not</em> the full <code>rg</code> CLI: there is no PCRE2,
no replacement output, no <code>-z</code> compressed-file search, no <code>--pre</code> preprocessors. Those omissions
are intentional. In the case of preprocessing, SOPS is a first-class integration rather
than something you bolt on with <code>--pre</code>.</p>
<h2 id="the-part-grep-cant-do-remember-previous-matches">The part grep can't do: remember previous matches</h2>
<p>Every ordinary search is remembered (unless you pass <code>--no-history</code>). Not file copies - only the
match records the search produced, plus enough metadata to replay the search. Then:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">stef -p -r database_password inventory/
</span></span></code></pre></div><p>compares today's matches against the most recent <em>distinct</em> earlier result for that same search,
diff-style:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-diff" data-lang="diff"><span class="line"><span class="cl"><span class="gu">@@ inventory/group_vars/all/secrets.sops.yml @@
</span></span></span><span class="line"><span class="cl"><span class="gu"></span><span class="gd">-     12 │ database_password: alpha
</span></span></span><span class="line"><span class="cl"><span class="gd"></span><span class="gi">+     12 │ database_password: beta
</span></span></span></code></pre></div><p>Red is what disappeared, green is what appeared. Changed values show up as a removal plus an
addition, because the comparison is over decrypted line text. Line movement is not a change -
identity is <code>path + matched line text</code>, so a secret moving from line 12 to line 97 doesn't
produce a false alarm. And <code>stef -p</code> with no other arguments replays your last remembered search
automatically, including returning to the directory where it originally ran.</p>
<p>There's also a time window:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">stef -p 7d -r forgejo inventory/
</span></span></code></pre></div><p>That considers every remembered snapshot of that exact search over the last seven days and
compares the union of what was seen with what exists now. If a value went <code>alpha → beta → gamma</code>
during the week and is <code>delta</code> today, the old values show as removals and <code>delta</code> as an
addition - rather than quietly vanishing because no single earlier snapshot matches &quot;the last
one&quot; cleanly.</p>
<p>I'll admit, this is not yet a perfect system. The only way for matches to enter the historical
search is, well, to search for them. I think there's still an uncomfortable gap there, where
contents might change independently of a git commit or a previous <code>stef</code> search, perhaps even
several times over - and that change history will still be lost. (As I write this, it occurs to
me that readers might be interested in looking at my article about <a href="/news/guardutils/">GuardUtils</a>
,
because that sort of thing feels like it could be solved with <code>mirro</code>! Hey Marco, can we get mirro to
'backup' SOPS files as they get edited?)</p>
<h2 id="encrypting-history">Encrypting history</h2>
<p>In my world, the matches themselves, if we are going to store those in state, <em>are secrets</em> too.
A tool that persisted secret lines in a plaintext SQLite database next to your dotfiles would be a
liability, not a feature. So the history store is encrypted at rest as well.</p>
<p>I thought about using SQLCipher, but decided we have a cheap way out: to be 'grepping' SOPS files
encrypted with GPG, and therefore decrypting them, this also means we can leverage the user's GPG
key for the history too. So:</p>
<ul>
<li>match records, paths, working directories and commands are serialised to JSON, then encrypted
with ChaCha20-Poly1305 under a random 256-bit master key;</li>
<li>search identities in the database are keyed BLAKE3 hashes rather than plaintext pattern
columns, so even the <em>query</em> isn't sitting there in the clear;</li>
<li>the state directory is created <code>0700</code> and files <code>0600</code>, and stef refuses to use a history key
whose permissions are looser;</li>
<li>when you search PGP-backed SOPS files, stef reads the PGP fingerprints from the encrypted SOPS
metadata without decrypting anything, checks them against your local GPG secret keyring, and
if one matches, wraps the master key to it as <code>history.key.gpg</code> - so no plaintext history key
is left on disk at all. age-only SOPS users get the local-key mode instead;</li>
<li><code>stef --clear-history</code> deletes and then <code>VACUUM</code>s the database, because a secrets database
that leaves the deleted rows in freed SQLite pages is not really deleted;</li>
<li><code>stef --history</code> shows run counts and commands, but <strong>never</strong> stored match text.</li>
</ul>
<h2 id="getting-it">Getting it</h2>
<p>Install from my APT repository:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo mkdir -p /usr/share/keyrings
</span></span><span class="line"><span class="cl">curl -fsSL https://mig5.net/static/mig5.asc <span class="p">|</span> sudo gpg --dearmor -o /usr/share/keyrings/mig5.gpg
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;deb [arch=amd64 signed-by=/usr/share/keyrings/mig5.gpg] https://apt.mig5.net </span><span class="k">$(</span>lsb_release -cs<span class="k">)</span><span class="s2"> main&#34;</span> <span class="p">|</span> sudo tee /etc/apt/sources.list.d/mig5.list
</span></span><span class="line"><span class="cl">sudo apt update
</span></span><span class="line"><span class="cl">sudo apt install stef
</span></span></code></pre></div><p>or <code>cargo install stef</code>, or build from source (Rust 1.88+). Plaintext searching needs nothing
else, but searching SOPS files still needs <code>sops</code> installed separately, and configured as you
already have it (and you probably already have it, else you wouldn't have the need for <code>stef</code>
in the first place).</p>
<p>The source code, as usual, is <a href="https://git.mig5.net/stef" target="_blank" rel="noopener noreferrer">in my Forgejo</a>
.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Language: Rust</li>
<li>Engine: ripgrep's <code>grep-*</code> and <code>ignore</code> crates, embedded</li>
<li>SOPS: transparent detection + <code>sops --decrypt</code> streaming, no temp files</li>
<li>History: ChaCha20-Poly1305 SQLite, keyed BLAKE3 identities, optional GPG-wrapped key</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://git.mig5.net/mig5/stef" target="_blank" rel="noopener noreferrer">stef source and issue tracker</a>
</li>
<li><a href="https://github.com/getsops/sops" target="_blank" rel="noopener noreferrer">SOPS</a>
</li>
<li><a href="https://github.com/BurntSushi/ripgrep" target="_blank" rel="noopener noreferrer">ripgrep</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Secrets sprawl in your config tree?</div>
  <div class="smallprint mb-3">I do contract sysadmin and DevSecOps work and can help you design SOPS workflows, secret rotation and the pipelines around them.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>DumpTales gets a C heart: a conversational tour of native.c</title>
      <link>https://mig5.net/news/dumptales-native-c/</link>
      <pubDate>Wed, 30 Sep 2026 00:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/dumptales-native-c/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-cpu"></i><span>DumpTales</span></div>
    <h1 class="display-6 fw-semibold mb-2">Teaching Python to skip a hundred thousand tuples, in C</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-30">September 30, 2026</time></p>
    <p class="lead mb-0">Most of DumpTales' MySQL parsing time used to go on one job: chopping a monster INSERT
statement into rows and values. So I translated that loop into C. Here is why the same
algorithm now works faster!</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-30T00:00:00&#43;10:00">30 September 2026</time>
        </div>

<h2 id="squeezing-performance-out-of-a-hotspot-database-diffing">Squeezing performance out of a hotspot: database diffing</h2>
<p>I recently released <a href="/news/dumptales/">DumpTales</a>
, which compares two database snapshots offline.</p>
<p>But before it can compare anything, it has to <em>read</em>. And conventional <code>mysqldump</code> output is not kind to readers:
one INSERT statement typically carries hundreds or thousands of rows, and a single statement
can legally be 128 MiB of comma-separated tuples:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="o">`</span><span class="n">accounts</span><span class="o">`</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">1</span><span class="p">,</span><span class="s1">&#39;Acme&#39;</span><span class="p">,</span><span class="s1">&#39;basic&#39;</span><span class="p">),(</span><span class="mi">2</span><span class="p">,</span><span class="s1">&#39;Harbour&#39;</span><span class="p">,</span><span class="s1">&#39;basic&#39;</span><span class="p">),(</span><span class="mi">3</span><span class="p">,</span><span class="s1">&#39;Old Co&#39;</span><span class="p">,</span><span class="s1">&#39;trial&#39;</span><span class="p">);</span><span class="w">
</span></span></span></code></pre></div><p>Splitting that into rows and values sounds trivial, until you remember that <code>,</code>, <code>)</code>, <code>'</code> and
<code>&quot;</code> are all perfectly legal <em>inside</em> a quoted value. You need a little state machine that
tracks whether you are inside quotes, handles backslash escapes and doubled quotes, counts
nested parentheses, and slices out values only at top-level commas. Do it wrong, and a value
containing the string `'),(`` will quietly corrupt the comparison.</p>
<p>Dumptales is, like most of my tools, a Python app, and so I first wrote that state machine in Python, as two functions called <code>parens</code>
and <code>split_top</code>.</p>
<p>Then I profiled, and almost all of the MySQL parsing time was sitting in <strong>those two loops</strong>. Not
because the algorithm was clever or expensive - but because it is a per-character loop, and a
per-character loop in an interpreter pays rent on every character.</p>
<p>In the end, with that <em>same algorithm</em> converted into a CPython extension module, the results were
pretty dramatic. Nothing cleverer, just the same logic compiled to machine code.</p>
<p>If you're like me, you very rarely delve into C these days, but it can be kind of interesting.</p>
<p>And it's only 82 lines! Let's go through it.</p>
<h2 id="chunk-1-whos-calling-and-where-are-we">Chunk 1: who's calling, and where are we?</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-c" data-lang="c"><span class="line"><span class="cl"><span class="cp">#define PY_SSIZE_T_CLEAN
</span></span></span><span class="line"><span class="cl"><span class="cp">#include</span> <span class="cpf">&lt;Python.h&gt;</span><span class="cp">
</span></span></span><span class="line"><span class="cl"><span class="cp"></span>
</span></span><span class="line"><span class="cl"><span class="k">static</span> <span class="n">PyObject</span> <span class="o">*</span><span class="nf">next_row</span><span class="p">(</span><span class="n">PyObject</span> <span class="o">*</span><span class="n">self</span><span class="p">,</span> <span class="n">PyObject</span> <span class="o">*</span><span class="n">args</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">PyObject</span> <span class="o">*</span><span class="n">sql</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="n">Py_ssize_t</span> <span class="n">at</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nf">PyArg_ParseTuple</span><span class="p">(</span><span class="n">args</span><span class="p">,</span> <span class="s">&#34;On&#34;</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">sql</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">at</span><span class="p">))</span> <span class="k">return</span> <span class="nb">NULL</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nf">PyUnicode_Check</span><span class="p">(</span><span class="n">sql</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nf">PyErr_SetString</span><span class="p">(</span><span class="n">PyExc_TypeError</span><span class="p">,</span> <span class="s">&#34;SQL statement must be Unicode&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="nb">NULL</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nf">PyUnicode_READY</span><span class="p">(</span><span class="n">sql</span><span class="p">)</span> <span class="o">&lt;</span> <span class="mi">0</span><span class="p">)</span> <span class="k">return</span> <span class="nb">NULL</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="n">Py_ssize_t</span> <span class="n">length</span> <span class="o">=</span> <span class="nf">PyUnicode_GET_LENGTH</span><span class="p">(</span><span class="n">sql</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="kt">int</span> <span class="n">kind</span> <span class="o">=</span> <span class="nf">PyUnicode_KIND</span><span class="p">(</span><span class="n">sql</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="kt">void</span> <span class="o">*</span><span class="n">data</span> <span class="o">=</span> <span class="nf">PyUnicode_DATA</span><span class="p">(</span><span class="n">sql</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">at</span> <span class="o">&lt;</span> <span class="mi">0</span> <span class="o">||</span> <span class="n">at</span> <span class="o">&gt;=</span> <span class="n">length</span> <span class="o">||</span> <span class="nf">PyUnicode_READ</span><span class="p">(</span><span class="n">kind</span><span class="p">,</span> <span class="n">data</span><span class="p">,</span> <span class="n">at</span><span class="p">)</span> <span class="o">!=</span> <span class="sc">&#39;(&#39;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nf">PyErr_SetString</span><span class="p">(</span><span class="n">PyExc_ValueError</span><span class="p">,</span> <span class="s">&#34;expected opening parenthesis&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="nb">NULL</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span></code></pre></div><p>The first two lines are housekeeping: <code>Python.h</code> is just the header that lets a C function
participate in Python's world of objects and reference counting.</p>
<p><code>next_row</code> is the one function Python will call. It receives its arguments as a tuple, and
<code>PyArg_ParseTuple(args, &quot;On&quot;, &amp;sql, &amp;at)</code> unpacks them: <code>O</code> means &quot;a Python object&quot; (the SQL
statement string) and <code>n</code> means &quot;a Python integer, stored as a native <code>Py_ssize_t</code>&quot; (the
position to start at). If the caller passes nonsense, we return <code>NULL</code> - in the CPython C
API, returning <code>NULL</code> from a function is how you say &quot;an exception has been raised&quot;.</p>
<p>Two sanity checks follow: the object really is a string, and <code>PyUnicode_READY</code> normalises the
string's internal storage so we can read it safely.</p>
<p>The <code>KIND</code>/<code>DATA</code>/<code>READ</code> trio is the interesting bit. Python does not always store strings the
same way internally: a string of plain ASCII may use <strong>one</strong> byte per character, while a string
with an emoji in it uses <strong>four</strong>. Rather than caring, we ask the string for its <code>kind</code> (1, 2 or 4
bytes) and a pointer to its <code>data</code> buffer, and <code>PyUnicode_READ(kind, data, i)</code> fetches
character <code>i</code> correctly for whichever shape it is. This is a direct array lookup into memory
the string already owns - we never copy the statement.</p>
<p>Finally, we check the caller has actually aimed us at an opening parenthesis.</p>
<h2 id="chunk-2-four-bits-of-state">Chunk 2: four bits of state</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-c" data-lang="c"><span class="line"><span class="cl">    <span class="n">PyObject</span> <span class="o">*</span><span class="n">values</span> <span class="o">=</span> <span class="nf">PyList_New</span><span class="p">(</span><span class="mi">0</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">values</span><span class="p">)</span> <span class="k">return</span> <span class="nb">NULL</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="n">Py_ssize_t</span> <span class="n">start</span> <span class="o">=</span> <span class="n">at</span> <span class="o">+</span> <span class="mi">1</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="n">Py_UCS4</span> <span class="n">quote</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kt">int</span> <span class="n">depth</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="p">(</span><span class="n">Py_ssize_t</span> <span class="n">i</span> <span class="o">=</span> <span class="n">start</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">length</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">Py_UCS4</span> <span class="n">ch</span> <span class="o">=</span> <span class="nf">PyUnicode_READ</span><span class="p">(</span><span class="n">kind</span><span class="p">,</span> <span class="n">data</span><span class="p">,</span> <span class="n">i</span><span class="p">);</span>
</span></span></code></pre></div><p>The whole scanner needs surprisingly little state:</p>
<ul>
<li><code>values</code> - an empty Python list to collect the row's values into;</li>
<li><code>start</code> - where the value we are currently inside begins (just after the opening <code>(</code>);</li>
<li><code>quote</code> - which quote character opened the current quoted string, or 0 for &quot;not inside a
string&quot;;</li>
<li><code>depth</code> - how many nested parentheses we are inside. It starts at 1, because we are already
inside the tuple's own <code>(</code>.</li>
</ul>
<p>Then the loop: walk every character from <code>start</code> to the end of the statement. Everything below
happens inside this loop.</p>
<h2 id="chunk-3-inside-quotes">Chunk 3: inside quotes</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-c" data-lang="c"><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="n">quote</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="p">(</span><span class="n">ch</span> <span class="o">==</span> <span class="sc">&#39;\\&#39;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                <span class="k">if</span> <span class="p">(</span><span class="n">i</span> <span class="o">+</span> <span class="mi">1</span> <span class="o">&lt;</span> <span class="n">length</span><span class="p">)</span> <span class="n">i</span><span class="o">++</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">                <span class="k">continue</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="p">(</span><span class="n">ch</span> <span class="o">==</span> <span class="n">quote</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                <span class="k">if</span> <span class="p">(</span><span class="n">i</span> <span class="o">+</span> <span class="mi">1</span> <span class="o">&lt;</span> <span class="n">length</span> <span class="o">&amp;&amp;</span> <span class="nf">PyUnicode_READ</span><span class="p">(</span><span class="n">kind</span><span class="p">,</span> <span class="n">data</span><span class="p">,</span> <span class="n">i</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span> <span class="o">==</span> <span class="n">quote</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                    <span class="n">i</span><span class="o">++</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">                    <span class="k">continue</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">                <span class="p">}</span>
</span></span><span class="line"><span class="cl">                <span class="n">quote</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="k">continue</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span></code></pre></div><p>If we are inside a quoted string, the only characters that matter are:</p>
<ul>
<li>a <strong>backslash</strong>, which escapes the next character - so <code>\'</code> inside a single-quoted value
does not end the string. We skip the next character by bumping the loop counter manually;</li>
<li>a <strong>quote character matching the one that opened the string</strong>. If it is immediately
followed by another identical quote, that is the SQL doubling convention (<code>''</code> meaning one
literal quote), so we skip both. Otherwise the string has ended, and <code>quote</code> goes back to 0.</li>
</ul>
<p>Everything else inside a string is ignored. That is the entire escaping story: get this right
and a value like `'),(`` is <strong>just data</strong>.</p>
<h2 id="chunk-4-structure-boundaries-and-finding-a-way-out">Chunk 4: structure, boundaries, and finding a way out</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-c" data-lang="c"><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="n">ch</span> <span class="o">==</span> <span class="sc">&#39;\&#39;&#39;</span> <span class="o">||</span> <span class="n">ch</span> <span class="o">==</span> <span class="sc">&#39;&#34;&#39;</span> <span class="o">||</span> <span class="n">ch</span> <span class="o">==</span> <span class="sc">&#39;`&#39;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="n">quote</span> <span class="o">=</span> <span class="n">ch</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="n">ch</span> <span class="o">==</span> <span class="sc">&#39;(&#39;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="n">depth</span><span class="o">++</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="n">ch</span> <span class="o">==</span> <span class="sc">&#39;)&#39;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="n">depth</span><span class="o">--</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="p">(</span><span class="n">depth</span> <span class="o">==</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                <span class="n">Py_ssize_t</span> <span class="n">left</span> <span class="o">=</span> <span class="n">start</span><span class="p">,</span> <span class="n">right</span> <span class="o">=</span> <span class="n">i</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">                <span class="k">while</span> <span class="p">(</span><span class="n">left</span> <span class="o">&lt;</span> <span class="n">right</span> <span class="o">&amp;&amp;</span> <span class="nf">Py_UNICODE_ISSPACE</span><span class="p">(</span><span class="nf">PyUnicode_READ</span><span class="p">(</span><span class="n">kind</span><span class="p">,</span> <span class="n">data</span><span class="p">,</span> <span class="n">left</span><span class="p">)))</span> <span class="n">left</span><span class="o">++</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">                <span class="k">while</span> <span class="p">(</span><span class="n">right</span> <span class="o">&gt;</span> <span class="n">left</span> <span class="o">&amp;&amp;</span> <span class="nf">Py_UNICODE_ISSPACE</span><span class="p">(</span><span class="nf">PyUnicode_READ</span><span class="p">(</span><span class="n">kind</span><span class="p">,</span> <span class="n">data</span><span class="p">,</span> <span class="n">right</span> <span class="o">-</span> <span class="mi">1</span><span class="p">)))</span> <span class="n">right</span><span class="o">--</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">                <span class="n">PyObject</span> <span class="o">*</span><span class="n">token</span> <span class="o">=</span> <span class="nf">PyUnicode_Substring</span><span class="p">(</span><span class="n">sql</span><span class="p">,</span> <span class="n">left</span><span class="p">,</span> <span class="n">right</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">                <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">token</span> <span class="o">||</span> <span class="nf">PyList_Append</span><span class="p">(</span><span class="n">values</span><span class="p">,</span> <span class="n">token</span><span class="p">)</span> <span class="o">&lt;</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span> <span class="nf">Py_XDECREF</span><span class="p">(</span><span class="n">token</span><span class="p">);</span> <span class="nf">Py_DECREF</span><span class="p">(</span><span class="n">values</span><span class="p">);</span> <span class="k">return</span> <span class="nb">NULL</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">                <span class="nf">Py_DECREF</span><span class="p">(</span><span class="n">token</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">                <span class="n">PyObject</span> <span class="o">*</span><span class="n">result</span> <span class="o">=</span> <span class="nf">PyTuple_New</span><span class="p">(</span><span class="mi">2</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">                <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">result</span><span class="p">)</span> <span class="p">{</span> <span class="nf">Py_DECREF</span><span class="p">(</span><span class="n">values</span><span class="p">);</span> <span class="k">return</span> <span class="nb">NULL</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">                <span class="n">PyObject</span> <span class="o">*</span><span class="n">position</span> <span class="o">=</span> <span class="nf">PyLong_FromSsize_t</span><span class="p">(</span><span class="n">i</span> <span class="o">+</span> <span class="mi">1</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">                <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">position</span><span class="p">)</span> <span class="p">{</span> <span class="nf">Py_DECREF</span><span class="p">(</span><span class="n">result</span><span class="p">);</span> <span class="nf">Py_DECREF</span><span class="p">(</span><span class="n">values</span><span class="p">);</span> <span class="k">return</span> <span class="nb">NULL</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">                <span class="nf">PyTuple_SET_ITEM</span><span class="p">(</span><span class="n">result</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="n">values</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">                <span class="nf">PyTuple_SET_ITEM</span><span class="p">(</span><span class="n">result</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="n">position</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">                <span class="k">return</span> <span class="n">result</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="n">ch</span> <span class="o">==</span> <span class="sc">&#39;,&#39;</span> <span class="o">&amp;&amp;</span> <span class="n">depth</span> <span class="o">==</span> <span class="mi">1</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="n">Py_ssize_t</span> <span class="n">left</span> <span class="o">=</span> <span class="n">start</span><span class="p">,</span> <span class="n">right</span> <span class="o">=</span> <span class="n">i</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">            <span class="k">while</span> <span class="p">(</span><span class="n">left</span> <span class="o">&lt;</span> <span class="n">right</span> <span class="o">&amp;&amp;</span> <span class="nf">Py_UNICODE_ISSPACE</span><span class="p">(</span><span class="nf">PyUnicode_READ</span><span class="p">(</span><span class="n">kind</span><span class="p">,</span> <span class="n">data</span><span class="p">,</span> <span class="n">left</span><span class="p">)))</span> <span class="n">left</span><span class="o">++</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">            <span class="k">while</span> <span class="p">(</span><span class="n">right</span> <span class="o">&gt;</span> <span class="n">left</span> <span class="o">&amp;&amp;</span> <span class="nf">Py_UNICODE_ISSPACE</span><span class="p">(</span><span class="nf">PyUnicode_READ</span><span class="p">(</span><span class="n">kind</span><span class="p">,</span> <span class="n">data</span><span class="p">,</span> <span class="n">right</span> <span class="o">-</span> <span class="mi">1</span><span class="p">)))</span> <span class="n">right</span><span class="o">--</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">            <span class="n">PyObject</span> <span class="o">*</span><span class="n">token</span> <span class="o">=</span> <span class="nf">PyUnicode_Substring</span><span class="p">(</span><span class="n">sql</span><span class="p">,</span> <span class="n">left</span><span class="p">,</span> <span class="n">right</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">token</span> <span class="o">||</span> <span class="nf">PyList_Append</span><span class="p">(</span><span class="n">values</span><span class="p">,</span> <span class="n">token</span><span class="p">)</span> <span class="o">&lt;</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span> <span class="nf">Py_XDECREF</span><span class="p">(</span><span class="n">token</span><span class="p">);</span> <span class="nf">Py_DECREF</span><span class="p">(</span><span class="n">values</span><span class="p">);</span> <span class="k">return</span> <span class="nb">NULL</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="nf">Py_DECREF</span><span class="p">(</span><span class="n">token</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">            <span class="n">start</span> <span class="o">=</span> <span class="n">i</span> <span class="o">+</span> <span class="mi">1</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="nf">Py_DECREF</span><span class="p">(</span><span class="n">values</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="nf">PyErr_SetString</span><span class="p">(</span><span class="n">PyExc_ValueError</span><span class="p">,</span> <span class="s">&#34;unbalanced parentheses&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">NULL</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>Outside quotes, only four characters matter:</p>
<ul>
<li><code>'</code>, <code>&quot;</code> or <code>`</code> open a quoted string, and we remember which one;</li>
<li><code>(</code> deepens the nesting (values can contain parenthesised expressions);</li>
<li><code>)</code> closes one level. If that brings <code>depth</code> back to 0, the tuple is finished: we trim
surrounding whitespace off the final value, cut it out of the statement with
<code>PyUnicode_Substring</code>, append it to the list, and return a two-item tuple of
<code>(list_of_values, position_just_after_the_closing_paren)</code>. That position is how the caller
finds the next row without being handed (or copying) the rest of the statement;</li>
<li><code>,</code> at <code>depth == 1</code> - a comma inside this tuple but not inside a nested one - is a value
boundary. Same ritual: trim, slice, append, and remember that the next value starts after
the comma.</li>
</ul>
<p>The <code>Py_DECREF</code> calls sprinkled through the error paths are reference-counting bookkeeping -
the C-side equivalent of Python's garbage collector, telling CPython &quot;I am done with this
object&quot;, so nothing leaks.</p>
<p>If the loop reaches the end of the statement without ever closing
the tuple, we tidy up, raise a Python <code>ValueError(&quot;unbalanced parentheses&quot;)</code> and return
<code>NULL</code>. DumpTales' Python caller catches that <code>ValueError</code> and re-raises it as its own
<code>DumpError</code>.</p>
<h2 id="chunk-5-the-glue-that-makes-it-importable">Chunk 5: the glue that makes it importable</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-c" data-lang="c"><span class="line"><span class="cl"><span class="k">static</span> <span class="n">PyMethodDef</span> <span class="n">methods</span><span class="p">[]</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span><span class="s">&#34;next_row&#34;</span><span class="p">,</span> <span class="n">next_row</span><span class="p">,</span> <span class="n">METH_VARARGS</span><span class="p">,</span> <span class="s">&#34;Parse the next SQL VALUES tuple without copying the remaining statement.&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span><span class="nb">NULL</span><span class="p">,</span> <span class="nb">NULL</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nb">NULL</span><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">};</span>
</span></span><span class="line"><span class="cl"><span class="k">static</span> <span class="k">struct</span> <span class="n">PyModuleDef</span> <span class="n">module</span> <span class="o">=</span> <span class="p">{</span><span class="n">PyModuleDef_HEAD_INIT</span><span class="p">,</span> <span class="s">&#34;_dumptales_native&#34;</span><span class="p">,</span> <span class="nb">NULL</span><span class="p">,</span> <span class="o">-</span><span class="mi">1</span><span class="p">,</span> <span class="n">methods</span><span class="p">};</span>
</span></span><span class="line"><span class="cl"><span class="n">PyMODINIT_FUNC</span> <span class="nf">PyInit__dumptales_native</span><span class="p">(</span><span class="kt">void</span><span class="p">)</span> <span class="p">{</span> <span class="k">return</span> <span class="nf">PyModule_Create</span><span class="p">(</span><span class="o">&amp;</span><span class="n">module</span><span class="p">);</span> <span class="p">}</span>
</span></span></code></pre></div><p>The last five lines are pure ceremony, but they are what make the whole thing work: a table
registering <code>next_row</code> under its Python name, a module definition calling the module
<code>_dumptales_native</code>, and the initialisation function CPython runs when you <code>import _dumptales_native</code>. After that, from Python's point of view, it is an ordinary module.</p>
<h2 id="why-the-c-wins">Why the C wins</h2>
<p>The Python functions <code>parens</code> and <code>split_top</code> in dumptales.py implement exactly this state machine (I've kept
them there in case, for whatever reason, the user doesn't or can't use the C parser).</p>
<p>But three things make the C version faster:</p>
<ol>
<li><strong>No interpreter tax.</strong> In the Python loop, each character costs bytecode dispatch, object
handling and reference counting before the actual comparison happens. The compiled loop is
a handful of machine instructions per character.</li>
<li><strong>No intermediate copies.</strong> The Python path slices the entire tuple text out of the
statement first (<code>raw, pos = parens(s, pos)</code>), then slices that again per value with
<code>split_top</code>. The C version reads characters in place and creates exactly one new string
per value - the minimum work needed to return Python objects at all.</li>
<li><strong>No copying of the remaining statement.</strong> The function hands back an integer offset and
the caller resumes there. The Python approach re-slices the still-unparsed tail of a
statement that can be megabytes long, once per row.</li>
</ol>
<h2 id="lets-do-some-measurements">Let's do some measurements!</h2>
<p>Consider this code (you'll need to have a copy of the <a href="https://git.mig5.net/mig5/dumptales" target="_blank" rel="noopener noreferrer">dumptales</a>

codebase handy and relative to the script):</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">sys</span><span class="o">,</span> <span class="nn">time</span>
</span></span><span class="line"><span class="cl"><span class="n">sys</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">insert</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="s1">&#39;/path/to/dumptales/source&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">dumptales</span> <span class="kn">import</span> <span class="n">parens</span><span class="p">,</span> <span class="n">split_top</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">_dumptales_native</span> <span class="k">as</span> <span class="nn">native</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">row</span> <span class="o">=</span> <span class="s2">&#34;(1,&#39;Acme &amp; Co&#39;,45.5,&#39;2026-09-30&#39;,`some_long_text`,NULL,&#39;plain&#39;)&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">s</span> <span class="o">=</span> <span class="s2">&#34;INSERT INTO `t` VALUES &#34;</span> <span class="o">+</span> <span class="s2">&#34;,&#34;</span><span class="o">.</span><span class="n">join</span><span class="p">([</span><span class="n">row</span><span class="p">]</span> <span class="o">*</span> <span class="mi">50000</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">start</span> <span class="o">=</span> <span class="n">s</span><span class="o">.</span><span class="n">index</span><span class="p">(</span><span class="s1">&#39;(&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">python_loop</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">    <span class="n">pos</span> <span class="o">=</span> <span class="n">start</span>
</span></span><span class="line"><span class="cl">    <span class="n">n</span> <span class="o">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">    <span class="k">while</span> <span class="n">pos</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">s</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">raw</span><span class="p">,</span> <span class="n">pos</span> <span class="o">=</span> <span class="n">parens</span><span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="n">pos</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">n</span> <span class="o">+=</span> <span class="nb">len</span><span class="p">(</span><span class="n">split_top</span><span class="p">(</span><span class="n">raw</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">        <span class="k">while</span> <span class="n">pos</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">s</span><span class="p">)</span> <span class="ow">and</span> <span class="n">s</span><span class="p">[</span><span class="n">pos</span><span class="p">]</span><span class="o">.</span><span class="n">isspace</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">            <span class="n">pos</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">pos</span> <span class="o">==</span> <span class="nb">len</span><span class="p">(</span><span class="n">s</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="k">break</span>
</span></span><span class="line"><span class="cl">        <span class="n">pos</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">        <span class="k">while</span> <span class="n">pos</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">s</span><span class="p">)</span> <span class="ow">and</span> <span class="n">s</span><span class="p">[</span><span class="n">pos</span><span class="p">]</span><span class="o">.</span><span class="n">isspace</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">            <span class="n">pos</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">n</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">native_loop</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">    <span class="n">pos</span> <span class="o">=</span> <span class="n">start</span>
</span></span><span class="line"><span class="cl">    <span class="n">n</span> <span class="o">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">    <span class="k">while</span> <span class="n">pos</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">s</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">raw_values</span><span class="p">,</span> <span class="n">pos</span> <span class="o">=</span> <span class="n">native</span><span class="o">.</span><span class="n">next_row</span><span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="n">pos</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">n</span> <span class="o">+=</span> <span class="nb">len</span><span class="p">(</span><span class="n">raw_values</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">while</span> <span class="n">pos</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">s</span><span class="p">)</span> <span class="ow">and</span> <span class="n">s</span><span class="p">[</span><span class="n">pos</span><span class="p">]</span><span class="o">.</span><span class="n">isspace</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">            <span class="n">pos</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">pos</span> <span class="o">==</span> <span class="nb">len</span><span class="p">(</span><span class="n">s</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="k">break</span>
</span></span><span class="line"><span class="cl">        <span class="n">pos</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">        <span class="k">while</span> <span class="n">pos</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">s</span><span class="p">)</span> <span class="ow">and</span> <span class="n">s</span><span class="p">[</span><span class="n">pos</span><span class="p">]</span><span class="o">.</span><span class="n">isspace</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">            <span class="n">pos</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">n</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">assert</span> <span class="n">python_loop</span><span class="p">()</span> <span class="o">==</span> <span class="n">native_loop</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="n">name</span><span class="p">,</span> <span class="n">fn</span> <span class="ow">in</span> <span class="p">((</span><span class="s1">&#39;python&#39;</span><span class="p">,</span> <span class="n">python_loop</span><span class="p">),</span> <span class="p">(</span><span class="s1">&#39;native&#39;</span><span class="p">,</span> <span class="n">native_loop</span><span class="p">)):</span>
</span></span><span class="line"><span class="cl">    <span class="n">t</span> <span class="o">=</span> <span class="n">time</span><span class="o">.</span><span class="n">perf_counter</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">fn</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s1">&#39;</span><span class="si">{</span><span class="n">name</span><span class="si">}</span><span class="s1">: </span><span class="si">{</span><span class="n">time</span><span class="o">.</span><span class="n">perf_counter</span><span class="p">()</span> <span class="o">-</span> <span class="n">t</span><span class="si">:</span><span class="s1">.3f</span><span class="si">}</span><span class="s1">s&#39;</span><span class="p">)</span>
</span></span></code></pre></div><p>One INSERT statement, 50,000 rows, both parsers walking it to completion:</p>
<table>
<thead>
<tr>
<th>Parser</th>
<th style="text-align:right">Time</th>
</tr>
</thead>
<tbody>
<tr>
<td>Python (<code>parens</code> + <code>split_top</code>)</td>
<td style="text-align:right">0.52 s</td>
</tr>
<tr>
<td>C (<code>_dumptales_native.next_row</code>)</td>
<td style="text-align:right">0.03 s</td>
</tr>
</tbody>
</table>
<p>About eighteen times faster, consistently across runs. Multiply by a dump with hundreds of
statements, and the saving is far from subtle!</p>
<p>In fact, real database dumps would likely stress-test the Python interpreter version <strong>even more</strong>,
because wider rows, with more real string content, would increase the tax on the interpreter.</p>
<p>For example, if we test with wider rows:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">sys</span><span class="o">,</span> <span class="nn">time</span>
</span></span><span class="line"><span class="cl"><span class="n">sys</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">insert</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="s1">&#39;/path/to/dumptales/source&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">dumptales</span> <span class="kn">import</span> <span class="n">parens</span><span class="p">,</span> <span class="n">split_top</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">_dumptales_native</span> <span class="k">as</span> <span class="nn">native</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">make</span><span class="p">(</span><span class="n">nrows</span><span class="p">,</span> <span class="n">ncols</span><span class="p">,</span> <span class="n">textlen</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">vals</span> <span class="o">=</span> <span class="s1">&#39;,&#39;</span><span class="o">.</span><span class="n">join</span><span class="p">([</span><span class="s2">&#34;&#39;x</span><span class="si">%d</span><span class="s2">_</span><span class="si">%s</span><span class="s2">&#39;&#34;</span> <span class="o">%</span> <span class="p">(</span><span class="n">j</span><span class="p">,</span> <span class="s1">&#39;y&#39;</span><span class="o">*</span><span class="p">(</span><span class="n">textlen</span><span class="o">//</span><span class="mi">6</span><span class="p">))</span> <span class="k">if</span> <span class="n">j</span> <span class="o">%</span> <span class="mi">3</span> <span class="o">==</span> <span class="mi">1</span> <span class="k">else</span> <span class="nb">str</span><span class="p">(</span><span class="n">j</span><span class="p">)</span> <span class="k">for</span> <span class="n">j</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">ncols</span><span class="p">)])</span>
</span></span><span class="line"><span class="cl">    <span class="n">row</span> <span class="o">=</span> <span class="s1">&#39;(&#39;</span> <span class="o">+</span> <span class="n">vals</span> <span class="o">+</span> <span class="s1">&#39;)&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="s2">&#34;INSERT INTO `t` VALUES &#34;</span> <span class="o">+</span> <span class="s1">&#39;,&#39;</span><span class="o">.</span><span class="n">join</span><span class="p">([</span><span class="n">row</span><span class="p">]</span><span class="o">*</span><span class="n">nrows</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">loop</span><span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="n">start</span><span class="p">,</span> <span class="n">native_mode</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">pos</span> <span class="o">=</span> <span class="n">start</span><span class="p">;</span> <span class="n">n</span> <span class="o">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">    <span class="k">while</span> <span class="n">pos</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">s</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">native_mode</span><span class="p">:</span>                                                                                                                                                                            <span class="n">raw_values</span><span class="p">,</span> <span class="n">pos</span> <span class="o">=</span> <span class="n">native</span><span class="o">.</span><span class="n">next_row</span><span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="n">pos</span><span class="p">);</span> <span class="n">n</span> <span class="o">+=</span> <span class="nb">len</span><span class="p">(</span><span class="n">raw_values</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">raw</span><span class="p">,</span> <span class="n">pos</span> <span class="o">=</span> <span class="n">parens</span><span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="n">pos</span><span class="p">);</span> <span class="n">n</span> <span class="o">+=</span> <span class="nb">len</span><span class="p">(</span><span class="n">split_top</span><span class="p">(</span><span class="n">raw</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">        <span class="k">while</span> <span class="n">pos</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">s</span><span class="p">)</span> <span class="ow">and</span> <span class="n">s</span><span class="p">[</span><span class="n">pos</span><span class="p">]</span><span class="o">.</span><span class="n">isspace</span><span class="p">():</span> <span class="n">pos</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="n">pos</span> <span class="o">==</span> <span class="nb">len</span><span class="p">(</span><span class="n">s</span><span class="p">):</span> <span class="k">break</span>
</span></span><span class="line"><span class="cl">            <span class="n">pos</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">            <span class="k">while</span> <span class="n">pos</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">s</span><span class="p">)</span> <span class="ow">and</span> <span class="n">s</span><span class="p">[</span><span class="n">pos</span><span class="p">]</span><span class="o">.</span><span class="n">isspace</span><span class="p">():</span> <span class="n">pos</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">n</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="n">label</span><span class="p">,</span> <span class="n">ncols</span><span class="p">,</span> <span class="n">textlen</span> <span class="ow">in</span> <span class="p">((</span><span class="s1">&#39;narrow (7 cols, ~30 ch)&#39;</span><span class="p">,</span> <span class="mi">7</span><span class="p">,</span> <span class="mi">30</span><span class="p">),</span> <span class="p">(</span><span class="s1">&#39;wide (20 cols, ~240 ch)&#39;</span><span class="p">,</span> <span class="mi">20</span><span class="p">,</span> <span class="mi">240</span><span class="p">)):</span>
</span></span><span class="line"><span class="cl">    <span class="n">s</span> <span class="o">=</span> <span class="n">make</span><span class="p">(</span><span class="mi">50000</span><span class="p">,</span> <span class="n">ncols</span><span class="p">,</span> <span class="n">textlen</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">start</span> <span class="o">=</span> <span class="n">s</span><span class="o">.</span><span class="n">index</span><span class="p">(</span><span class="s1">&#39;(&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">assert</span> <span class="n">loop</span><span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="n">start</span><span class="p">,</span> <span class="kc">False</span><span class="p">)</span> <span class="o">==</span> <span class="n">loop</span><span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="n">start</span><span class="p">,</span> <span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">out</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">mode</span> <span class="ow">in</span> <span class="p">(</span><span class="kc">False</span><span class="p">,</span> <span class="kc">True</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">t</span> <span class="o">=</span> <span class="n">time</span><span class="o">.</span><span class="n">perf_counter</span><span class="p">();</span> <span class="n">loop</span><span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="n">start</span><span class="p">,</span> <span class="n">mode</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">out</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">time</span><span class="o">.</span><span class="n">perf_counter</span><span class="p">()</span> <span class="o">-</span> <span class="n">t</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s1">&#39;</span><span class="si">{</span><span class="n">label</span><span class="si">}</span><span class="s1">: statement </span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">s</span><span class="p">)</span><span class="o">/</span><span class="mf">1e6</span><span class="si">:</span><span class="s1">.1f</span><span class="si">}</span><span class="s1"> MB | python </span><span class="si">{</span><span class="n">out</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="si">:</span><span class="s1">.2f</span><span class="si">}</span><span class="s1">s | native </span><span class="si">{</span><span class="n">out</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span><span class="si">:</span><span class="s1">.3f</span><span class="si">}</span><span class="s1">s | ratio </span><span class="si">{</span><span class="n">out</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">/</span><span class="n">out</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span><span class="si">:</span><span class="s1">.0f</span><span class="si">}</span><span class="s1">x&#39;</span><span class="p">)</span>
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">python3 /tmp/bench_wide.py <span class="o">&amp;&amp;</span> python3 /tmp/bench_wide.py
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">narrow <span class="o">(</span><span class="m">7</span> cols, ~30 ch<span class="o">)</span>: statement 1.7 MB <span class="p">|</span> python 0.38s <span class="p">|</span> native 0.028s <span class="p">|</span> ratio 14x
</span></span><span class="line"><span class="cl">wide <span class="o">(</span><span class="m">20</span> cols, ~240 ch<span class="o">)</span>: statement 18.0 MB <span class="p">|</span> python 3.83s <span class="p">|</span> native 0.177s <span class="p">|</span> ratio 22x
</span></span><span class="line"><span class="cl">narrow <span class="o">(</span><span class="m">7</span> cols, ~30 ch<span class="o">)</span>: statement 1.7 MB <span class="p">|</span> python 0.62s <span class="p">|</span> native 0.038s <span class="p">|</span> ratio 16x
</span></span><span class="line"><span class="cl">wide <span class="o">(</span><span class="m">20</span> cols, ~240 ch<span class="o">)</span>: statement 18.0 MB <span class="p">|</span> python 4.47s <span class="p">|</span> native 0.059s <span class="p">|</span> ratio 75x
</span></span></code></pre></div><p>So there you go. I'm pretty happy with the performance - still maybe could be better. Maybe Python and C
is the wrong solution here - will I end up using Rust for the first time? We'll see :)</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Language: C, exposed to Python via the CPython C API</li>
<li>Built at install time</li>
<li>Falls back to the pure Python parser when unavailable</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="/projects/dumptales/">DumpTales project page</a>
</li>
<li><a href="/news/dumptales/">Previous article announcing the project: DumpTales</a>
</li>
<li><a href="https://git.mig5.net/mig5/dumptales" target="_blank" rel="noopener noreferrer">Source and documentation</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need database performance or migration support?</div>
  <div class="smallprint mb-3">I do contract systems and database administration, and can help.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-cpu"></i><span>DumpTales</span></div>
    <h1 class="display-6 fw-semibold mb-2">Teaching Python to skip a hundred thousand tuples, in C</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-30">September 30, 2026</time></p>
    <p class="lead mb-0">Most of DumpTales' MySQL parsing time used to go on one job: chopping a monster INSERT
statement into rows and values. So I translated that loop into C. Here is why the same
algorithm now works faster!</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-30T00:00:00&#43;10:00">30 September 2026</time>
        </div>

<h2 id="squeezing-performance-out-of-a-hotspot-database-diffing">Squeezing performance out of a hotspot: database diffing</h2>
<p>I recently released <a href="/news/dumptales/">DumpTales</a>
, which compares two database snapshots offline.</p>
<p>But before it can compare anything, it has to <em>read</em>. And conventional <code>mysqldump</code> output is not kind to readers:
one INSERT statement typically carries hundreds or thousands of rows, and a single statement
can legally be 128 MiB of comma-separated tuples:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="o">`</span><span class="n">accounts</span><span class="o">`</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">1</span><span class="p">,</span><span class="s1">&#39;Acme&#39;</span><span class="p">,</span><span class="s1">&#39;basic&#39;</span><span class="p">),(</span><span class="mi">2</span><span class="p">,</span><span class="s1">&#39;Harbour&#39;</span><span class="p">,</span><span class="s1">&#39;basic&#39;</span><span class="p">),(</span><span class="mi">3</span><span class="p">,</span><span class="s1">&#39;Old Co&#39;</span><span class="p">,</span><span class="s1">&#39;trial&#39;</span><span class="p">);</span><span class="w">
</span></span></span></code></pre></div><p>Splitting that into rows and values sounds trivial, until you remember that <code>,</code>, <code>)</code>, <code>'</code> and
<code>&quot;</code> are all perfectly legal <em>inside</em> a quoted value. You need a little state machine that
tracks whether you are inside quotes, handles backslash escapes and doubled quotes, counts
nested parentheses, and slices out values only at top-level commas. Do it wrong, and a value
containing the string `'),(`` will quietly corrupt the comparison.</p>
<p>Dumptales is, like most of my tools, a Python app, and so I first wrote that state machine in Python, as two functions called <code>parens</code>
and <code>split_top</code>.</p>
<p>Then I profiled, and almost all of the MySQL parsing time was sitting in <strong>those two loops</strong>. Not
because the algorithm was clever or expensive - but because it is a per-character loop, and a
per-character loop in an interpreter pays rent on every character.</p>
<p>In the end, with that <em>same algorithm</em> converted into a CPython extension module, the results were
pretty dramatic. Nothing cleverer, just the same logic compiled to machine code.</p>
<p>If you're like me, you very rarely delve into C these days, but it can be kind of interesting.</p>
<p>And it's only 82 lines! Let's go through it.</p>
<h2 id="chunk-1-whos-calling-and-where-are-we">Chunk 1: who's calling, and where are we?</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-c" data-lang="c"><span class="line"><span class="cl"><span class="cp">#define PY_SSIZE_T_CLEAN
</span></span></span><span class="line"><span class="cl"><span class="cp">#include</span> <span class="cpf">&lt;Python.h&gt;</span><span class="cp">
</span></span></span><span class="line"><span class="cl"><span class="cp"></span>
</span></span><span class="line"><span class="cl"><span class="k">static</span> <span class="n">PyObject</span> <span class="o">*</span><span class="nf">next_row</span><span class="p">(</span><span class="n">PyObject</span> <span class="o">*</span><span class="n">self</span><span class="p">,</span> <span class="n">PyObject</span> <span class="o">*</span><span class="n">args</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">PyObject</span> <span class="o">*</span><span class="n">sql</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="n">Py_ssize_t</span> <span class="n">at</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nf">PyArg_ParseTuple</span><span class="p">(</span><span class="n">args</span><span class="p">,</span> <span class="s">&#34;On&#34;</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">sql</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">at</span><span class="p">))</span> <span class="k">return</span> <span class="nb">NULL</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nf">PyUnicode_Check</span><span class="p">(</span><span class="n">sql</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nf">PyErr_SetString</span><span class="p">(</span><span class="n">PyExc_TypeError</span><span class="p">,</span> <span class="s">&#34;SQL statement must be Unicode&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="nb">NULL</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nf">PyUnicode_READY</span><span class="p">(</span><span class="n">sql</span><span class="p">)</span> <span class="o">&lt;</span> <span class="mi">0</span><span class="p">)</span> <span class="k">return</span> <span class="nb">NULL</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="n">Py_ssize_t</span> <span class="n">length</span> <span class="o">=</span> <span class="nf">PyUnicode_GET_LENGTH</span><span class="p">(</span><span class="n">sql</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="kt">int</span> <span class="n">kind</span> <span class="o">=</span> <span class="nf">PyUnicode_KIND</span><span class="p">(</span><span class="n">sql</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="kt">void</span> <span class="o">*</span><span class="n">data</span> <span class="o">=</span> <span class="nf">PyUnicode_DATA</span><span class="p">(</span><span class="n">sql</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">at</span> <span class="o">&lt;</span> <span class="mi">0</span> <span class="o">||</span> <span class="n">at</span> <span class="o">&gt;=</span> <span class="n">length</span> <span class="o">||</span> <span class="nf">PyUnicode_READ</span><span class="p">(</span><span class="n">kind</span><span class="p">,</span> <span class="n">data</span><span class="p">,</span> <span class="n">at</span><span class="p">)</span> <span class="o">!=</span> <span class="sc">&#39;(&#39;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nf">PyErr_SetString</span><span class="p">(</span><span class="n">PyExc_ValueError</span><span class="p">,</span> <span class="s">&#34;expected opening parenthesis&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="nb">NULL</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span></code></pre></div><p>The first two lines are housekeeping: <code>Python.h</code> is just the header that lets a C function
participate in Python's world of objects and reference counting.</p>
<p><code>next_row</code> is the one function Python will call. It receives its arguments as a tuple, and
<code>PyArg_ParseTuple(args, &quot;On&quot;, &amp;sql, &amp;at)</code> unpacks them: <code>O</code> means &quot;a Python object&quot; (the SQL
statement string) and <code>n</code> means &quot;a Python integer, stored as a native <code>Py_ssize_t</code>&quot; (the
position to start at). If the caller passes nonsense, we return <code>NULL</code> - in the CPython C
API, returning <code>NULL</code> from a function is how you say &quot;an exception has been raised&quot;.</p>
<p>Two sanity checks follow: the object really is a string, and <code>PyUnicode_READY</code> normalises the
string's internal storage so we can read it safely.</p>
<p>The <code>KIND</code>/<code>DATA</code>/<code>READ</code> trio is the interesting bit. Python does not always store strings the
same way internally: a string of plain ASCII may use <strong>one</strong> byte per character, while a string
with an emoji in it uses <strong>four</strong>. Rather than caring, we ask the string for its <code>kind</code> (1, 2 or 4
bytes) and a pointer to its <code>data</code> buffer, and <code>PyUnicode_READ(kind, data, i)</code> fetches
character <code>i</code> correctly for whichever shape it is. This is a direct array lookup into memory
the string already owns - we never copy the statement.</p>
<p>Finally, we check the caller has actually aimed us at an opening parenthesis.</p>
<h2 id="chunk-2-four-bits-of-state">Chunk 2: four bits of state</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-c" data-lang="c"><span class="line"><span class="cl">    <span class="n">PyObject</span> <span class="o">*</span><span class="n">values</span> <span class="o">=</span> <span class="nf">PyList_New</span><span class="p">(</span><span class="mi">0</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">values</span><span class="p">)</span> <span class="k">return</span> <span class="nb">NULL</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="n">Py_ssize_t</span> <span class="n">start</span> <span class="o">=</span> <span class="n">at</span> <span class="o">+</span> <span class="mi">1</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="n">Py_UCS4</span> <span class="n">quote</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kt">int</span> <span class="n">depth</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="p">(</span><span class="n">Py_ssize_t</span> <span class="n">i</span> <span class="o">=</span> <span class="n">start</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">length</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">Py_UCS4</span> <span class="n">ch</span> <span class="o">=</span> <span class="nf">PyUnicode_READ</span><span class="p">(</span><span class="n">kind</span><span class="p">,</span> <span class="n">data</span><span class="p">,</span> <span class="n">i</span><span class="p">);</span>
</span></span></code></pre></div><p>The whole scanner needs surprisingly little state:</p>
<ul>
<li><code>values</code> - an empty Python list to collect the row's values into;</li>
<li><code>start</code> - where the value we are currently inside begins (just after the opening <code>(</code>);</li>
<li><code>quote</code> - which quote character opened the current quoted string, or 0 for &quot;not inside a
string&quot;;</li>
<li><code>depth</code> - how many nested parentheses we are inside. It starts at 1, because we are already
inside the tuple's own <code>(</code>.</li>
</ul>
<p>Then the loop: walk every character from <code>start</code> to the end of the statement. Everything below
happens inside this loop.</p>
<h2 id="chunk-3-inside-quotes">Chunk 3: inside quotes</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-c" data-lang="c"><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="n">quote</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="p">(</span><span class="n">ch</span> <span class="o">==</span> <span class="sc">&#39;\\&#39;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                <span class="k">if</span> <span class="p">(</span><span class="n">i</span> <span class="o">+</span> <span class="mi">1</span> <span class="o">&lt;</span> <span class="n">length</span><span class="p">)</span> <span class="n">i</span><span class="o">++</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">                <span class="k">continue</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="p">(</span><span class="n">ch</span> <span class="o">==</span> <span class="n">quote</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                <span class="k">if</span> <span class="p">(</span><span class="n">i</span> <span class="o">+</span> <span class="mi">1</span> <span class="o">&lt;</span> <span class="n">length</span> <span class="o">&amp;&amp;</span> <span class="nf">PyUnicode_READ</span><span class="p">(</span><span class="n">kind</span><span class="p">,</span> <span class="n">data</span><span class="p">,</span> <span class="n">i</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span> <span class="o">==</span> <span class="n">quote</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                    <span class="n">i</span><span class="o">++</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">                    <span class="k">continue</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">                <span class="p">}</span>
</span></span><span class="line"><span class="cl">                <span class="n">quote</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="k">continue</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span></code></pre></div><p>If we are inside a quoted string, the only characters that matter are:</p>
<ul>
<li>a <strong>backslash</strong>, which escapes the next character - so <code>\'</code> inside a single-quoted value
does not end the string. We skip the next character by bumping the loop counter manually;</li>
<li>a <strong>quote character matching the one that opened the string</strong>. If it is immediately
followed by another identical quote, that is the SQL doubling convention (<code>''</code> meaning one
literal quote), so we skip both. Otherwise the string has ended, and <code>quote</code> goes back to 0.</li>
</ul>
<p>Everything else inside a string is ignored. That is the entire escaping story: get this right
and a value like `'),(`` is <strong>just data</strong>.</p>
<h2 id="chunk-4-structure-boundaries-and-finding-a-way-out">Chunk 4: structure, boundaries, and finding a way out</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-c" data-lang="c"><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="n">ch</span> <span class="o">==</span> <span class="sc">&#39;\&#39;&#39;</span> <span class="o">||</span> <span class="n">ch</span> <span class="o">==</span> <span class="sc">&#39;&#34;&#39;</span> <span class="o">||</span> <span class="n">ch</span> <span class="o">==</span> <span class="sc">&#39;`&#39;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="n">quote</span> <span class="o">=</span> <span class="n">ch</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="n">ch</span> <span class="o">==</span> <span class="sc">&#39;(&#39;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="n">depth</span><span class="o">++</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="n">ch</span> <span class="o">==</span> <span class="sc">&#39;)&#39;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="n">depth</span><span class="o">--</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="p">(</span><span class="n">depth</span> <span class="o">==</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                <span class="n">Py_ssize_t</span> <span class="n">left</span> <span class="o">=</span> <span class="n">start</span><span class="p">,</span> <span class="n">right</span> <span class="o">=</span> <span class="n">i</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">                <span class="k">while</span> <span class="p">(</span><span class="n">left</span> <span class="o">&lt;</span> <span class="n">right</span> <span class="o">&amp;&amp;</span> <span class="nf">Py_UNICODE_ISSPACE</span><span class="p">(</span><span class="nf">PyUnicode_READ</span><span class="p">(</span><span class="n">kind</span><span class="p">,</span> <span class="n">data</span><span class="p">,</span> <span class="n">left</span><span class="p">)))</span> <span class="n">left</span><span class="o">++</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">                <span class="k">while</span> <span class="p">(</span><span class="n">right</span> <span class="o">&gt;</span> <span class="n">left</span> <span class="o">&amp;&amp;</span> <span class="nf">Py_UNICODE_ISSPACE</span><span class="p">(</span><span class="nf">PyUnicode_READ</span><span class="p">(</span><span class="n">kind</span><span class="p">,</span> <span class="n">data</span><span class="p">,</span> <span class="n">right</span> <span class="o">-</span> <span class="mi">1</span><span class="p">)))</span> <span class="n">right</span><span class="o">--</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">                <span class="n">PyObject</span> <span class="o">*</span><span class="n">token</span> <span class="o">=</span> <span class="nf">PyUnicode_Substring</span><span class="p">(</span><span class="n">sql</span><span class="p">,</span> <span class="n">left</span><span class="p">,</span> <span class="n">right</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">                <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">token</span> <span class="o">||</span> <span class="nf">PyList_Append</span><span class="p">(</span><span class="n">values</span><span class="p">,</span> <span class="n">token</span><span class="p">)</span> <span class="o">&lt;</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span> <span class="nf">Py_XDECREF</span><span class="p">(</span><span class="n">token</span><span class="p">);</span> <span class="nf">Py_DECREF</span><span class="p">(</span><span class="n">values</span><span class="p">);</span> <span class="k">return</span> <span class="nb">NULL</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">                <span class="nf">Py_DECREF</span><span class="p">(</span><span class="n">token</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">                <span class="n">PyObject</span> <span class="o">*</span><span class="n">result</span> <span class="o">=</span> <span class="nf">PyTuple_New</span><span class="p">(</span><span class="mi">2</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">                <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">result</span><span class="p">)</span> <span class="p">{</span> <span class="nf">Py_DECREF</span><span class="p">(</span><span class="n">values</span><span class="p">);</span> <span class="k">return</span> <span class="nb">NULL</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">                <span class="n">PyObject</span> <span class="o">*</span><span class="n">position</span> <span class="o">=</span> <span class="nf">PyLong_FromSsize_t</span><span class="p">(</span><span class="n">i</span> <span class="o">+</span> <span class="mi">1</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">                <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">position</span><span class="p">)</span> <span class="p">{</span> <span class="nf">Py_DECREF</span><span class="p">(</span><span class="n">result</span><span class="p">);</span> <span class="nf">Py_DECREF</span><span class="p">(</span><span class="n">values</span><span class="p">);</span> <span class="k">return</span> <span class="nb">NULL</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">                <span class="nf">PyTuple_SET_ITEM</span><span class="p">(</span><span class="n">result</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="n">values</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">                <span class="nf">PyTuple_SET_ITEM</span><span class="p">(</span><span class="n">result</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="n">position</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">                <span class="k">return</span> <span class="n">result</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="n">ch</span> <span class="o">==</span> <span class="sc">&#39;,&#39;</span> <span class="o">&amp;&amp;</span> <span class="n">depth</span> <span class="o">==</span> <span class="mi">1</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="n">Py_ssize_t</span> <span class="n">left</span> <span class="o">=</span> <span class="n">start</span><span class="p">,</span> <span class="n">right</span> <span class="o">=</span> <span class="n">i</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">            <span class="k">while</span> <span class="p">(</span><span class="n">left</span> <span class="o">&lt;</span> <span class="n">right</span> <span class="o">&amp;&amp;</span> <span class="nf">Py_UNICODE_ISSPACE</span><span class="p">(</span><span class="nf">PyUnicode_READ</span><span class="p">(</span><span class="n">kind</span><span class="p">,</span> <span class="n">data</span><span class="p">,</span> <span class="n">left</span><span class="p">)))</span> <span class="n">left</span><span class="o">++</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">            <span class="k">while</span> <span class="p">(</span><span class="n">right</span> <span class="o">&gt;</span> <span class="n">left</span> <span class="o">&amp;&amp;</span> <span class="nf">Py_UNICODE_ISSPACE</span><span class="p">(</span><span class="nf">PyUnicode_READ</span><span class="p">(</span><span class="n">kind</span><span class="p">,</span> <span class="n">data</span><span class="p">,</span> <span class="n">right</span> <span class="o">-</span> <span class="mi">1</span><span class="p">)))</span> <span class="n">right</span><span class="o">--</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">            <span class="n">PyObject</span> <span class="o">*</span><span class="n">token</span> <span class="o">=</span> <span class="nf">PyUnicode_Substring</span><span class="p">(</span><span class="n">sql</span><span class="p">,</span> <span class="n">left</span><span class="p">,</span> <span class="n">right</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">token</span> <span class="o">||</span> <span class="nf">PyList_Append</span><span class="p">(</span><span class="n">values</span><span class="p">,</span> <span class="n">token</span><span class="p">)</span> <span class="o">&lt;</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span> <span class="nf">Py_XDECREF</span><span class="p">(</span><span class="n">token</span><span class="p">);</span> <span class="nf">Py_DECREF</span><span class="p">(</span><span class="n">values</span><span class="p">);</span> <span class="k">return</span> <span class="nb">NULL</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="nf">Py_DECREF</span><span class="p">(</span><span class="n">token</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">            <span class="n">start</span> <span class="o">=</span> <span class="n">i</span> <span class="o">+</span> <span class="mi">1</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="nf">Py_DECREF</span><span class="p">(</span><span class="n">values</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="nf">PyErr_SetString</span><span class="p">(</span><span class="n">PyExc_ValueError</span><span class="p">,</span> <span class="s">&#34;unbalanced parentheses&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">NULL</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>Outside quotes, only four characters matter:</p>
<ul>
<li><code>'</code>, <code>&quot;</code> or <code>`</code> open a quoted string, and we remember which one;</li>
<li><code>(</code> deepens the nesting (values can contain parenthesised expressions);</li>
<li><code>)</code> closes one level. If that brings <code>depth</code> back to 0, the tuple is finished: we trim
surrounding whitespace off the final value, cut it out of the statement with
<code>PyUnicode_Substring</code>, append it to the list, and return a two-item tuple of
<code>(list_of_values, position_just_after_the_closing_paren)</code>. That position is how the caller
finds the next row without being handed (or copying) the rest of the statement;</li>
<li><code>,</code> at <code>depth == 1</code> - a comma inside this tuple but not inside a nested one - is a value
boundary. Same ritual: trim, slice, append, and remember that the next value starts after
the comma.</li>
</ul>
<p>The <code>Py_DECREF</code> calls sprinkled through the error paths are reference-counting bookkeeping -
the C-side equivalent of Python's garbage collector, telling CPython &quot;I am done with this
object&quot;, so nothing leaks.</p>
<p>If the loop reaches the end of the statement without ever closing
the tuple, we tidy up, raise a Python <code>ValueError(&quot;unbalanced parentheses&quot;)</code> and return
<code>NULL</code>. DumpTales' Python caller catches that <code>ValueError</code> and re-raises it as its own
<code>DumpError</code>.</p>
<h2 id="chunk-5-the-glue-that-makes-it-importable">Chunk 5: the glue that makes it importable</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-c" data-lang="c"><span class="line"><span class="cl"><span class="k">static</span> <span class="n">PyMethodDef</span> <span class="n">methods</span><span class="p">[]</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span><span class="s">&#34;next_row&#34;</span><span class="p">,</span> <span class="n">next_row</span><span class="p">,</span> <span class="n">METH_VARARGS</span><span class="p">,</span> <span class="s">&#34;Parse the next SQL VALUES tuple without copying the remaining statement.&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span><span class="nb">NULL</span><span class="p">,</span> <span class="nb">NULL</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nb">NULL</span><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">};</span>
</span></span><span class="line"><span class="cl"><span class="k">static</span> <span class="k">struct</span> <span class="n">PyModuleDef</span> <span class="n">module</span> <span class="o">=</span> <span class="p">{</span><span class="n">PyModuleDef_HEAD_INIT</span><span class="p">,</span> <span class="s">&#34;_dumptales_native&#34;</span><span class="p">,</span> <span class="nb">NULL</span><span class="p">,</span> <span class="o">-</span><span class="mi">1</span><span class="p">,</span> <span class="n">methods</span><span class="p">};</span>
</span></span><span class="line"><span class="cl"><span class="n">PyMODINIT_FUNC</span> <span class="nf">PyInit__dumptales_native</span><span class="p">(</span><span class="kt">void</span><span class="p">)</span> <span class="p">{</span> <span class="k">return</span> <span class="nf">PyModule_Create</span><span class="p">(</span><span class="o">&amp;</span><span class="n">module</span><span class="p">);</span> <span class="p">}</span>
</span></span></code></pre></div><p>The last five lines are pure ceremony, but they are what make the whole thing work: a table
registering <code>next_row</code> under its Python name, a module definition calling the module
<code>_dumptales_native</code>, and the initialisation function CPython runs when you <code>import _dumptales_native</code>. After that, from Python's point of view, it is an ordinary module.</p>
<h2 id="why-the-c-wins">Why the C wins</h2>
<p>The Python functions <code>parens</code> and <code>split_top</code> in dumptales.py implement exactly this state machine (I've kept
them there in case, for whatever reason, the user doesn't or can't use the C parser).</p>
<p>But three things make the C version faster:</p>
<ol>
<li><strong>No interpreter tax.</strong> In the Python loop, each character costs bytecode dispatch, object
handling and reference counting before the actual comparison happens. The compiled loop is
a handful of machine instructions per character.</li>
<li><strong>No intermediate copies.</strong> The Python path slices the entire tuple text out of the
statement first (<code>raw, pos = parens(s, pos)</code>), then slices that again per value with
<code>split_top</code>. The C version reads characters in place and creates exactly one new string
per value - the minimum work needed to return Python objects at all.</li>
<li><strong>No copying of the remaining statement.</strong> The function hands back an integer offset and
the caller resumes there. The Python approach re-slices the still-unparsed tail of a
statement that can be megabytes long, once per row.</li>
</ol>
<h2 id="lets-do-some-measurements">Let's do some measurements!</h2>
<p>Consider this code (you'll need to have a copy of the <a href="https://git.mig5.net/mig5/dumptales" target="_blank" rel="noopener noreferrer">dumptales</a>

codebase handy and relative to the script):</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">sys</span><span class="o">,</span> <span class="nn">time</span>
</span></span><span class="line"><span class="cl"><span class="n">sys</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">insert</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="s1">&#39;/path/to/dumptales/source&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">dumptales</span> <span class="kn">import</span> <span class="n">parens</span><span class="p">,</span> <span class="n">split_top</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">_dumptales_native</span> <span class="k">as</span> <span class="nn">native</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">row</span> <span class="o">=</span> <span class="s2">&#34;(1,&#39;Acme &amp; Co&#39;,45.5,&#39;2026-09-30&#39;,`some_long_text`,NULL,&#39;plain&#39;)&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">s</span> <span class="o">=</span> <span class="s2">&#34;INSERT INTO `t` VALUES &#34;</span> <span class="o">+</span> <span class="s2">&#34;,&#34;</span><span class="o">.</span><span class="n">join</span><span class="p">([</span><span class="n">row</span><span class="p">]</span> <span class="o">*</span> <span class="mi">50000</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">start</span> <span class="o">=</span> <span class="n">s</span><span class="o">.</span><span class="n">index</span><span class="p">(</span><span class="s1">&#39;(&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">python_loop</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">    <span class="n">pos</span> <span class="o">=</span> <span class="n">start</span>
</span></span><span class="line"><span class="cl">    <span class="n">n</span> <span class="o">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">    <span class="k">while</span> <span class="n">pos</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">s</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">raw</span><span class="p">,</span> <span class="n">pos</span> <span class="o">=</span> <span class="n">parens</span><span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="n">pos</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">n</span> <span class="o">+=</span> <span class="nb">len</span><span class="p">(</span><span class="n">split_top</span><span class="p">(</span><span class="n">raw</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">        <span class="k">while</span> <span class="n">pos</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">s</span><span class="p">)</span> <span class="ow">and</span> <span class="n">s</span><span class="p">[</span><span class="n">pos</span><span class="p">]</span><span class="o">.</span><span class="n">isspace</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">            <span class="n">pos</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">pos</span> <span class="o">==</span> <span class="nb">len</span><span class="p">(</span><span class="n">s</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="k">break</span>
</span></span><span class="line"><span class="cl">        <span class="n">pos</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">        <span class="k">while</span> <span class="n">pos</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">s</span><span class="p">)</span> <span class="ow">and</span> <span class="n">s</span><span class="p">[</span><span class="n">pos</span><span class="p">]</span><span class="o">.</span><span class="n">isspace</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">            <span class="n">pos</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">n</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">native_loop</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">    <span class="n">pos</span> <span class="o">=</span> <span class="n">start</span>
</span></span><span class="line"><span class="cl">    <span class="n">n</span> <span class="o">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">    <span class="k">while</span> <span class="n">pos</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">s</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">raw_values</span><span class="p">,</span> <span class="n">pos</span> <span class="o">=</span> <span class="n">native</span><span class="o">.</span><span class="n">next_row</span><span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="n">pos</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">n</span> <span class="o">+=</span> <span class="nb">len</span><span class="p">(</span><span class="n">raw_values</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">while</span> <span class="n">pos</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">s</span><span class="p">)</span> <span class="ow">and</span> <span class="n">s</span><span class="p">[</span><span class="n">pos</span><span class="p">]</span><span class="o">.</span><span class="n">isspace</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">            <span class="n">pos</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">pos</span> <span class="o">==</span> <span class="nb">len</span><span class="p">(</span><span class="n">s</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="k">break</span>
</span></span><span class="line"><span class="cl">        <span class="n">pos</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">        <span class="k">while</span> <span class="n">pos</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">s</span><span class="p">)</span> <span class="ow">and</span> <span class="n">s</span><span class="p">[</span><span class="n">pos</span><span class="p">]</span><span class="o">.</span><span class="n">isspace</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">            <span class="n">pos</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">n</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">assert</span> <span class="n">python_loop</span><span class="p">()</span> <span class="o">==</span> <span class="n">native_loop</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="n">name</span><span class="p">,</span> <span class="n">fn</span> <span class="ow">in</span> <span class="p">((</span><span class="s1">&#39;python&#39;</span><span class="p">,</span> <span class="n">python_loop</span><span class="p">),</span> <span class="p">(</span><span class="s1">&#39;native&#39;</span><span class="p">,</span> <span class="n">native_loop</span><span class="p">)):</span>
</span></span><span class="line"><span class="cl">    <span class="n">t</span> <span class="o">=</span> <span class="n">time</span><span class="o">.</span><span class="n">perf_counter</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">fn</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s1">&#39;</span><span class="si">{</span><span class="n">name</span><span class="si">}</span><span class="s1">: </span><span class="si">{</span><span class="n">time</span><span class="o">.</span><span class="n">perf_counter</span><span class="p">()</span> <span class="o">-</span> <span class="n">t</span><span class="si">:</span><span class="s1">.3f</span><span class="si">}</span><span class="s1">s&#39;</span><span class="p">)</span>
</span></span></code></pre></div><p>One INSERT statement, 50,000 rows, both parsers walking it to completion:</p>
<table>
<thead>
<tr>
<th>Parser</th>
<th style="text-align:right">Time</th>
</tr>
</thead>
<tbody>
<tr>
<td>Python (<code>parens</code> + <code>split_top</code>)</td>
<td style="text-align:right">0.52 s</td>
</tr>
<tr>
<td>C (<code>_dumptales_native.next_row</code>)</td>
<td style="text-align:right">0.03 s</td>
</tr>
</tbody>
</table>
<p>About eighteen times faster, consistently across runs. Multiply by a dump with hundreds of
statements, and the saving is far from subtle!</p>
<p>In fact, real database dumps would likely stress-test the Python interpreter version <strong>even more</strong>,
because wider rows, with more real string content, would increase the tax on the interpreter.</p>
<p>For example, if we test with wider rows:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">sys</span><span class="o">,</span> <span class="nn">time</span>
</span></span><span class="line"><span class="cl"><span class="n">sys</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">insert</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="s1">&#39;/path/to/dumptales/source&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">dumptales</span> <span class="kn">import</span> <span class="n">parens</span><span class="p">,</span> <span class="n">split_top</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">_dumptales_native</span> <span class="k">as</span> <span class="nn">native</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">make</span><span class="p">(</span><span class="n">nrows</span><span class="p">,</span> <span class="n">ncols</span><span class="p">,</span> <span class="n">textlen</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">vals</span> <span class="o">=</span> <span class="s1">&#39;,&#39;</span><span class="o">.</span><span class="n">join</span><span class="p">([</span><span class="s2">&#34;&#39;x</span><span class="si">%d</span><span class="s2">_</span><span class="si">%s</span><span class="s2">&#39;&#34;</span> <span class="o">%</span> <span class="p">(</span><span class="n">j</span><span class="p">,</span> <span class="s1">&#39;y&#39;</span><span class="o">*</span><span class="p">(</span><span class="n">textlen</span><span class="o">//</span><span class="mi">6</span><span class="p">))</span> <span class="k">if</span> <span class="n">j</span> <span class="o">%</span> <span class="mi">3</span> <span class="o">==</span> <span class="mi">1</span> <span class="k">else</span> <span class="nb">str</span><span class="p">(</span><span class="n">j</span><span class="p">)</span> <span class="k">for</span> <span class="n">j</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">ncols</span><span class="p">)])</span>
</span></span><span class="line"><span class="cl">    <span class="n">row</span> <span class="o">=</span> <span class="s1">&#39;(&#39;</span> <span class="o">+</span> <span class="n">vals</span> <span class="o">+</span> <span class="s1">&#39;)&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="s2">&#34;INSERT INTO `t` VALUES &#34;</span> <span class="o">+</span> <span class="s1">&#39;,&#39;</span><span class="o">.</span><span class="n">join</span><span class="p">([</span><span class="n">row</span><span class="p">]</span><span class="o">*</span><span class="n">nrows</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">loop</span><span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="n">start</span><span class="p">,</span> <span class="n">native_mode</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">pos</span> <span class="o">=</span> <span class="n">start</span><span class="p">;</span> <span class="n">n</span> <span class="o">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">    <span class="k">while</span> <span class="n">pos</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">s</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">native_mode</span><span class="p">:</span>                                                                                                                                                                            <span class="n">raw_values</span><span class="p">,</span> <span class="n">pos</span> <span class="o">=</span> <span class="n">native</span><span class="o">.</span><span class="n">next_row</span><span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="n">pos</span><span class="p">);</span> <span class="n">n</span> <span class="o">+=</span> <span class="nb">len</span><span class="p">(</span><span class="n">raw_values</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">raw</span><span class="p">,</span> <span class="n">pos</span> <span class="o">=</span> <span class="n">parens</span><span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="n">pos</span><span class="p">);</span> <span class="n">n</span> <span class="o">+=</span> <span class="nb">len</span><span class="p">(</span><span class="n">split_top</span><span class="p">(</span><span class="n">raw</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">        <span class="k">while</span> <span class="n">pos</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">s</span><span class="p">)</span> <span class="ow">and</span> <span class="n">s</span><span class="p">[</span><span class="n">pos</span><span class="p">]</span><span class="o">.</span><span class="n">isspace</span><span class="p">():</span> <span class="n">pos</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="n">pos</span> <span class="o">==</span> <span class="nb">len</span><span class="p">(</span><span class="n">s</span><span class="p">):</span> <span class="k">break</span>
</span></span><span class="line"><span class="cl">            <span class="n">pos</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">            <span class="k">while</span> <span class="n">pos</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">s</span><span class="p">)</span> <span class="ow">and</span> <span class="n">s</span><span class="p">[</span><span class="n">pos</span><span class="p">]</span><span class="o">.</span><span class="n">isspace</span><span class="p">():</span> <span class="n">pos</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">n</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="n">label</span><span class="p">,</span> <span class="n">ncols</span><span class="p">,</span> <span class="n">textlen</span> <span class="ow">in</span> <span class="p">((</span><span class="s1">&#39;narrow (7 cols, ~30 ch)&#39;</span><span class="p">,</span> <span class="mi">7</span><span class="p">,</span> <span class="mi">30</span><span class="p">),</span> <span class="p">(</span><span class="s1">&#39;wide (20 cols, ~240 ch)&#39;</span><span class="p">,</span> <span class="mi">20</span><span class="p">,</span> <span class="mi">240</span><span class="p">)):</span>
</span></span><span class="line"><span class="cl">    <span class="n">s</span> <span class="o">=</span> <span class="n">make</span><span class="p">(</span><span class="mi">50000</span><span class="p">,</span> <span class="n">ncols</span><span class="p">,</span> <span class="n">textlen</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">start</span> <span class="o">=</span> <span class="n">s</span><span class="o">.</span><span class="n">index</span><span class="p">(</span><span class="s1">&#39;(&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">assert</span> <span class="n">loop</span><span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="n">start</span><span class="p">,</span> <span class="kc">False</span><span class="p">)</span> <span class="o">==</span> <span class="n">loop</span><span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="n">start</span><span class="p">,</span> <span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">out</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">mode</span> <span class="ow">in</span> <span class="p">(</span><span class="kc">False</span><span class="p">,</span> <span class="kc">True</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">t</span> <span class="o">=</span> <span class="n">time</span><span class="o">.</span><span class="n">perf_counter</span><span class="p">();</span> <span class="n">loop</span><span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="n">start</span><span class="p">,</span> <span class="n">mode</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">out</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">time</span><span class="o">.</span><span class="n">perf_counter</span><span class="p">()</span> <span class="o">-</span> <span class="n">t</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s1">&#39;</span><span class="si">{</span><span class="n">label</span><span class="si">}</span><span class="s1">: statement </span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">s</span><span class="p">)</span><span class="o">/</span><span class="mf">1e6</span><span class="si">:</span><span class="s1">.1f</span><span class="si">}</span><span class="s1"> MB | python </span><span class="si">{</span><span class="n">out</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="si">:</span><span class="s1">.2f</span><span class="si">}</span><span class="s1">s | native </span><span class="si">{</span><span class="n">out</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span><span class="si">:</span><span class="s1">.3f</span><span class="si">}</span><span class="s1">s | ratio </span><span class="si">{</span><span class="n">out</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">/</span><span class="n">out</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span><span class="si">:</span><span class="s1">.0f</span><span class="si">}</span><span class="s1">x&#39;</span><span class="p">)</span>
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">python3 /tmp/bench_wide.py <span class="o">&amp;&amp;</span> python3 /tmp/bench_wide.py
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">narrow <span class="o">(</span><span class="m">7</span> cols, ~30 ch<span class="o">)</span>: statement 1.7 MB <span class="p">|</span> python 0.38s <span class="p">|</span> native 0.028s <span class="p">|</span> ratio 14x
</span></span><span class="line"><span class="cl">wide <span class="o">(</span><span class="m">20</span> cols, ~240 ch<span class="o">)</span>: statement 18.0 MB <span class="p">|</span> python 3.83s <span class="p">|</span> native 0.177s <span class="p">|</span> ratio 22x
</span></span><span class="line"><span class="cl">narrow <span class="o">(</span><span class="m">7</span> cols, ~30 ch<span class="o">)</span>: statement 1.7 MB <span class="p">|</span> python 0.62s <span class="p">|</span> native 0.038s <span class="p">|</span> ratio 16x
</span></span><span class="line"><span class="cl">wide <span class="o">(</span><span class="m">20</span> cols, ~240 ch<span class="o">)</span>: statement 18.0 MB <span class="p">|</span> python 4.47s <span class="p">|</span> native 0.059s <span class="p">|</span> ratio 75x
</span></span></code></pre></div><p>So there you go. I'm pretty happy with the performance - still maybe could be better. Maybe Python and C
is the wrong solution here - will I end up using Rust for the first time? We'll see :)</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Language: C, exposed to Python via the CPython C API</li>
<li>Built at install time</li>
<li>Falls back to the pure Python parser when unavailable</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="/projects/dumptales/">DumpTales project page</a>
</li>
<li><a href="/news/dumptales/">Previous article announcing the project: DumpTales</a>
</li>
<li><a href="https://git.mig5.net/mig5/dumptales" target="_blank" rel="noopener noreferrer">Source and documentation</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need database performance or migration support?</div>
  <div class="smallprint mb-3">I do contract systems and database administration, and can help.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>Gills: swimming upstream for new packages</title>
      <link>https://mig5.net/news/gills/</link>
      <pubDate>Tue, 29 Sep 2026 00:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/gills/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-box-seam"></i><span>Gills</span></div>
    <h1 class="display-6 fw-semibold mb-2">Swimming upstream for new packages</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-29">September 29, 2026</time></p>
    <p class="lead mb-0">Watch APT and RPM repositories for package changes and notify your build pipeline. Gills
fetches repository metadata, compares it to a SQLite baseline, and routes events to webhooks,
email, Slack, Signal or stdout.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-29T00:00:00&#43;10:00">29 September 2026</time>
        </div>

<figure class="text-center my-4">
  <a href="/news/gills/gills_hu4433969636851653068.png" class="image-lightbox" title="Click to view larger">
    <img src="/news/gills/gills_hu5344390138444184131.png" alt="A friendly salmon chasing deb and rpm parcels upstream" width="320" height="320" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
</figure>

<h2 id="the-premise">The premise</h2>
<p>I maintain <a href="/news/php-sqlcipher-history/">SQLCipher for PHP</a>
, which rebuilds PHP against the SQLCipher extension
for encrypted SQLite databases. It tracks <a href="https://packages.sury.org/php/" target="_blank" rel="noopener noreferrer">Ondrej Sury&#39;s PHP packages</a>

for Debian and Ubuntu, because every new upstream PHP version in Sury means a new PHP-SQLCipher
build is due on my side.</p>
<p>The challenge is operational: I want to know the moment new PHP packages enter the Sury repository,
so I can trigger a rebuild promptly.</p>
<p>The catch is that I do not necessarily have the Sury APT repository configured on any server of my own.</p>
<p>Installing it just to poll for changes would be a side effect on a host that has no other reason to
carry it, and it would tell me about new packages only after <code>apt update</code> has run on that particular
machine.</p>
<p>What I want is a small, dedicated watcher that reads the repository metadata directly, remembers
what it has already seen, and tells me the moment something new appears. APT gives me <code>apt-listchanges</code>
for changes inside packages I have installed; it does not give me a heads-up that <em>new</em> versions of
packages I have <em>never</em> installed have entered a remote repository.</p>
<p>So <a href="https://git.mig5.net/mig5/gills" target="_blank" rel="noopener noreferrer">gills</a>
 is that watcher. It reads APT <code>Release</code>/<code>InRelease</code> and RPM
<code>repomd.xml</code> metadata from an arbitrary repository, compares package versions and checksums against
a local SQLite baseline that's keeping state, and routes events to webhooks, email, Slack, Signal or
stdout.</p>
<p>It also can run as a one-shot command under cron or a systemd timer, and handle its own retries.</p>
<h2 id="keeping-it-simple">Keeping it simple</h2>
<p>Gills uses SQLite and has just two commands:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">gills -c config.yml check
</span></span><span class="line"><span class="cl">gills -c config.yml prune
</span></span></code></pre></div><p><code>check</code> runs once. Cron or a systemd timer invokes it periodically. Notifications and their retries
happen automatically during checks, according to your YAML.</p>
<p><code>prune</code> deletes old completed SQLite history while retaining baselines and pending work.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">gills -c config.yml prune            <span class="c1"># Delete completed history older than 30 days</span>
</span></span><span class="line"><span class="cl">gills -c config.yml prune --days <span class="m">7</span>
</span></span></code></pre></div><p>Prune removes old completed events, their delivery records and unreferenced completed batches, then
compacts SQLite.</p>
<h2 id="a-configuration">A configuration</h2>
<p>Here's a Sury example config that motivated the project. It watches PHP 8.3 and 8.4 sources in <code>trixie/main</code>,
excludes alpha/beta/RC versions, and waits for their matching CLI binaries on <code>amd64</code> before
notifying. A minimal configuration looks like this:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">version</span><span class="p">:</span><span class="w"> </span><span class="m">1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nt">state_dir</span><span class="p">:</span><span class="w"> </span><span class="l">./state</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nt">notify</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nt">watches</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">sury-php</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">apt</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">url</span><span class="p">:</span><span class="w"> </span><span class="l">https://packages.sury.org/php/</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">suites</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">trixie]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">kinds</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">source]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">filters</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">sources</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s1">&#39;php8.3&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;php8.4&#39;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">version_exclude</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;(?i)(alpha|beta|rc)&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nt">destinations</span><span class="p">:</span><span class="w"> </span>{}<span class="w">
</span></span></span></code></pre></div><p>All paths are relative to the YAML file. An empty <code>destinations</code> mapping means results only appear
on stdout. Remove <code>filters</code> to watch all packages in the configured suites/components/architectures.
Set <code>kinds: [source, binary]</code> to watch both types.</p>
<p>The first successful check quietly establishes a baseline. Set <code>notify_initial: true</code> on a watch to
announce existing matching packages too. Subsequent checks report additions, version changes and
changed checksums.</p>
<h2 id="dry-run">Dry run</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">gills -c config.yml check --dry-run
</span></span></code></pre></div><p>A dry run prints the check results and proposed events as JSON. An existing SQLite baseline is read
through a read-only connection and copied into memory. The real database and notification queue are
left untouched. A first-ever dry run does not create a state directory. Temporary metadata downloads
are discarded afterwards. Notifications are skipped. The command returns after one check.</p>
<h2 id="filtering-and-readiness">Filtering and 'readiness'</h2>
<p>Gills gives you a lot of control over what counts as an event:</p>
<ul>
<li>Select suites, components, architectures and source/binary package kinds.</li>
<li>Include/exclude package and source globs, regexes and native version ranges.</li>
<li><code>version_policy: upstream</code> triggers on upstream-version changes; <code>version_policy: packaging</code> selects
packaging revisions; <code>any</code> selects both.</li>
<li>Group related binary changes by source name and source version.</li>
<li>Use <code>readiness</code> to wait for source and required binary metadata before notifying.</li>
</ul>
<p>The <code>version_field</code> setting controls which part of a version the <code>version_include</code> / <code>version_exclude</code>
regexes test. For <code>2:8.4.1-3</code>, <code>upstream</code> is <code>8.4.1</code>; <code>full</code> includes epoch and revision. Native
Debian/RPM comparators handle epochs, revisions and prereleases, so <code>8.4.1-1</code> → <code>8.4.2-1</code> is an
upstream change while <code>8.4.1-1</code> → <code>8.4.1-2</code> is packaging.</p>
<p>'Readiness' is a nuance feature that ties the Sury case together. The repository publishes a new source
package first; the matching binaries might follow on their own schedule. A <code>readiness</code> block tells Gills
to hold an event until the same source version has the required indexed binaries:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">readiness</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">require_source</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">binaries</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s1">&#39;php*-cli&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;php*-common&#39;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">architectures</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">amd64, arm64]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">suites</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">trixie]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">timeout_seconds</span><span class="p">:</span><span class="w"> </span><span class="m">3600</span><span class="w">
</span></span></span></code></pre></div><p>Each pattern must match at least one binary from the same source name and exact source version for
every required architecture/suite. <code>all</code>/<code>noarch</code> satisfy any architecture. Multi-suite gates require
identical full source versions. The timeout alerts once; the event remains waiting and can become
ready on a later check.</p>
<h2 id="event-types">Event types</h2>
<p>Package identity includes suite, component, kind, name, architecture and full version. All selected
versions are retained in the snapshot.</p>
<ul>
<li><code>package.added</code>: a new slot, or an older version added beside the current version.</li>
<li><code>package.updated</code>: a newly published version above the previous highest version.</li>
<li><code>package.downgraded</code>: the highest available version moves backwards.</li>
<li><code>package.repacked</code>: the same version has changed artifact names/checksums/sizes or source association.</li>
<li><code>package.removed</code>: all selected versions of a package slot disappear.</li>
<li><code>repository.error</code>: repeated check failures, or an immediate metadata/file-integrity failure.</li>
<li><code>repository.recovered</code>: a successful check after an announced failure.</li>
<li><code>readiness.timeout</code>: a source release remains incomplete beyond its configured timeout.</li>
</ul>
<p>Related changes are grouped by type/source/version. New architecture changes can merge into an
existing waiting event. Changes arriving after delivery can create another event; use
<code>kinds: [source]</code> with binary readiness rules for a source-driven build pipeline.</p>
<h2 id="notifications">Notifications</h2>
<p>Of course, the whole goal of gills is to actually get told about the change. So I wanted the tool
to have a lot of first-class notification support.</p>
<p>Copy the desired destinations from <code>examples/destinations.yml</code>. Supported outputs are email (SMTP),
webhooks, Slack incoming webhooks, signal-cli-rest-api and stdout. Select their names in each watch
with <code>destinations: [builds, email]</code>. An empty or omitted watch list means no notifications.
Destination definitions themselves do not accept <code>watches</code>.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">version</span><span class="p">:</span><span class="w"> </span><span class="m">1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nt">notify</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nt">watches</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">sury-php</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">apt</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">url</span><span class="p">:</span><span class="w"> </span><span class="l">https://packages.sury.org/php/</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">suites</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">trixie]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">destinations</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">builds, email]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nt">destinations</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">builds</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">webhook</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">url_env</span><span class="p">:</span><span class="w"> </span><span class="l">BUILD_WEBHOOK_URL</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">secret_env</span><span class="p">:</span><span class="w"> </span><span class="l">BUILD_WEBHOOK_SECRET</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">events</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">package.added, package.updated, package.repacked]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">email</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">email</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">host</span><span class="p">:</span><span class="w"> </span><span class="l">smtp.example.org</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">tls</span><span class="p">:</span><span class="w"> </span><span class="l">starttls</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">username_env</span><span class="p">:</span><span class="w"> </span><span class="l">SMTP_USERNAME</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">password_env</span><span class="p">:</span><span class="w"> </span><span class="l">SMTP_PASSWORD</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">from</span><span class="p">:</span><span class="w"> </span><span class="l">gills@example.org</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">to</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">builds@example.org]</span><span class="w">
</span></span></span></code></pre></div><p>Configure credentials through the named environment variables. <code>notify: false</code> continues tracking
repository changes and printing results, while disabling both new notification queuing and delivery.
Existing retries remain paused. Set <code>enabled: false</code> on an individual destination to disable just
that destination. Changes observed while notifications are disabled are not sent retroactively.</p>
<p>Each ordinary <code>check</code> automatically attempts due deliveries, including retries from previous checks.
Failed destinations retry independently with persistent backoff: 30, 60, 120, ... seconds, capped at
an hour, indefinitely. Cron or timer frequency determines when a due retry is attempted. Digests are
available through <code>digest_seconds</code>.</p>
<p>Webhooks receive JSON with stable event IDs, so receivers can deduplicate build requests. Optional
HMAC-SHA256 signs the exact JSON body. Delivery is at least once: a timeout after the receiver
accepted a request may result in a duplicate.</p>
<h3 id="webhook-payloads">Webhook payloads</h3>
<p>Webhooks receive <code>schema_version</code>, delivery <code>id</code>, <code>created_at</code>, and an <code>events</code> array. Each event
has its own stable <code>id</code>, <code>watch</code>, <code>repository</code>, <code>backend</code>, <code>observed_at</code>, <code>type</code>, and relevant change
details. Package events include source name/version and full <code>old</code>/<code>new</code> package records. Captured
sources carry original URLs, hashes, names, sizes and local object paths. Times are UTC Unix seconds.</p>
<p>HTTP headers:</p>
<ul>
<li><code>X-Gills-Delivery</code>: stable delivery ID.</li>
<li>Optional <code>X-Gills-Signature: sha256=...</code>: HMAC over the exact JSON bytes.</li>
</ul>
<h3 id="signal">Signal</h3>
<p>Gills has direct Signal support through <a href="https://github.com/bbernhard/signal-cli-rest-api" target="_blank" rel="noopener noreferrer">signal-cli-rest-api</a>
.
The destination assumes an existing linked <code>signal-cli-rest-api</code> service reachable to Gills:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">destinations</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">signal</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">signal</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">url_env</span><span class="p">:</span><span class="w"> </span><span class="l">SIGNAL_SEND_URL           </span><span class="w"> </span><span class="c"># Complete URL, e.g. http://signal:8080/v2/send</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">allow_private_networks</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">       </span><span class="c"># Local signal-cli service</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">allow_http</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">number_env</span><span class="p">:</span><span class="w"> </span><span class="l">SIGNAL_NUMBER</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">recipients</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s1">&#39;+61000000000&#39;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">events</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">repository.error, repository.recovered, readiness.timeout]</span><span class="w">
</span></span></span></code></pre></div><p>The example explicitly permits a local, plain-HTTP Signal service. Set both flags false for a public
HTTPS service. TLS validation cannot be disabled.</p>
<h2 id="scheduling-and-sqlite-cleanup">Scheduling and SQLite cleanup</h2>
<p>Cron, every five minutes:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gdscript3" data-lang="gdscript3"><span class="line"><span class="cl"><span class="o">*/</span><span class="mi">5</span> <span class="o">*</span> <span class="o">*</span> <span class="o">*</span> <span class="o">*</span> <span class="o">/</span><span class="n">usr</span><span class="o">/</span><span class="n">bin</span><span class="o">/</span><span class="n">gills</span> <span class="o">-</span><span class="n">c</span> <span class="o">/</span><span class="n">etc</span><span class="o">/</span><span class="n">gills</span><span class="o">/</span><span class="n">config</span><span class="o">.</span><span class="n">yml</span> <span class="n">check</span> <span class="o">&gt;&gt;/</span><span class="k">var</span><span class="o">/</span><span class="nb">log</span><span class="o">/</span><span class="n">gills</span><span class="o">.</span><span class="n">log</span> <span class="mi">2</span><span class="o">&gt;&amp;</span><span class="mi">1</span>
</span></span></code></pre></div><p>The templates in <code>packaging/systemd/</code> invoke the same one-shot <code>check</code>. For a native package
installation, create a <code>gills</code> service account, put the YAML at <code>/etc/gills/config.yml</code>, and set
<code>state_dir: /var/lib/gills</code>. Copy the service and timer into <code>/etc/systemd/system</code>, then:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">sudo systemctl daemon-reload
</span></span><span class="line"><span class="cl">sudo systemctl <span class="nb">enable</span> --now gills.timer
</span></span></code></pre></div><h2 id="docker">Docker</h2>
<p>Docker also runs a single check and exits:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">cp examples/sury.yml config.yml
</span></span><span class="line"><span class="cl"><span class="c1"># Set state_dir: /var/lib/gills in config.yml</span>
</span></span><span class="line"><span class="cl">docker compose build
</span></span><span class="line"><span class="cl">docker compose run --rm gills check --dry-run
</span></span><span class="line"><span class="cl">docker compose run --rm gills check
</span></span><span class="line"><span class="cl">docker compose run --rm gills prune --days <span class="m">30</span>
</span></span></code></pre></div><p>Schedule <code>docker compose run --rm gills check</code> with cron or a host timer if preferred. There is no
built-in scheduler, restart loop or polling daemon.</p>
<h2 id="security-model">Security model</h2>
<p>Given gills contacts remote endpoints, I wanted a good security model as well. You can read more about
it <a href="https://git.mig5.net/mig5/gills/src/branch/main/docs/security.md" target="_blank" rel="noopener noreferrer">here</a>
.</p>
<h2 id="a-node-red-flow-for-the-webhook">A Node-RED flow for the webhook</h2>
<p>The following example Node-RED flow receives the payload via the 'webhook' notification model
and then handles it, in this case, by sending it on to Signal.</p>
<p>Since Gills has direct Signal CLI notification support, an example that forwards a Gills webhook into
Signal is, I admit, a bit contrived. But the main point is the webhook pattern itself: the
HMAC-SHA256 secret validation against the raw request body, and the parsing of the event JSON. The
downstream action could be anything that Node-RED can reach: a GitLab CI trigger, a Home Assistant
event, a Telegram message, etc. I have used Signal here because it is the same shape as
the <a href="/news/enroll-diff-webhook/">Enroll diff webhook</a>
 and shows the pattern end-to-end.</p>
<div class="mermaid">

sequenceDiagram
    participant G as Gills
    participant N as Node-RED
    participant S as signal-cli-rest-api
    participant P as Phone (Signal)
    G->>N: POST webhook (event JSON, X-Gills-Signature)
    N->>N: Verify HMAC-SHA256 over raw body
    N->>N: Parse events array, format summary
    par 201 response
        N-->>G: 201 Created
    and Signal send (fire-and-forget)
        N->>S: POST /v2/send
        S-->>P: Signal message
    end

</div>

<p>The flow starts with an HTTP input node listening for <code>POST</code> on a long, unguessable URL. The first
stop is a function node called <strong>Check secret</strong> that verifies the <code>X-Gills-Signature</code> header against
the raw request body using the shared secret stored in the flow's environment as <code>GILLS_WEBHOOK_SECRET</code>.
The HTTP In node has <code>skipBodyParsing</code> enabled so that the function receives the raw bytes; the
signature is computed over exactly those bytes, and JSON parsing happens only after authentication
succeeds.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">crypto</span> <span class="o">=</span> <span class="nx">global</span><span class="p">.</span><span class="nx">get</span><span class="p">(</span><span class="s2">&#34;crypto&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">secret</span> <span class="o">=</span> <span class="nx">env</span><span class="p">.</span><span class="nx">get</span><span class="p">(</span><span class="s2">&#34;GILLS_WEBHOOK_SECRET&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">function</span> <span class="nx">reject</span><span class="p">(</span><span class="nx">status</span><span class="p">,</span> <span class="nx">message</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">msg</span><span class="p">.</span><span class="nx">statusCode</span> <span class="o">=</span> <span class="nx">status</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="nx">msg</span><span class="p">.</span><span class="nx">headers</span> <span class="o">=</span> <span class="p">{</span> <span class="s2">&#34;content-type&#34;</span><span class="o">:</span> <span class="s2">&#34;text/plain&#34;</span> <span class="p">};</span>
</span></span><span class="line"><span class="cl">    <span class="nx">msg</span><span class="p">.</span><span class="nx">payload</span> <span class="o">=</span> <span class="nx">message</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="p">[</span><span class="kc">null</span><span class="p">,</span> <span class="nx">msg</span><span class="p">];</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">crypto</span> <span class="o">||</span> <span class="o">!</span><span class="nx">secret</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">node</span><span class="p">.</span><span class="nx">error</span><span class="p">(</span><span class="s2">&#34;Webhook verification is not configured&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nx">reject</span><span class="p">(</span><span class="mi">500</span><span class="p">,</span> <span class="s2">&#34;Webhook verification unavailable&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">Buffer</span><span class="p">.</span><span class="nx">isBuffer</span><span class="p">(</span><span class="nx">msg</span><span class="p">.</span><span class="nx">payload</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">node</span><span class="p">.</span><span class="nx">error</span><span class="p">(</span><span class="s2">&#34;Enable Skip body parsing on the HTTP In node&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nx">reject</span><span class="p">(</span><span class="mi">500</span><span class="p">,</span> <span class="s2">&#34;Raw request body unavailable&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">signature</span> <span class="o">=</span> <span class="nx">msg</span><span class="p">.</span><span class="nx">req</span><span class="o">?</span><span class="p">.</span><span class="nx">headers</span><span class="o">?</span><span class="p">.[</span><span class="s2">&#34;x-gills-signature&#34;</span><span class="p">];</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="k">typeof</span> <span class="nx">signature</span> <span class="o">!==</span> <span class="s2">&#34;string&#34;</span> <span class="o">||</span>
</span></span><span class="line"><span class="cl">    <span class="o">!</span><span class="sr">/^sha256=[0-9a-f]{64}$/i</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">signature</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nx">reject</span><span class="p">(</span><span class="mi">401</span><span class="p">,</span> <span class="s2">&#34;Invalid webhook signature&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">expected</span> <span class="o">=</span> <span class="nx">crypto</span>
</span></span><span class="line"><span class="cl">    <span class="p">.</span><span class="nx">createHmac</span><span class="p">(</span><span class="s2">&#34;sha256&#34;</span><span class="p">,</span> <span class="nx">secret</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">.</span><span class="nx">update</span><span class="p">(</span><span class="nx">msg</span><span class="p">.</span><span class="nx">payload</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">.</span><span class="nx">digest</span><span class="p">();</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">received</span> <span class="o">=</span> <span class="nx">Buffer</span><span class="p">.</span><span class="nx">from</span><span class="p">(</span><span class="nx">signature</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="mi">7</span><span class="p">),</span> <span class="s2">&#34;hex&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="nx">received</span><span class="p">.</span><span class="nx">length</span> <span class="o">!==</span> <span class="nx">expected</span><span class="p">.</span><span class="nx">length</span> <span class="o">||</span>
</span></span><span class="line"><span class="cl">    <span class="o">!</span><span class="nx">crypto</span><span class="p">.</span><span class="nx">timingSafeEqual</span><span class="p">(</span><span class="nx">received</span><span class="p">,</span> <span class="nx">expected</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nx">reject</span><span class="p">(</span><span class="mi">401</span><span class="p">,</span> <span class="s2">&#34;Invalid webhook signature&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// Parse only after authentication succeeds.
</span></span></span><span class="line"><span class="cl"><span class="c1"></span><span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">msg</span><span class="p">.</span><span class="nx">payload</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="nx">msg</span><span class="p">.</span><span class="nx">payload</span><span class="p">.</span><span class="nx">toString</span><span class="p">(</span><span class="s2">&#34;utf8&#34;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nx">reject</span><span class="p">(</span><span class="mi">400</span><span class="p">,</span> <span class="s2">&#34;Invalid JSON&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">msg</span><span class="p">.</span><span class="nx">payload</span> <span class="o">||</span> <span class="o">!</span><span class="nb">Array</span><span class="p">.</span><span class="nx">isArray</span><span class="p">(</span><span class="nx">msg</span><span class="p">.</span><span class="nx">payload</span><span class="p">.</span><span class="nx">events</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nx">reject</span><span class="p">(</span><span class="mi">400</span><span class="p">,</span> <span class="s2">&#34;Invalid Gills payload&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">return</span> <span class="p">[</span><span class="nx">msg</span><span class="p">,</span> <span class="kc">null</span><span class="p">];</span>
</span></span></code></pre></div><p>A few details worth highlighting. The signature format is <code>sha256=&lt;hex&gt;</code>, hence the <code>slice(7)</code> to
strip the prefix before decoding. The comparison uses <code>crypto.timingSafeEqual</code> on equal-length
buffers, which avoids short-circuit timing leaks. The expected digest is computed over the raw
<code>msg.payload</code> buffer (the exact bytes Gills sent), so any proxy or middleware that re-serialises the
body will break the signature, which is the desired behaviour. JSON parsing and the <code>events</code> array
shape check happen only after the signature verifies.</p>
<p>Once the payload is authenticated, a <strong>Parse</strong> function turns the structured event JSON into a
human-readable summary. It maps each event type to an emoji-prefixed label, walks the <code>changes</code>
array, and prints the package name with its suite/component/architecture and the version transition:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="kd">let</span> <span class="nx">batch</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">batch</span> <span class="o">=</span> <span class="k">typeof</span> <span class="nx">msg</span><span class="p">.</span><span class="nx">payload</span> <span class="o">===</span> <span class="s2">&#34;string&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="o">?</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="nx">msg</span><span class="p">.</span><span class="nx">payload</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="o">:</span> <span class="nx">msg</span><span class="p">.</span><span class="nx">payload</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">batch</span> <span class="o">||</span> <span class="o">!</span><span class="nb">Array</span><span class="p">.</span><span class="nx">isArray</span><span class="p">(</span><span class="nx">batch</span><span class="p">.</span><span class="nx">events</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">throw</span> <span class="k">new</span> <span class="nb">Error</span><span class="p">(</span><span class="s2">&#34;Expected a Gills payload with an events array&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">err</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">node</span><span class="p">.</span><span class="nx">error</span><span class="p">(</span><span class="sb">`Invalid Gills payload: </span><span class="si">${</span><span class="nx">err</span><span class="p">.</span><span class="nx">message</span><span class="si">}</span><span class="sb">`</span><span class="p">,</span> <span class="nx">msg</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="kc">null</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">labels</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;package.added&#34;</span><span class="o">:</span> <span class="s2">&#34;🆕 Package added&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;package.updated&#34;</span><span class="o">:</span> <span class="s2">&#34;⬆️ Package updated&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;package.downgraded&#34;</span><span class="o">:</span> <span class="s2">&#34;⬇️ Package downgraded&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;package.repacked&#34;</span><span class="o">:</span> <span class="s2">&#34;📦 Package repacked&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;package.removed&#34;</span><span class="o">:</span> <span class="s2">&#34;🗑️ Package removed&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;repository.error&#34;</span><span class="o">:</span> <span class="s2">&#34;⚠️ Repository error&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;repository.recovered&#34;</span><span class="o">:</span> <span class="s2">&#34;✅ Repository recovered&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;readiness.timeout&#34;</span><span class="o">:</span> <span class="s2">&#34;⏳ Publication incomplete&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">};</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">clean</span> <span class="o">=</span> <span class="nx">value</span> <span class="p">=&gt;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">String</span><span class="p">(</span><span class="nx">value</span> <span class="o">??</span> <span class="s2">&#34;&#34;</span><span class="p">).</span><span class="nx">replace</span><span class="p">(</span><span class="sr">/[\r\n\t]+/g</span><span class="p">,</span> <span class="s2">&#34; &#34;</span><span class="p">).</span><span class="nx">trim</span><span class="p">();</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">sections</span> <span class="o">=</span> <span class="nx">batch</span><span class="p">.</span><span class="nx">events</span><span class="p">.</span><span class="nx">map</span><span class="p">(</span><span class="nx">event</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">lines</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">        <span class="nx">labels</span><span class="p">[</span><span class="nx">event</span><span class="p">.</span><span class="nx">type</span><span class="p">]</span> <span class="o">||</span> <span class="nx">clean</span><span class="p">(</span><span class="nx">event</span><span class="p">.</span><span class="nx">type</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="sb">`Watch: </span><span class="si">${</span><span class="nx">clean</span><span class="p">(</span><span class="nx">event</span><span class="p">.</span><span class="nx">watch</span><span class="p">)</span><span class="si">}</span><span class="sb">`</span>
</span></span><span class="line"><span class="cl">    <span class="p">];</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="p">(</span><span class="kr">const</span> <span class="nx">change</span> <span class="k">of</span> <span class="nx">event</span><span class="p">.</span><span class="nx">changes</span> <span class="o">||</span> <span class="p">[])</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="kr">const</span> <span class="nx">pkg</span> <span class="o">=</span> <span class="nx">change</span><span class="p">.</span><span class="k">new</span> <span class="o">||</span> <span class="nx">change</span><span class="p">.</span><span class="nx">old</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">pkg</span><span class="p">)</span> <span class="k">continue</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="kr">const</span> <span class="nx">location</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">            <span class="nx">pkg</span><span class="p">.</span><span class="nx">suite</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="nx">pkg</span><span class="p">.</span><span class="nx">component</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="nx">pkg</span><span class="p">.</span><span class="nx">architecture</span>
</span></span><span class="line"><span class="cl">        <span class="p">].</span><span class="nx">filter</span><span class="p">(</span><span class="nb">Boolean</span><span class="p">).</span><span class="nx">map</span><span class="p">(</span><span class="nx">clean</span><span class="p">).</span><span class="nx">join</span><span class="p">(</span><span class="s2">&#34; / &#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="nx">lines</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="s2">&#34;&#34;</span><span class="p">,</span> <span class="sb">`</span><span class="si">${</span><span class="nx">clean</span><span class="p">(</span><span class="nx">pkg</span><span class="p">.</span><span class="nx">name</span><span class="p">)</span><span class="si">}</span><span class="sb"> (</span><span class="si">${</span><span class="nx">location</span><span class="si">}</span><span class="sb">)`</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="nx">change</span><span class="p">.</span><span class="nx">old</span> <span class="o">&amp;&amp;</span> <span class="nx">change</span><span class="p">.</span><span class="k">new</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nx">lines</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="sb">`From: </span><span class="si">${</span><span class="nx">clean</span><span class="p">(</span><span class="nx">change</span><span class="p">.</span><span class="nx">old</span><span class="p">.</span><span class="nx">version</span><span class="p">)</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">            <span class="nx">lines</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="sb">`To: </span><span class="si">${</span><span class="nx">clean</span><span class="p">(</span><span class="nx">change</span><span class="p">.</span><span class="k">new</span><span class="p">.</span><span class="nx">version</span><span class="p">)</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nx">lines</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="sb">`Version: </span><span class="si">${</span><span class="nx">clean</span><span class="p">(</span><span class="nx">pkg</span><span class="p">.</span><span class="nx">version</span><span class="p">)</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="nx">change</span><span class="p">.</span><span class="nx">level</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nx">lines</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="sb">`Change: </span><span class="si">${</span><span class="nx">clean</span><span class="p">(</span><span class="nx">change</span><span class="p">.</span><span class="nx">level</span><span class="p">)</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">event</span><span class="p">.</span><span class="nx">message</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nx">lines</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="s2">&#34;&#34;</span><span class="p">,</span> <span class="nx">clean</span><span class="p">(</span><span class="nx">event</span><span class="p">.</span><span class="nx">message</span><span class="p">));</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nx">lines</span><span class="p">.</span><span class="nx">join</span><span class="p">(</span><span class="s2">&#34;\n&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">}).</span><span class="nx">filter</span><span class="p">(</span><span class="nb">Boolean</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">msg</span><span class="p">.</span><span class="nx">payload</span> <span class="o">=</span> <span class="nx">sections</span><span class="p">.</span><span class="nx">join</span><span class="p">(</span><span class="s2">&#34;\n\n&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="k">return</span> <span class="nx">msg</span><span class="p">;</span>
</span></span></code></pre></div><p>The summary is wrapped into the Signal REST CLI's <code>/v2/send</code> payload and posted to that service.
Gills gets an acknowledgement as soon as the webhook receives the payload, and its own retry queue
handles the rest.</p>
<p>The full flow is available as a sanitised JSON file with the webhook URL and phone numbers replaced
by placeholders:</p>
<p><a href="/flows/gills-nodered-flow.json">Download gills-nodered-flow.json</a>
</p>
<p>In Node-RED: hamburger menu → Import → select the file. After importing, replace
<code>/your/secret/webhook/url</code> in the HTTP in node's URL, set <code>alice-number</code> and <code>bob-number</code> in the
Signal send function, and configure the <code>GILLS_WEBHOOK_SECRET</code> environment variable on the flow tab.</p>
<h2 id="how-to-install-gills">How to install Gills</h2>
<p>You can <code>pip(x) install gills</code>, install it from my APT or RPM repositories, or fetch the AppImage
from the <a href="https://git.mig5.net/mig5/gills/releases" target="_blank" rel="noopener noreferrer">releases</a>
 page.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo mkdir -p /usr/share/keyrings
</span></span><span class="line"><span class="cl">curl -fsSL https://mig5.net/static/mig5.asc <span class="p">|</span> sudo gpg --dearmor -o /usr/share/keyrings/mig5.gpg
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;deb [arch=amd64 signed-by=/usr/share/keyrings/mig5.gpg] https://apt.mig5.net </span><span class="k">$(</span>lsb_release -cs<span class="k">)</span><span class="s2"> main&#34;</span> <span class="p">|</span> sudo tee /etc/apt/sources.list.d/mig5.list
</span></span><span class="line"><span class="cl">sudo apt update
</span></span><span class="line"><span class="cl">sudo apt install gills
</span></span></code></pre></div><p>If Gills sounds useful to you, the <a href="https://git.mig5.net/mig5/gills" target="_blank" rel="noopener noreferrer">source and issue tracker</a>
 are
on my Forgejo. I'd be glad to hear how you use it.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Commands: <code>check</code> (one-shot) and <code>prune</code> (history retention)</li>
<li>Backends: APT and RPM</li>
<li>Events: added, updated, downgraded, repacked, removed, repository error/recovered, readiness timeout</li>
<li>Destinations: webhook (HMAC-SHA256), email (SMTP), Slack, signal-cli-rest-api, stdout</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://git.mig5.net/mig5/gills" target="_blank" rel="noopener noreferrer">Gills source and issue tracker</a>
</li>
<li><a href="https://git.mig5.net/mig5/gills/src/branch/main/docs/security.md" target="_blank" rel="noopener noreferrer">Security model</a>
</li>
<li><a href="/news/enroll-diff-webhook/">Enroll diff to Signal webhook</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Repository-driven build pipelines?</div>
  <div class="smallprint mb-3">I do contract sysadmin and DevSecOps work and can help wire repository monitoring, build triggers and notification pipelines together.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-box-seam"></i><span>Gills</span></div>
    <h1 class="display-6 fw-semibold mb-2">Swimming upstream for new packages</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-29">September 29, 2026</time></p>
    <p class="lead mb-0">Watch APT and RPM repositories for package changes and notify your build pipeline. Gills
fetches repository metadata, compares it to a SQLite baseline, and routes events to webhooks,
email, Slack, Signal or stdout.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-29T00:00:00&#43;10:00">29 September 2026</time>
        </div>

<figure class="text-center my-4">
  <a href="/news/gills/gills_hu4433969636851653068.png" class="image-lightbox" title="Click to view larger">
    <img src="/news/gills/gills_hu5344390138444184131.png" alt="A friendly salmon chasing deb and rpm parcels upstream" width="320" height="320" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
</figure>

<h2 id="the-premise">The premise</h2>
<p>I maintain <a href="/news/php-sqlcipher-history/">SQLCipher for PHP</a>
, which rebuilds PHP against the SQLCipher extension
for encrypted SQLite databases. It tracks <a href="https://packages.sury.org/php/" target="_blank" rel="noopener noreferrer">Ondrej Sury&#39;s PHP packages</a>

for Debian and Ubuntu, because every new upstream PHP version in Sury means a new PHP-SQLCipher
build is due on my side.</p>
<p>The challenge is operational: I want to know the moment new PHP packages enter the Sury repository,
so I can trigger a rebuild promptly.</p>
<p>The catch is that I do not necessarily have the Sury APT repository configured on any server of my own.</p>
<p>Installing it just to poll for changes would be a side effect on a host that has no other reason to
carry it, and it would tell me about new packages only after <code>apt update</code> has run on that particular
machine.</p>
<p>What I want is a small, dedicated watcher that reads the repository metadata directly, remembers
what it has already seen, and tells me the moment something new appears. APT gives me <code>apt-listchanges</code>
for changes inside packages I have installed; it does not give me a heads-up that <em>new</em> versions of
packages I have <em>never</em> installed have entered a remote repository.</p>
<p>So <a href="https://git.mig5.net/mig5/gills" target="_blank" rel="noopener noreferrer">gills</a>
 is that watcher. It reads APT <code>Release</code>/<code>InRelease</code> and RPM
<code>repomd.xml</code> metadata from an arbitrary repository, compares package versions and checksums against
a local SQLite baseline that's keeping state, and routes events to webhooks, email, Slack, Signal or
stdout.</p>
<p>It also can run as a one-shot command under cron or a systemd timer, and handle its own retries.</p>
<h2 id="keeping-it-simple">Keeping it simple</h2>
<p>Gills uses SQLite and has just two commands:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">gills -c config.yml check
</span></span><span class="line"><span class="cl">gills -c config.yml prune
</span></span></code></pre></div><p><code>check</code> runs once. Cron or a systemd timer invokes it periodically. Notifications and their retries
happen automatically during checks, according to your YAML.</p>
<p><code>prune</code> deletes old completed SQLite history while retaining baselines and pending work.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">gills -c config.yml prune            <span class="c1"># Delete completed history older than 30 days</span>
</span></span><span class="line"><span class="cl">gills -c config.yml prune --days <span class="m">7</span>
</span></span></code></pre></div><p>Prune removes old completed events, their delivery records and unreferenced completed batches, then
compacts SQLite.</p>
<h2 id="a-configuration">A configuration</h2>
<p>Here's a Sury example config that motivated the project. It watches PHP 8.3 and 8.4 sources in <code>trixie/main</code>,
excludes alpha/beta/RC versions, and waits for their matching CLI binaries on <code>amd64</code> before
notifying. A minimal configuration looks like this:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">version</span><span class="p">:</span><span class="w"> </span><span class="m">1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nt">state_dir</span><span class="p">:</span><span class="w"> </span><span class="l">./state</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nt">notify</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nt">watches</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">sury-php</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">apt</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">url</span><span class="p">:</span><span class="w"> </span><span class="l">https://packages.sury.org/php/</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">suites</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">trixie]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">kinds</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">source]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">filters</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">sources</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s1">&#39;php8.3&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;php8.4&#39;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">version_exclude</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;(?i)(alpha|beta|rc)&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nt">destinations</span><span class="p">:</span><span class="w"> </span>{}<span class="w">
</span></span></span></code></pre></div><p>All paths are relative to the YAML file. An empty <code>destinations</code> mapping means results only appear
on stdout. Remove <code>filters</code> to watch all packages in the configured suites/components/architectures.
Set <code>kinds: [source, binary]</code> to watch both types.</p>
<p>The first successful check quietly establishes a baseline. Set <code>notify_initial: true</code> on a watch to
announce existing matching packages too. Subsequent checks report additions, version changes and
changed checksums.</p>
<h2 id="dry-run">Dry run</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">gills -c config.yml check --dry-run
</span></span></code></pre></div><p>A dry run prints the check results and proposed events as JSON. An existing SQLite baseline is read
through a read-only connection and copied into memory. The real database and notification queue are
left untouched. A first-ever dry run does not create a state directory. Temporary metadata downloads
are discarded afterwards. Notifications are skipped. The command returns after one check.</p>
<h2 id="filtering-and-readiness">Filtering and 'readiness'</h2>
<p>Gills gives you a lot of control over what counts as an event:</p>
<ul>
<li>Select suites, components, architectures and source/binary package kinds.</li>
<li>Include/exclude package and source globs, regexes and native version ranges.</li>
<li><code>version_policy: upstream</code> triggers on upstream-version changes; <code>version_policy: packaging</code> selects
packaging revisions; <code>any</code> selects both.</li>
<li>Group related binary changes by source name and source version.</li>
<li>Use <code>readiness</code> to wait for source and required binary metadata before notifying.</li>
</ul>
<p>The <code>version_field</code> setting controls which part of a version the <code>version_include</code> / <code>version_exclude</code>
regexes test. For <code>2:8.4.1-3</code>, <code>upstream</code> is <code>8.4.1</code>; <code>full</code> includes epoch and revision. Native
Debian/RPM comparators handle epochs, revisions and prereleases, so <code>8.4.1-1</code> → <code>8.4.2-1</code> is an
upstream change while <code>8.4.1-1</code> → <code>8.4.1-2</code> is packaging.</p>
<p>'Readiness' is a nuance feature that ties the Sury case together. The repository publishes a new source
package first; the matching binaries might follow on their own schedule. A <code>readiness</code> block tells Gills
to hold an event until the same source version has the required indexed binaries:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">readiness</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">require_source</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">binaries</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s1">&#39;php*-cli&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;php*-common&#39;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">architectures</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">amd64, arm64]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">suites</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">trixie]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">timeout_seconds</span><span class="p">:</span><span class="w"> </span><span class="m">3600</span><span class="w">
</span></span></span></code></pre></div><p>Each pattern must match at least one binary from the same source name and exact source version for
every required architecture/suite. <code>all</code>/<code>noarch</code> satisfy any architecture. Multi-suite gates require
identical full source versions. The timeout alerts once; the event remains waiting and can become
ready on a later check.</p>
<h2 id="event-types">Event types</h2>
<p>Package identity includes suite, component, kind, name, architecture and full version. All selected
versions are retained in the snapshot.</p>
<ul>
<li><code>package.added</code>: a new slot, or an older version added beside the current version.</li>
<li><code>package.updated</code>: a newly published version above the previous highest version.</li>
<li><code>package.downgraded</code>: the highest available version moves backwards.</li>
<li><code>package.repacked</code>: the same version has changed artifact names/checksums/sizes or source association.</li>
<li><code>package.removed</code>: all selected versions of a package slot disappear.</li>
<li><code>repository.error</code>: repeated check failures, or an immediate metadata/file-integrity failure.</li>
<li><code>repository.recovered</code>: a successful check after an announced failure.</li>
<li><code>readiness.timeout</code>: a source release remains incomplete beyond its configured timeout.</li>
</ul>
<p>Related changes are grouped by type/source/version. New architecture changes can merge into an
existing waiting event. Changes arriving after delivery can create another event; use
<code>kinds: [source]</code> with binary readiness rules for a source-driven build pipeline.</p>
<h2 id="notifications">Notifications</h2>
<p>Of course, the whole goal of gills is to actually get told about the change. So I wanted the tool
to have a lot of first-class notification support.</p>
<p>Copy the desired destinations from <code>examples/destinations.yml</code>. Supported outputs are email (SMTP),
webhooks, Slack incoming webhooks, signal-cli-rest-api and stdout. Select their names in each watch
with <code>destinations: [builds, email]</code>. An empty or omitted watch list means no notifications.
Destination definitions themselves do not accept <code>watches</code>.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">version</span><span class="p">:</span><span class="w"> </span><span class="m">1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nt">notify</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nt">watches</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">sury-php</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">apt</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">url</span><span class="p">:</span><span class="w"> </span><span class="l">https://packages.sury.org/php/</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">suites</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">trixie]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">destinations</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">builds, email]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nt">destinations</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">builds</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">webhook</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">url_env</span><span class="p">:</span><span class="w"> </span><span class="l">BUILD_WEBHOOK_URL</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">secret_env</span><span class="p">:</span><span class="w"> </span><span class="l">BUILD_WEBHOOK_SECRET</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">events</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">package.added, package.updated, package.repacked]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">email</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">email</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">host</span><span class="p">:</span><span class="w"> </span><span class="l">smtp.example.org</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">tls</span><span class="p">:</span><span class="w"> </span><span class="l">starttls</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">username_env</span><span class="p">:</span><span class="w"> </span><span class="l">SMTP_USERNAME</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">password_env</span><span class="p">:</span><span class="w"> </span><span class="l">SMTP_PASSWORD</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">from</span><span class="p">:</span><span class="w"> </span><span class="l">gills@example.org</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">to</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">builds@example.org]</span><span class="w">
</span></span></span></code></pre></div><p>Configure credentials through the named environment variables. <code>notify: false</code> continues tracking
repository changes and printing results, while disabling both new notification queuing and delivery.
Existing retries remain paused. Set <code>enabled: false</code> on an individual destination to disable just
that destination. Changes observed while notifications are disabled are not sent retroactively.</p>
<p>Each ordinary <code>check</code> automatically attempts due deliveries, including retries from previous checks.
Failed destinations retry independently with persistent backoff: 30, 60, 120, ... seconds, capped at
an hour, indefinitely. Cron or timer frequency determines when a due retry is attempted. Digests are
available through <code>digest_seconds</code>.</p>
<p>Webhooks receive JSON with stable event IDs, so receivers can deduplicate build requests. Optional
HMAC-SHA256 signs the exact JSON body. Delivery is at least once: a timeout after the receiver
accepted a request may result in a duplicate.</p>
<h3 id="webhook-payloads">Webhook payloads</h3>
<p>Webhooks receive <code>schema_version</code>, delivery <code>id</code>, <code>created_at</code>, and an <code>events</code> array. Each event
has its own stable <code>id</code>, <code>watch</code>, <code>repository</code>, <code>backend</code>, <code>observed_at</code>, <code>type</code>, and relevant change
details. Package events include source name/version and full <code>old</code>/<code>new</code> package records. Captured
sources carry original URLs, hashes, names, sizes and local object paths. Times are UTC Unix seconds.</p>
<p>HTTP headers:</p>
<ul>
<li><code>X-Gills-Delivery</code>: stable delivery ID.</li>
<li>Optional <code>X-Gills-Signature: sha256=...</code>: HMAC over the exact JSON bytes.</li>
</ul>
<h3 id="signal">Signal</h3>
<p>Gills has direct Signal support through <a href="https://github.com/bbernhard/signal-cli-rest-api" target="_blank" rel="noopener noreferrer">signal-cli-rest-api</a>
.
The destination assumes an existing linked <code>signal-cli-rest-api</code> service reachable to Gills:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">destinations</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">signal</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">signal</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">url_env</span><span class="p">:</span><span class="w"> </span><span class="l">SIGNAL_SEND_URL           </span><span class="w"> </span><span class="c"># Complete URL, e.g. http://signal:8080/v2/send</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">allow_private_networks</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">       </span><span class="c"># Local signal-cli service</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">allow_http</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">number_env</span><span class="p">:</span><span class="w"> </span><span class="l">SIGNAL_NUMBER</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">recipients</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s1">&#39;+61000000000&#39;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">events</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">repository.error, repository.recovered, readiness.timeout]</span><span class="w">
</span></span></span></code></pre></div><p>The example explicitly permits a local, plain-HTTP Signal service. Set both flags false for a public
HTTPS service. TLS validation cannot be disabled.</p>
<h2 id="scheduling-and-sqlite-cleanup">Scheduling and SQLite cleanup</h2>
<p>Cron, every five minutes:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gdscript3" data-lang="gdscript3"><span class="line"><span class="cl"><span class="o">*/</span><span class="mi">5</span> <span class="o">*</span> <span class="o">*</span> <span class="o">*</span> <span class="o">*</span> <span class="o">/</span><span class="n">usr</span><span class="o">/</span><span class="n">bin</span><span class="o">/</span><span class="n">gills</span> <span class="o">-</span><span class="n">c</span> <span class="o">/</span><span class="n">etc</span><span class="o">/</span><span class="n">gills</span><span class="o">/</span><span class="n">config</span><span class="o">.</span><span class="n">yml</span> <span class="n">check</span> <span class="o">&gt;&gt;/</span><span class="k">var</span><span class="o">/</span><span class="nb">log</span><span class="o">/</span><span class="n">gills</span><span class="o">.</span><span class="n">log</span> <span class="mi">2</span><span class="o">&gt;&amp;</span><span class="mi">1</span>
</span></span></code></pre></div><p>The templates in <code>packaging/systemd/</code> invoke the same one-shot <code>check</code>. For a native package
installation, create a <code>gills</code> service account, put the YAML at <code>/etc/gills/config.yml</code>, and set
<code>state_dir: /var/lib/gills</code>. Copy the service and timer into <code>/etc/systemd/system</code>, then:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">sudo systemctl daemon-reload
</span></span><span class="line"><span class="cl">sudo systemctl <span class="nb">enable</span> --now gills.timer
</span></span></code></pre></div><h2 id="docker">Docker</h2>
<p>Docker also runs a single check and exits:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">cp examples/sury.yml config.yml
</span></span><span class="line"><span class="cl"><span class="c1"># Set state_dir: /var/lib/gills in config.yml</span>
</span></span><span class="line"><span class="cl">docker compose build
</span></span><span class="line"><span class="cl">docker compose run --rm gills check --dry-run
</span></span><span class="line"><span class="cl">docker compose run --rm gills check
</span></span><span class="line"><span class="cl">docker compose run --rm gills prune --days <span class="m">30</span>
</span></span></code></pre></div><p>Schedule <code>docker compose run --rm gills check</code> with cron or a host timer if preferred. There is no
built-in scheduler, restart loop or polling daemon.</p>
<h2 id="security-model">Security model</h2>
<p>Given gills contacts remote endpoints, I wanted a good security model as well. You can read more about
it <a href="https://git.mig5.net/mig5/gills/src/branch/main/docs/security.md" target="_blank" rel="noopener noreferrer">here</a>
.</p>
<h2 id="a-node-red-flow-for-the-webhook">A Node-RED flow for the webhook</h2>
<p>The following example Node-RED flow receives the payload via the 'webhook' notification model
and then handles it, in this case, by sending it on to Signal.</p>
<p>Since Gills has direct Signal CLI notification support, an example that forwards a Gills webhook into
Signal is, I admit, a bit contrived. But the main point is the webhook pattern itself: the
HMAC-SHA256 secret validation against the raw request body, and the parsing of the event JSON. The
downstream action could be anything that Node-RED can reach: a GitLab CI trigger, a Home Assistant
event, a Telegram message, etc. I have used Signal here because it is the same shape as
the <a href="/news/enroll-diff-webhook/">Enroll diff webhook</a>
 and shows the pattern end-to-end.</p>
<div class="mermaid">

sequenceDiagram
    participant G as Gills
    participant N as Node-RED
    participant S as signal-cli-rest-api
    participant P as Phone (Signal)
    G->>N: POST webhook (event JSON, X-Gills-Signature)
    N->>N: Verify HMAC-SHA256 over raw body
    N->>N: Parse events array, format summary
    par 201 response
        N-->>G: 201 Created
    and Signal send (fire-and-forget)
        N->>S: POST /v2/send
        S-->>P: Signal message
    end

</div>

<p>The flow starts with an HTTP input node listening for <code>POST</code> on a long, unguessable URL. The first
stop is a function node called <strong>Check secret</strong> that verifies the <code>X-Gills-Signature</code> header against
the raw request body using the shared secret stored in the flow's environment as <code>GILLS_WEBHOOK_SECRET</code>.
The HTTP In node has <code>skipBodyParsing</code> enabled so that the function receives the raw bytes; the
signature is computed over exactly those bytes, and JSON parsing happens only after authentication
succeeds.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">crypto</span> <span class="o">=</span> <span class="nx">global</span><span class="p">.</span><span class="nx">get</span><span class="p">(</span><span class="s2">&#34;crypto&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">secret</span> <span class="o">=</span> <span class="nx">env</span><span class="p">.</span><span class="nx">get</span><span class="p">(</span><span class="s2">&#34;GILLS_WEBHOOK_SECRET&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">function</span> <span class="nx">reject</span><span class="p">(</span><span class="nx">status</span><span class="p">,</span> <span class="nx">message</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">msg</span><span class="p">.</span><span class="nx">statusCode</span> <span class="o">=</span> <span class="nx">status</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="nx">msg</span><span class="p">.</span><span class="nx">headers</span> <span class="o">=</span> <span class="p">{</span> <span class="s2">&#34;content-type&#34;</span><span class="o">:</span> <span class="s2">&#34;text/plain&#34;</span> <span class="p">};</span>
</span></span><span class="line"><span class="cl">    <span class="nx">msg</span><span class="p">.</span><span class="nx">payload</span> <span class="o">=</span> <span class="nx">message</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="p">[</span><span class="kc">null</span><span class="p">,</span> <span class="nx">msg</span><span class="p">];</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">crypto</span> <span class="o">||</span> <span class="o">!</span><span class="nx">secret</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">node</span><span class="p">.</span><span class="nx">error</span><span class="p">(</span><span class="s2">&#34;Webhook verification is not configured&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nx">reject</span><span class="p">(</span><span class="mi">500</span><span class="p">,</span> <span class="s2">&#34;Webhook verification unavailable&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">Buffer</span><span class="p">.</span><span class="nx">isBuffer</span><span class="p">(</span><span class="nx">msg</span><span class="p">.</span><span class="nx">payload</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">node</span><span class="p">.</span><span class="nx">error</span><span class="p">(</span><span class="s2">&#34;Enable Skip body parsing on the HTTP In node&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nx">reject</span><span class="p">(</span><span class="mi">500</span><span class="p">,</span> <span class="s2">&#34;Raw request body unavailable&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">signature</span> <span class="o">=</span> <span class="nx">msg</span><span class="p">.</span><span class="nx">req</span><span class="o">?</span><span class="p">.</span><span class="nx">headers</span><span class="o">?</span><span class="p">.[</span><span class="s2">&#34;x-gills-signature&#34;</span><span class="p">];</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="k">typeof</span> <span class="nx">signature</span> <span class="o">!==</span> <span class="s2">&#34;string&#34;</span> <span class="o">||</span>
</span></span><span class="line"><span class="cl">    <span class="o">!</span><span class="sr">/^sha256=[0-9a-f]{64}$/i</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">signature</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nx">reject</span><span class="p">(</span><span class="mi">401</span><span class="p">,</span> <span class="s2">&#34;Invalid webhook signature&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">expected</span> <span class="o">=</span> <span class="nx">crypto</span>
</span></span><span class="line"><span class="cl">    <span class="p">.</span><span class="nx">createHmac</span><span class="p">(</span><span class="s2">&#34;sha256&#34;</span><span class="p">,</span> <span class="nx">secret</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">.</span><span class="nx">update</span><span class="p">(</span><span class="nx">msg</span><span class="p">.</span><span class="nx">payload</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">.</span><span class="nx">digest</span><span class="p">();</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">received</span> <span class="o">=</span> <span class="nx">Buffer</span><span class="p">.</span><span class="nx">from</span><span class="p">(</span><span class="nx">signature</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="mi">7</span><span class="p">),</span> <span class="s2">&#34;hex&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="nx">received</span><span class="p">.</span><span class="nx">length</span> <span class="o">!==</span> <span class="nx">expected</span><span class="p">.</span><span class="nx">length</span> <span class="o">||</span>
</span></span><span class="line"><span class="cl">    <span class="o">!</span><span class="nx">crypto</span><span class="p">.</span><span class="nx">timingSafeEqual</span><span class="p">(</span><span class="nx">received</span><span class="p">,</span> <span class="nx">expected</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nx">reject</span><span class="p">(</span><span class="mi">401</span><span class="p">,</span> <span class="s2">&#34;Invalid webhook signature&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// Parse only after authentication succeeds.
</span></span></span><span class="line"><span class="cl"><span class="c1"></span><span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">msg</span><span class="p">.</span><span class="nx">payload</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="nx">msg</span><span class="p">.</span><span class="nx">payload</span><span class="p">.</span><span class="nx">toString</span><span class="p">(</span><span class="s2">&#34;utf8&#34;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nx">reject</span><span class="p">(</span><span class="mi">400</span><span class="p">,</span> <span class="s2">&#34;Invalid JSON&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">msg</span><span class="p">.</span><span class="nx">payload</span> <span class="o">||</span> <span class="o">!</span><span class="nb">Array</span><span class="p">.</span><span class="nx">isArray</span><span class="p">(</span><span class="nx">msg</span><span class="p">.</span><span class="nx">payload</span><span class="p">.</span><span class="nx">events</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nx">reject</span><span class="p">(</span><span class="mi">400</span><span class="p">,</span> <span class="s2">&#34;Invalid Gills payload&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">return</span> <span class="p">[</span><span class="nx">msg</span><span class="p">,</span> <span class="kc">null</span><span class="p">];</span>
</span></span></code></pre></div><p>A few details worth highlighting. The signature format is <code>sha256=&lt;hex&gt;</code>, hence the <code>slice(7)</code> to
strip the prefix before decoding. The comparison uses <code>crypto.timingSafeEqual</code> on equal-length
buffers, which avoids short-circuit timing leaks. The expected digest is computed over the raw
<code>msg.payload</code> buffer (the exact bytes Gills sent), so any proxy or middleware that re-serialises the
body will break the signature, which is the desired behaviour. JSON parsing and the <code>events</code> array
shape check happen only after the signature verifies.</p>
<p>Once the payload is authenticated, a <strong>Parse</strong> function turns the structured event JSON into a
human-readable summary. It maps each event type to an emoji-prefixed label, walks the <code>changes</code>
array, and prints the package name with its suite/component/architecture and the version transition:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="kd">let</span> <span class="nx">batch</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">batch</span> <span class="o">=</span> <span class="k">typeof</span> <span class="nx">msg</span><span class="p">.</span><span class="nx">payload</span> <span class="o">===</span> <span class="s2">&#34;string&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="o">?</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="nx">msg</span><span class="p">.</span><span class="nx">payload</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="o">:</span> <span class="nx">msg</span><span class="p">.</span><span class="nx">payload</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">batch</span> <span class="o">||</span> <span class="o">!</span><span class="nb">Array</span><span class="p">.</span><span class="nx">isArray</span><span class="p">(</span><span class="nx">batch</span><span class="p">.</span><span class="nx">events</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">throw</span> <span class="k">new</span> <span class="nb">Error</span><span class="p">(</span><span class="s2">&#34;Expected a Gills payload with an events array&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">err</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">node</span><span class="p">.</span><span class="nx">error</span><span class="p">(</span><span class="sb">`Invalid Gills payload: </span><span class="si">${</span><span class="nx">err</span><span class="p">.</span><span class="nx">message</span><span class="si">}</span><span class="sb">`</span><span class="p">,</span> <span class="nx">msg</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="kc">null</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">labels</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;package.added&#34;</span><span class="o">:</span> <span class="s2">&#34;🆕 Package added&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;package.updated&#34;</span><span class="o">:</span> <span class="s2">&#34;⬆️ Package updated&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;package.downgraded&#34;</span><span class="o">:</span> <span class="s2">&#34;⬇️ Package downgraded&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;package.repacked&#34;</span><span class="o">:</span> <span class="s2">&#34;📦 Package repacked&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;package.removed&#34;</span><span class="o">:</span> <span class="s2">&#34;🗑️ Package removed&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;repository.error&#34;</span><span class="o">:</span> <span class="s2">&#34;⚠️ Repository error&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;repository.recovered&#34;</span><span class="o">:</span> <span class="s2">&#34;✅ Repository recovered&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;readiness.timeout&#34;</span><span class="o">:</span> <span class="s2">&#34;⏳ Publication incomplete&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">};</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">clean</span> <span class="o">=</span> <span class="nx">value</span> <span class="p">=&gt;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">String</span><span class="p">(</span><span class="nx">value</span> <span class="o">??</span> <span class="s2">&#34;&#34;</span><span class="p">).</span><span class="nx">replace</span><span class="p">(</span><span class="sr">/[\r\n\t]+/g</span><span class="p">,</span> <span class="s2">&#34; &#34;</span><span class="p">).</span><span class="nx">trim</span><span class="p">();</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">sections</span> <span class="o">=</span> <span class="nx">batch</span><span class="p">.</span><span class="nx">events</span><span class="p">.</span><span class="nx">map</span><span class="p">(</span><span class="nx">event</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">lines</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">        <span class="nx">labels</span><span class="p">[</span><span class="nx">event</span><span class="p">.</span><span class="nx">type</span><span class="p">]</span> <span class="o">||</span> <span class="nx">clean</span><span class="p">(</span><span class="nx">event</span><span class="p">.</span><span class="nx">type</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="sb">`Watch: </span><span class="si">${</span><span class="nx">clean</span><span class="p">(</span><span class="nx">event</span><span class="p">.</span><span class="nx">watch</span><span class="p">)</span><span class="si">}</span><span class="sb">`</span>
</span></span><span class="line"><span class="cl">    <span class="p">];</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="p">(</span><span class="kr">const</span> <span class="nx">change</span> <span class="k">of</span> <span class="nx">event</span><span class="p">.</span><span class="nx">changes</span> <span class="o">||</span> <span class="p">[])</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="kr">const</span> <span class="nx">pkg</span> <span class="o">=</span> <span class="nx">change</span><span class="p">.</span><span class="k">new</span> <span class="o">||</span> <span class="nx">change</span><span class="p">.</span><span class="nx">old</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">pkg</span><span class="p">)</span> <span class="k">continue</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="kr">const</span> <span class="nx">location</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">            <span class="nx">pkg</span><span class="p">.</span><span class="nx">suite</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="nx">pkg</span><span class="p">.</span><span class="nx">component</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="nx">pkg</span><span class="p">.</span><span class="nx">architecture</span>
</span></span><span class="line"><span class="cl">        <span class="p">].</span><span class="nx">filter</span><span class="p">(</span><span class="nb">Boolean</span><span class="p">).</span><span class="nx">map</span><span class="p">(</span><span class="nx">clean</span><span class="p">).</span><span class="nx">join</span><span class="p">(</span><span class="s2">&#34; / &#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="nx">lines</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="s2">&#34;&#34;</span><span class="p">,</span> <span class="sb">`</span><span class="si">${</span><span class="nx">clean</span><span class="p">(</span><span class="nx">pkg</span><span class="p">.</span><span class="nx">name</span><span class="p">)</span><span class="si">}</span><span class="sb"> (</span><span class="si">${</span><span class="nx">location</span><span class="si">}</span><span class="sb">)`</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="nx">change</span><span class="p">.</span><span class="nx">old</span> <span class="o">&amp;&amp;</span> <span class="nx">change</span><span class="p">.</span><span class="k">new</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nx">lines</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="sb">`From: </span><span class="si">${</span><span class="nx">clean</span><span class="p">(</span><span class="nx">change</span><span class="p">.</span><span class="nx">old</span><span class="p">.</span><span class="nx">version</span><span class="p">)</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">            <span class="nx">lines</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="sb">`To: </span><span class="si">${</span><span class="nx">clean</span><span class="p">(</span><span class="nx">change</span><span class="p">.</span><span class="k">new</span><span class="p">.</span><span class="nx">version</span><span class="p">)</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nx">lines</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="sb">`Version: </span><span class="si">${</span><span class="nx">clean</span><span class="p">(</span><span class="nx">pkg</span><span class="p">.</span><span class="nx">version</span><span class="p">)</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="nx">change</span><span class="p">.</span><span class="nx">level</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nx">lines</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="sb">`Change: </span><span class="si">${</span><span class="nx">clean</span><span class="p">(</span><span class="nx">change</span><span class="p">.</span><span class="nx">level</span><span class="p">)</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">event</span><span class="p">.</span><span class="nx">message</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nx">lines</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="s2">&#34;&#34;</span><span class="p">,</span> <span class="nx">clean</span><span class="p">(</span><span class="nx">event</span><span class="p">.</span><span class="nx">message</span><span class="p">));</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nx">lines</span><span class="p">.</span><span class="nx">join</span><span class="p">(</span><span class="s2">&#34;\n&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">}).</span><span class="nx">filter</span><span class="p">(</span><span class="nb">Boolean</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">msg</span><span class="p">.</span><span class="nx">payload</span> <span class="o">=</span> <span class="nx">sections</span><span class="p">.</span><span class="nx">join</span><span class="p">(</span><span class="s2">&#34;\n\n&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="k">return</span> <span class="nx">msg</span><span class="p">;</span>
</span></span></code></pre></div><p>The summary is wrapped into the Signal REST CLI's <code>/v2/send</code> payload and posted to that service.
Gills gets an acknowledgement as soon as the webhook receives the payload, and its own retry queue
handles the rest.</p>
<p>The full flow is available as a sanitised JSON file with the webhook URL and phone numbers replaced
by placeholders:</p>
<p><a href="/flows/gills-nodered-flow.json">Download gills-nodered-flow.json</a>
</p>
<p>In Node-RED: hamburger menu → Import → select the file. After importing, replace
<code>/your/secret/webhook/url</code> in the HTTP in node's URL, set <code>alice-number</code> and <code>bob-number</code> in the
Signal send function, and configure the <code>GILLS_WEBHOOK_SECRET</code> environment variable on the flow tab.</p>
<h2 id="how-to-install-gills">How to install Gills</h2>
<p>You can <code>pip(x) install gills</code>, install it from my APT or RPM repositories, or fetch the AppImage
from the <a href="https://git.mig5.net/mig5/gills/releases" target="_blank" rel="noopener noreferrer">releases</a>
 page.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo mkdir -p /usr/share/keyrings
</span></span><span class="line"><span class="cl">curl -fsSL https://mig5.net/static/mig5.asc <span class="p">|</span> sudo gpg --dearmor -o /usr/share/keyrings/mig5.gpg
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;deb [arch=amd64 signed-by=/usr/share/keyrings/mig5.gpg] https://apt.mig5.net </span><span class="k">$(</span>lsb_release -cs<span class="k">)</span><span class="s2"> main&#34;</span> <span class="p">|</span> sudo tee /etc/apt/sources.list.d/mig5.list
</span></span><span class="line"><span class="cl">sudo apt update
</span></span><span class="line"><span class="cl">sudo apt install gills
</span></span></code></pre></div><p>If Gills sounds useful to you, the <a href="https://git.mig5.net/mig5/gills" target="_blank" rel="noopener noreferrer">source and issue tracker</a>
 are
on my Forgejo. I'd be glad to hear how you use it.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Commands: <code>check</code> (one-shot) and <code>prune</code> (history retention)</li>
<li>Backends: APT and RPM</li>
<li>Events: added, updated, downgraded, repacked, removed, repository error/recovered, readiness timeout</li>
<li>Destinations: webhook (HMAC-SHA256), email (SMTP), Slack, signal-cli-rest-api, stdout</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://git.mig5.net/mig5/gills" target="_blank" rel="noopener noreferrer">Gills source and issue tracker</a>
</li>
<li><a href="https://git.mig5.net/mig5/gills/src/branch/main/docs/security.md" target="_blank" rel="noopener noreferrer">Security model</a>
</li>
<li><a href="/news/enroll-diff-webhook/">Enroll diff to Signal webhook</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Repository-driven build pipelines?</div>
  <div class="smallprint mb-3">I do contract sysadmin and DevSecOps work and can help wire repository monitoring, build triggers and notification pipelines together.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>Enroll 0.9.0: safer captures and better Ansible projects</title>
      <link>https://mig5.net/news/enroll-0.9.0/</link>
      <pubDate>Sun, 27 Sep 2026 00:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/enroll-0.9.0/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-hdd-network"></i><span>Enroll / release</span></div>
    <h1 class="display-6 fw-semibold mb-2">Enroll 0.9.0: safer captures and better Ansible projects</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-27">September 27, 2026</time></p>
    <p class="lead mb-0">Better protection around captured data, fixes for service and package handling, and shared Ansible roles that preserve each server's configuration.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-27T00:00:00&#43;10:00">27 September 2026</time>
        </div>

<p><a href="https://enroll.sh" target="_blank" rel="noopener noreferrer">Enroll</a>
 captures the state of an existing Linux server and turns it into Ansible you can review and maintain. I <a href="/news/enroll-history/">wrote about its origins</a>
 earlier: it grew out of wanting to bring existing, occasionally rather individual servers under configuration management.</p>
<p>Version 0.9.0 brings a fairly substantial collection of improvements: more careful handling of captured data, better package and service attribution, fixes to generated Ansible, and a reworked multi-host workflow. Personally, I'm finding the breadth of capture, consistency and ordering better in terms of Ansible using the new version, and I hope you will too.</p>
<h2 id="two-servers-one-project">Two servers, one project</h2>
<p>Let's talk about one of the major refactors: that being the 'merging' of multiple host harvests into the one 'manifested' Ansible set of roles.</p>
<p>For a hypothetical example, imagine <code>web01.example.net</code> and <code>web02.example.net</code>. Both run web services, with some shared configuration and some differences. We want those differences represented clearly when we bring them into the same project.</p>
<p>This release makes that workflow much more useful. You can start a project with one harvest, then extend it with another:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">enroll manifest --harvest web01.harvest <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  --host web01.example.net --out ansible
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">enroll manifest --harvest web02.harvest <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  --host web02.example.net --out ansible --extend
</span></span></code></pre></div><p>This logic with <code>--host</code> and <code>--extend</code> replaces the old <code>--fqdn</code> argument, and does it more safely, without sacrifincg on reusability.</p>
<p>The project retains the information needed to add further hosts, so you can extend it later without keeping every earlier harvest to hand. Each host keeps its own settings, capture notes and play order.</p>
<h2 id="sharing-the-things-that-match">Sharing the things that match</h2>
<p>The interesting part is what happens when two servers have the same configuration file.</p>
<p>If their captured copies are identical, Enroll keeps <em>one</em> shared copy in the role. If a later host has <em>different</em> contents for that same file, Enroll moves the affected hosts' copies into <code>inventory/host_files/</code>, preserving <em>each server's version</em>. Host settings live in <code>inventory/host_vars/</code>, and they tell the shared tasks what to deploy.</p>
<p>A third server follows the same rule. Its own captured file becomes its host-specific copy once that path has been split. Adding a host should always preserve what was harvested from that host.</p>
<p>This keeps common configuration convenient to maintain while giving differences an obvious home. Roles with matching task and handler implementations can be shared. Where the actual implementations differ (that is, at the 'task' level, not at the file content level), Enroll retains separate roles with readable hostnames in their names.</p>
<h2 id="one-role-can-manage-different-services">One role can manage different services</h2>
<p>Suppose <code>web01</code> runs both Apache and Nginx, while <code>web02</code> runs Nginx alone. Both can have an <code>httpd</code> role. The group name comes from package classification, so it can cover several related services.</p>
<p>Generated grouped roles now use a common restart handler which loops over that host's list of active services. The same role can therefore handle those two arrangements. This applies across the grouped roles, including mail services and other package groups.</p>
<p>There is an operational detail worth knowing: <strong>when that grouped handler is notified, it restarts all the active services listed for that role on that host</strong>. Review those lists alongside the configuration before applying a manifest.</p>
<h2 id="getting-the-order-right">Getting the order right</h2>
<p>A generated playbook needs packages and accounts in place before it can deploy files owned by those accounts. Services need their configuration ready before activation.</p>
<p>Version 0.9.0 makes those stages explicit: repository configuration first, package and account prerequisites next, then configuration, followed by service activation. APT and DNF configuration run once in their early stage. The configuration pass also avoids repeating the package and account work already completed.</p>
<p>Generated roles still have a complete default entry point for use independently in your own playbooks. Inside Enroll's generated plays, they participate in the ordered stages.</p>
<p>There are correctness fixes around this too. Handler notifications are scoped to the relevant role or unit, and loop results are filtered so notifications reflect changed items. Changes to managed symlinks and systemd units or drop-ins now trigger the appropriate activation work. Prerequisite and activation imports also follow the role selected for each host in a multi-host project. Group IDs are preserved, conflicting identity mappings are refused, and supplementary group membership remains additive.</p>
<h2 id="finding-the-package-behind-a-service">Finding the package behind a service</h2>
<p>A service's configuration can belong to a related package whose name differs from the unit. Enroll now uses installed direct dependencies and shared source-package identity to help associate those pieces on Debian/Ubuntu and RPM/DNF/Yum systems.</p>
<p>That improves how related configuration ends up alongside its service. Attribution evidence is recorded, and ambiguous cases receive deterministic handling. Independently captured packages and services also have separate artifact namespaces, avoiding collisions when similarly named resources cannot be confidently associated.</p>
<p>Locally supplied systemd unit fragments are captured too, including those outside package ownership, and Enroll reports executable prerequisites. That helps make the requirements of a custom service visible when reviewing the generated project.</p>
<h2 id="smaller-fixes-that-matter-during-replay">Smaller fixes that matter during replay</h2>
<p>Drift comparison has expanded: DNF and firewall artifacts, directories, symlinks, container images and application inventories receive broader coverage. Package observation includes automatically installed dependencies.</p>
<p>There are several other practical fixes:</p>
<ul>
<li>Destination exclusions now apply to generated runtime configuration as well, so an excluded path stays excluded when Enroll produces files from captured runtime state.</li>
<li>Snap replay fails explicitly when requested attributes cannot be honoured. That makes an incomplete restoration visible to the operator.</li>
<li>Incompatible retained templates are refused, preventing an old template from being quietly carried into output it no longer fits.</li>
<li>Standalone output always includes its Ansible configuration and collection requirements. Modern and legacy package metadata are also kept in sync.</li>
</ul>
<p>These are easy details to overlook until a generated project encounters them. Getting them right makes the output easier to review and the failures easier to understand.</p>
<h2 id="more-careful-capture-and-project-updates">More careful capture and project updates</h2>
<p>Security work has continued per the last couple releases, and follows Enroll's <a href="https://git.mig5.net/mig5/enroll/src/branch/main/SECURITY.md" target="_blank" rel="noopener noreferrer">documented threat model</a>
: protect administrators against accidental exposure and unsafe filesystem handling while keeping the trust decisions visible.</p>
<p>Secret screening now covers <strong>Flatpak remote metadata and opted-in runtime output</strong>, extending the checks around information that can end up in a harvest. Safe-mode content checks remain conservative; unusual configuration may still need an explicit decision about sensitive capture.</p>
<p>There is also a fix for <strong>FIFO handling</strong>. A FIFO is a named pipe, and opening one as though it were an ordinary file can leave a process waiting for another process to write to it. Enroll now prevents that blocking read before validation, so an unsuitable filesystem entry can be rejected promptly.</p>
<p>Generated role names receive collision checks, and <code>enroll_runtime</code> is reserved for Enroll's own role. Those checks protect the association between a role and the tasks it is intended to run. New manifest publication is staged as well.</p>
<p>Extending a project is staged and locked. Enroll checks for concurrent changes and publishes the completed extension using an atomic Linux directory exchange. Unsafe filesystem entries and unsupported atomic publication cause the operation to stop without changing the existing project. It also refuses duplicate hosts, conflicting collection requirements and edits to roles needed for sharing, while preserving edited host variables.</p>
<p>Harvests still deserve the same care as other sensitive administrative material. Structural validation checks the bundle and its artifacts; applying its desired state requires trusting its provenance. Safe-mode secret detection has limits, and <code>--dangerous</code> deliberately broadens what can be captured. SOPS encryption remains available for sensitive harvests.</p>
<h2 id="changes-to-know-before-upgrading">Changes to know before upgrading</h2>
<p>Two changes deserve attention if you already are used to Enroll:</p>
<ul>
<li><strong><code>manifest --fqdn</code> is replaced by <code>--host</code> and <code>--extend</code>.</strong> Start a fresh project with this version's metadata before extending it with additional hosts. Standalone generation continues to require a new output directory. The host options also work with <code>single-shot</code>; encrypted projects need to be unpacked before extension.</li>
<li><strong>Live firewall and sysctl capture are now opt-in</strong>, using <code>--harvest-firewall</code> and <code>--harvest-sysctl</code> on local or remote <code>harvest</code> and <code>single-shot</code>. Persistent configuration is still harvested normally, and existing bundles retain their recorded runtime snapshots. The reason I did this is because maybe you already have sysctl parameters or iptables rules captured in other scripts that Enroll can't deduce easily: it's not great to add new config files/scripts if you've already got them, so opt-in is a better model.</li>
</ul>
<p>For captured firewall state, optional boot restoration restores ipsets before iptables rules and includes reconciliation during a run. Persistence defaults to off through <code>firewall_runtime_persist: false</code>, giving you an explicit choice about how it fits with any existing firewall persistence service.</p>
<p>My recommended starting point for a generated project is still a review followed by a check-mode run:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">cd</span> ansible
</span></span><span class="line"><span class="cl">ansible-playbook -i inventory/hosts.yml playbook.yml --check --diff
</span></span></code></pre></div><p>That preview helps inspect the proposed changes. Service lifecycle and firewall behaviour also deserve testing on a suitable machine before relying on them in production.</p>
<p>As usual, the <a href="https://enroll.sh" target="_blank" rel="noopener noreferrer">Enroll website</a>
 has the usage documentation. The <a href="https://git.mig5.net/mig5/enroll" target="_blank" rel="noopener noreferrer">Forgejo repository</a>
 has the source, changelog and (now, since I opened up Github auth for others) an issue tracker.</p>
<p>Phew! That's a lot of detail, and probably some of it is too low-level. Suffice it to say, the aim throughout this release has been to make captured state more dependable to work with, from the initial harvest through to the Ansible you keep maintaining.</p>
<p>Let me know if you try it out!</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Release: Enroll 0.9.0</li>
<li>Multi-host projects: <code>--host</code> and <code>--extend</code></li>
<li>Shared files stay in roles; differing copies move into host inventory</li>
<li>Common grouped handlers use each host's active service list</li>
<li>Repository, package, account, configuration and activation stages</li>
<li>Secret screening for Flatpak metadata and opted-in runtime output</li>
<li>FIFO handling fixes, collision checks and staged project publication</li>
<li>Broader drift comparison and service/package attribution</li>
<li>Live firewall and sysctl capture now require explicit flags</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://enroll.sh" target="_blank" rel="noopener noreferrer">Enroll documentation</a>
</li>
<li><a href="https://git.mig5.net/mig5/enroll" target="_blank" rel="noopener noreferrer">Source and issue tracker on Forgejo</a>
</li>
<li><a href="https://git.mig5.net/mig5/enroll/src/branch/main/CHANGELOG.md" target="_blank" rel="noopener noreferrer">Changelog</a>
</li>
<li><a href="https://git.mig5.net/mig5/enroll/src/branch/main/SECURITY.md" target="_blank" rel="noopener noreferrer">Security model</a>
</li>
<li><a href="/news/enroll-history/">Enroll: from snowflake to Ansible</a>
</li>
</ul>

</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-hdd-network"></i><span>Enroll / release</span></div>
    <h1 class="display-6 fw-semibold mb-2">Enroll 0.9.0: safer captures and better Ansible projects</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-27">September 27, 2026</time></p>
    <p class="lead mb-0">Better protection around captured data, fixes for service and package handling, and shared Ansible roles that preserve each server's configuration.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-27T00:00:00&#43;10:00">27 September 2026</time>
        </div>

<p><a href="https://enroll.sh" target="_blank" rel="noopener noreferrer">Enroll</a>
 captures the state of an existing Linux server and turns it into Ansible you can review and maintain. I <a href="/news/enroll-history/">wrote about its origins</a>
 earlier: it grew out of wanting to bring existing, occasionally rather individual servers under configuration management.</p>
<p>Version 0.9.0 brings a fairly substantial collection of improvements: more careful handling of captured data, better package and service attribution, fixes to generated Ansible, and a reworked multi-host workflow. Personally, I'm finding the breadth of capture, consistency and ordering better in terms of Ansible using the new version, and I hope you will too.</p>
<h2 id="two-servers-one-project">Two servers, one project</h2>
<p>Let's talk about one of the major refactors: that being the 'merging' of multiple host harvests into the one 'manifested' Ansible set of roles.</p>
<p>For a hypothetical example, imagine <code>web01.example.net</code> and <code>web02.example.net</code>. Both run web services, with some shared configuration and some differences. We want those differences represented clearly when we bring them into the same project.</p>
<p>This release makes that workflow much more useful. You can start a project with one harvest, then extend it with another:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">enroll manifest --harvest web01.harvest <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  --host web01.example.net --out ansible
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">enroll manifest --harvest web02.harvest <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  --host web02.example.net --out ansible --extend
</span></span></code></pre></div><p>This logic with <code>--host</code> and <code>--extend</code> replaces the old <code>--fqdn</code> argument, and does it more safely, without sacrifincg on reusability.</p>
<p>The project retains the information needed to add further hosts, so you can extend it later without keeping every earlier harvest to hand. Each host keeps its own settings, capture notes and play order.</p>
<h2 id="sharing-the-things-that-match">Sharing the things that match</h2>
<p>The interesting part is what happens when two servers have the same configuration file.</p>
<p>If their captured copies are identical, Enroll keeps <em>one</em> shared copy in the role. If a later host has <em>different</em> contents for that same file, Enroll moves the affected hosts' copies into <code>inventory/host_files/</code>, preserving <em>each server's version</em>. Host settings live in <code>inventory/host_vars/</code>, and they tell the shared tasks what to deploy.</p>
<p>A third server follows the same rule. Its own captured file becomes its host-specific copy once that path has been split. Adding a host should always preserve what was harvested from that host.</p>
<p>This keeps common configuration convenient to maintain while giving differences an obvious home. Roles with matching task and handler implementations can be shared. Where the actual implementations differ (that is, at the 'task' level, not at the file content level), Enroll retains separate roles with readable hostnames in their names.</p>
<h2 id="one-role-can-manage-different-services">One role can manage different services</h2>
<p>Suppose <code>web01</code> runs both Apache and Nginx, while <code>web02</code> runs Nginx alone. Both can have an <code>httpd</code> role. The group name comes from package classification, so it can cover several related services.</p>
<p>Generated grouped roles now use a common restart handler which loops over that host's list of active services. The same role can therefore handle those two arrangements. This applies across the grouped roles, including mail services and other package groups.</p>
<p>There is an operational detail worth knowing: <strong>when that grouped handler is notified, it restarts all the active services listed for that role on that host</strong>. Review those lists alongside the configuration before applying a manifest.</p>
<h2 id="getting-the-order-right">Getting the order right</h2>
<p>A generated playbook needs packages and accounts in place before it can deploy files owned by those accounts. Services need their configuration ready before activation.</p>
<p>Version 0.9.0 makes those stages explicit: repository configuration first, package and account prerequisites next, then configuration, followed by service activation. APT and DNF configuration run once in their early stage. The configuration pass also avoids repeating the package and account work already completed.</p>
<p>Generated roles still have a complete default entry point for use independently in your own playbooks. Inside Enroll's generated plays, they participate in the ordered stages.</p>
<p>There are correctness fixes around this too. Handler notifications are scoped to the relevant role or unit, and loop results are filtered so notifications reflect changed items. Changes to managed symlinks and systemd units or drop-ins now trigger the appropriate activation work. Prerequisite and activation imports also follow the role selected for each host in a multi-host project. Group IDs are preserved, conflicting identity mappings are refused, and supplementary group membership remains additive.</p>
<h2 id="finding-the-package-behind-a-service">Finding the package behind a service</h2>
<p>A service's configuration can belong to a related package whose name differs from the unit. Enroll now uses installed direct dependencies and shared source-package identity to help associate those pieces on Debian/Ubuntu and RPM/DNF/Yum systems.</p>
<p>That improves how related configuration ends up alongside its service. Attribution evidence is recorded, and ambiguous cases receive deterministic handling. Independently captured packages and services also have separate artifact namespaces, avoiding collisions when similarly named resources cannot be confidently associated.</p>
<p>Locally supplied systemd unit fragments are captured too, including those outside package ownership, and Enroll reports executable prerequisites. That helps make the requirements of a custom service visible when reviewing the generated project.</p>
<h2 id="smaller-fixes-that-matter-during-replay">Smaller fixes that matter during replay</h2>
<p>Drift comparison has expanded: DNF and firewall artifacts, directories, symlinks, container images and application inventories receive broader coverage. Package observation includes automatically installed dependencies.</p>
<p>There are several other practical fixes:</p>
<ul>
<li>Destination exclusions now apply to generated runtime configuration as well, so an excluded path stays excluded when Enroll produces files from captured runtime state.</li>
<li>Snap replay fails explicitly when requested attributes cannot be honoured. That makes an incomplete restoration visible to the operator.</li>
<li>Incompatible retained templates are refused, preventing an old template from being quietly carried into output it no longer fits.</li>
<li>Standalone output always includes its Ansible configuration and collection requirements. Modern and legacy package metadata are also kept in sync.</li>
</ul>
<p>These are easy details to overlook until a generated project encounters them. Getting them right makes the output easier to review and the failures easier to understand.</p>
<h2 id="more-careful-capture-and-project-updates">More careful capture and project updates</h2>
<p>Security work has continued per the last couple releases, and follows Enroll's <a href="https://git.mig5.net/mig5/enroll/src/branch/main/SECURITY.md" target="_blank" rel="noopener noreferrer">documented threat model</a>
: protect administrators against accidental exposure and unsafe filesystem handling while keeping the trust decisions visible.</p>
<p>Secret screening now covers <strong>Flatpak remote metadata and opted-in runtime output</strong>, extending the checks around information that can end up in a harvest. Safe-mode content checks remain conservative; unusual configuration may still need an explicit decision about sensitive capture.</p>
<p>There is also a fix for <strong>FIFO handling</strong>. A FIFO is a named pipe, and opening one as though it were an ordinary file can leave a process waiting for another process to write to it. Enroll now prevents that blocking read before validation, so an unsuitable filesystem entry can be rejected promptly.</p>
<p>Generated role names receive collision checks, and <code>enroll_runtime</code> is reserved for Enroll's own role. Those checks protect the association between a role and the tasks it is intended to run. New manifest publication is staged as well.</p>
<p>Extending a project is staged and locked. Enroll checks for concurrent changes and publishes the completed extension using an atomic Linux directory exchange. Unsafe filesystem entries and unsupported atomic publication cause the operation to stop without changing the existing project. It also refuses duplicate hosts, conflicting collection requirements and edits to roles needed for sharing, while preserving edited host variables.</p>
<p>Harvests still deserve the same care as other sensitive administrative material. Structural validation checks the bundle and its artifacts; applying its desired state requires trusting its provenance. Safe-mode secret detection has limits, and <code>--dangerous</code> deliberately broadens what can be captured. SOPS encryption remains available for sensitive harvests.</p>
<h2 id="changes-to-know-before-upgrading">Changes to know before upgrading</h2>
<p>Two changes deserve attention if you already are used to Enroll:</p>
<ul>
<li><strong><code>manifest --fqdn</code> is replaced by <code>--host</code> and <code>--extend</code>.</strong> Start a fresh project with this version's metadata before extending it with additional hosts. Standalone generation continues to require a new output directory. The host options also work with <code>single-shot</code>; encrypted projects need to be unpacked before extension.</li>
<li><strong>Live firewall and sysctl capture are now opt-in</strong>, using <code>--harvest-firewall</code> and <code>--harvest-sysctl</code> on local or remote <code>harvest</code> and <code>single-shot</code>. Persistent configuration is still harvested normally, and existing bundles retain their recorded runtime snapshots. The reason I did this is because maybe you already have sysctl parameters or iptables rules captured in other scripts that Enroll can't deduce easily: it's not great to add new config files/scripts if you've already got them, so opt-in is a better model.</li>
</ul>
<p>For captured firewall state, optional boot restoration restores ipsets before iptables rules and includes reconciliation during a run. Persistence defaults to off through <code>firewall_runtime_persist: false</code>, giving you an explicit choice about how it fits with any existing firewall persistence service.</p>
<p>My recommended starting point for a generated project is still a review followed by a check-mode run:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">cd</span> ansible
</span></span><span class="line"><span class="cl">ansible-playbook -i inventory/hosts.yml playbook.yml --check --diff
</span></span></code></pre></div><p>That preview helps inspect the proposed changes. Service lifecycle and firewall behaviour also deserve testing on a suitable machine before relying on them in production.</p>
<p>As usual, the <a href="https://enroll.sh" target="_blank" rel="noopener noreferrer">Enroll website</a>
 has the usage documentation. The <a href="https://git.mig5.net/mig5/enroll" target="_blank" rel="noopener noreferrer">Forgejo repository</a>
 has the source, changelog and (now, since I opened up Github auth for others) an issue tracker.</p>
<p>Phew! That's a lot of detail, and probably some of it is too low-level. Suffice it to say, the aim throughout this release has been to make captured state more dependable to work with, from the initial harvest through to the Ansible you keep maintaining.</p>
<p>Let me know if you try it out!</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Release: Enroll 0.9.0</li>
<li>Multi-host projects: <code>--host</code> and <code>--extend</code></li>
<li>Shared files stay in roles; differing copies move into host inventory</li>
<li>Common grouped handlers use each host's active service list</li>
<li>Repository, package, account, configuration and activation stages</li>
<li>Secret screening for Flatpak metadata and opted-in runtime output</li>
<li>FIFO handling fixes, collision checks and staged project publication</li>
<li>Broader drift comparison and service/package attribution</li>
<li>Live firewall and sysctl capture now require explicit flags</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://enroll.sh" target="_blank" rel="noopener noreferrer">Enroll documentation</a>
</li>
<li><a href="https://git.mig5.net/mig5/enroll" target="_blank" rel="noopener noreferrer">Source and issue tracker on Forgejo</a>
</li>
<li><a href="https://git.mig5.net/mig5/enroll/src/branch/main/CHANGELOG.md" target="_blank" rel="noopener noreferrer">Changelog</a>
</li>
<li><a href="https://git.mig5.net/mig5/enroll/src/branch/main/SECURITY.md" target="_blank" rel="noopener noreferrer">Security model</a>
</li>
<li><a href="/news/enroll-history/">Enroll: from snowflake to Ansible</a>
</li>
</ul>

</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>Auditing OpenVPN certificates with openvpn-cert-check</title>
      <link>https://mig5.net/news/openvpn-cert-check/</link>
      <pubDate>Sat, 26 Sep 2026 00:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/openvpn-cert-check/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-lock"></i><span>OpenVPN / PKI auditing</span></div>
    <h1 class="display-6 fw-semibold mb-2">Auditing OpenVPN certificates with openvpn-cert-check</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-26">September 26, 2026</time></p>
    <p class="lead mb-0">An OpenVPN PKI is just an OpenSSL CA, and its index.txt knows every certificate ever
issued. This script compares the valid ones against a list you expect, flags unexpected
certificates, and warns about upcoming expiries from CI.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-26T00:00:00&#43;10:00">26 September 2026</time>
        </div>

<h2 id="an-under-observed-weakness-in-access-audits">An under-observed weakness in access audits</h2>
<p>OpenVPN's PKI is an OpenSSL certificate authority. Every certificate ever issued -
valid, revoked, or expired - is recorded in a tab-separated file called <code>index.txt</code>.</p>
<p>In my experience, two things are often forgotten when it comes to OpenVPN cert issuance.</p>
<ol>
<li><strong>Are the certificates currently valid, exactly the ones I expect?</strong> Someone issues a
cert. The employee leaves. The cert is still valid as revoking it was overlooked.</li>
<li><strong>Is anything about to expire?</strong> Certificates silently expire. OpenVPN clients stop
connecting, with cryptic TLS errors. You, the sysadmin, are the last to know, instead
of the first!</li>
</ol>
<h2 id="what-this-script-does">What this script does</h2>
<p><code>openvpn-cert-check</code> reads an OpenSSL <code>index.txt</code> and a file of <strong>expected</strong> valid certificate
names (CNs), one per line. It reports:</p>
<ul>
<li>Valid certificates that are <strong>not</strong> in the expected list (unexpected)</li>
<li>Expected certificates that are <strong>not</strong> currently valid (missing?)</li>
<li>Valid certificates whose CN cannot be parsed (unparseable)</li>
<li>Multiple valid certificates sharing the same CN (duplicates)</li>
<li>Certificates expiring within a warning window (<code>--warn-days</code>)</li>
<li>Revoked and expired certificates (informational)</li>
</ul>
<p>A certificate whose status field still says <code>V</code> but whose expiry time has <strong>passed</strong>, is treated as
expired.</p>
<p>The <code>--warn-days N</code> flag flags valid certificates expiring within N days. It adds an
&quot;Expiring soon&quot; section to the human output and an <code>expiring</code> array to the JSON output,
each entry with the certificate name, expiry timestamp, and days remaining.</p>
<p>Exit code 3 means &quot;valid certificates found expiring within the warning window.&quot; It is
distinct from exit code 1 (unexpected certificates), so CI can treat them
differently if necessary. When both conditions apply, exit 1 takes
precedence (an unexpected certificate is a failure, regardless of its expiry).</p>
<h2 id="example-normal-run">Example: normal run</h2>
<p>Against a CA with four valid, three revoked, and one expired certificate:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">OpenVPN certificate audit
</span></span><span class="line"><span class="cl">=========================
</span></span><span class="line"><span class="cl">CA database : index.txt
</span></span><span class="line"><span class="cl">Checked     : 2026-09-26 00:21:41 UTC
</span></span><span class="line"><span class="cl">Expected    : 4
</span></span><span class="line"><span class="cl">Valid       : 4
</span></span><span class="line"><span class="cl">Revoked     : 3
</span></span><span class="line"><span class="cl">Expired     : 1
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Valid certificates
</span></span><span class="line"><span class="cl">------------------
</span></span><span class="line"><span class="cl">  alice  OK            expires 2031-08-27 23:40:24 UTC
</span></span><span class="line"><span class="cl">  bob    OK            expires 2033-02-25 08:52:27 UTC
</span></span><span class="line"><span class="cl">  cora   OK            expires 2030-12-15 07:12:12 UTC
</span></span><span class="line"><span class="cl">  dan    OK            expires 2029-12-14 00:58:19 UTC
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Revoked certificates
</span></span><span class="line"><span class="cl">--------------------
</span></span><span class="line"><span class="cl">  edward    expires 2032-05-09 15:53:12 UTC  serial 20
</span></span><span class="line"><span class="cl">  florence  expires 2032-02-15 12:19:26 UTC  serial 1C
</span></span><span class="line"><span class="cl">  gordon    expires 2032-04-09 01:04:29 UTC  serial 1F
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Expired certificates
</span></span><span class="line"><span class="cl">--------------------
</span></span><span class="line"><span class="cl">  harriet  expired 2026-05-07 23:19:23 UTC  serial 0F
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">OK: all valid certificates are expected.
</span></span></code></pre></div><h2 id="example-unexpected-certificate">Example: unexpected certificate</h2>
<p>When a fifth valid certificate (<code>eve</code>) is present but not in the expected list:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">Valid certificates
</span></span><span class="line"><span class="cl">------------------
</span></span><span class="line"><span class="cl">  alice  OK            expires 2031-08-27 23:40:24 UTC
</span></span><span class="line"><span class="cl">  bob    OK            expires 2033-02-25 08:52:27 UTC
</span></span><span class="line"><span class="cl">  cora   OK            expires 2030-12-15 07:12:12 UTC
</span></span><span class="line"><span class="cl">  dan    OK            expires 2029-12-14 00:58:19 UTC
</span></span><span class="line"><span class="cl">  eve    UNEXPECTED    expires 2033-01-01 00:00:00 UTC
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">FAILED: 1 unexpected valid certificate found.
</span></span></code></pre></div><p>Exit code 1.</p>
<h2 id="example-expiry-warning-in-ci">Example: expiry warning in CI</h2>
<p>With <code>--warn-days 1200</code>, <code>dan</code> (expiring in 1175 days) is flagged:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">Expiring soon (within 1200 days)
</span></span><span class="line"><span class="cl">--------------------------------
</span></span><span class="line"><span class="cl">  dan   expires 2029-12-14 00:58:19 UTC  in 1175 days
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">WARNING: 1 valid certificate expiring within 1200 days.
</span></span></code></pre></div><p>Exit code 3.</p>
<p>The JSON output includes the same information with a <code>days</code> field for
dashboard tooling:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;expected_count&#34;</span><span class="p">:</span> <span class="mi">4</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;valid_count&#34;</span><span class="p">:</span> <span class="mi">4</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;unexpected&#34;</span><span class="p">:</span> <span class="p">[],</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;expiring&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;dan&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;expires&#34;</span><span class="p">:</span> <span class="s2">&#34;2029-12-14T00:58:19+00:00&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;days&#34;</span><span class="p">:</span> <span class="mi">1175</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">],</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;expiring_count&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;warn_days&#34;</span><span class="p">:</span> <span class="mi">1200</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;metric_value&#34;</span><span class="p">:</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><h2 id="exit-codes">Exit codes</h2>
<table>
<thead>
<tr>
<th>Code</th>
<th>Meaning</th>
</tr>
</thead>
<tbody>
<tr>
<td>0</td>
<td>No unexpected valid certificates, no expiring warnings</td>
</tr>
<tr>
<td>1</td>
<td>Unexpected or unparseable valid certificates found</td>
</tr>
<tr>
<td>2</td>
<td>Configuration or input error</td>
</tr>
<tr>
<td>3</td>
<td>Valid certificate(s) expiring within <code>--warn-days</code> (warning only)</td>
</tr>
</tbody>
</table>
<h2 id="works-with-any-openssl-ca">Works with any OpenSSL CA</h2>
<p>Despite the name, this tool reads a standard OpenSSL <code>index.txt</code>. It works with any CA
that uses <code>openssl ca</code>, be it OpenVPN, a custom PKI, or anything that produces the six-column
tab-separated format. The <code>index.txt</code> format is documented in OpenSSL's
<a href="https://www.openssl.org/docs/manmaster/man1/ca.html" target="_blank" rel="noopener noreferrer">ca(1)</a>
 manual page.</p>
<h2 id="running-it">Running it</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">openvpn-cert-check -i index.txt -e expected.txt [--warn-days N] [--json]
</span></span></code></pre></div><p>The expected file is one certificate CN per line, with <code>#</code> comments allowed.</p>
<p>The source is on <a href="https://git.mig5.net/mig5/openvpn-cert-check" target="_blank" rel="noopener noreferrer">My Forgejo</a>
.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Input: OpenSSL <code>index.txt</code> + expected names file</li>
<li>Output: human-readable or JSON (<code>--json</code>)</li>
<li>Exit codes: 0 (OK), 1 (unexpected), 2 (error), 3 (expiring soon)</li>
<li>CI feature: <code>--warn-days N</code> for expiry warnings</li>
<li>Works with: any OpenSSL CA, not just OpenVPN</li>
<li>Source: <a href="https://git.mig5.net/mig5/openvpn-cert-check" target="_blank" rel="noopener noreferrer">Forgejo</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://www.openssl.org/docs/manmaster/man1/ca.html" target="_blank" rel="noopener noreferrer">OpenSSL ca(1) manual&#39;s index.txt format</a>
</li>
<li><a href="https://openvpn.net/community-docs/setting-up-your-own-certificate-authority--ca--and-generating-certificates-and-keys-for-an-openvpn-server-and-multiple-clients.html" target="_blank" rel="noopener noreferrer">OpenVPN Easy-RSA PKI documentation</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need help with PKI or OpenVPN?</div>
  <div class="smallprint mb-3">I help organisations make Linux and secure network infrastructure easier to operate.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-lock"></i><span>OpenVPN / PKI auditing</span></div>
    <h1 class="display-6 fw-semibold mb-2">Auditing OpenVPN certificates with openvpn-cert-check</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-26">September 26, 2026</time></p>
    <p class="lead mb-0">An OpenVPN PKI is just an OpenSSL CA, and its index.txt knows every certificate ever
issued. This script compares the valid ones against a list you expect, flags unexpected
certificates, and warns about upcoming expiries from CI.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-26T00:00:00&#43;10:00">26 September 2026</time>
        </div>

<h2 id="an-under-observed-weakness-in-access-audits">An under-observed weakness in access audits</h2>
<p>OpenVPN's PKI is an OpenSSL certificate authority. Every certificate ever issued -
valid, revoked, or expired - is recorded in a tab-separated file called <code>index.txt</code>.</p>
<p>In my experience, two things are often forgotten when it comes to OpenVPN cert issuance.</p>
<ol>
<li><strong>Are the certificates currently valid, exactly the ones I expect?</strong> Someone issues a
cert. The employee leaves. The cert is still valid as revoking it was overlooked.</li>
<li><strong>Is anything about to expire?</strong> Certificates silently expire. OpenVPN clients stop
connecting, with cryptic TLS errors. You, the sysadmin, are the last to know, instead
of the first!</li>
</ol>
<h2 id="what-this-script-does">What this script does</h2>
<p><code>openvpn-cert-check</code> reads an OpenSSL <code>index.txt</code> and a file of <strong>expected</strong> valid certificate
names (CNs), one per line. It reports:</p>
<ul>
<li>Valid certificates that are <strong>not</strong> in the expected list (unexpected)</li>
<li>Expected certificates that are <strong>not</strong> currently valid (missing?)</li>
<li>Valid certificates whose CN cannot be parsed (unparseable)</li>
<li>Multiple valid certificates sharing the same CN (duplicates)</li>
<li>Certificates expiring within a warning window (<code>--warn-days</code>)</li>
<li>Revoked and expired certificates (informational)</li>
</ul>
<p>A certificate whose status field still says <code>V</code> but whose expiry time has <strong>passed</strong>, is treated as
expired.</p>
<p>The <code>--warn-days N</code> flag flags valid certificates expiring within N days. It adds an
&quot;Expiring soon&quot; section to the human output and an <code>expiring</code> array to the JSON output,
each entry with the certificate name, expiry timestamp, and days remaining.</p>
<p>Exit code 3 means &quot;valid certificates found expiring within the warning window.&quot; It is
distinct from exit code 1 (unexpected certificates), so CI can treat them
differently if necessary. When both conditions apply, exit 1 takes
precedence (an unexpected certificate is a failure, regardless of its expiry).</p>
<h2 id="example-normal-run">Example: normal run</h2>
<p>Against a CA with four valid, three revoked, and one expired certificate:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">OpenVPN certificate audit
</span></span><span class="line"><span class="cl">=========================
</span></span><span class="line"><span class="cl">CA database : index.txt
</span></span><span class="line"><span class="cl">Checked     : 2026-09-26 00:21:41 UTC
</span></span><span class="line"><span class="cl">Expected    : 4
</span></span><span class="line"><span class="cl">Valid       : 4
</span></span><span class="line"><span class="cl">Revoked     : 3
</span></span><span class="line"><span class="cl">Expired     : 1
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Valid certificates
</span></span><span class="line"><span class="cl">------------------
</span></span><span class="line"><span class="cl">  alice  OK            expires 2031-08-27 23:40:24 UTC
</span></span><span class="line"><span class="cl">  bob    OK            expires 2033-02-25 08:52:27 UTC
</span></span><span class="line"><span class="cl">  cora   OK            expires 2030-12-15 07:12:12 UTC
</span></span><span class="line"><span class="cl">  dan    OK            expires 2029-12-14 00:58:19 UTC
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Revoked certificates
</span></span><span class="line"><span class="cl">--------------------
</span></span><span class="line"><span class="cl">  edward    expires 2032-05-09 15:53:12 UTC  serial 20
</span></span><span class="line"><span class="cl">  florence  expires 2032-02-15 12:19:26 UTC  serial 1C
</span></span><span class="line"><span class="cl">  gordon    expires 2032-04-09 01:04:29 UTC  serial 1F
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Expired certificates
</span></span><span class="line"><span class="cl">--------------------
</span></span><span class="line"><span class="cl">  harriet  expired 2026-05-07 23:19:23 UTC  serial 0F
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">OK: all valid certificates are expected.
</span></span></code></pre></div><h2 id="example-unexpected-certificate">Example: unexpected certificate</h2>
<p>When a fifth valid certificate (<code>eve</code>) is present but not in the expected list:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">Valid certificates
</span></span><span class="line"><span class="cl">------------------
</span></span><span class="line"><span class="cl">  alice  OK            expires 2031-08-27 23:40:24 UTC
</span></span><span class="line"><span class="cl">  bob    OK            expires 2033-02-25 08:52:27 UTC
</span></span><span class="line"><span class="cl">  cora   OK            expires 2030-12-15 07:12:12 UTC
</span></span><span class="line"><span class="cl">  dan    OK            expires 2029-12-14 00:58:19 UTC
</span></span><span class="line"><span class="cl">  eve    UNEXPECTED    expires 2033-01-01 00:00:00 UTC
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">FAILED: 1 unexpected valid certificate found.
</span></span></code></pre></div><p>Exit code 1.</p>
<h2 id="example-expiry-warning-in-ci">Example: expiry warning in CI</h2>
<p>With <code>--warn-days 1200</code>, <code>dan</code> (expiring in 1175 days) is flagged:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">Expiring soon (within 1200 days)
</span></span><span class="line"><span class="cl">--------------------------------
</span></span><span class="line"><span class="cl">  dan   expires 2029-12-14 00:58:19 UTC  in 1175 days
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">WARNING: 1 valid certificate expiring within 1200 days.
</span></span></code></pre></div><p>Exit code 3.</p>
<p>The JSON output includes the same information with a <code>days</code> field for
dashboard tooling:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;expected_count&#34;</span><span class="p">:</span> <span class="mi">4</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;valid_count&#34;</span><span class="p">:</span> <span class="mi">4</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;unexpected&#34;</span><span class="p">:</span> <span class="p">[],</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;expiring&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;dan&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;expires&#34;</span><span class="p">:</span> <span class="s2">&#34;2029-12-14T00:58:19+00:00&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;days&#34;</span><span class="p">:</span> <span class="mi">1175</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">],</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;expiring_count&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;warn_days&#34;</span><span class="p">:</span> <span class="mi">1200</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;metric_value&#34;</span><span class="p">:</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><h2 id="exit-codes">Exit codes</h2>
<table>
<thead>
<tr>
<th>Code</th>
<th>Meaning</th>
</tr>
</thead>
<tbody>
<tr>
<td>0</td>
<td>No unexpected valid certificates, no expiring warnings</td>
</tr>
<tr>
<td>1</td>
<td>Unexpected or unparseable valid certificates found</td>
</tr>
<tr>
<td>2</td>
<td>Configuration or input error</td>
</tr>
<tr>
<td>3</td>
<td>Valid certificate(s) expiring within <code>--warn-days</code> (warning only)</td>
</tr>
</tbody>
</table>
<h2 id="works-with-any-openssl-ca">Works with any OpenSSL CA</h2>
<p>Despite the name, this tool reads a standard OpenSSL <code>index.txt</code>. It works with any CA
that uses <code>openssl ca</code>, be it OpenVPN, a custom PKI, or anything that produces the six-column
tab-separated format. The <code>index.txt</code> format is documented in OpenSSL's
<a href="https://www.openssl.org/docs/manmaster/man1/ca.html" target="_blank" rel="noopener noreferrer">ca(1)</a>
 manual page.</p>
<h2 id="running-it">Running it</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">openvpn-cert-check -i index.txt -e expected.txt [--warn-days N] [--json]
</span></span></code></pre></div><p>The expected file is one certificate CN per line, with <code>#</code> comments allowed.</p>
<p>The source is on <a href="https://git.mig5.net/mig5/openvpn-cert-check" target="_blank" rel="noopener noreferrer">My Forgejo</a>
.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Input: OpenSSL <code>index.txt</code> + expected names file</li>
<li>Output: human-readable or JSON (<code>--json</code>)</li>
<li>Exit codes: 0 (OK), 1 (unexpected), 2 (error), 3 (expiring soon)</li>
<li>CI feature: <code>--warn-days N</code> for expiry warnings</li>
<li>Works with: any OpenSSL CA, not just OpenVPN</li>
<li>Source: <a href="https://git.mig5.net/mig5/openvpn-cert-check" target="_blank" rel="noopener noreferrer">Forgejo</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://www.openssl.org/docs/manmaster/man1/ca.html" target="_blank" rel="noopener noreferrer">OpenSSL ca(1) manual&#39;s index.txt format</a>
</li>
<li><a href="https://openvpn.net/community-docs/setting-up-your-own-certificate-authority--ca--and-generating-certificates-and-keys-for-an-openvpn-server-and-multiple-clients.html" target="_blank" rel="noopener noreferrer">OpenVPN Easy-RSA PKI documentation</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need help with PKI or OpenVPN?</div>
  <div class="smallprint mb-3">I help organisations make Linux and secure network infrastructure easier to operate.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>Shipping OSSEC alerts to Loki with Grafana Alloy</title>
      <link>https://mig5.net/news/ossec-loki-alloy/</link>
      <pubDate>Sat, 26 Sep 2026 00:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/ossec-loki-alloy/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-graph-up"></i><span>OSSEC / Loki / Alloy</span></div>
    <h1 class="display-6 fw-semibold mb-2">Shipping OSSEC alerts to Loki with Grafana Alloy</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-26">September 26, 2026</time></p>
    <p class="lead mb-0">OSSEC has been able to write its alerts as JSON since version 2.9. That one line of
configuration is enough to hand them to Grafana Alloy, which can label, timestamp, and
metric them on the way into Loki.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-26T00:00:00&#43;10:00">26 September 2026</time>
        </div>

<figure class="text-center my-4">
  <a href="/news/ossec-loki-alloy/grafana-ossec_hu7349599067916317778.png" class="image-lightbox" title="Click to view larger">
    <img src="/news/ossec-loki-alloy/grafana-ossec_hu10301647744775528509.png" alt="OSSEC alerts in Grafana, with labels applied by the Alloy pipeline visible on each log line" width="760" height="406" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">OSSEC alerts in Grafana, with the labels extracted by Alloy on each line.</figcaption>
</figure>

<h2 id="getting-ossec-logs-into-loki-how">Getting OSSEC logs into Loki? How?</h2>
<p>Let's say you run <a href="https://www.ossec.net/" target="_blank" rel="noopener noreferrer">OSSEC</a>
 as a host IDS - file integrity checking, rootkit
detection, log-based rules, active responses - and you already operate a <a href="https://grafana.com/oss/loki/" target="_blank" rel="noopener noreferrer">Grafana Loki</a>
 stack.</p>
<p>The alerts live on disk in <code>/var/ossec/logs/alerts/</code>, rotating and compressing daily.</p>
<p>What you want is for those same alerts to appear in Loki, queryable alongside your application and
system logs, labelled by the things you actually want to filter by, such as the rule level, the rule
group, the host that produced the alert, or the source IP.</p>
<p><a href="https://grafana.com/docs/alloy/latest/" target="_blank" rel="noopener noreferrer">Grafana Alloy</a>
 is what ships logs to Loki. Already popular
for syslogs and other types of log format, it can tails the JSON file that OSSEC emits, parse each line,
promote chosen fields to labels, and send them on to Loki.</p>
<p>All it takes on OSSEC's side is a one-line change in <code>ossec.conf</code> to create on the JSON output that
Alloy reads from.</p>
<h2 id="why-ship-ossec-alerts-to-loki">Why ship OSSEC alerts to Loki?</h2>
<p>OSSEC's local <code>alerts.log</code> and <code>alerts.json</code> are rotated and checksummed daily, and they live
on the OSSEC manager itself. That's fine for forensics on that host, but it's an awkward place
to look when you're correlating a security event with what the application, the firewall, and
the web server were doing at the same time.</p>
<p>Loki gives those alerts a single query plane with everything else. Once the alerts are labelled
and in Loki, a LogQL query like <code>{system=&quot;ossec&quot;, level=~&quot;0[7-9]|1[0-5]&quot;}</code> shows every alert at
level 7 and above across every host you ship from.</p>
<p>The same query can feed a Grafana dashboard panel, a Grafana alerting rule, or a one-off investigation in
Grafana's Explore pane.</p>
<p>Retention is cheap, because Loki is really index-light (and aside from speed, is another reason I prefer
it over Elasticsearch): it indexes the labels, keeps the log lines compressed, and leaves the full alert
text for you to expand when you need it.</p>
<p>There's also a free metric that comes out of this pipeline. Alloy can count alerts as they pass
through and export a counter on its own <code>/metrics</code> endpoint, so you get an <code>ossec_alerts_total</code>
series to alert on - &quot;alert rate above threshold for this host&quot; - without standing up a
separate monitoring path for security events.</p>
<h2 id="native-ossec-alongside-loki">Native OSSEC alongside Loki</h2>
<p>OSSEC is a lightweight, single-process daemon. Its rule set, decoders, file integrity engine,
and active responses are enough on their own for a host that needs local detection and response.
For a site already standardised on the Grafana stack - Loki for logs, perhaps Prometheus for
metrics, Grafana for dashboards and alerting - pointing OSSEC's JSON output into Loki keeps
security events in the same observability plane as everything else. One query language, one
dashboarding tool, one alerting layer. The OSSEC manager keeps doing detection; Loki keeps
the history; Grafana keeps your eyes on it all.</p>
<h2 id="turning-on-json-output-in-ossec">Turning on JSON output in OSSEC</h2>
<p>OSSEC has been able to write JSON-formatted alerts since version 2.9. The output lands at
<code>/var/ossec/logs/alerts/alerts.json</code>, side by side with the legacy <code>alerts.log</code>. The legacy
file is unaffected, and the daily rotation and checksumming applies to both.</p>
<p>The switch is a single element in the <code>&lt;global&gt;</code> block of
<code>/var/ossec/etc/ossec.conf</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-xml" data-lang="xml"><span class="line"><span class="cl"><span class="nt">&lt;ossec_config&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&lt;global&gt;</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&lt;jsonout_output&gt;</span>yes<span class="nt">&lt;/jsonout_output&gt;</span>
</span></span><span class="line"><span class="cl">    <span class="c">&lt;!-- ...your other global settings... --&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&lt;/global&gt;</span>
</span></span><span class="line"><span class="cl"><span class="nt">&lt;/ossec_config&gt;</span>
</span></span></code></pre></div><p>Restart OSSEC and confirm the file is being written to:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">sudo systemctl restart ossec
</span></span><span class="line"><span class="cl">tail -n <span class="m">1</span> /var/ossec/logs/alerts/alerts.json <span class="p">|</span> jq .
</span></span></code></pre></div><p>Each line is one JSON object. The classic OSSEC format - the one this pipeline is written
against - looks like:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span><span class="nt">&#34;rule&#34;</span><span class="p">:{</span><span class="nt">&#34;level&#34;</span><span class="p">:</span><span class="mi">5</span><span class="p">,</span><span class="nt">&#34;comment&#34;</span><span class="p">:</span><span class="s2">&#34;Syslogd restarted.&#34;</span><span class="p">,</span><span class="nt">&#34;sidid&#34;</span><span class="p">:</span><span class="mi">1005</span><span class="p">,</span><span class="nt">&#34;group&#34;</span><span class="p">:</span><span class="s2">&#34;syslog,errors,&#34;</span><span class="p">},</span><span class="nt">&#34;id&#34;</span><span class="p">:</span><span class="s2">&#34;1510376401.0&#34;</span><span class="p">,</span><span class="nt">&#34;TimeStamp&#34;</span><span class="p">:</span><span class="mi">1510376401000</span><span class="p">,</span><span class="nt">&#34;location&#34;</span><span class="p">:</span><span class="s2">&#34;/var/log/messages&#34;</span><span class="p">,</span><span class="nt">&#34;full_log&#34;</span><span class="p">:</span><span class="s2">&#34;Nov 11 00:00:01 ix syslogd[72090]: restart&#34;</span><span class="p">,</span><span class="nt">&#34;hostname&#34;</span><span class="p">:</span><span class="s2">&#34;ix&#34;</span><span class="p">,</span><span class="nt">&#34;program_name&#34;</span><span class="p">:</span><span class="s2">&#34;syslogd&#34;</span><span class="p">}</span>
</span></span></code></pre></div><p>The fields this pipeline reads are:</p>
<ul>
<li><code>TimeStamp</code> - when OSSEC processed the event.</li>
<li><code>rule.level</code> - the rule's severity, 0 to 15.</li>
<li><code>rule.group</code> - the comma-separated rule groups (singular <code>group</code>, a string, in this format).</li>
<li><code>hostname</code> - the agent or manager name. This can be a bare hostname, or a parenthesised
form like <code>(junction) 192.168.17.17-&gt;/var/log/maillog</code> when OSSEC records the agent and the
log path together. The pipeline handles both.</li>
<li><code>srcip</code> - the source IP, when the rule extracted one.</li>
</ul>
<h2 id="the-alloy-pipeline">The Alloy pipeline</h2>
<p>The config below tails <code>alerts.json</code>, runs it through a processing pipeline, and writes the
result to Loki. I'll walk through what each stage does and why.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gdscript3" data-lang="gdscript3"><span class="line"><span class="cl"><span class="n">loki</span><span class="o">.</span><span class="n">write</span> <span class="s2">&#34;loki&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="n">endpoint</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">url</span> <span class="o">=</span> <span class="s2">&#34;https://loki.example.net/loki/api/v1/push&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">basic_auth</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="n">username</span> <span class="o">=</span> <span class="s2">&#34;someuser&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="n">password</span> <span class="o">=</span> <span class="s2">&#34;some-pass&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="n">external_labels</span> <span class="o">=</span> <span class="p">{}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">loki</span><span class="o">.</span><span class="n">source</span><span class="o">.</span><span class="n">file</span> <span class="s2">&#34;ossec_alerts&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="n">targets</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="n">__path__</span>    <span class="o">=</span> <span class="s2">&#34;/var/ossec/logs/alerts/alerts.json&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="n">__address__</span> <span class="o">=</span> <span class="s2">&#34;localhost&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="n">system</span>      <span class="o">=</span> <span class="s2">&#34;ossec&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="p">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">forward_to</span> <span class="o">=</span> <span class="p">[</span><span class="n">loki</span><span class="o">.</span><span class="n">process</span><span class="o">.</span><span class="n">ossec_alerts</span><span class="o">.</span><span class="n">receiver</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">loki</span><span class="o">.</span><span class="n">process</span> <span class="s2">&#34;ossec_alerts&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="n">stage</span><span class="o">.</span><span class="n">json</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">expressions</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="n">timestamp</span>   <span class="o">=</span> <span class="s2">&#34;TimeStamp&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="n">level</span>       <span class="o">=</span> <span class="s2">&#34;rule.level&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="n">ossec_group</span> <span class="o">=</span> <span class="s2">&#34;rule.group&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="n">ossec_host</span>  <span class="o">=</span> <span class="s2">&#34;hostname&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="n">ip</span>          <span class="o">=</span> <span class="s2">&#34;srcip&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="o">//</span> <span class="n">This</span> <span class="n">strips</span> <span class="n">parentheses</span> <span class="n">from</span> <span class="n">ossec_host</span> <span class="n">by</span> <span class="n">overwriting</span> <span class="n">it</span> <span class="n">with</span> <span class="n">the</span> <span class="n">capture</span> <span class="n">group</span><span class="o">.</span>
</span></span><span class="line"><span class="cl">  <span class="n">stage</span><span class="o">.</span><span class="n">regex</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">source</span>     <span class="o">=</span> <span class="s2">&#34;ossec_host&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">expression</span> <span class="o">=</span> <span class="s2">&#34;^</span><span class="se">\\</span><span class="s2">((?P&lt;ossec_host&gt;</span><span class="se">\\</span><span class="s2">S+)</span><span class="se">\\</span><span class="s2">)&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="o">//</span> <span class="n">Pad</span> <span class="n">level</span> <span class="n">so</span> <span class="n">sorting</span> <span class="n">works</span> <span class="n">as</span> <span class="n">intended</span>
</span></span><span class="line"><span class="cl">  <span class="n">stage</span><span class="o">.</span><span class="n">template</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">source</span>   <span class="o">=</span> <span class="s2">&#34;level&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">template</span> <span class="o">=</span> <span class="s2">&#34;{{ printf </span><span class="se">\&#34;</span><span class="si">%02s</span><span class="se">\&#34;</span><span class="s2"> .Value }}&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="o">//</span> <span class="n">Promote</span> <span class="n">extracted</span> <span class="n">fields</span> <span class="n">to</span> <span class="n">labels</span>
</span></span><span class="line"><span class="cl">  <span class="n">stage</span><span class="o">.</span><span class="n">labels</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">values</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="n">level</span>       <span class="o">=</span> <span class="s2">&#34;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="n">ossec_group</span> <span class="o">=</span> <span class="s2">&#34;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="n">ossec_host</span>  <span class="o">=</span> <span class="s2">&#34;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="n">ip</span>          <span class="o">=</span> <span class="s2">&#34;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="o">//</span> <span class="n">Use</span> <span class="n">the</span> <span class="n">parsed</span> <span class="n">timestamp</span> <span class="n">as</span> <span class="n">the</span> <span class="nb">log</span> <span class="n">entry</span> <span class="n">timestamp</span>
</span></span><span class="line"><span class="cl">  <span class="n">stage</span><span class="o">.</span><span class="n">timestamp</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">source</span> <span class="o">=</span> <span class="s2">&#34;timestamp&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">format</span> <span class="o">=</span> <span class="s2">&#34;RFC3339&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="o">//</span> <span class="n">Export</span> <span class="n">a</span> <span class="n">counter</span> <span class="n">metric</span> <span class="p">(</span><span class="n">exposed</span> <span class="n">on</span> <span class="n">Alloy</span><span class="s1">&#39;s /metrics endpoint)</span>
</span></span><span class="line"><span class="cl">  <span class="n">stage</span><span class="o">.</span><span class="n">metrics</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">metric</span><span class="o">.</span><span class="n">counter</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="n">name</span>        <span class="o">=</span> <span class="s2">&#34;ossec_alerts_total&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="n">description</span> <span class="o">=</span> <span class="s2">&#34;count of alerts&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="n">action</span>      <span class="o">=</span> <span class="s2">&#34;inc&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="n">source</span>      <span class="o">=</span> <span class="s2">&#34;level&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="n">prefix</span>      <span class="o">=</span> <span class="s2">&#34;&#34;</span> <span class="o">//</span> <span class="n">default</span> <span class="n">prefix</span> <span class="n">would</span> <span class="n">otherwise</span> <span class="n">be</span> <span class="n">added</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="o">//</span> <span class="n">send</span> <span class="n">processed</span> <span class="n">logs</span> <span class="n">onward</span>
</span></span><span class="line"><span class="cl">  <span class="n">forward_to</span> <span class="o">=</span> <span class="p">[</span><span class="n">loki</span><span class="o">.</span><span class="n">write</span><span class="o">.</span><span class="n">loki</span><span class="o">.</span><span class="n">receiver</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><h3 id="reading-the-file">Reading the file</h3>
<p><code>loki.source.file</code> tails <code>/var/ossec/logs/alerts/alerts.json</code>. The <code>system = &quot;ossec&quot;</code> key on
the target becomes a label on every line that comes through it, so even before the processing
pipeline runs, the log stream already carries <code>system=&quot;ossec&quot;</code>. If you tail other files through
their own <code>loki.source.file</code> blocks, the <code>system</code> label is what keeps them apart in Loki.</p>
<h3 id="parsing-the-json-line">Parsing the JSON line</h3>
<p><code>stage.json</code> extracts five fields from each line into the pipeline's extracted map:
<code>TimeStamp</code> becomes <code>timestamp</code>, <code>rule.level</code> becomes <code>level</code>, <code>rule.group</code> becomes
<code>ossec_group</code>, <code>hostname</code> becomes <code>ossec_host</code>, and <code>srcip</code> becomes <code>ip</code>. At this point they're
in the pipeline, but they're not labels yet.</p>
<h3 id="normalising-the-hostname">Normalising the hostname</h3>
<p>The <code>hostname</code> field isn't always a clean hostname. When OSSEC records the agent alongside the
log path, it looks like <code>(junction) 192.168.17.17-&gt;/var/log/maillog</code>. As a label value that's
noisy and high-cardinality, which reduces the performance benefits you otherwise aim for in Loki:
the IP and path make every value nearly unique. The <code>stage.regex</code> strips it back to just the agent
name: the expression <code>^\((?P&lt;ossec_host&gt;\S+)\)</code> matches a leading parenthesised token and, by
naming the capture group <code>ossec_host</code>, overwrites the extracted value with just <code>junction</code>.
Lines whose <code>hostname</code> is already a bare hostname are left untouched, because the regex simply
doesn't match, and the value passes through.</p>
<h3 id="padding-the-rule-level">Padding the rule level</h3>
<p>OSSEC rule levels run from 0 to 15. As a label value they're strings, so lexicographic sorting
puts <code>11</code> before <code>3</code> - which makes a mess of dropdowns and ordered queries. The <code>stage.template</code>
pads the level to two characters with <code>{{ printf &quot;%02s&quot; .Value }}</code>, so <code>3</code> becomes <code>03</code> and
sorting works as you'd expect. The padding happens before the <code>stage.labels</code> promotes the value
to a label, so Loki only ever sees the padded form.</p>
<h3 id="promoting-to-labels">Promoting to labels</h3>
<p><code>stage.labels</code> takes four of the extracted values - <code>level</code>, <code>ossec_group</code>, <code>ossec_host</code>, and
<code>ip</code> - and turns them into Loki labels. From this point on, every alert line in Loki is
labelled with its rule level, its rule group, its host, and (when the rule extracted one) its
source IP. Combined with the <code>system=&quot;ossec&quot;</code> label from the source block, that's the shape of
the log stream in Loki.</p>
<h3 id="setting-the-entry-timestamp">Setting the entry timestamp</h3>
<p><code>stage.timestamp</code> uses the parsed <code>TimeStamp</code> as the log entry's time in Loki, so the alert
appears at the moment OSSEC processed it - rather than at the moment Alloy ingested it. The <code>format</code>
here is <code>RFC3339</code>. The <code>TimeStamp</code> field's format varies across OSSEC versions: some emit a
Unix epoch integer (for which <code>UnixMs</code> is the right format), others an ISO 8601 string. If the
stage can't parse the value, Loki falls back to ingestion time and the lines still arrive - so
it's worth confirming in Grafana that the entry timestamps line up with the alert times in
<code>alerts.json</code> rather than the ingestion time. A quick <code>tail</code> of <code>alerts.json</code> next to the same
moment in Grafana's Explore view will tell you.</p>
<h3 id="exporting-an-alert-counter">Exporting an alert counter</h3>
<p><code>stage.metrics</code> increments a counter named <code>ossec_alerts_total</code> for every alert that passes
through, labelled by <code>level</code>. The counter is exposed on Alloy's own <code>/metrics</code> endpoint, so if
Alloy is already scraped by Mimir or Prometheus, you have an alert-rate series for free. The
<code>prefix = &quot;&quot;</code> stops Alloy from prepending its default metric prefix, so the series is
<code>ossec_alerts_total{level=&quot;07&quot;, ...}</code> and not something longer. A Grafana alerting rule on
<code>rate(ossec_alerts_total[5m])</code> is the natural &quot;alert burst&quot; detector.</p>
<h2 id="verifying-it-in-grafana">Verifying it in Grafana</h2>
<p>Once Alloy is running and OSSEC is writing JSON, open Grafana's Explore, pick the Loki
datasource, and start with the stream:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-promql" data-lang="promql"><span class="line"><span class="cl"><span class="p">{</span><span class="nl">system</span><span class="o">=</span><span class="p">&#34;</span><span class="s">ossec</span><span class="p">&#34;}</span><span class="w">
</span></span></span></code></pre></div><p>You should see alert lines flowing in. From there, the labels let you narrow down:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-promql" data-lang="promql"><span class="line"><span class="cl"><span class="p">{</span><span class="nl">system</span><span class="o">=</span><span class="p">&#34;</span><span class="s">ossec</span><span class="p">&#34;,</span><span class="w"> </span><span class="nl">level</span><span class="o">=~</span><span class="p">&#34;</span><span class="s">0[7-9]|1[0-5]</span><span class="p">&#34;}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="p">{</span><span class="nl">system</span><span class="o">=</span><span class="p">&#34;</span><span class="s">ossec</span><span class="p">&#34;,</span><span class="w"> </span><span class="nl">ossec_host</span><span class="o">=</span><span class="p">&#34;</span><span class="s">web-01</span><span class="p">&#34;,</span><span class="w"> </span><span class="nl">ossec_group</span><span class="o">=~</span><span class="p">&#34;</span><span class="s">.*sshd.*</span><span class="p">&#34;}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="p">{</span><span class="nl">system</span><span class="o">=</span><span class="p">&#34;</span><span class="s">ossec</span><span class="p">&#34;,</span><span class="w"> </span><span class="nl">ip</span><span class="o">=</span><span class="p">&#34;</span><span class="s">203.0.113.42</span><span class="p">&#34;}</span><span class="w">
</span></span></span></code></pre></div><p>The first shows every alert at level 7 and above. The second shows SSH-related alerts from a
specific host. The third shows every alert attributed to a given source IP, across all hosts and
all rule groups - which is often the fastest way to follow one actor across a fleet.</p>
<p>If you've added the counter metric and you're scraping it, a panel with
<code>sum by (ossec_host) (rate(ossec_alerts_total[5m]))</code> shows the alert rate per host, and a
Grafana alerting rule on the same query is how you get paged on a burst.</p>
<h3 id="taking-it-further-with-geoip">Taking it further with GeoIP</h3>
<p>With the MaxMind GeoIP database or similar, it's super easy to integrate with Alloy
and thereby get your OSSEC attacker IP addresses represented geographically in
Grafana as well!</p>
<p>Assuming you've installed <code>geoip-database</code> and configured it with a license in
<code>/etc/GeoIP.conf</code>, and all is well with that db, add this to the <code>ossec_alerts</code>
section of your Alloy config. I used the 'city' database.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gdscript3" data-lang="gdscript3"><span class="line"><span class="cl"><span class="n">stage</span><span class="o">.</span><span class="n">geoip</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="n">db</span>      <span class="o">=</span> <span class="s2">&#34;/var/lib/GeoIP/GeoLite2-City.mmdb&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="n">source</span>  <span class="o">=</span> <span class="s2">&#34;ip&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="n">db_type</span> <span class="o">=</span> <span class="s2">&#34;city&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">stage</span><span class="o">.</span><span class="n">labels</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="n">values</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">geoip_location_latitude</span>  <span class="o">=</span> <span class="s2">&#34;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">geoip_location_longitude</span> <span class="o">=</span> <span class="s2">&#34;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">stage</span><span class="o">.</span><span class="n">label_drop</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="n">values</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;geoip_postal_code&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;geoip_subdivision_code&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;geoip_continent_code&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;geoip_timezone&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>We're dropping some less useful labels there to reduce cardinality in Loki and keep things
performing well.</p>
<p>In Grafana, the Geomap plugin is easy to add to a dashboard. Consider a LogQL query like this:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-promql" data-lang="promql"><span class="line"><span class="cl"><span class="p">{</span><span class="nl">system</span><span class="o">=</span><span class="p">&#34;</span><span class="s">ossec</span><span class="p">&#34;}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="err">|</span><span class="w"> </span><span class="nv">json</span><span class="w"> </span><span class="nv">url</span><span class="err">=</span><span class="p">&#34;</span><span class="s">url</span><span class="p">&#34;,</span><span class="w"> </span><span class="nv">comment</span><span class="err">=</span><span class="p">&#34;</span><span class="s">rule.comment</span><span class="p">&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="err">|</span><span class="w"> </span><span class="nv">url</span><span class="w"> </span><span class="o">!=</span><span class="w"> </span><span class="p">&#34;&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="err">|</span><span class="w"> </span><span class="nv">geoip_location_latitude</span><span class="w"> </span><span class="o">!=</span><span class="w"> </span><span class="p">&#34;&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="err">|</span><span class="w"> </span><span class="nv">geoip_location_longitude</span><span class="w"> </span><span class="o">!=</span><span class="w"> </span><span class="p">&#34;&#34;</span><span class="w">
</span></span></span></code></pre></div><p>(Make sure to also set a Transformation to turn those latitude/longitude fields into Numbers).</p>
<p>Then select Geomap's Location Mode to 'Coords', and <code>geoip_location_latitude</code> / <code>geoip_location_longitude</code>
as the Latitude/Longitude fields in Geomap.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">LogQL starting points</div>
  <ul>
<li><code>{system=&quot;ossec&quot;, level=~&quot;0[7-9]|1[0-5]&quot;}</code> - level 7 and above</li>
<li><code>{system=&quot;ossec&quot;, ossec_host=&quot;web-01&quot;}</code> - one host</li>
<li><code>{system=&quot;ossec&quot;, ip=&quot;203.0.113.42&quot;}</code> - one source IP across all hosts</li>
<li><code>sum by (ossec_host) (rate(ossec_alerts_total[5m]))</code> - alert rate per host</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://www.ossec.net/docs/docs/manual/output/json-alert-log-output.html" target="_blank" rel="noopener noreferrer">OSSEC: Storing alerts as JSON</a>
</li>
<li><a href="https://www.ossec.net/docs/docs/formats/alerts.html" target="_blank" rel="noopener noreferrer">OSSEC: alert log samples</a>
</li>
<li><a href="https://grafana.com/docs/loki/latest/query/" target="_blank" rel="noopener noreferrer">Grafana Loki: LogQL</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need help with OSSEC or a Loki stack?</div>
  <div class="smallprint mb-3">I do contract sysadmin and security infrastructure work: host IDS, log aggregation, and the dashboards and alerting on top.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-graph-up"></i><span>OSSEC / Loki / Alloy</span></div>
    <h1 class="display-6 fw-semibold mb-2">Shipping OSSEC alerts to Loki with Grafana Alloy</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-26">September 26, 2026</time></p>
    <p class="lead mb-0">OSSEC has been able to write its alerts as JSON since version 2.9. That one line of
configuration is enough to hand them to Grafana Alloy, which can label, timestamp, and
metric them on the way into Loki.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-26T00:00:00&#43;10:00">26 September 2026</time>
        </div>

<figure class="text-center my-4">
  <a href="/news/ossec-loki-alloy/grafana-ossec_hu7349599067916317778.png" class="image-lightbox" title="Click to view larger">
    <img src="/news/ossec-loki-alloy/grafana-ossec_hu10301647744775528509.png" alt="OSSEC alerts in Grafana, with labels applied by the Alloy pipeline visible on each log line" width="760" height="406" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">OSSEC alerts in Grafana, with the labels extracted by Alloy on each line.</figcaption>
</figure>

<h2 id="getting-ossec-logs-into-loki-how">Getting OSSEC logs into Loki? How?</h2>
<p>Let's say you run <a href="https://www.ossec.net/" target="_blank" rel="noopener noreferrer">OSSEC</a>
 as a host IDS - file integrity checking, rootkit
detection, log-based rules, active responses - and you already operate a <a href="https://grafana.com/oss/loki/" target="_blank" rel="noopener noreferrer">Grafana Loki</a>
 stack.</p>
<p>The alerts live on disk in <code>/var/ossec/logs/alerts/</code>, rotating and compressing daily.</p>
<p>What you want is for those same alerts to appear in Loki, queryable alongside your application and
system logs, labelled by the things you actually want to filter by, such as the rule level, the rule
group, the host that produced the alert, or the source IP.</p>
<p><a href="https://grafana.com/docs/alloy/latest/" target="_blank" rel="noopener noreferrer">Grafana Alloy</a>
 is what ships logs to Loki. Already popular
for syslogs and other types of log format, it can tails the JSON file that OSSEC emits, parse each line,
promote chosen fields to labels, and send them on to Loki.</p>
<p>All it takes on OSSEC's side is a one-line change in <code>ossec.conf</code> to create on the JSON output that
Alloy reads from.</p>
<h2 id="why-ship-ossec-alerts-to-loki">Why ship OSSEC alerts to Loki?</h2>
<p>OSSEC's local <code>alerts.log</code> and <code>alerts.json</code> are rotated and checksummed daily, and they live
on the OSSEC manager itself. That's fine for forensics on that host, but it's an awkward place
to look when you're correlating a security event with what the application, the firewall, and
the web server were doing at the same time.</p>
<p>Loki gives those alerts a single query plane with everything else. Once the alerts are labelled
and in Loki, a LogQL query like <code>{system=&quot;ossec&quot;, level=~&quot;0[7-9]|1[0-5]&quot;}</code> shows every alert at
level 7 and above across every host you ship from.</p>
<p>The same query can feed a Grafana dashboard panel, a Grafana alerting rule, or a one-off investigation in
Grafana's Explore pane.</p>
<p>Retention is cheap, because Loki is really index-light (and aside from speed, is another reason I prefer
it over Elasticsearch): it indexes the labels, keeps the log lines compressed, and leaves the full alert
text for you to expand when you need it.</p>
<p>There's also a free metric that comes out of this pipeline. Alloy can count alerts as they pass
through and export a counter on its own <code>/metrics</code> endpoint, so you get an <code>ossec_alerts_total</code>
series to alert on - &quot;alert rate above threshold for this host&quot; - without standing up a
separate monitoring path for security events.</p>
<h2 id="native-ossec-alongside-loki">Native OSSEC alongside Loki</h2>
<p>OSSEC is a lightweight, single-process daemon. Its rule set, decoders, file integrity engine,
and active responses are enough on their own for a host that needs local detection and response.
For a site already standardised on the Grafana stack - Loki for logs, perhaps Prometheus for
metrics, Grafana for dashboards and alerting - pointing OSSEC's JSON output into Loki keeps
security events in the same observability plane as everything else. One query language, one
dashboarding tool, one alerting layer. The OSSEC manager keeps doing detection; Loki keeps
the history; Grafana keeps your eyes on it all.</p>
<h2 id="turning-on-json-output-in-ossec">Turning on JSON output in OSSEC</h2>
<p>OSSEC has been able to write JSON-formatted alerts since version 2.9. The output lands at
<code>/var/ossec/logs/alerts/alerts.json</code>, side by side with the legacy <code>alerts.log</code>. The legacy
file is unaffected, and the daily rotation and checksumming applies to both.</p>
<p>The switch is a single element in the <code>&lt;global&gt;</code> block of
<code>/var/ossec/etc/ossec.conf</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-xml" data-lang="xml"><span class="line"><span class="cl"><span class="nt">&lt;ossec_config&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&lt;global&gt;</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&lt;jsonout_output&gt;</span>yes<span class="nt">&lt;/jsonout_output&gt;</span>
</span></span><span class="line"><span class="cl">    <span class="c">&lt;!-- ...your other global settings... --&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&lt;/global&gt;</span>
</span></span><span class="line"><span class="cl"><span class="nt">&lt;/ossec_config&gt;</span>
</span></span></code></pre></div><p>Restart OSSEC and confirm the file is being written to:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">sudo systemctl restart ossec
</span></span><span class="line"><span class="cl">tail -n <span class="m">1</span> /var/ossec/logs/alerts/alerts.json <span class="p">|</span> jq .
</span></span></code></pre></div><p>Each line is one JSON object. The classic OSSEC format - the one this pipeline is written
against - looks like:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span><span class="nt">&#34;rule&#34;</span><span class="p">:{</span><span class="nt">&#34;level&#34;</span><span class="p">:</span><span class="mi">5</span><span class="p">,</span><span class="nt">&#34;comment&#34;</span><span class="p">:</span><span class="s2">&#34;Syslogd restarted.&#34;</span><span class="p">,</span><span class="nt">&#34;sidid&#34;</span><span class="p">:</span><span class="mi">1005</span><span class="p">,</span><span class="nt">&#34;group&#34;</span><span class="p">:</span><span class="s2">&#34;syslog,errors,&#34;</span><span class="p">},</span><span class="nt">&#34;id&#34;</span><span class="p">:</span><span class="s2">&#34;1510376401.0&#34;</span><span class="p">,</span><span class="nt">&#34;TimeStamp&#34;</span><span class="p">:</span><span class="mi">1510376401000</span><span class="p">,</span><span class="nt">&#34;location&#34;</span><span class="p">:</span><span class="s2">&#34;/var/log/messages&#34;</span><span class="p">,</span><span class="nt">&#34;full_log&#34;</span><span class="p">:</span><span class="s2">&#34;Nov 11 00:00:01 ix syslogd[72090]: restart&#34;</span><span class="p">,</span><span class="nt">&#34;hostname&#34;</span><span class="p">:</span><span class="s2">&#34;ix&#34;</span><span class="p">,</span><span class="nt">&#34;program_name&#34;</span><span class="p">:</span><span class="s2">&#34;syslogd&#34;</span><span class="p">}</span>
</span></span></code></pre></div><p>The fields this pipeline reads are:</p>
<ul>
<li><code>TimeStamp</code> - when OSSEC processed the event.</li>
<li><code>rule.level</code> - the rule's severity, 0 to 15.</li>
<li><code>rule.group</code> - the comma-separated rule groups (singular <code>group</code>, a string, in this format).</li>
<li><code>hostname</code> - the agent or manager name. This can be a bare hostname, or a parenthesised
form like <code>(junction) 192.168.17.17-&gt;/var/log/maillog</code> when OSSEC records the agent and the
log path together. The pipeline handles both.</li>
<li><code>srcip</code> - the source IP, when the rule extracted one.</li>
</ul>
<h2 id="the-alloy-pipeline">The Alloy pipeline</h2>
<p>The config below tails <code>alerts.json</code>, runs it through a processing pipeline, and writes the
result to Loki. I'll walk through what each stage does and why.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gdscript3" data-lang="gdscript3"><span class="line"><span class="cl"><span class="n">loki</span><span class="o">.</span><span class="n">write</span> <span class="s2">&#34;loki&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="n">endpoint</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">url</span> <span class="o">=</span> <span class="s2">&#34;https://loki.example.net/loki/api/v1/push&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">basic_auth</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="n">username</span> <span class="o">=</span> <span class="s2">&#34;someuser&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="n">password</span> <span class="o">=</span> <span class="s2">&#34;some-pass&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="n">external_labels</span> <span class="o">=</span> <span class="p">{}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">loki</span><span class="o">.</span><span class="n">source</span><span class="o">.</span><span class="n">file</span> <span class="s2">&#34;ossec_alerts&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="n">targets</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="n">__path__</span>    <span class="o">=</span> <span class="s2">&#34;/var/ossec/logs/alerts/alerts.json&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="n">__address__</span> <span class="o">=</span> <span class="s2">&#34;localhost&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="n">system</span>      <span class="o">=</span> <span class="s2">&#34;ossec&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="p">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">forward_to</span> <span class="o">=</span> <span class="p">[</span><span class="n">loki</span><span class="o">.</span><span class="n">process</span><span class="o">.</span><span class="n">ossec_alerts</span><span class="o">.</span><span class="n">receiver</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">loki</span><span class="o">.</span><span class="n">process</span> <span class="s2">&#34;ossec_alerts&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="n">stage</span><span class="o">.</span><span class="n">json</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">expressions</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="n">timestamp</span>   <span class="o">=</span> <span class="s2">&#34;TimeStamp&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="n">level</span>       <span class="o">=</span> <span class="s2">&#34;rule.level&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="n">ossec_group</span> <span class="o">=</span> <span class="s2">&#34;rule.group&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="n">ossec_host</span>  <span class="o">=</span> <span class="s2">&#34;hostname&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="n">ip</span>          <span class="o">=</span> <span class="s2">&#34;srcip&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="o">//</span> <span class="n">This</span> <span class="n">strips</span> <span class="n">parentheses</span> <span class="n">from</span> <span class="n">ossec_host</span> <span class="n">by</span> <span class="n">overwriting</span> <span class="n">it</span> <span class="n">with</span> <span class="n">the</span> <span class="n">capture</span> <span class="n">group</span><span class="o">.</span>
</span></span><span class="line"><span class="cl">  <span class="n">stage</span><span class="o">.</span><span class="n">regex</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">source</span>     <span class="o">=</span> <span class="s2">&#34;ossec_host&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">expression</span> <span class="o">=</span> <span class="s2">&#34;^</span><span class="se">\\</span><span class="s2">((?P&lt;ossec_host&gt;</span><span class="se">\\</span><span class="s2">S+)</span><span class="se">\\</span><span class="s2">)&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="o">//</span> <span class="n">Pad</span> <span class="n">level</span> <span class="n">so</span> <span class="n">sorting</span> <span class="n">works</span> <span class="n">as</span> <span class="n">intended</span>
</span></span><span class="line"><span class="cl">  <span class="n">stage</span><span class="o">.</span><span class="n">template</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">source</span>   <span class="o">=</span> <span class="s2">&#34;level&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">template</span> <span class="o">=</span> <span class="s2">&#34;{{ printf </span><span class="se">\&#34;</span><span class="si">%02s</span><span class="se">\&#34;</span><span class="s2"> .Value }}&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="o">//</span> <span class="n">Promote</span> <span class="n">extracted</span> <span class="n">fields</span> <span class="n">to</span> <span class="n">labels</span>
</span></span><span class="line"><span class="cl">  <span class="n">stage</span><span class="o">.</span><span class="n">labels</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">values</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="n">level</span>       <span class="o">=</span> <span class="s2">&#34;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="n">ossec_group</span> <span class="o">=</span> <span class="s2">&#34;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="n">ossec_host</span>  <span class="o">=</span> <span class="s2">&#34;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="n">ip</span>          <span class="o">=</span> <span class="s2">&#34;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="o">//</span> <span class="n">Use</span> <span class="n">the</span> <span class="n">parsed</span> <span class="n">timestamp</span> <span class="n">as</span> <span class="n">the</span> <span class="nb">log</span> <span class="n">entry</span> <span class="n">timestamp</span>
</span></span><span class="line"><span class="cl">  <span class="n">stage</span><span class="o">.</span><span class="n">timestamp</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">source</span> <span class="o">=</span> <span class="s2">&#34;timestamp&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">format</span> <span class="o">=</span> <span class="s2">&#34;RFC3339&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="o">//</span> <span class="n">Export</span> <span class="n">a</span> <span class="n">counter</span> <span class="n">metric</span> <span class="p">(</span><span class="n">exposed</span> <span class="n">on</span> <span class="n">Alloy</span><span class="s1">&#39;s /metrics endpoint)</span>
</span></span><span class="line"><span class="cl">  <span class="n">stage</span><span class="o">.</span><span class="n">metrics</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">metric</span><span class="o">.</span><span class="n">counter</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="n">name</span>        <span class="o">=</span> <span class="s2">&#34;ossec_alerts_total&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="n">description</span> <span class="o">=</span> <span class="s2">&#34;count of alerts&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="n">action</span>      <span class="o">=</span> <span class="s2">&#34;inc&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="n">source</span>      <span class="o">=</span> <span class="s2">&#34;level&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="n">prefix</span>      <span class="o">=</span> <span class="s2">&#34;&#34;</span> <span class="o">//</span> <span class="n">default</span> <span class="n">prefix</span> <span class="n">would</span> <span class="n">otherwise</span> <span class="n">be</span> <span class="n">added</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="o">//</span> <span class="n">send</span> <span class="n">processed</span> <span class="n">logs</span> <span class="n">onward</span>
</span></span><span class="line"><span class="cl">  <span class="n">forward_to</span> <span class="o">=</span> <span class="p">[</span><span class="n">loki</span><span class="o">.</span><span class="n">write</span><span class="o">.</span><span class="n">loki</span><span class="o">.</span><span class="n">receiver</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><h3 id="reading-the-file">Reading the file</h3>
<p><code>loki.source.file</code> tails <code>/var/ossec/logs/alerts/alerts.json</code>. The <code>system = &quot;ossec&quot;</code> key on
the target becomes a label on every line that comes through it, so even before the processing
pipeline runs, the log stream already carries <code>system=&quot;ossec&quot;</code>. If you tail other files through
their own <code>loki.source.file</code> blocks, the <code>system</code> label is what keeps them apart in Loki.</p>
<h3 id="parsing-the-json-line">Parsing the JSON line</h3>
<p><code>stage.json</code> extracts five fields from each line into the pipeline's extracted map:
<code>TimeStamp</code> becomes <code>timestamp</code>, <code>rule.level</code> becomes <code>level</code>, <code>rule.group</code> becomes
<code>ossec_group</code>, <code>hostname</code> becomes <code>ossec_host</code>, and <code>srcip</code> becomes <code>ip</code>. At this point they're
in the pipeline, but they're not labels yet.</p>
<h3 id="normalising-the-hostname">Normalising the hostname</h3>
<p>The <code>hostname</code> field isn't always a clean hostname. When OSSEC records the agent alongside the
log path, it looks like <code>(junction) 192.168.17.17-&gt;/var/log/maillog</code>. As a label value that's
noisy and high-cardinality, which reduces the performance benefits you otherwise aim for in Loki:
the IP and path make every value nearly unique. The <code>stage.regex</code> strips it back to just the agent
name: the expression <code>^\((?P&lt;ossec_host&gt;\S+)\)</code> matches a leading parenthesised token and, by
naming the capture group <code>ossec_host</code>, overwrites the extracted value with just <code>junction</code>.
Lines whose <code>hostname</code> is already a bare hostname are left untouched, because the regex simply
doesn't match, and the value passes through.</p>
<h3 id="padding-the-rule-level">Padding the rule level</h3>
<p>OSSEC rule levels run from 0 to 15. As a label value they're strings, so lexicographic sorting
puts <code>11</code> before <code>3</code> - which makes a mess of dropdowns and ordered queries. The <code>stage.template</code>
pads the level to two characters with <code>{{ printf &quot;%02s&quot; .Value }}</code>, so <code>3</code> becomes <code>03</code> and
sorting works as you'd expect. The padding happens before the <code>stage.labels</code> promotes the value
to a label, so Loki only ever sees the padded form.</p>
<h3 id="promoting-to-labels">Promoting to labels</h3>
<p><code>stage.labels</code> takes four of the extracted values - <code>level</code>, <code>ossec_group</code>, <code>ossec_host</code>, and
<code>ip</code> - and turns them into Loki labels. From this point on, every alert line in Loki is
labelled with its rule level, its rule group, its host, and (when the rule extracted one) its
source IP. Combined with the <code>system=&quot;ossec&quot;</code> label from the source block, that's the shape of
the log stream in Loki.</p>
<h3 id="setting-the-entry-timestamp">Setting the entry timestamp</h3>
<p><code>stage.timestamp</code> uses the parsed <code>TimeStamp</code> as the log entry's time in Loki, so the alert
appears at the moment OSSEC processed it - rather than at the moment Alloy ingested it. The <code>format</code>
here is <code>RFC3339</code>. The <code>TimeStamp</code> field's format varies across OSSEC versions: some emit a
Unix epoch integer (for which <code>UnixMs</code> is the right format), others an ISO 8601 string. If the
stage can't parse the value, Loki falls back to ingestion time and the lines still arrive - so
it's worth confirming in Grafana that the entry timestamps line up with the alert times in
<code>alerts.json</code> rather than the ingestion time. A quick <code>tail</code> of <code>alerts.json</code> next to the same
moment in Grafana's Explore view will tell you.</p>
<h3 id="exporting-an-alert-counter">Exporting an alert counter</h3>
<p><code>stage.metrics</code> increments a counter named <code>ossec_alerts_total</code> for every alert that passes
through, labelled by <code>level</code>. The counter is exposed on Alloy's own <code>/metrics</code> endpoint, so if
Alloy is already scraped by Mimir or Prometheus, you have an alert-rate series for free. The
<code>prefix = &quot;&quot;</code> stops Alloy from prepending its default metric prefix, so the series is
<code>ossec_alerts_total{level=&quot;07&quot;, ...}</code> and not something longer. A Grafana alerting rule on
<code>rate(ossec_alerts_total[5m])</code> is the natural &quot;alert burst&quot; detector.</p>
<h2 id="verifying-it-in-grafana">Verifying it in Grafana</h2>
<p>Once Alloy is running and OSSEC is writing JSON, open Grafana's Explore, pick the Loki
datasource, and start with the stream:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-promql" data-lang="promql"><span class="line"><span class="cl"><span class="p">{</span><span class="nl">system</span><span class="o">=</span><span class="p">&#34;</span><span class="s">ossec</span><span class="p">&#34;}</span><span class="w">
</span></span></span></code></pre></div><p>You should see alert lines flowing in. From there, the labels let you narrow down:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-promql" data-lang="promql"><span class="line"><span class="cl"><span class="p">{</span><span class="nl">system</span><span class="o">=</span><span class="p">&#34;</span><span class="s">ossec</span><span class="p">&#34;,</span><span class="w"> </span><span class="nl">level</span><span class="o">=~</span><span class="p">&#34;</span><span class="s">0[7-9]|1[0-5]</span><span class="p">&#34;}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="p">{</span><span class="nl">system</span><span class="o">=</span><span class="p">&#34;</span><span class="s">ossec</span><span class="p">&#34;,</span><span class="w"> </span><span class="nl">ossec_host</span><span class="o">=</span><span class="p">&#34;</span><span class="s">web-01</span><span class="p">&#34;,</span><span class="w"> </span><span class="nl">ossec_group</span><span class="o">=~</span><span class="p">&#34;</span><span class="s">.*sshd.*</span><span class="p">&#34;}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="p">{</span><span class="nl">system</span><span class="o">=</span><span class="p">&#34;</span><span class="s">ossec</span><span class="p">&#34;,</span><span class="w"> </span><span class="nl">ip</span><span class="o">=</span><span class="p">&#34;</span><span class="s">203.0.113.42</span><span class="p">&#34;}</span><span class="w">
</span></span></span></code></pre></div><p>The first shows every alert at level 7 and above. The second shows SSH-related alerts from a
specific host. The third shows every alert attributed to a given source IP, across all hosts and
all rule groups - which is often the fastest way to follow one actor across a fleet.</p>
<p>If you've added the counter metric and you're scraping it, a panel with
<code>sum by (ossec_host) (rate(ossec_alerts_total[5m]))</code> shows the alert rate per host, and a
Grafana alerting rule on the same query is how you get paged on a burst.</p>
<h3 id="taking-it-further-with-geoip">Taking it further with GeoIP</h3>
<p>With the MaxMind GeoIP database or similar, it's super easy to integrate with Alloy
and thereby get your OSSEC attacker IP addresses represented geographically in
Grafana as well!</p>
<p>Assuming you've installed <code>geoip-database</code> and configured it with a license in
<code>/etc/GeoIP.conf</code>, and all is well with that db, add this to the <code>ossec_alerts</code>
section of your Alloy config. I used the 'city' database.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gdscript3" data-lang="gdscript3"><span class="line"><span class="cl"><span class="n">stage</span><span class="o">.</span><span class="n">geoip</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="n">db</span>      <span class="o">=</span> <span class="s2">&#34;/var/lib/GeoIP/GeoLite2-City.mmdb&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="n">source</span>  <span class="o">=</span> <span class="s2">&#34;ip&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="n">db_type</span> <span class="o">=</span> <span class="s2">&#34;city&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">stage</span><span class="o">.</span><span class="n">labels</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="n">values</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">geoip_location_latitude</span>  <span class="o">=</span> <span class="s2">&#34;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">geoip_location_longitude</span> <span class="o">=</span> <span class="s2">&#34;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">stage</span><span class="o">.</span><span class="n">label_drop</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="n">values</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;geoip_postal_code&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;geoip_subdivision_code&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;geoip_continent_code&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;geoip_timezone&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>We're dropping some less useful labels there to reduce cardinality in Loki and keep things
performing well.</p>
<p>In Grafana, the Geomap plugin is easy to add to a dashboard. Consider a LogQL query like this:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-promql" data-lang="promql"><span class="line"><span class="cl"><span class="p">{</span><span class="nl">system</span><span class="o">=</span><span class="p">&#34;</span><span class="s">ossec</span><span class="p">&#34;}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="err">|</span><span class="w"> </span><span class="nv">json</span><span class="w"> </span><span class="nv">url</span><span class="err">=</span><span class="p">&#34;</span><span class="s">url</span><span class="p">&#34;,</span><span class="w"> </span><span class="nv">comment</span><span class="err">=</span><span class="p">&#34;</span><span class="s">rule.comment</span><span class="p">&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="err">|</span><span class="w"> </span><span class="nv">url</span><span class="w"> </span><span class="o">!=</span><span class="w"> </span><span class="p">&#34;&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="err">|</span><span class="w"> </span><span class="nv">geoip_location_latitude</span><span class="w"> </span><span class="o">!=</span><span class="w"> </span><span class="p">&#34;&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="err">|</span><span class="w"> </span><span class="nv">geoip_location_longitude</span><span class="w"> </span><span class="o">!=</span><span class="w"> </span><span class="p">&#34;&#34;</span><span class="w">
</span></span></span></code></pre></div><p>(Make sure to also set a Transformation to turn those latitude/longitude fields into Numbers).</p>
<p>Then select Geomap's Location Mode to 'Coords', and <code>geoip_location_latitude</code> / <code>geoip_location_longitude</code>
as the Latitude/Longitude fields in Geomap.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">LogQL starting points</div>
  <ul>
<li><code>{system=&quot;ossec&quot;, level=~&quot;0[7-9]|1[0-5]&quot;}</code> - level 7 and above</li>
<li><code>{system=&quot;ossec&quot;, ossec_host=&quot;web-01&quot;}</code> - one host</li>
<li><code>{system=&quot;ossec&quot;, ip=&quot;203.0.113.42&quot;}</code> - one source IP across all hosts</li>
<li><code>sum by (ossec_host) (rate(ossec_alerts_total[5m]))</code> - alert rate per host</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://www.ossec.net/docs/docs/manual/output/json-alert-log-output.html" target="_blank" rel="noopener noreferrer">OSSEC: Storing alerts as JSON</a>
</li>
<li><a href="https://www.ossec.net/docs/docs/formats/alerts.html" target="_blank" rel="noopener noreferrer">OSSEC: alert log samples</a>
</li>
<li><a href="https://grafana.com/docs/loki/latest/query/" target="_blank" rel="noopener noreferrer">Grafana Loki: LogQL</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need help with OSSEC or a Loki stack?</div>
  <div class="smallprint mb-3">I do contract sysadmin and security infrastructure work: host IDS, log aggregation, and the dashboards and alerting on top.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>DumpTales: finding what changed between database dumps</title>
      <link>https://mig5.net/news/dumptales/</link>
      <pubDate>Thu, 24 Sep 2026 00:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/dumptales/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-database"></i><span>DumpTales</span></div>
    <h1 class="display-6 fw-semibold mb-2">Finding what changed between database dumps</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-24">September 24, 2026</time></p>
    <p class="lead mb-0">Two SQL dumps can tell you what a database looked like on Tuesday and Thursday. DumpTales tells you what happened in between, as far as those two snapshots can show.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-24T00:00:00&#43;10:00">24 September 2026</time>
        </div>

<figure class="text-center my-4">
  <a href="/news/dumptales/icon_hu12242275652085875557.png" class="image-lightbox" title="Click to view larger">
    <img src="/news/dumptales/icon_hu13269075224589352327.png" alt="DumpTales duck and database logo" width="320" height="320" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
</figure>

<h2 id="a-question-between-backups">A question between backups</h2>
<p>I have often had two dumps of the same database and wanted a quick answer to a very specific question: which records appeared, disappeared or changed? Looking through thousands of lines of <code>INSERT</code> statements with a text diff gets tiring, especially if rows move around or the dump format changes.</p>
<p><a href="/projects/dumptales/">DumpTales</a>
 reads snapshots locally and compares rows by primary key. It can report supported changes to table columns, primary keys and foreign keys as well. It reads conventional MySQL and MariaDB SQL dumps, experimental PostgreSQL <code>COPY</code> dumps, and native SQLite database files. It does not execute the SQL or connect to a database server.</p>
<h2 id="a-tiny-example">A tiny example</h2>
<p>Here are two deliberately small MySQL dumps. The second upgrades Acme's plan, removes Old Co, and adds New Co. Harbour stays as it was.</p>
<p><strong>before.sql</strong></p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="o">`</span><span class="n">accounts</span><span class="o">`</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="o">`</span><span class="n">id</span><span class="o">`</span><span class="w"> </span><span class="nb">int</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="o">`</span><span class="n">name</span><span class="o">`</span><span class="w"> </span><span class="nb">varchar</span><span class="p">(</span><span class="mi">80</span><span class="p">)</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="o">`</span><span class="n">plan</span><span class="o">`</span><span class="w"> </span><span class="nb">varchar</span><span class="p">(</span><span class="mi">20</span><span class="p">)</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="o">`</span><span class="n">id</span><span class="o">`</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="o">`</span><span class="n">accounts</span><span class="o">`</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">1</span><span class="p">,</span><span class="s1">&#39;Acme&#39;</span><span class="p">,</span><span class="s1">&#39;basic&#39;</span><span class="p">),(</span><span class="mi">2</span><span class="p">,</span><span class="s1">&#39;Harbour&#39;</span><span class="p">,</span><span class="s1">&#39;basic&#39;</span><span class="p">),(</span><span class="mi">3</span><span class="p">,</span><span class="s1">&#39;Old Co&#39;</span><span class="p">,</span><span class="s1">&#39;trial&#39;</span><span class="p">);</span><span class="w">
</span></span></span></code></pre></div><p><strong>after.sql</strong></p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="o">`</span><span class="n">accounts</span><span class="o">`</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="o">`</span><span class="n">id</span><span class="o">`</span><span class="w"> </span><span class="nb">int</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="o">`</span><span class="n">name</span><span class="o">`</span><span class="w"> </span><span class="nb">varchar</span><span class="p">(</span><span class="mi">80</span><span class="p">)</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="o">`</span><span class="n">plan</span><span class="o">`</span><span class="w"> </span><span class="nb">varchar</span><span class="p">(</span><span class="mi">20</span><span class="p">)</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="o">`</span><span class="n">id</span><span class="o">`</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="o">`</span><span class="n">accounts</span><span class="o">`</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">1</span><span class="p">,</span><span class="s1">&#39;Acme&#39;</span><span class="p">,</span><span class="s1">&#39;pro&#39;</span><span class="p">),(</span><span class="mi">2</span><span class="p">,</span><span class="s1">&#39;Harbour&#39;</span><span class="p">,</span><span class="s1">&#39;basic&#39;</span><span class="p">),(</span><span class="mi">4</span><span class="p">,</span><span class="s1">&#39;New Co&#39;</span><span class="p">,</span><span class="s1">&#39;trial&#39;</span><span class="p">);</span><span class="w">
</span></span></span></code></pre></div><p>You can <a href="before.sql">download before.sql</a>
 and <a href="after.sql">after.sql</a>
 to try the example yourself:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">dumptales before.sql after.sql --color always
</span></span></code></pre></div><p>This is the actual output from those files, with its terminal colours represented on the page:</p>
<pre class="dumptales-output"><code><span class="dt-change">~ accounts ['1']</span>
    plan: 'basic' → 'pro'
<span class="dt-remove">- accounts ['3']</span>
<span class="dt-add">+ accounts ['4']</span>
Summary: added=1, removed=1, changed=1, schema=0 (engine=stream, parser=python)</code></pre>
<p>The yellow <code>~</code> marks a changed row and shows the field values. Red <code>-</code> and green <code>+</code> mark removed and added keys. This is a snapshot comparison: if a primary key changes, the result is a removal and an addition. The output does not claim to know what sequence of SQL statements caused the difference.</p>
<h2 id="working-with-bigger-dumps">Working with bigger dumps</h2>
<p>DumpTales inspects both inputs to choose its MySQL, PostgreSQL or SQLite reader. If it cannot recognise a dump, or the two inputs appear to use different dialects, it stops and asks you to specify <code>--dialect</code> explicitly. The same command works for a conventional plain-text <code>pg_dump</code> as for the MySQL example above:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">dumptales forgejo-before.sql forgejo-after.sql
</span></span></code></pre></div><p>Compression is detected from the file contents, regardless of the filename. Plain SQL, gzip, bzip2 and ZIP archives containing exactly one SQL dump can be compared directly. Native SQLite database files need to remain uncompressed. A PostgreSQL custom-format archive made with <code>pg_dump -Fc</code> is a different format from a ZIP file and is outside the supported inputs.</p>
<p>For MySQL dumps in compatible primary-key order, DumpTales streams through both inputs. When rows are out of order, its automatic engine falls back to compressed hash partitions on temporary disk. There are JSON and JSONL output formats if you want to inspect changes with another tool:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">dumptales old.sql.gz new.sql.gz --format json <span class="p">|</span> jq
</span></span></code></pre></div><p>This yields:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;version&#34;</span><span class="p">:</span> <span class="s2">&#34;0.1.2&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;changes&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;kind&#34;</span><span class="p">:</span> <span class="s2">&#34;changed&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;table&#34;</span><span class="p">:</span> <span class="s2">&#34;accounts&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;key&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;1&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="p">],</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;fields&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&#34;plan&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;before&#34;</span><span class="p">:</span> <span class="s2">&#34;basic&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;after&#34;</span><span class="p">:</span> <span class="s2">&#34;pro&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;kind&#34;</span><span class="p">:</span> <span class="s2">&#34;removed&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;table&#34;</span><span class="p">:</span> <span class="s2">&#34;accounts&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;key&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;3&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="p">],</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;before&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;3&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;Old Co&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&#34;plan&#34;</span><span class="p">:</span> <span class="s2">&#34;trial&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;kind&#34;</span><span class="p">:</span> <span class="s2">&#34;added&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;table&#34;</span><span class="p">:</span> <span class="s2">&#34;accounts&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;key&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;4&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="p">],</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;after&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;4&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;New Co&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&#34;plan&#34;</span><span class="p">:</span> <span class="s2">&#34;trial&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">],</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;summary&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;counts&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;changed&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;removed&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;added&#34;</span><span class="p">:</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;skipped_tables&#34;</span><span class="p">:</span> <span class="p">[],</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;unchanged_tables_skipped&#34;</span><span class="p">:</span> <span class="p">[],</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;old_rows&#34;</span><span class="p">:</span> <span class="mi">3</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;new_rows&#34;</span><span class="p">:</span> <span class="mi">3</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;engine&#34;</span><span class="p">:</span> <span class="s2">&#34;stream&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;parser&#34;</span><span class="p">:</span> <span class="s2">&#34;native&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;relationships_complete&#34;</span><span class="p">:</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>I made this for those moments when the backup exists, the database may be elsewhere, and I just need to understand the change. The <a href="https://git.mig5.net/mig5/dumptales" target="_blank" rel="noopener noreferrer">source and installation instructions</a>
 are available on my Forgejo.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Input: MySQL/MariaDB SQL dumps, experimental PostgreSQL <code>COPY</code> dumps, native SQLite files</li>
<li>Compression: plain, gzip, bzip2 or single-dump ZIP for SQL; native SQLite files remain uncompressed</li>
<li>Output: coloured terminal summary, JSON, or JSONL</li>
<li>Comparison: primary-key rows and supported schema details</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="/projects/dumptales/">DumpTales project page</a>
</li>
<li><a href="https://git.mig5.net/mig5/dumptales" target="_blank" rel="noopener noreferrer">Source and documentation</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need database performance or replication support?</div>
  <div class="smallprint mb-3">I do contract systems and database administration, and can help.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-database"></i><span>DumpTales</span></div>
    <h1 class="display-6 fw-semibold mb-2">Finding what changed between database dumps</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-24">September 24, 2026</time></p>
    <p class="lead mb-0">Two SQL dumps can tell you what a database looked like on Tuesday and Thursday. DumpTales tells you what happened in between, as far as those two snapshots can show.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-24T00:00:00&#43;10:00">24 September 2026</time>
        </div>

<figure class="text-center my-4">
  <a href="/news/dumptales/icon_hu12242275652085875557.png" class="image-lightbox" title="Click to view larger">
    <img src="/news/dumptales/icon_hu13269075224589352327.png" alt="DumpTales duck and database logo" width="320" height="320" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
</figure>

<h2 id="a-question-between-backups">A question between backups</h2>
<p>I have often had two dumps of the same database and wanted a quick answer to a very specific question: which records appeared, disappeared or changed? Looking through thousands of lines of <code>INSERT</code> statements with a text diff gets tiring, especially if rows move around or the dump format changes.</p>
<p><a href="/projects/dumptales/">DumpTales</a>
 reads snapshots locally and compares rows by primary key. It can report supported changes to table columns, primary keys and foreign keys as well. It reads conventional MySQL and MariaDB SQL dumps, experimental PostgreSQL <code>COPY</code> dumps, and native SQLite database files. It does not execute the SQL or connect to a database server.</p>
<h2 id="a-tiny-example">A tiny example</h2>
<p>Here are two deliberately small MySQL dumps. The second upgrades Acme's plan, removes Old Co, and adds New Co. Harbour stays as it was.</p>
<p><strong>before.sql</strong></p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="o">`</span><span class="n">accounts</span><span class="o">`</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="o">`</span><span class="n">id</span><span class="o">`</span><span class="w"> </span><span class="nb">int</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="o">`</span><span class="n">name</span><span class="o">`</span><span class="w"> </span><span class="nb">varchar</span><span class="p">(</span><span class="mi">80</span><span class="p">)</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="o">`</span><span class="n">plan</span><span class="o">`</span><span class="w"> </span><span class="nb">varchar</span><span class="p">(</span><span class="mi">20</span><span class="p">)</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="o">`</span><span class="n">id</span><span class="o">`</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="o">`</span><span class="n">accounts</span><span class="o">`</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">1</span><span class="p">,</span><span class="s1">&#39;Acme&#39;</span><span class="p">,</span><span class="s1">&#39;basic&#39;</span><span class="p">),(</span><span class="mi">2</span><span class="p">,</span><span class="s1">&#39;Harbour&#39;</span><span class="p">,</span><span class="s1">&#39;basic&#39;</span><span class="p">),(</span><span class="mi">3</span><span class="p">,</span><span class="s1">&#39;Old Co&#39;</span><span class="p">,</span><span class="s1">&#39;trial&#39;</span><span class="p">);</span><span class="w">
</span></span></span></code></pre></div><p><strong>after.sql</strong></p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="o">`</span><span class="n">accounts</span><span class="o">`</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="o">`</span><span class="n">id</span><span class="o">`</span><span class="w"> </span><span class="nb">int</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="o">`</span><span class="n">name</span><span class="o">`</span><span class="w"> </span><span class="nb">varchar</span><span class="p">(</span><span class="mi">80</span><span class="p">)</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="o">`</span><span class="n">plan</span><span class="o">`</span><span class="w"> </span><span class="nb">varchar</span><span class="p">(</span><span class="mi">20</span><span class="p">)</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="o">`</span><span class="n">id</span><span class="o">`</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="o">`</span><span class="n">accounts</span><span class="o">`</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">1</span><span class="p">,</span><span class="s1">&#39;Acme&#39;</span><span class="p">,</span><span class="s1">&#39;pro&#39;</span><span class="p">),(</span><span class="mi">2</span><span class="p">,</span><span class="s1">&#39;Harbour&#39;</span><span class="p">,</span><span class="s1">&#39;basic&#39;</span><span class="p">),(</span><span class="mi">4</span><span class="p">,</span><span class="s1">&#39;New Co&#39;</span><span class="p">,</span><span class="s1">&#39;trial&#39;</span><span class="p">);</span><span class="w">
</span></span></span></code></pre></div><p>You can <a href="before.sql">download before.sql</a>
 and <a href="after.sql">after.sql</a>
 to try the example yourself:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">dumptales before.sql after.sql --color always
</span></span></code></pre></div><p>This is the actual output from those files, with its terminal colours represented on the page:</p>
<pre class="dumptales-output"><code><span class="dt-change">~ accounts ['1']</span>
    plan: 'basic' → 'pro'
<span class="dt-remove">- accounts ['3']</span>
<span class="dt-add">+ accounts ['4']</span>
Summary: added=1, removed=1, changed=1, schema=0 (engine=stream, parser=python)</code></pre>
<p>The yellow <code>~</code> marks a changed row and shows the field values. Red <code>-</code> and green <code>+</code> mark removed and added keys. This is a snapshot comparison: if a primary key changes, the result is a removal and an addition. The output does not claim to know what sequence of SQL statements caused the difference.</p>
<h2 id="working-with-bigger-dumps">Working with bigger dumps</h2>
<p>DumpTales inspects both inputs to choose its MySQL, PostgreSQL or SQLite reader. If it cannot recognise a dump, or the two inputs appear to use different dialects, it stops and asks you to specify <code>--dialect</code> explicitly. The same command works for a conventional plain-text <code>pg_dump</code> as for the MySQL example above:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">dumptales forgejo-before.sql forgejo-after.sql
</span></span></code></pre></div><p>Compression is detected from the file contents, regardless of the filename. Plain SQL, gzip, bzip2 and ZIP archives containing exactly one SQL dump can be compared directly. Native SQLite database files need to remain uncompressed. A PostgreSQL custom-format archive made with <code>pg_dump -Fc</code> is a different format from a ZIP file and is outside the supported inputs.</p>
<p>For MySQL dumps in compatible primary-key order, DumpTales streams through both inputs. When rows are out of order, its automatic engine falls back to compressed hash partitions on temporary disk. There are JSON and JSONL output formats if you want to inspect changes with another tool:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">dumptales old.sql.gz new.sql.gz --format json <span class="p">|</span> jq
</span></span></code></pre></div><p>This yields:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;version&#34;</span><span class="p">:</span> <span class="s2">&#34;0.1.2&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;changes&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;kind&#34;</span><span class="p">:</span> <span class="s2">&#34;changed&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;table&#34;</span><span class="p">:</span> <span class="s2">&#34;accounts&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;key&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;1&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="p">],</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;fields&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&#34;plan&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;before&#34;</span><span class="p">:</span> <span class="s2">&#34;basic&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;after&#34;</span><span class="p">:</span> <span class="s2">&#34;pro&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;kind&#34;</span><span class="p">:</span> <span class="s2">&#34;removed&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;table&#34;</span><span class="p">:</span> <span class="s2">&#34;accounts&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;key&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;3&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="p">],</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;before&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;3&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;Old Co&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&#34;plan&#34;</span><span class="p">:</span> <span class="s2">&#34;trial&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;kind&#34;</span><span class="p">:</span> <span class="s2">&#34;added&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;table&#34;</span><span class="p">:</span> <span class="s2">&#34;accounts&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;key&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;4&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="p">],</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;after&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;4&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;New Co&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&#34;plan&#34;</span><span class="p">:</span> <span class="s2">&#34;trial&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">],</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;summary&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;counts&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;changed&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;removed&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;added&#34;</span><span class="p">:</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;skipped_tables&#34;</span><span class="p">:</span> <span class="p">[],</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;unchanged_tables_skipped&#34;</span><span class="p">:</span> <span class="p">[],</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;old_rows&#34;</span><span class="p">:</span> <span class="mi">3</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;new_rows&#34;</span><span class="p">:</span> <span class="mi">3</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;engine&#34;</span><span class="p">:</span> <span class="s2">&#34;stream&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;parser&#34;</span><span class="p">:</span> <span class="s2">&#34;native&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;relationships_complete&#34;</span><span class="p">:</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>I made this for those moments when the backup exists, the database may be elsewhere, and I just need to understand the change. The <a href="https://git.mig5.net/mig5/dumptales" target="_blank" rel="noopener noreferrer">source and installation instructions</a>
 are available on my Forgejo.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Input: MySQL/MariaDB SQL dumps, experimental PostgreSQL <code>COPY</code> dumps, native SQLite files</li>
<li>Compression: plain, gzip, bzip2 or single-dump ZIP for SQL; native SQLite files remain uncompressed</li>
<li>Output: coloured terminal summary, JSON, or JSONL</li>
<li>Comparison: primary-key rows and supported schema details</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="/projects/dumptales/">DumpTales project page</a>
</li>
<li><a href="https://git.mig5.net/mig5/dumptales" target="_blank" rel="noopener noreferrer">Source and documentation</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need database performance or replication support?</div>
  <div class="smallprint mb-3">I do contract systems and database administration, and can help.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>Finding unusual source IPs in CloudTrail Event History</title>
      <link>https://mig5.net/news/cloudtrail-unusual-source-monitor/</link>
      <pubDate>Wed, 23 Sep 2026 00:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/cloudtrail-unusual-source-monitor/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-shield-check"></i><span>AWS / monitoring</span></div>
    <h1 class="display-6 fw-semibold mb-2">Finding unusual source IPs in CloudTrail Event History</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-23">September 23, 2026</time></p>
    <p class="lead mb-0">An AWS API call from a network I don't recognise is something I want to investigate. This small scheduled
check reads recent CloudTrail management events, compares their source IPs with my expected
networks, and sends a grouped alert to a webhook.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-23T00:00:00&#43;10:00">23 September 2026</time>
        </div>

<h2 id="the-question-i-wanted-answered">The question I wanted answered</h2>
<p>Most days, the management API calls in an AWS account have fairly predictable origins: a CI
runner, an office or VPN connection, an EC2 instance, or a person working from a familiar network.
If credentials turn up somewhere else, I want to know which principal used them, where the calls
came from, and what they tried to do.</p>
<p>I already have a host that can run a scheduled Python job and receive webhook notifications. For
this particular question, I didn't need to start by delivering CloudTrail logs into CloudWatch
Logs, configuring metric filters, and maintaining alarms. CloudTrail's <a href="https://docs.aws.amazon.com/awscloudtrail/latest/userguide/view-cloudtrail-events.html" target="_blank" rel="noopener noreferrer">Event History</a>

is enabled by default and exposes recent management events through the
<a href="https://docs.aws.amazon.com/awscloudtrail/latest/APIReference/API_LookupEvents.html" target="_blank" rel="noopener noreferrer">LookupEvents API</a>
.
The script polls that history and applies the policy in Python.</p>
<p>This is a useful fit for a modest account and an existing scheduler. It avoids the additional
log delivery and alarm setup for this one check. The host, network access, and time spent operating
the job still have costs. If you need faster reaction, managed retries, or many event-driven
rules, <a href="https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-service-event-cloudtrail.html" target="_blank" rel="noopener noreferrer">EventBridge</a>

is a sensible option. I wouldn't claim this script is cheaper for every account without measuring
that account's event volume and existing infrastructure.</p>
<h2 id="what-counts-as-unusual">What counts as unusual?</h2>
<p>The policy starts with two kinds of expected source:</p>
<ul>
<li>Your own public CIDRs, such as a VPN exit address, NAT gateways from this or other related AWS accounts, or a fixed CI runner egress address.</li>
<li>Published AWS IP ranges in the AWS source Regions you choose to allow.</li>
</ul>
<p>The script downloads <a href="https://docs.aws.amazon.com/vpc/latest/userguide/aws-ip-ranges.html" target="_blank" rel="noopener noreferrer">AWS&#39;s published IP ranges</a>
,
looks up the source IP in each CloudTrail event, and compares matching AWS ranges with the allowed
source Regions. It can suppress exact role, service, and API combinations that you have reviewed
as routine. Findings are grouped by actor with source IPs, API calls, timestamps, and event IDs.</p>
<p>An AWS IP range is a broad signal. Anyone able to use an AWS-hosted machine in an allowed Region
could originate traffic there. A source outside the expected ranges is a reason to inspect an
event; a source inside them is not proof that its actor was authorised. <code>sourceIPAddress</code> is also
occasionally a service identifier; the current script skips those events.</p>
<div class="mermaid">

flowchart TD
    A[Scheduled run] --> B[CloudTrail Event History]
    B --> C[Compare source IP]
    C --> D{Unexpected?}
    D -- Yes --> E[Group findings by actor]
    E --> F[POST JSON to webhook]
    D -- No --> G[Record event ID]

</div>

<h2 id="why-monitor-if-iam-can-restrict-source-ips">Why monitor if IAM can restrict source IPs?</h2>
<p>For a credential used exclusively from fixed public networks, I would consider an IAM policy with
<a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_examples_aws_deny-ip.html" target="_blank" rel="noopener noreferrer">&lt;code&gt;aws:SourceIp&lt;/code&gt;</a>
.
An explicit deny can stop a request before it succeeds. It needs testing against the actual call
paths: calls through VPC endpoints and service-to-service requests have different network context,
and people working from changing connections may need a different policy.</p>
<p>The monitor is useful where a strict source policy is impractical, and for visibility into
unexpected attempts or configuration drift. An alert gives you the principal, API names and source
to investigate. I would still enforce source restrictions for credentials with genuinely fixed
network origins.</p>
<h2 id="running-the-check">Running the check</h2>
<p>Download <a href="/scripts/cloudtrail-event-watch.py">cloudtrail-event-watch.py</a>
 and install <code>boto3</code> in a
Python environment. Give its AWS identity <code>cloudtrail:LookupEvents</code> access in the accounts you
intend to inspect. Configure every Region you need to scan; Event History is regional, and the
script scans one account at a time using the selected AWS profile.</p>
<p>For example, a scheduled host with a persistent working directory could use:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">AWS_PROFILE</span><span class="o">=</span>monitoring
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">AWS_REGIONS_TO_SCAN</span><span class="o">=</span>eu-west-2,us-east-1
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">ALLOWED_AWS_SOURCE_REGIONS</span><span class="o">=</span>eu-west-2
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">EXPECTED_CIDRS</span><span class="o">=</span>203.0.113.10/32,2001:db8::/48
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">STATE_FILE</span><span class="o">=</span>/var/lib/cloudtrail-watch/monitoring.json
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">LOOKBACK_MINUTES</span><span class="o">=</span><span class="m">90</span>
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">WEBHOOK_URL</span><span class="o">=</span>https://alerts.example.net/cloudtrail
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">WEBHOOK_TOKEN</span><span class="o">=</span>your-secret-token
</span></span><span class="line"><span class="cl">python3 cloudtrail-event-watch.py
</span></span></code></pre></div><p>The example addresses and URL are placeholders. Keep the webhook token in your scheduler's secret
store. Avoid putting it in a repository. The endpoint receives a JSON rollup with
<code>alert_id</code>, counts, actor information, source IPs, event names, and event IDs. It can translate that
into a Google Chat or Slack message, an email, or an incident in your own system. A Google Chat or
Slack incoming webhook cannot consume this arbitrary rollup directly: each has its own message
format, so use a receiver that formats the alert if those are your destinations.</p>
<p>Schedule the script more frequently than <code>LOOKBACK_MINUTES</code>. Overlapping windows and a state file
avoid sending the same event on each run. Keep the state file on persistent storage and run a
single instance per state file. The script locks concurrent runs and writes its state atomically.
It saves alert event IDs after successful webhook delivery; a failed delivery will be retried on
later runs while the event remains in the lookback window. The receiving endpoint should
also deduplicate on <code>alert_id</code>, since an HTTP timeout might follow a successful delivery.</p>
<p>By default, a successful delivery exits <code>0</code>; set <code>ALERT_EXIT_CODE=2</code> if your scheduler should
also flag a run containing findings. A delivery failure exits <code>1</code>. Monitor failures and missed
runs separately: a broken scheduled job can go silent.</p>
<h2 id="what-this-check-can-and-cannot-see">What this check can and cannot see</h2>
<p>Event History contains the last 90 days of <strong>management events</strong> in each Region. It does not
contain ordinary data events such as S3 object reads, and it does not aggregate accounts for you.
A missing Region or account leaves a gap. API activity may also take time to appear, so an hourly
job is a periodic investigation aid with a detection delay.</p>
<p>The local state keeps a bounded list of 10,000 event IDs. If your lookback window contains more
than that many events, old IDs can fall out and trigger repeat alerts. Reduce the window, run more
often, or use a durable state store if that describes your account. The script also needs outbound
access to CloudTrail, AWS's IP range file, and your webhook. Check that the job is still running
and that those dependencies are reachable.</p>
<p>I treat an alert as a prompt to review the CloudTrail record, the principal's expected workload,
and related events. It is one small layer of operational visibility, useful precisely because the
question is narrow: <strong>did recent management activity come from a source I didn't expect?</strong></p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>AWS permission: <code>cloudtrail:LookupEvents</code> in each account scanned</li>
<li>Coverage: management events in configured Regions</li>
<li>Policy: expected CIDRs, allowed AWS source Regions, optional exact role/API exceptions</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://docs.aws.amazon.com/awscloudtrail/latest/userguide/view-cloudtrail-events.html" target="_blank" rel="noopener noreferrer">CloudTrail Event History and its limits</a>
</li>
<li><a href="https://docs.aws.amazon.com/awscloudtrail/latest/APIReference/API_LookupEvents.html" target="_blank" rel="noopener noreferrer">LookupEvents API</a>
</li>
<li><a href="https://docs.aws.amazon.com/vpc/latest/userguide/aws-ip-ranges.html" target="_blank" rel="noopener noreferrer">AWS published IP ranges</a>
</li>
<li><a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_examples_aws_deny-ip.html" target="_blank" rel="noopener noreferrer">IAM example: deny by source IP</a>
</li>
<li><a href="https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-service-event-cloudtrail.html" target="_blank" rel="noopener noreferrer">CloudTrail events in EventBridge</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need practical AWS monitoring?</div>
  <div class="smallprint mb-3">I help organisations make Linux and cloud infrastructure easier to operate and investigate.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-shield-check"></i><span>AWS / monitoring</span></div>
    <h1 class="display-6 fw-semibold mb-2">Finding unusual source IPs in CloudTrail Event History</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-23">September 23, 2026</time></p>
    <p class="lead mb-0">An AWS API call from a network I don't recognise is something I want to investigate. This small scheduled
check reads recent CloudTrail management events, compares their source IPs with my expected
networks, and sends a grouped alert to a webhook.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-23T00:00:00&#43;10:00">23 September 2026</time>
        </div>

<h2 id="the-question-i-wanted-answered">The question I wanted answered</h2>
<p>Most days, the management API calls in an AWS account have fairly predictable origins: a CI
runner, an office or VPN connection, an EC2 instance, or a person working from a familiar network.
If credentials turn up somewhere else, I want to know which principal used them, where the calls
came from, and what they tried to do.</p>
<p>I already have a host that can run a scheduled Python job and receive webhook notifications. For
this particular question, I didn't need to start by delivering CloudTrail logs into CloudWatch
Logs, configuring metric filters, and maintaining alarms. CloudTrail's <a href="https://docs.aws.amazon.com/awscloudtrail/latest/userguide/view-cloudtrail-events.html" target="_blank" rel="noopener noreferrer">Event History</a>

is enabled by default and exposes recent management events through the
<a href="https://docs.aws.amazon.com/awscloudtrail/latest/APIReference/API_LookupEvents.html" target="_blank" rel="noopener noreferrer">LookupEvents API</a>
.
The script polls that history and applies the policy in Python.</p>
<p>This is a useful fit for a modest account and an existing scheduler. It avoids the additional
log delivery and alarm setup for this one check. The host, network access, and time spent operating
the job still have costs. If you need faster reaction, managed retries, or many event-driven
rules, <a href="https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-service-event-cloudtrail.html" target="_blank" rel="noopener noreferrer">EventBridge</a>

is a sensible option. I wouldn't claim this script is cheaper for every account without measuring
that account's event volume and existing infrastructure.</p>
<h2 id="what-counts-as-unusual">What counts as unusual?</h2>
<p>The policy starts with two kinds of expected source:</p>
<ul>
<li>Your own public CIDRs, such as a VPN exit address, NAT gateways from this or other related AWS accounts, or a fixed CI runner egress address.</li>
<li>Published AWS IP ranges in the AWS source Regions you choose to allow.</li>
</ul>
<p>The script downloads <a href="https://docs.aws.amazon.com/vpc/latest/userguide/aws-ip-ranges.html" target="_blank" rel="noopener noreferrer">AWS&#39;s published IP ranges</a>
,
looks up the source IP in each CloudTrail event, and compares matching AWS ranges with the allowed
source Regions. It can suppress exact role, service, and API combinations that you have reviewed
as routine. Findings are grouped by actor with source IPs, API calls, timestamps, and event IDs.</p>
<p>An AWS IP range is a broad signal. Anyone able to use an AWS-hosted machine in an allowed Region
could originate traffic there. A source outside the expected ranges is a reason to inspect an
event; a source inside them is not proof that its actor was authorised. <code>sourceIPAddress</code> is also
occasionally a service identifier; the current script skips those events.</p>
<div class="mermaid">

flowchart TD
    A[Scheduled run] --> B[CloudTrail Event History]
    B --> C[Compare source IP]
    C --> D{Unexpected?}
    D -- Yes --> E[Group findings by actor]
    E --> F[POST JSON to webhook]
    D -- No --> G[Record event ID]

</div>

<h2 id="why-monitor-if-iam-can-restrict-source-ips">Why monitor if IAM can restrict source IPs?</h2>
<p>For a credential used exclusively from fixed public networks, I would consider an IAM policy with
<a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_examples_aws_deny-ip.html" target="_blank" rel="noopener noreferrer">&lt;code&gt;aws:SourceIp&lt;/code&gt;</a>
.
An explicit deny can stop a request before it succeeds. It needs testing against the actual call
paths: calls through VPC endpoints and service-to-service requests have different network context,
and people working from changing connections may need a different policy.</p>
<p>The monitor is useful where a strict source policy is impractical, and for visibility into
unexpected attempts or configuration drift. An alert gives you the principal, API names and source
to investigate. I would still enforce source restrictions for credentials with genuinely fixed
network origins.</p>
<h2 id="running-the-check">Running the check</h2>
<p>Download <a href="/scripts/cloudtrail-event-watch.py">cloudtrail-event-watch.py</a>
 and install <code>boto3</code> in a
Python environment. Give its AWS identity <code>cloudtrail:LookupEvents</code> access in the accounts you
intend to inspect. Configure every Region you need to scan; Event History is regional, and the
script scans one account at a time using the selected AWS profile.</p>
<p>For example, a scheduled host with a persistent working directory could use:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">AWS_PROFILE</span><span class="o">=</span>monitoring
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">AWS_REGIONS_TO_SCAN</span><span class="o">=</span>eu-west-2,us-east-1
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">ALLOWED_AWS_SOURCE_REGIONS</span><span class="o">=</span>eu-west-2
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">EXPECTED_CIDRS</span><span class="o">=</span>203.0.113.10/32,2001:db8::/48
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">STATE_FILE</span><span class="o">=</span>/var/lib/cloudtrail-watch/monitoring.json
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">LOOKBACK_MINUTES</span><span class="o">=</span><span class="m">90</span>
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">WEBHOOK_URL</span><span class="o">=</span>https://alerts.example.net/cloudtrail
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">WEBHOOK_TOKEN</span><span class="o">=</span>your-secret-token
</span></span><span class="line"><span class="cl">python3 cloudtrail-event-watch.py
</span></span></code></pre></div><p>The example addresses and URL are placeholders. Keep the webhook token in your scheduler's secret
store. Avoid putting it in a repository. The endpoint receives a JSON rollup with
<code>alert_id</code>, counts, actor information, source IPs, event names, and event IDs. It can translate that
into a Google Chat or Slack message, an email, or an incident in your own system. A Google Chat or
Slack incoming webhook cannot consume this arbitrary rollup directly: each has its own message
format, so use a receiver that formats the alert if those are your destinations.</p>
<p>Schedule the script more frequently than <code>LOOKBACK_MINUTES</code>. Overlapping windows and a state file
avoid sending the same event on each run. Keep the state file on persistent storage and run a
single instance per state file. The script locks concurrent runs and writes its state atomically.
It saves alert event IDs after successful webhook delivery; a failed delivery will be retried on
later runs while the event remains in the lookback window. The receiving endpoint should
also deduplicate on <code>alert_id</code>, since an HTTP timeout might follow a successful delivery.</p>
<p>By default, a successful delivery exits <code>0</code>; set <code>ALERT_EXIT_CODE=2</code> if your scheduler should
also flag a run containing findings. A delivery failure exits <code>1</code>. Monitor failures and missed
runs separately: a broken scheduled job can go silent.</p>
<h2 id="what-this-check-can-and-cannot-see">What this check can and cannot see</h2>
<p>Event History contains the last 90 days of <strong>management events</strong> in each Region. It does not
contain ordinary data events such as S3 object reads, and it does not aggregate accounts for you.
A missing Region or account leaves a gap. API activity may also take time to appear, so an hourly
job is a periodic investigation aid with a detection delay.</p>
<p>The local state keeps a bounded list of 10,000 event IDs. If your lookback window contains more
than that many events, old IDs can fall out and trigger repeat alerts. Reduce the window, run more
often, or use a durable state store if that describes your account. The script also needs outbound
access to CloudTrail, AWS's IP range file, and your webhook. Check that the job is still running
and that those dependencies are reachable.</p>
<p>I treat an alert as a prompt to review the CloudTrail record, the principal's expected workload,
and related events. It is one small layer of operational visibility, useful precisely because the
question is narrow: <strong>did recent management activity come from a source I didn't expect?</strong></p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>AWS permission: <code>cloudtrail:LookupEvents</code> in each account scanned</li>
<li>Coverage: management events in configured Regions</li>
<li>Policy: expected CIDRs, allowed AWS source Regions, optional exact role/API exceptions</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://docs.aws.amazon.com/awscloudtrail/latest/userguide/view-cloudtrail-events.html" target="_blank" rel="noopener noreferrer">CloudTrail Event History and its limits</a>
</li>
<li><a href="https://docs.aws.amazon.com/awscloudtrail/latest/APIReference/API_LookupEvents.html" target="_blank" rel="noopener noreferrer">LookupEvents API</a>
</li>
<li><a href="https://docs.aws.amazon.com/vpc/latest/userguide/aws-ip-ranges.html" target="_blank" rel="noopener noreferrer">AWS published IP ranges</a>
</li>
<li><a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_examples_aws_deny-ip.html" target="_blank" rel="noopener noreferrer">IAM example: deny by source IP</a>
</li>
<li><a href="https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-service-event-cloudtrail.html" target="_blank" rel="noopener noreferrer">CloudTrail events in EventBridge</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need practical AWS monitoring?</div>
  <div class="smallprint mb-3">I help organisations make Linux and cloud infrastructure easier to operate and investigate.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>Automatically attaching a YubiKey to a Qubes OS vault for GPG</title>
      <link>https://mig5.net/news/qubes-yubikey/</link>
      <pubDate>Tue, 22 Sep 2026 00:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/qubes-yubikey/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-diagram-3"></i><span>Qubes OS</span></div>
    <h1 class="display-6 fw-semibold mb-2">Automatically attaching a YubiKey to a Qubes OS vault for GPG</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-22">September 22, 2026</time></p>
    <p class="lead mb-0">Use Qubes OS device assignments to automatically attach a YubiKey to your vault qube for GPG use, avoiding the need to manually reattach it every time you plug it in.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-22T00:00:00&#43;10:00">22 September 2026</time>
        </div>

<hr>
<p>I learned a useful Qubes OS trick recently that saves me a small but repetitive step every time I plug in my YubiKey.</p>
<p>My setup has a YubiKey containing my GPG keys, with the USB device normally exposed by <code>sys-usb</code>. I want the YubiKey available inside my <code>vault</code> qube so that GPG can use its OpenPGP smart-card interface.</p>
<p>Until now, every time I plugged the YubiKey in, I would run something like this in dom0:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">DEVICE</span><span class="o">=</span><span class="k">$(</span>qvm-usb list <span class="p">|</span> grep -i Yubikey <span class="p">|</span> awk <span class="s1">&#39;{print $1}&#39;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">qvm-usb attach vault <span class="s2">&#34;</span><span class="nv">$DEVICE</span><span class="s2">&#34;</span>
</span></span></code></pre></div><p>Without doing that, attempting an operation requiring the private key would result in GPG asking me to insert the card:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Please insert the card with serial number ...
</span></span></code></pre></div><p>After attaching the USB device to <code>vault</code>, GPG could see the YubiKey and everything worked.</p>
<p>This was fine, but I had assumed that manually attaching the device every time was simply how USB passthrough in Qubes worked.</p>
<p>It turns out Qubes can remember the assignment.</p>
<h2 id="attachment-versus-assignment">Attachment versus assignment</h2>
<p>The useful distinction is between <strong>attaching</strong> and <strong>assigning</strong> a device.</p>
<p>When I run:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-usb attach vault <span class="s2">&#34;</span><span class="nv">$DEVICE</span><span class="s2">&#34;</span>
</span></span></code></pre></div><p>I am attaching the currently connected USB device to <code>vault</code>.</p>
<p>That is temporary. If I unplug the YubiKey, that particular attachment disappears. The next time it is inserted, it is once again available through the USB qube rather than automatically being handed to <code>vault</code>.</p>
<p>Qubes also has the concept of a device <strong>assignment</strong>.</p>
<p>An assignment tells Qubes that when a matching device appears in the future, it should automatically be attached to a particular qube.</p>
<p>So instead of repeatedly running <code>attach</code>, I can plug in my YubiKey once and run <em>just once</em>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">DEVICE</span><span class="o">=</span><span class="k">$(</span>qvm-usb list <span class="p">|</span> grep -i Yubikey <span class="p">|</span> awk <span class="s1">&#39;{print $1}&#39;</span> <span class="p">|</span> head -1<span class="k">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">qvm-usb assign --device vault <span class="s2">&#34;</span><span class="nv">$DEVICE</span><span class="s2">&#34;</span>
</span></span></code></pre></div><p>That is now a <em>persistent</em> configuration, rather than a one-off attachment.</p>
<p>Now I can unplug the YubiKey and plug it back in later and Qubes will automatically attach it to <code>vault</code>.</p>
<h2 id="why---device">Why <code>--device</code>?</h2>
<p>There is an important detail in the command:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">--device
</span></span></code></pre></div><p>Without it, a Qubes USB assignment can be associated with both the device and the USB port where it was connected.</p>
<p>For example:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-usb assign vault <span class="s2">&#34;</span><span class="nv">$DEVICE</span><span class="s2">&#34;</span>
</span></span></code></pre></div><p>is useful if I deliberately want the assignment tied to that physical USB connection.</p>
<p>In my case, however, I want:</p>
<blockquote>
<p>Whenever this YubiKey appears, attach it to <code>vault</code>.</p>
</blockquote>
<p>I don't particularly care which USB socket I used.</p>
<p>So:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-usb assign --device vault <span class="s2">&#34;</span><span class="nv">$DEVICE</span><span class="s2">&#34;</span>
</span></span></code></pre></div><p>tells Qubes to base the automatic assignment on the identity presented by the device rather than its current USB port.</p>
<p>The practical result is:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">YubiKey plugged in
</span></span><span class="line"><span class="cl">       │
</span></span><span class="line"><span class="cl">       ▼
</span></span><span class="line"><span class="cl">sys-usb detects it
</span></span><span class="line"><span class="cl">       │
</span></span><span class="line"><span class="cl">       ▼
</span></span><span class="line"><span class="cl">Qubes finds its saved device assignment
</span></span><span class="line"><span class="cl">       │
</span></span><span class="line"><span class="cl">       ▼
</span></span><span class="line"><span class="cl">USB device automatically attaches to vault
</span></span><span class="line"><span class="cl">       │
</span></span><span class="line"><span class="cl">       ▼
</span></span><span class="line"><span class="cl">GPG can see the YubiKey/OpenPGP smart card
</span></span></code></pre></div><h2 id="checking-the-assignment">Checking the assignment</h2>
<p>Assignments can be displayed with:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-usb list --assignments
</span></span></code></pre></div><p>Qubes marks assigned qubes separately from devices that are merely attached at that moment.</p>
<p>After reconnecting the YubiKey, a normal:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-usb list
</span></span></code></pre></div><p>should show it as being used by <code>vault</code>, without having to manually run <code>qvm-usb attach</code>.</p>
<h2 id="what-about---persistent-mode">What about <code>--persistent</code> mode?</h2>
<p>Another potentially confusing option is <code>--persistent</code> on <code>qvm-usb attach</code>.</p>
<p>It sounds like exactly what is needed here, but it isn't quite the same thing.</p>
<p>The modern Qubes device model distinguishes a normal automatic assignment from a <strong>required</strong> device.</p>
<p>A required device is expected to be available when the target qube starts. If it isn't available, that can prevent the qube from starting.</p>
<p>That is not what I want for a hardware token which normally remains unplugged until I need it.</p>
<p>A normal:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-usb assign --device vault <span class="s2">&#34;</span><span class="nv">$DEVICE</span><span class="s2">&#34;</span>
</span></span></code></pre></div><p>means, effectively:</p>
<blockquote>
<p>If this device appears, automatically attach it to <code>vault</code>.</p>
</blockquote>
<p>It does <strong>not</strong> mean:</p>
<blockquote>
<p><code>vault</code> must have this device in order to start.</p>
</blockquote>
<p>That's a much better fit for a YubiKey.</p>
<h2 id="removing-the-assignment">Removing the assignment</h2>
<p>If I later decide that I no longer want the YubiKey automatically attached to <code>vault</code>, I can plug it in, identify it again and remove the assignment:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">DEVICE</span><span class="o">=</span><span class="k">$(</span>qvm-usb list <span class="p">|</span> grep -i Yubikey <span class="p">|</span> awk <span class="s1">&#39;{print $1}&#39;</span> <span class="p">|</span> head -1<span class="k">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">qvm-usb unassign --device vault <span class="s2">&#34;</span><span class="nv">$DEVICE</span><span class="s2">&#34;</span>
</span></span></code></pre></div><p>A manual <code>detach</code> and an <code>unassign</code> are different operations just as <code>attach</code> and <code>assign</code> are.</p>
<p>Detaching changes what is connected <strong>now</strong>.</p>
<p>Unassigning removes the rule governing what should happen <strong>in the future</strong>.</p>
<h2 id="a-small-security-consideration">A small security consideration</h2>
<p>Automatic USB assignment is worth thinking about before enabling it for a high-value qube.</p>
<p>The device identity used for matching ultimately comes from information presented by the USB device itself. Device identification should therefore not be treated as equivalent to cryptographic authentication of a particular physical YubiKey.</p>
<p>If I wanted a stricter setup, I could omit <code>--device</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-usb assign vault <span class="s2">&#34;</span><span class="nv">$DEVICE</span><span class="s2">&#34;</span>
</span></span></code></pre></div><p>and deliberately associate the assignment with the particular USB port as well.</p>
<p>There is a trade-off here between convenience and how broadly the automatic assignment should match.</p>
<p>For my setup, where the YubiKey is specifically intended for the <code>vault</code> qube, matching the device regardless of port is the useful behaviour.</p>
<hr>
<p>It's a very small Qubes feature, but one I wish I'd known about earlier!</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>One-off: <code>qvm-usb attach vault DEVICE</code></li>
<li>Persistent: <code>qvm-usb assign --device vault DEVICE</code></li>
<li>Remove assignment: <code>qvm-usb unassign --device vault DEVICE</code></li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://doc.qubes-os.org/en/latest/user/how-to-guides/how-to-use-usb-devices.html" target="_blank" rel="noopener noreferrer">Qubes OS device handling documentation</a>
</li>
<li><a href="https://doc.qubes-os.org/projects/core-admin/en/latest/qubes-devices.html" target="_blank" rel="noopener noreferrer">Qubes OS device API documentation</a>
</li>
<li><a href="https://doc.qubes-os.org/en/latest/user/security-in-qubes/device-handling-security.html" target="_blank" rel="noopener noreferrer">Qubes OS USB security documentation</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need help securing or automating a Qubes OS workflow?</div>
  <div class="smallprint mb-3">I do contract sysadmin, security and privacy-infrastructure work, and previously sysadminned the Qubes OS onion apt/yum repositories. I also built CI and workflow tooling for SecureDrop Workstation on Qubes OS. Let&#39;s talk compartmentalisation!</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-diagram-3"></i><span>Qubes OS</span></div>
    <h1 class="display-6 fw-semibold mb-2">Automatically attaching a YubiKey to a Qubes OS vault for GPG</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-22">September 22, 2026</time></p>
    <p class="lead mb-0">Use Qubes OS device assignments to automatically attach a YubiKey to your vault qube for GPG use, avoiding the need to manually reattach it every time you plug it in.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-22T00:00:00&#43;10:00">22 September 2026</time>
        </div>

<hr>
<p>I learned a useful Qubes OS trick recently that saves me a small but repetitive step every time I plug in my YubiKey.</p>
<p>My setup has a YubiKey containing my GPG keys, with the USB device normally exposed by <code>sys-usb</code>. I want the YubiKey available inside my <code>vault</code> qube so that GPG can use its OpenPGP smart-card interface.</p>
<p>Until now, every time I plugged the YubiKey in, I would run something like this in dom0:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">DEVICE</span><span class="o">=</span><span class="k">$(</span>qvm-usb list <span class="p">|</span> grep -i Yubikey <span class="p">|</span> awk <span class="s1">&#39;{print $1}&#39;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">qvm-usb attach vault <span class="s2">&#34;</span><span class="nv">$DEVICE</span><span class="s2">&#34;</span>
</span></span></code></pre></div><p>Without doing that, attempting an operation requiring the private key would result in GPG asking me to insert the card:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Please insert the card with serial number ...
</span></span></code></pre></div><p>After attaching the USB device to <code>vault</code>, GPG could see the YubiKey and everything worked.</p>
<p>This was fine, but I had assumed that manually attaching the device every time was simply how USB passthrough in Qubes worked.</p>
<p>It turns out Qubes can remember the assignment.</p>
<h2 id="attachment-versus-assignment">Attachment versus assignment</h2>
<p>The useful distinction is between <strong>attaching</strong> and <strong>assigning</strong> a device.</p>
<p>When I run:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-usb attach vault <span class="s2">&#34;</span><span class="nv">$DEVICE</span><span class="s2">&#34;</span>
</span></span></code></pre></div><p>I am attaching the currently connected USB device to <code>vault</code>.</p>
<p>That is temporary. If I unplug the YubiKey, that particular attachment disappears. The next time it is inserted, it is once again available through the USB qube rather than automatically being handed to <code>vault</code>.</p>
<p>Qubes also has the concept of a device <strong>assignment</strong>.</p>
<p>An assignment tells Qubes that when a matching device appears in the future, it should automatically be attached to a particular qube.</p>
<p>So instead of repeatedly running <code>attach</code>, I can plug in my YubiKey once and run <em>just once</em>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">DEVICE</span><span class="o">=</span><span class="k">$(</span>qvm-usb list <span class="p">|</span> grep -i Yubikey <span class="p">|</span> awk <span class="s1">&#39;{print $1}&#39;</span> <span class="p">|</span> head -1<span class="k">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">qvm-usb assign --device vault <span class="s2">&#34;</span><span class="nv">$DEVICE</span><span class="s2">&#34;</span>
</span></span></code></pre></div><p>That is now a <em>persistent</em> configuration, rather than a one-off attachment.</p>
<p>Now I can unplug the YubiKey and plug it back in later and Qubes will automatically attach it to <code>vault</code>.</p>
<h2 id="why---device">Why <code>--device</code>?</h2>
<p>There is an important detail in the command:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">--device
</span></span></code></pre></div><p>Without it, a Qubes USB assignment can be associated with both the device and the USB port where it was connected.</p>
<p>For example:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-usb assign vault <span class="s2">&#34;</span><span class="nv">$DEVICE</span><span class="s2">&#34;</span>
</span></span></code></pre></div><p>is useful if I deliberately want the assignment tied to that physical USB connection.</p>
<p>In my case, however, I want:</p>
<blockquote>
<p>Whenever this YubiKey appears, attach it to <code>vault</code>.</p>
</blockquote>
<p>I don't particularly care which USB socket I used.</p>
<p>So:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-usb assign --device vault <span class="s2">&#34;</span><span class="nv">$DEVICE</span><span class="s2">&#34;</span>
</span></span></code></pre></div><p>tells Qubes to base the automatic assignment on the identity presented by the device rather than its current USB port.</p>
<p>The practical result is:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">YubiKey plugged in
</span></span><span class="line"><span class="cl">       │
</span></span><span class="line"><span class="cl">       ▼
</span></span><span class="line"><span class="cl">sys-usb detects it
</span></span><span class="line"><span class="cl">       │
</span></span><span class="line"><span class="cl">       ▼
</span></span><span class="line"><span class="cl">Qubes finds its saved device assignment
</span></span><span class="line"><span class="cl">       │
</span></span><span class="line"><span class="cl">       ▼
</span></span><span class="line"><span class="cl">USB device automatically attaches to vault
</span></span><span class="line"><span class="cl">       │
</span></span><span class="line"><span class="cl">       ▼
</span></span><span class="line"><span class="cl">GPG can see the YubiKey/OpenPGP smart card
</span></span></code></pre></div><h2 id="checking-the-assignment">Checking the assignment</h2>
<p>Assignments can be displayed with:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-usb list --assignments
</span></span></code></pre></div><p>Qubes marks assigned qubes separately from devices that are merely attached at that moment.</p>
<p>After reconnecting the YubiKey, a normal:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-usb list
</span></span></code></pre></div><p>should show it as being used by <code>vault</code>, without having to manually run <code>qvm-usb attach</code>.</p>
<h2 id="what-about---persistent-mode">What about <code>--persistent</code> mode?</h2>
<p>Another potentially confusing option is <code>--persistent</code> on <code>qvm-usb attach</code>.</p>
<p>It sounds like exactly what is needed here, but it isn't quite the same thing.</p>
<p>The modern Qubes device model distinguishes a normal automatic assignment from a <strong>required</strong> device.</p>
<p>A required device is expected to be available when the target qube starts. If it isn't available, that can prevent the qube from starting.</p>
<p>That is not what I want for a hardware token which normally remains unplugged until I need it.</p>
<p>A normal:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-usb assign --device vault <span class="s2">&#34;</span><span class="nv">$DEVICE</span><span class="s2">&#34;</span>
</span></span></code></pre></div><p>means, effectively:</p>
<blockquote>
<p>If this device appears, automatically attach it to <code>vault</code>.</p>
</blockquote>
<p>It does <strong>not</strong> mean:</p>
<blockquote>
<p><code>vault</code> must have this device in order to start.</p>
</blockquote>
<p>That's a much better fit for a YubiKey.</p>
<h2 id="removing-the-assignment">Removing the assignment</h2>
<p>If I later decide that I no longer want the YubiKey automatically attached to <code>vault</code>, I can plug it in, identify it again and remove the assignment:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">DEVICE</span><span class="o">=</span><span class="k">$(</span>qvm-usb list <span class="p">|</span> grep -i Yubikey <span class="p">|</span> awk <span class="s1">&#39;{print $1}&#39;</span> <span class="p">|</span> head -1<span class="k">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">qvm-usb unassign --device vault <span class="s2">&#34;</span><span class="nv">$DEVICE</span><span class="s2">&#34;</span>
</span></span></code></pre></div><p>A manual <code>detach</code> and an <code>unassign</code> are different operations just as <code>attach</code> and <code>assign</code> are.</p>
<p>Detaching changes what is connected <strong>now</strong>.</p>
<p>Unassigning removes the rule governing what should happen <strong>in the future</strong>.</p>
<h2 id="a-small-security-consideration">A small security consideration</h2>
<p>Automatic USB assignment is worth thinking about before enabling it for a high-value qube.</p>
<p>The device identity used for matching ultimately comes from information presented by the USB device itself. Device identification should therefore not be treated as equivalent to cryptographic authentication of a particular physical YubiKey.</p>
<p>If I wanted a stricter setup, I could omit <code>--device</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-usb assign vault <span class="s2">&#34;</span><span class="nv">$DEVICE</span><span class="s2">&#34;</span>
</span></span></code></pre></div><p>and deliberately associate the assignment with the particular USB port as well.</p>
<p>There is a trade-off here between convenience and how broadly the automatic assignment should match.</p>
<p>For my setup, where the YubiKey is specifically intended for the <code>vault</code> qube, matching the device regardless of port is the useful behaviour.</p>
<hr>
<p>It's a very small Qubes feature, but one I wish I'd known about earlier!</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>One-off: <code>qvm-usb attach vault DEVICE</code></li>
<li>Persistent: <code>qvm-usb assign --device vault DEVICE</code></li>
<li>Remove assignment: <code>qvm-usb unassign --device vault DEVICE</code></li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://doc.qubes-os.org/en/latest/user/how-to-guides/how-to-use-usb-devices.html" target="_blank" rel="noopener noreferrer">Qubes OS device handling documentation</a>
</li>
<li><a href="https://doc.qubes-os.org/projects/core-admin/en/latest/qubes-devices.html" target="_blank" rel="noopener noreferrer">Qubes OS device API documentation</a>
</li>
<li><a href="https://doc.qubes-os.org/en/latest/user/security-in-qubes/device-handling-security.html" target="_blank" rel="noopener noreferrer">Qubes OS USB security documentation</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need help securing or automating a Qubes OS workflow?</div>
  <div class="smallprint mb-3">I do contract sysadmin, security and privacy-infrastructure work, and previously sysadminned the Qubes OS onion apt/yum repositories. I also built CI and workflow tooling for SecureDrop Workstation on Qubes OS. Let&#39;s talk compartmentalisation!</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>Automatically fixing vulnerable Poetry dependencies with Grype, Syft and Forgejo Actions</title>
      <link>https://mig5.net/news/forgejo-grype-syft-poetry-remediation/</link>
      <pubDate>Tue, 22 Sep 2026 00:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/forgejo-grype-syft-poetry-remediation/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-shield-check"></i><span>Forgejo Actions</span></div>
    <h1 class="display-6 fw-semibold mb-2">Automatically fixing vulnerable Poetry dependencies with Grype and Syft</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-22">September 22, 2026</time></p>
    <p class="lead mb-0">A scheduled dependency-security workflow for my Python projects: Syft inventories what is locked,
Grype finds vulnerable packages, Poetry applies targeted updates, and Forgejo opens a pull request
only after a second scan proves the fix actually helped.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-22T00:00:00&#43;10:00">22 September 2026</time>
        </div>

<h2 id="from-an-alert-to-something-actionable">From an alert to something actionable</h2>
<p>I have a handful of Python projects hosted in my own <a href="https://git.mig5.net/" target="_blank" rel="noopener noreferrer">Forgejo</a>
 instance:
<a href="/projects/bouquin/">Bouquin</a>
, <a href="/projects/cspresso/">cspresso</a>
, <a href="/projects/enroll/">Enroll</a>
,
<a href="/projects/heckle/">Heckle</a>
, and <a href="/projects/jinjaturtle/">JinjaTurtle</a>
. They all use Poetry and
have <code>poetry.lock</code> files, and they all already use Forgejo Actions for their normal CI.</p>
<p>For a while I also had a scheduled Trivy job watching dependencies. It did the useful first half
of the job - find something bad - and then sent a notification to my Node-RED endpoint. But that
still left me with the same manual sequence every time: identify the affected package, work out
whether a fixed release exists, update the lockfile, run tests, re-check the vulnerability, then
open a pull request.</p>
<p>I deliberately removed Trivy from this setup for <a href="https://www.crowdstrike.com/en-us/blog/from-scanner-to-stealer-inside-the-trivy-action-supply-chain-compromise/" target="_blank" rel="noopener noreferrer">other reasons</a>
, but I still wanted dependency scanning. This time I
wanted to move past &quot;there is a problem&quot; and into &quot;here is a small, reviewable patch that appears
to solve the problem&quot;.</p>
<p>The result is a scheduled Forgejo Actions workflow built around
<a href="https://github.com/anchore/syft" target="_blank" rel="noopener noreferrer">Syft</a>
, <a href="https://github.com/anchore/grype" target="_blank" rel="noopener noreferrer">Grype</a>
, and
<a href="https://python-poetry.org/" target="_blank" rel="noopener noreferrer">Poetry</a>
.</p>
<div class="mermaid">

flowchart TD
    A[Scheduled Forgejo Action] --> B[Checkout repository]
    B --> C[Syft: build Python SBOM]
    C --> D[Grype: scan SBOM]
    D --> E{Fixable findings?}
    E -- No findings --> F[Finish cleanly]
    E -- Only unfixed findings --> G[Report in log and finish]
    E -- Yes --> H[Poetry update only affected packages]
    H --> I[poetry check --lock]
    I --> J[Syft: build new SBOM]
    J --> K[Grype: re-scan]
    K --> L{Did fixable findings disappear?}
    L -- No --> M[Fail and notify]
    L -- Yes --> N[Push security branch]
    N --> O[Open or update Forgejo PR]
    O --> P[Normal project CI runs on bot push]

</div>

<h2 id="why-syft-and-grype-are-separate">Why Syft and Grype are separate</h2>
<p>Syft and Grype are both <a href="https://anchore.com/opensource/" target="_blank" rel="noopener noreferrer">Anchore</a>
 projects, but they do different jobs.</p>
<p><strong>Syft inventories software.</strong> In this workflow it scans the repository with only the Python
cataloguers enabled and emits a Syft JSON SBOM. That gives the next stage a structured list of the
Python packages and versions represented by the project, including the Poetry lockfile.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">syft dir:. <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  --select-catalogers python <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  -o syft-json &gt; before-sbom.json
</span></span></code></pre></div><p><strong>Grype evaluates that inventory for vulnerabilities.</strong> Rather than asking Grype to independently
walk the repository, I hand it the SBOM Syft just produced:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">grype sbom:before-sbom.json -o json &gt; before-grype.json
</span></span></code></pre></div><p>The inventory is an artefact in its own right, and the scanner consumes a
well-defined representation of what Syft found. It also makes the workflow easy to reason about:
first establish the dependency inventory, then assess it.</p>
<h2 id="scheduled-but-staggered">Scheduled, but staggered</h2>
<p>Each repository gets the same <code>Dependency security</code> workflow, with both a scheduled trigger and
<code>workflow_dispatch</code> so I can run it manually from Forgejo:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Dependency security</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">schedule</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">cron</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;17 1 * * *&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">workflow_dispatch</span><span class="p">:</span><span class="w">
</span></span></span></code></pre></div><p>I kept the old daily 01:00 UTC window I had used for dependency scanning, but staggered the five
repositories through the hour rather than waking the runner up with five jobs at once. The exact
minute is different in each repo.</p>
<p>The workflow runs on my existing <code>docker</code> runner. It installs Poetry plus the small set of system
packages it needs, then downloads pinned Syft and Grype release tarballs. Importantly, it also
downloads Anchore's checksum file and verifies the archive before installing the binary:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -fsSL <span class="s2">&#34;</span><span class="si">${</span><span class="nv">base_url</span><span class="si">}</span><span class="s2">/</span><span class="si">${</span><span class="nv">archive</span><span class="si">}</span><span class="s2">&#34;</span> -o <span class="s2">&#34;</span><span class="si">${</span><span class="nv">tmpdir</span><span class="si">}</span><span class="s2">/</span><span class="si">${</span><span class="nv">archive</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">curl -fsSL <span class="s2">&#34;</span><span class="si">${</span><span class="nv">base_url</span><span class="si">}</span><span class="s2">/</span><span class="si">${</span><span class="nv">tool</span><span class="si">}</span><span class="s2">_</span><span class="si">${</span><span class="nv">version</span><span class="si">}</span><span class="s2">_checksums.txt&#34;</span> -o <span class="s2">&#34;</span><span class="si">${</span><span class="nv">tmpdir</span><span class="si">}</span><span class="s2">/checksums.txt&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">(</span>
</span></span><span class="line"><span class="cl">  <span class="nb">cd</span> <span class="s2">&#34;</span><span class="nv">$tmpdir</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  grep -F <span class="s2">&#34;  </span><span class="nv">$archive</span><span class="s2">&#34;</span> checksums.txt <span class="p">|</span> sha256sum -c -
</span></span><span class="line"><span class="cl"><span class="o">)</span>
</span></span></code></pre></div><p>At the time of writing I pin Poetry 2.4.1, Syft 1.52.0, and Grype 0.119.0. I prefer explicit CI
versions over quietly getting whatever happens to be latest on the day a scheduled job runs.</p>
<h2 id="deciding-what-is-actually-fixable">Deciding what is actually fixable</h2>
<p>The Grype JSON output contains the package, installed version, vulnerability identifier, severity,
fix state, and any advertised fixed versions. The helper turns those into a small internal
<code>Finding</code> object.</p>
<p>The important distinction is between <strong>a vulnerability</strong> and <strong>a vulnerability for which Grype
knows a fixed version</strong>.</p>
<p>That mirrors something I liked about my old Trivy setup, where I used <code>--ignore-unfixed</code>. A CVE
that has no upstream fix is still useful information, but waking me up every day about something I
can't act on doesn't improve security, it just creates alert fatigue.</p>
<p>So the workflow behaves like this:</p>
<ul>
<li>no findings at all: success;</li>
<li>findings, but none with an advertised fix: log them and succeed;</li>
<li>one or more fixable findings: attempt remediation;</li>
<li>a fix is theoretically available but cannot be applied safely: fail and notify me.</li>
</ul>
<h2 id="targeted-poetry-updates">Targeted Poetry updates</h2>
<p>I did <strong>not</strong> want an automated vulnerability job to run a blanket <code>poetry update</code> and hand me a
pull request containing twenty unrelated dependency changes. That would make review harder, make
regressions more likely, and obscure which change was actually intended to fix the vulnerability.</p>
<p>Instead, the helper collects only the package names from Grype's fixable findings and asks Poetry
to re-resolve those packages:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">poetry update package1 package2 --lock --no-interaction
</span></span><span class="line"><span class="cl">poetry check --lock
</span></span></code></pre></div><p>Poetry still resolves the dependency graph correctly, of course, so a transitive dependency may
move where required by the resolver. But the <em>request</em> is deliberately limited to packages for
which the security scan says a fix exists.</p>
<p>There is another important boundary: the automation <strong>never widens dependency constraints in
<code>pyproject.toml</code></strong>.</p>
<p>If the project says it requires a dependency below version 2 and the vulnerability is only fixed
in version 2.1, that is a maintainer decision. The workflow isn't allowed to quietly turn a
security patch into a potentially breaking major-version upgrade.</p>
<p>If Poetry cannot produce a changed <code>poetry.lock</code> within the existing constraints, the job fails
instead of opening a misleading pull request.</p>
<h2 id="the-second-scan-is-the-gate">The second scan is the gate</h2>
<p>After Poetry changes the lockfile, the helper runs Syft and Grype again from scratch. It compares
the package/vulnerability pairs from before and after and checks that at least one of the fixable
findings has genuinely disappeared.</p>
<p>If the new lockfile does <strong>not</strong> remove any fixable Grype finding, the automation refuses to
publish it:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">The updated lockfile did not remove any fixable Grype finding;
</span></span><span class="line"><span class="cl">refusing to publish a PR.
</span></span></code></pre></div><p>It also refuses to continue if Poetry changes anything other than <code>poetry.lock</code>. That is another
small fail-closed check: this job has one job, and an unexpected working-tree change is something I
would rather investigate than normalise.</p>
<p>If the re-scan fixes some findings but other fixable findings remain, the workflow can still
publish the partial remediation, but the job exits as failed so the normal failure-notification
path still fires. I get the useful patch <em>and</em> I get told there is still work to do.</p>
<h2 id="the-pull-request-is-the-audit-trail">The pull request is the audit trail</h2>
<p>Once the re-scan proves that the lockfile improved things, the helper creates a branch called:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">security/grype-dependency-fixes
</span></span></code></pre></div><p>and commits only <code>poetry.lock</code> with:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">security: remediate dependency vulnerabilities
</span></span></code></pre></div><p>The pull request body is generated from the actual before/after scan data. It includes counts of
findings before and after, the targeted packages, a table of remediated vulnerabilities, any
remaining findings, and a note that the workflow re-ran both Grype and <code>poetry check --lock</code>.</p>
<p>If the security PR already exists, the next scheduled run force-updates the same security branch
and updates the existing pull request instead of opening an endless sequence of duplicate PRs.</p>
<p>The bot <strong>does not merge anything</strong>. I still get a normal pull request to review, and the project
still has to pass its normal CI.</p>
<h2 id="why-i-use-a-bot-pat-instead-of-forgejo_token">Why I use a bot PAT instead of <code>FORGEJO_TOKEN</code></h2>
<p>Forgejo Actions provides an automatic workflow token, and it would be tempting to use that to push
the remediation branch.</p>
<p>Forgejo suppresses workflows caused by changes made with its automatic token. That behaviour is
useful for avoiding accidental workflow loops, but it is exactly the wrong behaviour here: if a
security workflow writes a new lockfile, I very much want my ordinary test and lint workflow to
run against it.</p>
<p>So the checkout explicitly avoids retaining credentials:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl">- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Checkout</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">persist-credentials</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span></span></span></code></pre></div><p>and the publication step uses a dedicated Forgejo bot PAT instead.</p>
<p>On Forgejo 15 I can restrict that PAT to the five repositories in question, with
<code>write:repository</code> access rather than giving it broad account-wide powers. The bot is able to push
the security branch and create or update its pull request, but it has no reason to have access to
anything else. Branch protection rules are also in place to prevent that bot merging its own PRs
into <code>main</code> branch or pushing directly to that branch.</p>
<p>There are two Actions settings:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Actions secret:
</span></span><span class="line"><span class="cl">  SECURITY_BOT_TOKEN
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Actions variable:
</span></span><span class="line"><span class="cl">  SECURITY_BOT_USERNAME
</span></span></code></pre></div><h2 id="keeping-the-token-away-from-the-scanners">Keeping the token away from the scanners</h2>
<p>At startup it removes the bot username, PAT, Forgejo API URL, server URL, and repository name from
the process environment. The values remain only inside the Python process. Syft, Grype, Poetry,
and ordinary Git commands are therefore not launched with the PAT sitting in their inherited
environment.</p>
<p>The token only comes back into play at publication time: as an HTTP authentication header for the
Git push and as the authorization header for Forgejo's API when opening or updating the pull
request.</p>
<p>That doesn't make a CI credential magically risk-free, of course, but it reduces the number of subprocesses
that ever have a chance to see it.</p>
<h2 id="notifications-still-have-a-role">Notifications still have a role</h2>
<p>Several of these projects already send Forgejo Actions failures to a Node-RED webhook. The new
security job plugs into that same failure path. But instead of notifying me simply because a
vulnerability exists, it normally does the useful work first.</p>
<p>I get notified when something needs human attention: a fix exists but Poetry cannot select it
inside the current constraints, the re-scan doesn't show the expected result, a partial remediation
leaves other fixable findings behind, publication fails, or some other part of the job breaks in
a way I can't imagine.</p>
<h2 id="wheres-the-script">Where's the script?</h2>
<p>You can see an example of the <a href="https://git.mig5.net/mig5/cspresso/src/commit/aac4cf53d444900e95ca77e5491927f29c11b288/.forgejo/scripts/grype_poetry_remediate.py" target="_blank" rel="noopener noreferrer">Python script here</a>

and the corresponding <a href="https://git.mig5.net/mig5/cspresso/src/commit/aac4cf53d444900e95ca77e5491927f29c11b288/.forgejo/workflows/dependency-security.yaml" target="_blank" rel="noopener noreferrer">Actions workflow here</a>
.</p>
<h2 id="talk-is-cheap-does-it-work">Talk is cheap! Does it work?</h2>
<p>Yep! See <a href="https://git.mig5.net/mig5/cspresso/pulls/1" target="_blank" rel="noopener noreferrer">this Pull Request</a>
.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>CI: Forgejo Actions on a self-hosted <code>docker</code> runner</li>
<li>Projects: Bouquin, cspresso, Enroll, Heckle, JinjaTurtle</li>
<li>Inventory: Syft Python SBOM</li>
<li>Vulnerability scanner: Grype</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://forgejo.org/docs/latest/user/actions/" target="_blank" rel="noopener noreferrer">Forgejo Actions documentation</a>
</li>
<li><a href="https://github.com/anchore/syft" target="_blank" rel="noopener noreferrer">Syft</a>
</li>
<li><a href="https://github.com/anchore/grype" target="_blank" rel="noopener noreferrer">Grype</a>
</li>
<li><a href="https://python-poetry.org/" target="_blank" rel="noopener noreferrer">Poetry</a>
</li>
<li><a href="https://git.mig5.net/mig5" target="_blank" rel="noopener noreferrer">My Forgejo</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Want less noisy security automation?</div>
  <div class="smallprint mb-3">I do contract Linux infrastructure and DevSecOps work, including CI/CD, dependency security, and supply-chain controls.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-shield-check"></i><span>Forgejo Actions</span></div>
    <h1 class="display-6 fw-semibold mb-2">Automatically fixing vulnerable Poetry dependencies with Grype and Syft</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-22">September 22, 2026</time></p>
    <p class="lead mb-0">A scheduled dependency-security workflow for my Python projects: Syft inventories what is locked,
Grype finds vulnerable packages, Poetry applies targeted updates, and Forgejo opens a pull request
only after a second scan proves the fix actually helped.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-22T00:00:00&#43;10:00">22 September 2026</time>
        </div>

<h2 id="from-an-alert-to-something-actionable">From an alert to something actionable</h2>
<p>I have a handful of Python projects hosted in my own <a href="https://git.mig5.net/" target="_blank" rel="noopener noreferrer">Forgejo</a>
 instance:
<a href="/projects/bouquin/">Bouquin</a>
, <a href="/projects/cspresso/">cspresso</a>
, <a href="/projects/enroll/">Enroll</a>
,
<a href="/projects/heckle/">Heckle</a>
, and <a href="/projects/jinjaturtle/">JinjaTurtle</a>
. They all use Poetry and
have <code>poetry.lock</code> files, and they all already use Forgejo Actions for their normal CI.</p>
<p>For a while I also had a scheduled Trivy job watching dependencies. It did the useful first half
of the job - find something bad - and then sent a notification to my Node-RED endpoint. But that
still left me with the same manual sequence every time: identify the affected package, work out
whether a fixed release exists, update the lockfile, run tests, re-check the vulnerability, then
open a pull request.</p>
<p>I deliberately removed Trivy from this setup for <a href="https://www.crowdstrike.com/en-us/blog/from-scanner-to-stealer-inside-the-trivy-action-supply-chain-compromise/" target="_blank" rel="noopener noreferrer">other reasons</a>
, but I still wanted dependency scanning. This time I
wanted to move past &quot;there is a problem&quot; and into &quot;here is a small, reviewable patch that appears
to solve the problem&quot;.</p>
<p>The result is a scheduled Forgejo Actions workflow built around
<a href="https://github.com/anchore/syft" target="_blank" rel="noopener noreferrer">Syft</a>
, <a href="https://github.com/anchore/grype" target="_blank" rel="noopener noreferrer">Grype</a>
, and
<a href="https://python-poetry.org/" target="_blank" rel="noopener noreferrer">Poetry</a>
.</p>
<div class="mermaid">

flowchart TD
    A[Scheduled Forgejo Action] --> B[Checkout repository]
    B --> C[Syft: build Python SBOM]
    C --> D[Grype: scan SBOM]
    D --> E{Fixable findings?}
    E -- No findings --> F[Finish cleanly]
    E -- Only unfixed findings --> G[Report in log and finish]
    E -- Yes --> H[Poetry update only affected packages]
    H --> I[poetry check --lock]
    I --> J[Syft: build new SBOM]
    J --> K[Grype: re-scan]
    K --> L{Did fixable findings disappear?}
    L -- No --> M[Fail and notify]
    L -- Yes --> N[Push security branch]
    N --> O[Open or update Forgejo PR]
    O --> P[Normal project CI runs on bot push]

</div>

<h2 id="why-syft-and-grype-are-separate">Why Syft and Grype are separate</h2>
<p>Syft and Grype are both <a href="https://anchore.com/opensource/" target="_blank" rel="noopener noreferrer">Anchore</a>
 projects, but they do different jobs.</p>
<p><strong>Syft inventories software.</strong> In this workflow it scans the repository with only the Python
cataloguers enabled and emits a Syft JSON SBOM. That gives the next stage a structured list of the
Python packages and versions represented by the project, including the Poetry lockfile.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">syft dir:. <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  --select-catalogers python <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  -o syft-json &gt; before-sbom.json
</span></span></code></pre></div><p><strong>Grype evaluates that inventory for vulnerabilities.</strong> Rather than asking Grype to independently
walk the repository, I hand it the SBOM Syft just produced:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">grype sbom:before-sbom.json -o json &gt; before-grype.json
</span></span></code></pre></div><p>The inventory is an artefact in its own right, and the scanner consumes a
well-defined representation of what Syft found. It also makes the workflow easy to reason about:
first establish the dependency inventory, then assess it.</p>
<h2 id="scheduled-but-staggered">Scheduled, but staggered</h2>
<p>Each repository gets the same <code>Dependency security</code> workflow, with both a scheduled trigger and
<code>workflow_dispatch</code> so I can run it manually from Forgejo:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Dependency security</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">schedule</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">cron</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;17 1 * * *&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">workflow_dispatch</span><span class="p">:</span><span class="w">
</span></span></span></code></pre></div><p>I kept the old daily 01:00 UTC window I had used for dependency scanning, but staggered the five
repositories through the hour rather than waking the runner up with five jobs at once. The exact
minute is different in each repo.</p>
<p>The workflow runs on my existing <code>docker</code> runner. It installs Poetry plus the small set of system
packages it needs, then downloads pinned Syft and Grype release tarballs. Importantly, it also
downloads Anchore's checksum file and verifies the archive before installing the binary:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -fsSL <span class="s2">&#34;</span><span class="si">${</span><span class="nv">base_url</span><span class="si">}</span><span class="s2">/</span><span class="si">${</span><span class="nv">archive</span><span class="si">}</span><span class="s2">&#34;</span> -o <span class="s2">&#34;</span><span class="si">${</span><span class="nv">tmpdir</span><span class="si">}</span><span class="s2">/</span><span class="si">${</span><span class="nv">archive</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">curl -fsSL <span class="s2">&#34;</span><span class="si">${</span><span class="nv">base_url</span><span class="si">}</span><span class="s2">/</span><span class="si">${</span><span class="nv">tool</span><span class="si">}</span><span class="s2">_</span><span class="si">${</span><span class="nv">version</span><span class="si">}</span><span class="s2">_checksums.txt&#34;</span> -o <span class="s2">&#34;</span><span class="si">${</span><span class="nv">tmpdir</span><span class="si">}</span><span class="s2">/checksums.txt&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">(</span>
</span></span><span class="line"><span class="cl">  <span class="nb">cd</span> <span class="s2">&#34;</span><span class="nv">$tmpdir</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  grep -F <span class="s2">&#34;  </span><span class="nv">$archive</span><span class="s2">&#34;</span> checksums.txt <span class="p">|</span> sha256sum -c -
</span></span><span class="line"><span class="cl"><span class="o">)</span>
</span></span></code></pre></div><p>At the time of writing I pin Poetry 2.4.1, Syft 1.52.0, and Grype 0.119.0. I prefer explicit CI
versions over quietly getting whatever happens to be latest on the day a scheduled job runs.</p>
<h2 id="deciding-what-is-actually-fixable">Deciding what is actually fixable</h2>
<p>The Grype JSON output contains the package, installed version, vulnerability identifier, severity,
fix state, and any advertised fixed versions. The helper turns those into a small internal
<code>Finding</code> object.</p>
<p>The important distinction is between <strong>a vulnerability</strong> and <strong>a vulnerability for which Grype
knows a fixed version</strong>.</p>
<p>That mirrors something I liked about my old Trivy setup, where I used <code>--ignore-unfixed</code>. A CVE
that has no upstream fix is still useful information, but waking me up every day about something I
can't act on doesn't improve security, it just creates alert fatigue.</p>
<p>So the workflow behaves like this:</p>
<ul>
<li>no findings at all: success;</li>
<li>findings, but none with an advertised fix: log them and succeed;</li>
<li>one or more fixable findings: attempt remediation;</li>
<li>a fix is theoretically available but cannot be applied safely: fail and notify me.</li>
</ul>
<h2 id="targeted-poetry-updates">Targeted Poetry updates</h2>
<p>I did <strong>not</strong> want an automated vulnerability job to run a blanket <code>poetry update</code> and hand me a
pull request containing twenty unrelated dependency changes. That would make review harder, make
regressions more likely, and obscure which change was actually intended to fix the vulnerability.</p>
<p>Instead, the helper collects only the package names from Grype's fixable findings and asks Poetry
to re-resolve those packages:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">poetry update package1 package2 --lock --no-interaction
</span></span><span class="line"><span class="cl">poetry check --lock
</span></span></code></pre></div><p>Poetry still resolves the dependency graph correctly, of course, so a transitive dependency may
move where required by the resolver. But the <em>request</em> is deliberately limited to packages for
which the security scan says a fix exists.</p>
<p>There is another important boundary: the automation <strong>never widens dependency constraints in
<code>pyproject.toml</code></strong>.</p>
<p>If the project says it requires a dependency below version 2 and the vulnerability is only fixed
in version 2.1, that is a maintainer decision. The workflow isn't allowed to quietly turn a
security patch into a potentially breaking major-version upgrade.</p>
<p>If Poetry cannot produce a changed <code>poetry.lock</code> within the existing constraints, the job fails
instead of opening a misleading pull request.</p>
<h2 id="the-second-scan-is-the-gate">The second scan is the gate</h2>
<p>After Poetry changes the lockfile, the helper runs Syft and Grype again from scratch. It compares
the package/vulnerability pairs from before and after and checks that at least one of the fixable
findings has genuinely disappeared.</p>
<p>If the new lockfile does <strong>not</strong> remove any fixable Grype finding, the automation refuses to
publish it:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">The updated lockfile did not remove any fixable Grype finding;
</span></span><span class="line"><span class="cl">refusing to publish a PR.
</span></span></code></pre></div><p>It also refuses to continue if Poetry changes anything other than <code>poetry.lock</code>. That is another
small fail-closed check: this job has one job, and an unexpected working-tree change is something I
would rather investigate than normalise.</p>
<p>If the re-scan fixes some findings but other fixable findings remain, the workflow can still
publish the partial remediation, but the job exits as failed so the normal failure-notification
path still fires. I get the useful patch <em>and</em> I get told there is still work to do.</p>
<h2 id="the-pull-request-is-the-audit-trail">The pull request is the audit trail</h2>
<p>Once the re-scan proves that the lockfile improved things, the helper creates a branch called:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">security/grype-dependency-fixes
</span></span></code></pre></div><p>and commits only <code>poetry.lock</code> with:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">security: remediate dependency vulnerabilities
</span></span></code></pre></div><p>The pull request body is generated from the actual before/after scan data. It includes counts of
findings before and after, the targeted packages, a table of remediated vulnerabilities, any
remaining findings, and a note that the workflow re-ran both Grype and <code>poetry check --lock</code>.</p>
<p>If the security PR already exists, the next scheduled run force-updates the same security branch
and updates the existing pull request instead of opening an endless sequence of duplicate PRs.</p>
<p>The bot <strong>does not merge anything</strong>. I still get a normal pull request to review, and the project
still has to pass its normal CI.</p>
<h2 id="why-i-use-a-bot-pat-instead-of-forgejo_token">Why I use a bot PAT instead of <code>FORGEJO_TOKEN</code></h2>
<p>Forgejo Actions provides an automatic workflow token, and it would be tempting to use that to push
the remediation branch.</p>
<p>Forgejo suppresses workflows caused by changes made with its automatic token. That behaviour is
useful for avoiding accidental workflow loops, but it is exactly the wrong behaviour here: if a
security workflow writes a new lockfile, I very much want my ordinary test and lint workflow to
run against it.</p>
<p>So the checkout explicitly avoids retaining credentials:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl">- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Checkout</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">persist-credentials</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span></span></span></code></pre></div><p>and the publication step uses a dedicated Forgejo bot PAT instead.</p>
<p>On Forgejo 15 I can restrict that PAT to the five repositories in question, with
<code>write:repository</code> access rather than giving it broad account-wide powers. The bot is able to push
the security branch and create or update its pull request, but it has no reason to have access to
anything else. Branch protection rules are also in place to prevent that bot merging its own PRs
into <code>main</code> branch or pushing directly to that branch.</p>
<p>There are two Actions settings:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Actions secret:
</span></span><span class="line"><span class="cl">  SECURITY_BOT_TOKEN
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Actions variable:
</span></span><span class="line"><span class="cl">  SECURITY_BOT_USERNAME
</span></span></code></pre></div><h2 id="keeping-the-token-away-from-the-scanners">Keeping the token away from the scanners</h2>
<p>At startup it removes the bot username, PAT, Forgejo API URL, server URL, and repository name from
the process environment. The values remain only inside the Python process. Syft, Grype, Poetry,
and ordinary Git commands are therefore not launched with the PAT sitting in their inherited
environment.</p>
<p>The token only comes back into play at publication time: as an HTTP authentication header for the
Git push and as the authorization header for Forgejo's API when opening or updating the pull
request.</p>
<p>That doesn't make a CI credential magically risk-free, of course, but it reduces the number of subprocesses
that ever have a chance to see it.</p>
<h2 id="notifications-still-have-a-role">Notifications still have a role</h2>
<p>Several of these projects already send Forgejo Actions failures to a Node-RED webhook. The new
security job plugs into that same failure path. But instead of notifying me simply because a
vulnerability exists, it normally does the useful work first.</p>
<p>I get notified when something needs human attention: a fix exists but Poetry cannot select it
inside the current constraints, the re-scan doesn't show the expected result, a partial remediation
leaves other fixable findings behind, publication fails, or some other part of the job breaks in
a way I can't imagine.</p>
<h2 id="wheres-the-script">Where's the script?</h2>
<p>You can see an example of the <a href="https://git.mig5.net/mig5/cspresso/src/commit/aac4cf53d444900e95ca77e5491927f29c11b288/.forgejo/scripts/grype_poetry_remediate.py" target="_blank" rel="noopener noreferrer">Python script here</a>

and the corresponding <a href="https://git.mig5.net/mig5/cspresso/src/commit/aac4cf53d444900e95ca77e5491927f29c11b288/.forgejo/workflows/dependency-security.yaml" target="_blank" rel="noopener noreferrer">Actions workflow here</a>
.</p>
<h2 id="talk-is-cheap-does-it-work">Talk is cheap! Does it work?</h2>
<p>Yep! See <a href="https://git.mig5.net/mig5/cspresso/pulls/1" target="_blank" rel="noopener noreferrer">this Pull Request</a>
.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>CI: Forgejo Actions on a self-hosted <code>docker</code> runner</li>
<li>Projects: Bouquin, cspresso, Enroll, Heckle, JinjaTurtle</li>
<li>Inventory: Syft Python SBOM</li>
<li>Vulnerability scanner: Grype</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://forgejo.org/docs/latest/user/actions/" target="_blank" rel="noopener noreferrer">Forgejo Actions documentation</a>
</li>
<li><a href="https://github.com/anchore/syft" target="_blank" rel="noopener noreferrer">Syft</a>
</li>
<li><a href="https://github.com/anchore/grype" target="_blank" rel="noopener noreferrer">Grype</a>
</li>
<li><a href="https://python-poetry.org/" target="_blank" rel="noopener noreferrer">Poetry</a>
</li>
<li><a href="https://git.mig5.net/mig5" target="_blank" rel="noopener noreferrer">My Forgejo</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Want less noisy security automation?</div>
  <div class="smallprint mb-3">I do contract Linux infrastructure and DevSecOps work, including CI/CD, dependency security, and supply-chain controls.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>Bouquin: an encrypted notebook and a whole lot more</title>
      <link>https://mig5.net/news/bouquin-history/</link>
      <pubDate>Sat, 19 Sep 2026 00:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/bouquin-history/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-file-earmark-lock"></i><span>Bouquin</span></div>
    <h1 class="display-6 fw-semibold mb-2">An encrypted notebook and a whole lot more</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-19">September 19, 2026</time></p>
    <p class="lead mb-0">Bouquin is a Python/Qt notebook that treats each day as a page, encrypts everything with
SQLCipher, versions every change, and - unusually for a notepad - can track time and invoice for it.
Features are modular and toggleable.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-19T00:00:00&#43;10:00">19 September 2026</time>
        </div>

<h2 id="you-didnt-write-yet-another-notebook-app-did-you-mig">You didn't write yet another notebook app, did you Mig?</h2>
<p>For a very long time, I've subscribed to Tom Limoncelli's approach to time management, whereby
each day is effectively a checklist of TODO items. As best as I can, I try to diligently proceed
in the order of the TODOs, roughly prioritised (ignoring the heavy context-switching that is
involved in between a contract sysadmin dealing with reactive work).</p>
<p>Whatever doesn't get ticked off the list at the end of the day, moves to the top of tomorrow's
list, and like so, things eventually get done (ha ha, oh the earnestness of this statement...).</p>
<p>For a long time, my 'TODO list' app has simply been Red Notebook. Simple, date-based, effective.</p>
<p>Frustratingly, the Debian version lagged behind some neat features for a long time. Then upon
Debian 13, it caught up on a lot of them, but also added a bunch of new bugs that didn't exist
in the earlier versions, too. I finally had enough...</p>
<p>Most notebook apps fall into one of two camps. There's the cloud-synced, collaborative,
everything-lives-on-someone-else's-server camp - convenient, but your notes leave your machine
and you trust a vendor with them. And there's the plain-text-files-in-a-folder camp - yours,
forever, but with no structure, no versioning, and no concept of time.</p>
<p>I wanted neither. I wanted a notebook that lives on one machine, encrypts itself on disk, treats
each day as its own page, and remembers every edit I ever made to it.</p>
<h2 id="sqlcipher-not-sqlite">SQLCipher, not SQLite</h2>
<p>The database is <a href="https://www.zetetic.net/sqlcipher/" target="_blank" rel="noopener noreferrer">SQLCipher</a>
, not stock SQLite.</p>
<p>SQLCipher is a drop-in replacement for SQLite that adds transparent, AES-256 encryption at the
file level. The database is encrypted on disk; the key never touches storage unless you
explicitly tell it to. From the application's point of view, nothing changes - you open the
database, send <code>PRAGMA key</code>, and read and write rows as normal. The encryption is invisible to
the code above it.</p>
<p>Bouquin depends on a packaged <code>bouquin-sqlcipher4</code> wheel rather than relying on whatever
SQLite the system happens to ship, so the encrypted-at-rest guarantee is real and not dependent
on your distro's choices. The key is requested when the app opens and is held in memory only.
Saving the key to the settings file is possible, but it is opt-in and off by default - the
safer path is being prompted each time.</p>
<p>A few consequences fall out of this that matter to me, even if they sound mundane:</p>
<ul>
<li>The notebook database is a single file you can back up, copy, or move - and it's ciphertext
without the key.</li>
<li>You can rekey the database (change the password) from inside the app.</li>
<li>You can back up to another encrypted SQLCipher database, or export to plaintext JSON, HTML,
CSV, Markdown, or <code>.sql</code> for stock sqlite3. The export is deliberate and user-initiated; the
at-rest state is always encrypted.</li>
</ul>
<p>There is deliberately no network connectivity. No syncing, no cloud, no telemetry. The only
outbound calls are an optional in-app bug report and an optional check for new versions. If you
don't want either, they stay off. The notebook is yours and stays on the machine.</p>
<h2 id="every-day-is-a-page-every-edit-is-versioned">Every day is a page, every edit is versioned</h2>
<p>My big inspiration for the core functionality was that of Red Notebook. Each date is its own page.</p>
<p>You navigate with a calendar widget, or jump to today, or right-click a date to open it in a new tab.
Tabs are first-class - you can have several dates open at once, ordered by date.</p>
<p>The editor is Markdown, <strong>rendered live as you type</strong>, with a toolbar for the styles you'd expect.
Code blocks get their own dialog with syntax highlighting. Sections of text can be collapsed and
expanded. Images are supported. Typing <code>TODO</code> at the start of a line auto-converts to a
checkbox, and there's an option to carry unchecked TODOs from the last seven days forward to the
next weekday - so yesterday's unfinished tasks don't silently disappear.</p>
<figure class="text-center my-4">
  <a href="/news/bouquin-history/general_hu9699118336799005267.png" class="image-lightbox" title="Click to view larger">
    <img src="/news/bouquin-history/general_hu7178546094477256334.png" alt="Bouquin&#39;s main window showing a daily page with markdown rendering, a calendar, and a sidebar" width="760" height="554" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">The main window - a calendar of dates, each one its own page, with live markdown rendering.</figcaption>
</figure>

<p>The feature I lean on most is version control. Every save creates a revision (and it also auto-saves).</p>
<p>A History dialog lists every version of a page, previews the content, and shows a diff between any two revisions.
You can revert to a previous version without losing the newer ones - revert is additive, not
destructive - and you can delete revisions you don't need. For a notebook, this is surprisingly
liberating. You can delete a paragraph you're unsure about, knowing it's recoverable. You can
see what a page looked like three weeks ago. It's <code>git</code> for your own notes, local and private.</p>
<figure class="text-center my-4">
  <a href="/news/bouquin-history/history-diff_hu913092713938585782.png" class="image-lightbox" title="Click to view larger">
    <img src="/news/bouquin-history/history-diff_hu5056974619930019612.png" alt="The History dialog showing a diff between two revisions of a page" width="760" height="534" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">Every edit is a revision. Diff any two, revert without losing newer work.</figcaption>
</figure>

<h2 id="tags-search-and-statistics">Tags, search, and statistics</h2>
<p>Tags can be applied to pages and documents, and browsed through a tag manager. Search runs across
all pages, or just the current one via a find bar. The calendar itself highlights dates with
search matches.</p>
<p>There's a statistics view with a heatmap of activity, clickable dates that jump straight to the
page, and - when the time logging feature is on - breakdowns of logged time. It's a useful way to
see at a glance which days you actually worked and which you didn't.</p>
<figure class="text-center my-4">
  <a href="/news/bouquin-history/statistics_hu8368377103747285296.png" class="image-lightbox" title="Click to view larger">
    <img src="/news/bouquin-history/statistics_hu5983646297486580393.png" alt="The statistics dialog showing a heatmap of daily activity" width="760" height="473" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">A heatmap of activity. Click a cell to open that page.</figcaption>
</figure>

<h2 id="the-modular-feature-model">The modular feature model</h2>
<p>After a period of time using Bouquin as my daily driver, it grew beyond merely 'TODO lists' and
scratchpads of notes. It truly began to become my personal 'lone contract sysadmin' project management
tool.</p>
<p>At the time, I was also self-hosting a crusty old Redmine instance whereby I would log my time against
different 'client' project tickets. The clients themselves didn't use my Redmine: they have their own
project management tools. My Redmine was just a bloaty time-tracking system.</p>
<p>So, I eventually added a Pomodoro timer widget into Bouquin to track time against a specific 'TODO' item
on the page. I was soon logging my time per client
(which also meant introducing a 'projects' and 'activities' concept to Bouquin, structurally) all in
the one app. Goodbye, old Redmine instance! I don't need you anymore.</p>
<p>At the end of the month, my previous pattern was essentially, per client:</p>
<ul>
<li>Get the total hours worked, out of Redmine</li>
<li>Get an export with a breakdown based on activity, out of Redmine</li>
<li>Create an invoice in Paypal (just because it has a handy free invoicing feature - you don't need to <em>send</em> it via Paypal)</li>
<li>Send the PDF invoice and timesheet to the client</li>
</ul>
<p>All the data I needed was in Bouquin. Given it now had a concept of 'client', I realised that I could
also add a basic invoicing module into it which let me generate the invoices directly from the data.</p>
<p>With my 'reminders' feature, I also realised I could automatically program in the due date of the invoice,
and have it remind me if a client hasn't paid, come that day.</p>
<p>What ties it together is the <strong>Projects</strong> interface, added in 0.9.0. A Project is a single bucket
that unifies time logged, invoices, and documents. You can set a prepaid &quot;bucket&quot; of hours against
a project and get warned when logged time approaches it - useful for fixed-scope contracts. You can
invoice for an increase in prepaid hours without having to fake time entries for them. And
documents stored against a project sit alongside the time and the invoices, all in one view.</p>
<p>The most recent release added an <strong>Earnings</strong> view for tracking income across reporting periods -
the kind of thing you need when putting together a tax-period summary. It's a small addition, but
it's the logical end of the thread that started with &quot;let me log how long I worked today&quot;.</p>
<h2 id="documents-embedded-and-encrypted">Documents, embedded and encrypted</h2>
<p>Finally, I am in the position where I can do all my project planning, note taking, time logging, and
invoicing, all from the one application!</p>
<figure class="text-center my-4">
  <a href="/news/bouquin-history/time-log_hu4089961667039832624.png" class="image-lightbox" title="Click to view larger">
    <img src="/news/bouquin-history/time-log_hu12680502859720352344.png" alt="The time logging interface showing entries against projects with durations" width="760" height="641" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">Time logging per project, with a Pomodoro timer in the sidebar.</figcaption>
</figure>

<h3 id="modular-life">Modular life</h3>
<p>Importantly, I've made it modular. Not everyone wants invoicing in their notebook. Not
everyone wants time logging. Some people just want an encrypted journal.</p>
<p>So the heavier features are modular and toggleable in Settings:</p>
<ul>
<li><strong>Tags</strong> - on by default, but can be switched off.</li>
<li><strong>Time logging</strong> - on by default, but optional.</li>
<li><strong>Invoicing</strong> - off by default, and <em>only available if time logging is on</em>. It depends on it.</li>
<li><strong>Reminders</strong> - optional, with webhook and email notification support.</li>
<li><strong>Documents</strong> - optional file storage, embedded in the encrypted database.</li>
</ul>
<p>When you toggle a feature off, its UI disappears. The settings dialog enforces the dependency:
disable time logging and invoicing goes with it.</p>
<p>The Documents feature stores files - PDFs, images, whatever - inside the encrypted database,
tied to Projects the same way time and invoices are. They're not links to files on disk; they're
embedded, which means they're encrypted along with everything else and travel with the database
when you back it up. Tags apply to documents too.</p>
<p>If the Documents feature is enabled, and so is Invoicing, then Invoicing also saves the invoice
as a Document!</p>
<p>This is one of the quiet benefits of the SQLCipher backend: binary blobs in the database are
ciphertext on disk, just like the text. There's no separate &quot;attachments folder&quot; sitting
unencrypted next to the notebook.</p>
<h2 id="reminders-that-can-reach-you">Reminders that can reach you</h2>
<p>Reminders can be one-off or recurring - daily, weekly, fortnightly, monthly on the Nth weekday.
When they fire, they flash in-app. But they can also be sent as webhooks or email notifications,
which means a reminder can reach you even when Bouquin isn't open. A URL and a secret (sent as an
<code>X-Bouquin-Secret</code> header) are configured in Settings, and the reminder fires to that endpoint. Wire it
into <a href="/news/signal-ai-assistant/">Node-RED</a>
 or any webhook receiver and your notebook can ping
your phone.</p>
<h2 id="where-to-find-it">Where to find it</h2>
<p>Bouquin is packaged for Debian and Fedora from my own APT and RPM repositories, available as an
AppImage, on PyPI (<code>pip install bouquin</code>), and from source. The source and full changelog are at
<a href="https://git.mig5.net/mig5/bouquin" target="_blank" rel="noopener noreferrer">git.mig5.net/mig5/bouquin</a>
, with more on the
<a href="/projects/bouquin/">project page</a>
.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Language: Python, Qt (PySide6)</li>
<li>Backend: SQLCipher (encrypted at rest)</li>
<li>Distribution: APT, RPM, AppImage, PyPI</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://git.mig5.net/mig5/bouquin" target="_blank" rel="noopener noreferrer">Source repo</a>
</li>
<li><a href="/projects/bouquin/">Project page</a>
</li>
<li><a href="https://www.zetetic.net/sqlcipher/" target="_blank" rel="noopener noreferrer">SQLCipher</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need encryption-at-rest or hardened packaging?</div>
  <div class="smallprint mb-3">I do security-minded sysadmin work: encryption, secrets, hardened build pipelines, and packaging. I can help.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-file-earmark-lock"></i><span>Bouquin</span></div>
    <h1 class="display-6 fw-semibold mb-2">An encrypted notebook and a whole lot more</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-19">September 19, 2026</time></p>
    <p class="lead mb-0">Bouquin is a Python/Qt notebook that treats each day as a page, encrypts everything with
SQLCipher, versions every change, and - unusually for a notepad - can track time and invoice for it.
Features are modular and toggleable.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-19T00:00:00&#43;10:00">19 September 2026</time>
        </div>

<h2 id="you-didnt-write-yet-another-notebook-app-did-you-mig">You didn't write yet another notebook app, did you Mig?</h2>
<p>For a very long time, I've subscribed to Tom Limoncelli's approach to time management, whereby
each day is effectively a checklist of TODO items. As best as I can, I try to diligently proceed
in the order of the TODOs, roughly prioritised (ignoring the heavy context-switching that is
involved in between a contract sysadmin dealing with reactive work).</p>
<p>Whatever doesn't get ticked off the list at the end of the day, moves to the top of tomorrow's
list, and like so, things eventually get done (ha ha, oh the earnestness of this statement...).</p>
<p>For a long time, my 'TODO list' app has simply been Red Notebook. Simple, date-based, effective.</p>
<p>Frustratingly, the Debian version lagged behind some neat features for a long time. Then upon
Debian 13, it caught up on a lot of them, but also added a bunch of new bugs that didn't exist
in the earlier versions, too. I finally had enough...</p>
<p>Most notebook apps fall into one of two camps. There's the cloud-synced, collaborative,
everything-lives-on-someone-else's-server camp - convenient, but your notes leave your machine
and you trust a vendor with them. And there's the plain-text-files-in-a-folder camp - yours,
forever, but with no structure, no versioning, and no concept of time.</p>
<p>I wanted neither. I wanted a notebook that lives on one machine, encrypts itself on disk, treats
each day as its own page, and remembers every edit I ever made to it.</p>
<h2 id="sqlcipher-not-sqlite">SQLCipher, not SQLite</h2>
<p>The database is <a href="https://www.zetetic.net/sqlcipher/" target="_blank" rel="noopener noreferrer">SQLCipher</a>
, not stock SQLite.</p>
<p>SQLCipher is a drop-in replacement for SQLite that adds transparent, AES-256 encryption at the
file level. The database is encrypted on disk; the key never touches storage unless you
explicitly tell it to. From the application's point of view, nothing changes - you open the
database, send <code>PRAGMA key</code>, and read and write rows as normal. The encryption is invisible to
the code above it.</p>
<p>Bouquin depends on a packaged <code>bouquin-sqlcipher4</code> wheel rather than relying on whatever
SQLite the system happens to ship, so the encrypted-at-rest guarantee is real and not dependent
on your distro's choices. The key is requested when the app opens and is held in memory only.
Saving the key to the settings file is possible, but it is opt-in and off by default - the
safer path is being prompted each time.</p>
<p>A few consequences fall out of this that matter to me, even if they sound mundane:</p>
<ul>
<li>The notebook database is a single file you can back up, copy, or move - and it's ciphertext
without the key.</li>
<li>You can rekey the database (change the password) from inside the app.</li>
<li>You can back up to another encrypted SQLCipher database, or export to plaintext JSON, HTML,
CSV, Markdown, or <code>.sql</code> for stock sqlite3. The export is deliberate and user-initiated; the
at-rest state is always encrypted.</li>
</ul>
<p>There is deliberately no network connectivity. No syncing, no cloud, no telemetry. The only
outbound calls are an optional in-app bug report and an optional check for new versions. If you
don't want either, they stay off. The notebook is yours and stays on the machine.</p>
<h2 id="every-day-is-a-page-every-edit-is-versioned">Every day is a page, every edit is versioned</h2>
<p>My big inspiration for the core functionality was that of Red Notebook. Each date is its own page.</p>
<p>You navigate with a calendar widget, or jump to today, or right-click a date to open it in a new tab.
Tabs are first-class - you can have several dates open at once, ordered by date.</p>
<p>The editor is Markdown, <strong>rendered live as you type</strong>, with a toolbar for the styles you'd expect.
Code blocks get their own dialog with syntax highlighting. Sections of text can be collapsed and
expanded. Images are supported. Typing <code>TODO</code> at the start of a line auto-converts to a
checkbox, and there's an option to carry unchecked TODOs from the last seven days forward to the
next weekday - so yesterday's unfinished tasks don't silently disappear.</p>
<figure class="text-center my-4">
  <a href="/news/bouquin-history/general_hu9699118336799005267.png" class="image-lightbox" title="Click to view larger">
    <img src="/news/bouquin-history/general_hu7178546094477256334.png" alt="Bouquin&#39;s main window showing a daily page with markdown rendering, a calendar, and a sidebar" width="760" height="554" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">The main window - a calendar of dates, each one its own page, with live markdown rendering.</figcaption>
</figure>

<p>The feature I lean on most is version control. Every save creates a revision (and it also auto-saves).</p>
<p>A History dialog lists every version of a page, previews the content, and shows a diff between any two revisions.
You can revert to a previous version without losing the newer ones - revert is additive, not
destructive - and you can delete revisions you don't need. For a notebook, this is surprisingly
liberating. You can delete a paragraph you're unsure about, knowing it's recoverable. You can
see what a page looked like three weeks ago. It's <code>git</code> for your own notes, local and private.</p>
<figure class="text-center my-4">
  <a href="/news/bouquin-history/history-diff_hu913092713938585782.png" class="image-lightbox" title="Click to view larger">
    <img src="/news/bouquin-history/history-diff_hu5056974619930019612.png" alt="The History dialog showing a diff between two revisions of a page" width="760" height="534" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">Every edit is a revision. Diff any two, revert without losing newer work.</figcaption>
</figure>

<h2 id="tags-search-and-statistics">Tags, search, and statistics</h2>
<p>Tags can be applied to pages and documents, and browsed through a tag manager. Search runs across
all pages, or just the current one via a find bar. The calendar itself highlights dates with
search matches.</p>
<p>There's a statistics view with a heatmap of activity, clickable dates that jump straight to the
page, and - when the time logging feature is on - breakdowns of logged time. It's a useful way to
see at a glance which days you actually worked and which you didn't.</p>
<figure class="text-center my-4">
  <a href="/news/bouquin-history/statistics_hu8368377103747285296.png" class="image-lightbox" title="Click to view larger">
    <img src="/news/bouquin-history/statistics_hu5983646297486580393.png" alt="The statistics dialog showing a heatmap of daily activity" width="760" height="473" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">A heatmap of activity. Click a cell to open that page.</figcaption>
</figure>

<h2 id="the-modular-feature-model">The modular feature model</h2>
<p>After a period of time using Bouquin as my daily driver, it grew beyond merely 'TODO lists' and
scratchpads of notes. It truly began to become my personal 'lone contract sysadmin' project management
tool.</p>
<p>At the time, I was also self-hosting a crusty old Redmine instance whereby I would log my time against
different 'client' project tickets. The clients themselves didn't use my Redmine: they have their own
project management tools. My Redmine was just a bloaty time-tracking system.</p>
<p>So, I eventually added a Pomodoro timer widget into Bouquin to track time against a specific 'TODO' item
on the page. I was soon logging my time per client
(which also meant introducing a 'projects' and 'activities' concept to Bouquin, structurally) all in
the one app. Goodbye, old Redmine instance! I don't need you anymore.</p>
<p>At the end of the month, my previous pattern was essentially, per client:</p>
<ul>
<li>Get the total hours worked, out of Redmine</li>
<li>Get an export with a breakdown based on activity, out of Redmine</li>
<li>Create an invoice in Paypal (just because it has a handy free invoicing feature - you don't need to <em>send</em> it via Paypal)</li>
<li>Send the PDF invoice and timesheet to the client</li>
</ul>
<p>All the data I needed was in Bouquin. Given it now had a concept of 'client', I realised that I could
also add a basic invoicing module into it which let me generate the invoices directly from the data.</p>
<p>With my 'reminders' feature, I also realised I could automatically program in the due date of the invoice,
and have it remind me if a client hasn't paid, come that day.</p>
<p>What ties it together is the <strong>Projects</strong> interface, added in 0.9.0. A Project is a single bucket
that unifies time logged, invoices, and documents. You can set a prepaid &quot;bucket&quot; of hours against
a project and get warned when logged time approaches it - useful for fixed-scope contracts. You can
invoice for an increase in prepaid hours without having to fake time entries for them. And
documents stored against a project sit alongside the time and the invoices, all in one view.</p>
<p>The most recent release added an <strong>Earnings</strong> view for tracking income across reporting periods -
the kind of thing you need when putting together a tax-period summary. It's a small addition, but
it's the logical end of the thread that started with &quot;let me log how long I worked today&quot;.</p>
<h2 id="documents-embedded-and-encrypted">Documents, embedded and encrypted</h2>
<p>Finally, I am in the position where I can do all my project planning, note taking, time logging, and
invoicing, all from the one application!</p>
<figure class="text-center my-4">
  <a href="/news/bouquin-history/time-log_hu4089961667039832624.png" class="image-lightbox" title="Click to view larger">
    <img src="/news/bouquin-history/time-log_hu12680502859720352344.png" alt="The time logging interface showing entries against projects with durations" width="760" height="641" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">Time logging per project, with a Pomodoro timer in the sidebar.</figcaption>
</figure>

<h3 id="modular-life">Modular life</h3>
<p>Importantly, I've made it modular. Not everyone wants invoicing in their notebook. Not
everyone wants time logging. Some people just want an encrypted journal.</p>
<p>So the heavier features are modular and toggleable in Settings:</p>
<ul>
<li><strong>Tags</strong> - on by default, but can be switched off.</li>
<li><strong>Time logging</strong> - on by default, but optional.</li>
<li><strong>Invoicing</strong> - off by default, and <em>only available if time logging is on</em>. It depends on it.</li>
<li><strong>Reminders</strong> - optional, with webhook and email notification support.</li>
<li><strong>Documents</strong> - optional file storage, embedded in the encrypted database.</li>
</ul>
<p>When you toggle a feature off, its UI disappears. The settings dialog enforces the dependency:
disable time logging and invoicing goes with it.</p>
<p>The Documents feature stores files - PDFs, images, whatever - inside the encrypted database,
tied to Projects the same way time and invoices are. They're not links to files on disk; they're
embedded, which means they're encrypted along with everything else and travel with the database
when you back it up. Tags apply to documents too.</p>
<p>If the Documents feature is enabled, and so is Invoicing, then Invoicing also saves the invoice
as a Document!</p>
<p>This is one of the quiet benefits of the SQLCipher backend: binary blobs in the database are
ciphertext on disk, just like the text. There's no separate &quot;attachments folder&quot; sitting
unencrypted next to the notebook.</p>
<h2 id="reminders-that-can-reach-you">Reminders that can reach you</h2>
<p>Reminders can be one-off or recurring - daily, weekly, fortnightly, monthly on the Nth weekday.
When they fire, they flash in-app. But they can also be sent as webhooks or email notifications,
which means a reminder can reach you even when Bouquin isn't open. A URL and a secret (sent as an
<code>X-Bouquin-Secret</code> header) are configured in Settings, and the reminder fires to that endpoint. Wire it
into <a href="/news/signal-ai-assistant/">Node-RED</a>
 or any webhook receiver and your notebook can ping
your phone.</p>
<h2 id="where-to-find-it">Where to find it</h2>
<p>Bouquin is packaged for Debian and Fedora from my own APT and RPM repositories, available as an
AppImage, on PyPI (<code>pip install bouquin</code>), and from source. The source and full changelog are at
<a href="https://git.mig5.net/mig5/bouquin" target="_blank" rel="noopener noreferrer">git.mig5.net/mig5/bouquin</a>
, with more on the
<a href="/projects/bouquin/">project page</a>
.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Language: Python, Qt (PySide6)</li>
<li>Backend: SQLCipher (encrypted at rest)</li>
<li>Distribution: APT, RPM, AppImage, PyPI</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://git.mig5.net/mig5/bouquin" target="_blank" rel="noopener noreferrer">Source repo</a>
</li>
<li><a href="/projects/bouquin/">Project page</a>
</li>
<li><a href="https://www.zetetic.net/sqlcipher/" target="_blank" rel="noopener noreferrer">SQLCipher</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need encryption-at-rest or hardened packaging?</div>
  <div class="smallprint mb-3">I do security-minded sysadmin work: encryption, secrets, hardened build pipelines, and packaging. I can help.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>Publishing a directory of files to IPFS with Python</title>
      <link>https://mig5.net/news/publishing-a-directory-to-ipfs-with-python/</link>
      <pubDate>Wed, 16 Sep 2026 16:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/publishing-a-directory-to-ipfs-with-python/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-cloud-arrow-up"></i><span>IPFS</span></div>
    <h1 class="display-6 fw-semibold mb-2">Publishing a directory of files to IPFS with Python</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-16">September 16, 2026</time></p>
    <p class="lead mb-0">A technical walkthrough of publishing a directory of files to IPFS via its HTTP API from
Python: content-derived CIDs, the multipart form-data trick that makes go-ipfs treat separate
uploads as one folder, and using IPNS to keep a stable pointer at the latest version.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-16T16:00:00&#43;10:00">16 September 2026</time>
        </div>

<h2 id="what-ipfs-actually-is">What IPFS actually is</h2>
<p>IPFS is a peer-to-peer protocol for addressing and distributing files by their <em>content</em>
rather than by their location. Instead of a URL that says &quot;fetch this file from that server&quot;,
an IPFS content identifier (CID) is a cryptographic hash computed from the bytes of the file
itself. If you have the CID, any IPFS node that has a copy of those bytes can serve them to
you - there is no origin server to take down, no hostname to DNS-poison, and no single point
of failure for censorship to lean on. In that respect it has something in common with
BitTorrent: files are mirrored across nodes that have requested them, and the network as a
whole holds the content as long as someone, somewhere, is pinning it.</p>
<h2 id="content-is-king-the-implication-that-shapes-everything-else">Content is king: the implication that shapes everything else</h2>
<p>Because a CID is derived from the <em>content</em> of the file, changing a single byte produces a
completely new CID. There is no in-protocol relationship between the old CID and the new one.
IPFS does not maintain revisions, history, or a &quot;this supersedes that&quot; link between them. If
you publish a file today and it changes tomorrow, tomorrow's file has a different CID,
and it is entirely your job as the publisher to tell the world which CID is the current one.</p>
<p>That is manageable for a one-off file. It is not manageable for a dataset that updates hourly.
Handing out a fresh CID every time the content changes is a poor experience for anyone trying
to fetch it - they would need to be told the new CID each time, out of band.</p>
<h2 id="ipns-a-stable-name-for-a-moving-target">IPNS: a stable name for a moving target</h2>
<p>This is the problem IPNS (InterPlanetary Name System) solves. An IPNS name is a public key -
or a human-readable alias of one - that you can republish to point at a new CID as often as
you like. The IPNS name itself never changes; only its target does. Consumers only ever need
to know the IPNS name, and they will always resolve to whatever CID the publisher has most
recently pointed it at.</p>
<p>The mental model is closer to DNS than to a symlink: a static name that resolves to a
destination, with the publisher holding the private key needed to update that destination.
Unlike DNS, the binding is cryptographic and the resolution can happen peer-to-peer without a
registrar in the path.</p>
<p>So the publish loop for a changing dataset is: add the new content to IPFS, get back its CID,
and call <code>name/publish</code> on the IPNS key to point at that CID. Repeat whenever the content
changes.</p>
<h2 id="publishing-a-single-file-the-easy-case">Publishing a single file: the easy case</h2>
<p>The go-ipfs daemon exposes an HTTP RPC API on <code>127.0.0.1:5001</code> by default. Adding a single
file is a straightforward <code>POST /api/v0/add</code> with the file in the multipart body. The response
is a JSON object containing the CID (<code>Hash</code>), the <code>Name</code>, and the <code>Size</code>. That is the case
most examples on the internet cover, and it is genuinely simple.</p>
<h2 id="publishing-a-directory-the-not-easy-case">Publishing a directory: the not-easy case</h2>
<p>What is <em>not</em> well documented is how to publish a <em>directory</em> containing multiple files as a
single IPFS object, so that the result is one CID for the folder and individual CIDs for the
files inside it - addressable as <code>ipfs://&lt;folder-cid&gt;/&lt;filename&gt;</code>.</p>
<p>The naive approach of calling <code>add</code> once per file gives you N independent CIDs with no folder
relationship between them. What you want is the equivalent of <code>ipfs add -r my-files/</code> on the
command line, but driven from the HTTP API.</p>
<p>The trick is in how the multipart form data is constructed. Each file's <code>filename</code> field in
the multipart part must carry the <em>escaped absolute path within the directory</em>, using <code>%2F</code>
(URL-encoded <code>/</code>) as the separator - so <code>my-files/file1.txt</code> becomes <code>my-files%2Ffile1.txt</code>.
go-ipfs infers the directory from that path. You never explicitly &quot;add&quot; the directory; it is
implied by the common prefix across the files.</p>
<p>This was not easy to discover. The <a href="https://docs.ipfs.io/reference/http/api/#api-v0-add" target="_blank" rel="noopener noreferrer">IPFS HTTP API reference</a>

describes the <code>add</code> endpoint but does not make the directory inference behaviour obvious, and
the most common Python library for the API (<code>py-ipfs-http-client</code>) was, at the time,
semi-abandoned and did not work reliably with current go-ipfs releases. Falling back to plain
<code>requests</code> against the HTTP API turned out to be simpler than fighting the library.</p>
<h2 id="the-script">The script</h2>
<p>Below is a self-contained example that publishes two files inside a directory called
<code>my-files</code> to a local go-ipfs daemon, then points an IPNS key at the resulting folder CID.
The same pattern extends to any number of files.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="ch">#!/usr/bin/env python3</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># This code is distributed in the hope that it will be useful,</span>
</span></span><span class="line"><span class="cl"><span class="c1"># but WITHOUT ANY WARRANTY; without even the implied warranty of</span>
</span></span><span class="line"><span class="cl"><span class="c1"># MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. The author</span>
</span></span><span class="line"><span class="cl"><span class="c1"># accepts no responsibility for its use, of which you do so at</span>
</span></span><span class="line"><span class="cl"><span class="c1"># your own risk.</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">json</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">requests</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">pathlib</span> <span class="kn">import</span> <span class="n">Path</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Directory we want to publish</span>
</span></span><span class="line"><span class="cl"><span class="n">directory</span> <span class="o">=</span> <span class="s2">&#34;my-files&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Read the first file, which is already inside that directory</span>
</span></span><span class="line"><span class="cl"><span class="n">file1</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">directory</span><span class="si">}</span><span class="s2">/file1.txt&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">p</span> <span class="o">=</span> <span class="n">Path</span><span class="p">(</span><span class="n">file1</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">with</span> <span class="n">p</span><span class="o">.</span><span class="n">open</span><span class="p">(</span><span class="s2">&#34;rb&#34;</span><span class="p">)</span> <span class="k">as</span> <span class="n">fp</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">file1_bytes</span> <span class="o">=</span> <span class="n">fp</span><span class="o">.</span><span class="n">read</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">file1_name</span> <span class="o">=</span> <span class="n">p</span><span class="o">.</span><span class="n">name</span>
</span></span><span class="line"><span class="cl">    <span class="n">file1_absolute_escaped</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">directory</span><span class="si">}</span><span class="s2">%2F</span><span class="si">{</span><span class="n">file1_name</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Read the second file</span>
</span></span><span class="line"><span class="cl"><span class="n">file2</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">directory</span><span class="si">}</span><span class="s2">/file2.txt&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">p</span> <span class="o">=</span> <span class="n">Path</span><span class="p">(</span><span class="n">file2</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">with</span> <span class="n">p</span><span class="o">.</span><span class="n">open</span><span class="p">(</span><span class="s2">&#34;rb&#34;</span><span class="p">)</span> <span class="k">as</span> <span class="n">fp</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">file2_bytes</span> <span class="o">=</span> <span class="n">fp</span><span class="o">.</span><span class="n">read</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">file2_name</span> <span class="o">=</span> <span class="n">p</span><span class="o">.</span><span class="n">name</span>
</span></span><span class="line"><span class="cl">    <span class="n">file2_absolute_escaped</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">directory</span><span class="si">}</span><span class="s2">%2F</span><span class="si">{</span><span class="n">file2_name</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Construct our files payload for the POST request to the IPFS API.</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Note how we use the absolute &#39;escaped&#39; folder+file names for</span>
</span></span><span class="line"><span class="cl"><span class="c1"># the file info. This is important for the IPFS API to understand that</span>
</span></span><span class="line"><span class="cl"><span class="c1"># these files are &#39;inside&#39; a directory. Notice that the directory doesn&#39;t</span>
</span></span><span class="line"><span class="cl"><span class="c1"># explicitly get added, it is inferred from that path.</span>
</span></span><span class="line"><span class="cl"><span class="n">folder_and_files</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">file1_name</span><span class="p">:</span> <span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">file1_absolute_escaped</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">file1_bytes</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;application/octet-stream&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span><span class="s2">&#34;filename&#34;</span><span class="p">:</span> <span class="n">file1_absolute_escaped</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="n">file2_name</span><span class="p">:</span> <span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">file2_absolute_escaped</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">file2_bytes</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;application/octet-stream&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span><span class="s2">&#34;filename&#34;</span><span class="p">:</span> <span class="n">file1_absolute_escaped</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">),</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># IPFS API endpoint</span>
</span></span><span class="line"><span class="cl"><span class="n">url</span> <span class="o">=</span> <span class="s2">&#34;http://127.0.0.1:5001/api/v0&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Post the request to the IPFS API</span>
</span></span><span class="line"><span class="cl"><span class="n">response</span> <span class="o">=</span> <span class="n">requests</span><span class="o">.</span><span class="n">post</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">url</span><span class="si">}</span><span class="s2">/add&#34;</span><span class="p">,</span> <span class="n">files</span><span class="o">=</span><span class="n">folder_and_files</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># The response contains a list of Name/Hash/Size attributes</span>
</span></span><span class="line"><span class="cl"><span class="c1"># for each file, separated by a new line, but it&#39;s not a JSON blob</span>
</span></span><span class="line"><span class="cl"><span class="c1"># apparently.</span>
</span></span><span class="line"><span class="cl"><span class="c1">#</span>
</span></span><span class="line"><span class="cl"><span class="c1"># It will look something like this:</span>
</span></span><span class="line"><span class="cl"><span class="c1">#</span>
</span></span><span class="line"><span class="cl"><span class="c1"># [&#39;{&#34;Name&#34;:&#34;my-files/file1.txt&#34;,&#34;Hash&#34;:&#34;QmZLRFWaz9Kypt2ACNMDzA5uzACDRiCqwdkNSP1UZsu56D&#34;,&#34;Size&#34;:&#34;11&#34;}&#39;,</span>
</span></span><span class="line"><span class="cl"><span class="c1">#  &#39;{&#34;Name&#34;:&#34;my-files/file2.txt&#34;,&#34;Hash&#34;:&#34;QmfTitKrVzFGbLCG2KaxBg5UMo6F2iYxsQCeC2er59cFTU&#34;,&#34;Size&#34;:&#34;11&#34;}&#39;,</span>
</span></span><span class="line"><span class="cl"><span class="c1">#  &#39;{&#34;Name&#34;:&#34;my-files&#34;,&#34;Hash&#34;:&#34;QmVRFp9xNPRLXXe4qXTwDx5sG3Wm6Y6jcbRJnv7u8EZcQu&#34;,&#34;Size&#34;:&#34;128&#34;}&#39;,</span>
</span></span><span class="line"><span class="cl"><span class="c1"># &#39;&#39;]</span>
</span></span><span class="line"><span class="cl"><span class="c1">#</span>
</span></span><span class="line"><span class="cl"><span class="c1"># It&#39;s the Hash of the directory that we want to point our IPNS</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ID at, so that our IPNS ID always points at the latest version.</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">ipfs_response</span> <span class="o">=</span> <span class="n">response</span><span class="o">.</span><span class="n">text</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="s2">&#34;</span><span class="se">\n</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="n">item</span> <span class="ow">in</span> <span class="n">ipfs_response</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">j</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">loads</span><span class="p">(</span><span class="n">item</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">j</span><span class="p">[</span><span class="s2">&#34;Name&#34;</span><span class="p">]</span> <span class="o">==</span> <span class="n">directory</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">ipfs_hash</span> <span class="o">=</span> <span class="n">j</span><span class="p">[</span><span class="s2">&#34;Hash&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Point our IPNS at the directory&#39;s hash</span>
</span></span><span class="line"><span class="cl">        <span class="n">response</span> <span class="o">=</span> <span class="n">requests</span><span class="o">.</span><span class="n">post</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">url</span><span class="si">}</span><span class="s2">/name/publish?arg=</span><span class="si">{</span><span class="n">ipfs_hash</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">break</span>
</span></span></code></pre></div><h2 id="walkthrough">Walkthrough</h2>
<p>The first thing to notice is the <code>file1_absolute_escaped</code> value: <code>my-files%2Ffile1.txt</code>. That
is the <code>filename</code> sent in the multipart part's <code>Content-Disposition</code> header. It is the
URL-encoded form of the relative path <code>my-files/file1.txt</code>. This is the single line that makes
the whole directory trick work - go-ipfs sees the <code>%2F</code>, decodes it to <code>/</code>, and infers that
these two files belong to a directory called <code>my-files</code>.</p>
<p>The <code>folder_and_files</code> dictionary is structured as <code>requests</code> expects for multipart uploads:
each key is a form field name, and each value is a tuple of <code>(filename, bytes, content_type, extra_headers)</code>. The <code>extra_headers</code> dict with <code>{&quot;filename&quot;: file1_absolute_escaped}</code> is what
actually sets the <code>filename</code> in the <code>Content-Disposition</code> header of that part - this is the
field go-ipfs reads.</p>
<p>The response from <code>/add</code> is not a single JSON object. It is a series of newline-separated JSON
objects, one per file added, followed by one for the directory itself. The example response in
the comments shows the shape: two file entries, then a third entry whose <code>Name</code> is the
directory name (<code>my-files</code>) and whose <code>Hash</code> is the folder CID. The script loops through those
lines, parses each as JSON, and waits for the entry where <code>Name</code> matches the directory name.
That entry's <code>Hash</code> is the CID to publish under IPNS.</p>
<p>The final <code>requests.post(f&quot;{url}/name/publish?arg={ipfs_hash}&quot;)</code> is the IPNS update. It tells
the daemon's IPNS key to point at the new folder CID. From that point on, anyone resolving the
IPNS name gets the latest version of the directory and all the files inside it.</p>
<h2 id="extending-it">Extending it</h2>
<p>The script hardcodes two files for clarity, but the pattern generalises. Read every file in
the directory, build an escaped path for each, construct the <code>folder_and_files</code> dict in a loop,
and POST them all in a single <code>add</code> request. The directory CID comes back as the entry whose
<code>Name</code> is the directory name. Wrap it in a function, call it from a cron job or a systemd
timer whenever the source files change, and you have a self-updating IPFS publication pipeline
with a stable IPNS name that consumers can pin.</p>
<p>If you want a changelog of which CID was published when - useful for a dataset that updates
over time - append the folder CID and a timestamp to a log file before publishing it to IPNS,
then include that log file as one of the files in the directory on the next publish. The
changelog ships inside the directory it describes.</p>
<h2 id="installing-the-ipfs-daemon">Installing the IPFS daemon</h2>
<p>If you need to get go-ipfs onto a Linux server in the first place, I co-maintain an
<a href="https://github.com/madoke/ansible-ipfs-cluster" target="_blank" rel="noopener noreferrer">Ansible role for IPFS</a>
 that handles the
installation and configuration. It is what I used to stand up the node this script talks to.</p>
<h2 id="why-not-the-python-library">Why not the Python library?</h2>
<p>At the time this was written, <code>py-ipfs-http-client</code> did not work reliably with the current
go-ipfs release and appeared semi-abandoned. The HTTP API is stable, well-documented, and
<code>requests</code> is already a dependency in most Python projects that talk to HTTP services. Skipping
the library and talking to the API directly was less code and less risk than pinning to a
specific old version of a library that might break on the next go-ipfs upgrade. That may have
changed since - check the current state of the library before deciding.</p>
<p>This article was adapted from work originally written for my customer
<a href="https://freedom.press/tech/news/us-pft-on-ipfs/" target="_blank" rel="noopener noreferrer">Freedom of the Press Foundation</a>
.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>IPFS CIDs are derived from content, so any change produces a new CID</li>
<li>IPNS provides a stable name that repoints to the latest CID</li>
<li>Directory inference: escape <code>/</code> as <code>%2F</code> in the multipart filename field</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://docs.ipfs.io/reference/http/api/#api-v0-add" target="_blank" rel="noopener noreferrer">IPFS HTTP API reference</a>
</li>
<li><a href="https://docs.ipfs.io/concepts/ipns/" target="_blank" rel="noopener noreferrer">IPNS documentation</a>
</li>
<li><a href="https://freedom.press/tech/news/us-pft-on-ipfs/" target="_blank" rel="noopener noreferrer">Original article on Freedom of the Press Foundation</a>
</li>
<li><a href="https://github.com/madoke/ansible-ipfs-cluster" target="_blank" rel="noopener noreferrer">ansible-ipfs-cluster</a>
</li>
<li><a href="https://docs.python-requests.org/en/latest/" target="_blank" rel="noopener noreferrer">Python requests library</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need help with decentralized publishing?</div>
  <div class="smallprint mb-3">I do contract sysadmin and automation work, including IPFS, Tor, and censorship-resistant infrastructure.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-cloud-arrow-up"></i><span>IPFS</span></div>
    <h1 class="display-6 fw-semibold mb-2">Publishing a directory of files to IPFS with Python</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-16">September 16, 2026</time></p>
    <p class="lead mb-0">A technical walkthrough of publishing a directory of files to IPFS via its HTTP API from
Python: content-derived CIDs, the multipart form-data trick that makes go-ipfs treat separate
uploads as one folder, and using IPNS to keep a stable pointer at the latest version.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-16T16:00:00&#43;10:00">16 September 2026</time>
        </div>

<h2 id="what-ipfs-actually-is">What IPFS actually is</h2>
<p>IPFS is a peer-to-peer protocol for addressing and distributing files by their <em>content</em>
rather than by their location. Instead of a URL that says &quot;fetch this file from that server&quot;,
an IPFS content identifier (CID) is a cryptographic hash computed from the bytes of the file
itself. If you have the CID, any IPFS node that has a copy of those bytes can serve them to
you - there is no origin server to take down, no hostname to DNS-poison, and no single point
of failure for censorship to lean on. In that respect it has something in common with
BitTorrent: files are mirrored across nodes that have requested them, and the network as a
whole holds the content as long as someone, somewhere, is pinning it.</p>
<h2 id="content-is-king-the-implication-that-shapes-everything-else">Content is king: the implication that shapes everything else</h2>
<p>Because a CID is derived from the <em>content</em> of the file, changing a single byte produces a
completely new CID. There is no in-protocol relationship between the old CID and the new one.
IPFS does not maintain revisions, history, or a &quot;this supersedes that&quot; link between them. If
you publish a file today and it changes tomorrow, tomorrow's file has a different CID,
and it is entirely your job as the publisher to tell the world which CID is the current one.</p>
<p>That is manageable for a one-off file. It is not manageable for a dataset that updates hourly.
Handing out a fresh CID every time the content changes is a poor experience for anyone trying
to fetch it - they would need to be told the new CID each time, out of band.</p>
<h2 id="ipns-a-stable-name-for-a-moving-target">IPNS: a stable name for a moving target</h2>
<p>This is the problem IPNS (InterPlanetary Name System) solves. An IPNS name is a public key -
or a human-readable alias of one - that you can republish to point at a new CID as often as
you like. The IPNS name itself never changes; only its target does. Consumers only ever need
to know the IPNS name, and they will always resolve to whatever CID the publisher has most
recently pointed it at.</p>
<p>The mental model is closer to DNS than to a symlink: a static name that resolves to a
destination, with the publisher holding the private key needed to update that destination.
Unlike DNS, the binding is cryptographic and the resolution can happen peer-to-peer without a
registrar in the path.</p>
<p>So the publish loop for a changing dataset is: add the new content to IPFS, get back its CID,
and call <code>name/publish</code> on the IPNS key to point at that CID. Repeat whenever the content
changes.</p>
<h2 id="publishing-a-single-file-the-easy-case">Publishing a single file: the easy case</h2>
<p>The go-ipfs daemon exposes an HTTP RPC API on <code>127.0.0.1:5001</code> by default. Adding a single
file is a straightforward <code>POST /api/v0/add</code> with the file in the multipart body. The response
is a JSON object containing the CID (<code>Hash</code>), the <code>Name</code>, and the <code>Size</code>. That is the case
most examples on the internet cover, and it is genuinely simple.</p>
<h2 id="publishing-a-directory-the-not-easy-case">Publishing a directory: the not-easy case</h2>
<p>What is <em>not</em> well documented is how to publish a <em>directory</em> containing multiple files as a
single IPFS object, so that the result is one CID for the folder and individual CIDs for the
files inside it - addressable as <code>ipfs://&lt;folder-cid&gt;/&lt;filename&gt;</code>.</p>
<p>The naive approach of calling <code>add</code> once per file gives you N independent CIDs with no folder
relationship between them. What you want is the equivalent of <code>ipfs add -r my-files/</code> on the
command line, but driven from the HTTP API.</p>
<p>The trick is in how the multipart form data is constructed. Each file's <code>filename</code> field in
the multipart part must carry the <em>escaped absolute path within the directory</em>, using <code>%2F</code>
(URL-encoded <code>/</code>) as the separator - so <code>my-files/file1.txt</code> becomes <code>my-files%2Ffile1.txt</code>.
go-ipfs infers the directory from that path. You never explicitly &quot;add&quot; the directory; it is
implied by the common prefix across the files.</p>
<p>This was not easy to discover. The <a href="https://docs.ipfs.io/reference/http/api/#api-v0-add" target="_blank" rel="noopener noreferrer">IPFS HTTP API reference</a>

describes the <code>add</code> endpoint but does not make the directory inference behaviour obvious, and
the most common Python library for the API (<code>py-ipfs-http-client</code>) was, at the time,
semi-abandoned and did not work reliably with current go-ipfs releases. Falling back to plain
<code>requests</code> against the HTTP API turned out to be simpler than fighting the library.</p>
<h2 id="the-script">The script</h2>
<p>Below is a self-contained example that publishes two files inside a directory called
<code>my-files</code> to a local go-ipfs daemon, then points an IPNS key at the resulting folder CID.
The same pattern extends to any number of files.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="ch">#!/usr/bin/env python3</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># This code is distributed in the hope that it will be useful,</span>
</span></span><span class="line"><span class="cl"><span class="c1"># but WITHOUT ANY WARRANTY; without even the implied warranty of</span>
</span></span><span class="line"><span class="cl"><span class="c1"># MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. The author</span>
</span></span><span class="line"><span class="cl"><span class="c1"># accepts no responsibility for its use, of which you do so at</span>
</span></span><span class="line"><span class="cl"><span class="c1"># your own risk.</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">json</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">requests</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">pathlib</span> <span class="kn">import</span> <span class="n">Path</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Directory we want to publish</span>
</span></span><span class="line"><span class="cl"><span class="n">directory</span> <span class="o">=</span> <span class="s2">&#34;my-files&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Read the first file, which is already inside that directory</span>
</span></span><span class="line"><span class="cl"><span class="n">file1</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">directory</span><span class="si">}</span><span class="s2">/file1.txt&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">p</span> <span class="o">=</span> <span class="n">Path</span><span class="p">(</span><span class="n">file1</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">with</span> <span class="n">p</span><span class="o">.</span><span class="n">open</span><span class="p">(</span><span class="s2">&#34;rb&#34;</span><span class="p">)</span> <span class="k">as</span> <span class="n">fp</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">file1_bytes</span> <span class="o">=</span> <span class="n">fp</span><span class="o">.</span><span class="n">read</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">file1_name</span> <span class="o">=</span> <span class="n">p</span><span class="o">.</span><span class="n">name</span>
</span></span><span class="line"><span class="cl">    <span class="n">file1_absolute_escaped</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">directory</span><span class="si">}</span><span class="s2">%2F</span><span class="si">{</span><span class="n">file1_name</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Read the second file</span>
</span></span><span class="line"><span class="cl"><span class="n">file2</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">directory</span><span class="si">}</span><span class="s2">/file2.txt&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">p</span> <span class="o">=</span> <span class="n">Path</span><span class="p">(</span><span class="n">file2</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">with</span> <span class="n">p</span><span class="o">.</span><span class="n">open</span><span class="p">(</span><span class="s2">&#34;rb&#34;</span><span class="p">)</span> <span class="k">as</span> <span class="n">fp</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">file2_bytes</span> <span class="o">=</span> <span class="n">fp</span><span class="o">.</span><span class="n">read</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">file2_name</span> <span class="o">=</span> <span class="n">p</span><span class="o">.</span><span class="n">name</span>
</span></span><span class="line"><span class="cl">    <span class="n">file2_absolute_escaped</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">directory</span><span class="si">}</span><span class="s2">%2F</span><span class="si">{</span><span class="n">file2_name</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Construct our files payload for the POST request to the IPFS API.</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Note how we use the absolute &#39;escaped&#39; folder+file names for</span>
</span></span><span class="line"><span class="cl"><span class="c1"># the file info. This is important for the IPFS API to understand that</span>
</span></span><span class="line"><span class="cl"><span class="c1"># these files are &#39;inside&#39; a directory. Notice that the directory doesn&#39;t</span>
</span></span><span class="line"><span class="cl"><span class="c1"># explicitly get added, it is inferred from that path.</span>
</span></span><span class="line"><span class="cl"><span class="n">folder_and_files</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">file1_name</span><span class="p">:</span> <span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">file1_absolute_escaped</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">file1_bytes</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;application/octet-stream&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span><span class="s2">&#34;filename&#34;</span><span class="p">:</span> <span class="n">file1_absolute_escaped</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="n">file2_name</span><span class="p">:</span> <span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">file2_absolute_escaped</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">file2_bytes</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;application/octet-stream&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span><span class="s2">&#34;filename&#34;</span><span class="p">:</span> <span class="n">file1_absolute_escaped</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">),</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># IPFS API endpoint</span>
</span></span><span class="line"><span class="cl"><span class="n">url</span> <span class="o">=</span> <span class="s2">&#34;http://127.0.0.1:5001/api/v0&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Post the request to the IPFS API</span>
</span></span><span class="line"><span class="cl"><span class="n">response</span> <span class="o">=</span> <span class="n">requests</span><span class="o">.</span><span class="n">post</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">url</span><span class="si">}</span><span class="s2">/add&#34;</span><span class="p">,</span> <span class="n">files</span><span class="o">=</span><span class="n">folder_and_files</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># The response contains a list of Name/Hash/Size attributes</span>
</span></span><span class="line"><span class="cl"><span class="c1"># for each file, separated by a new line, but it&#39;s not a JSON blob</span>
</span></span><span class="line"><span class="cl"><span class="c1"># apparently.</span>
</span></span><span class="line"><span class="cl"><span class="c1">#</span>
</span></span><span class="line"><span class="cl"><span class="c1"># It will look something like this:</span>
</span></span><span class="line"><span class="cl"><span class="c1">#</span>
</span></span><span class="line"><span class="cl"><span class="c1"># [&#39;{&#34;Name&#34;:&#34;my-files/file1.txt&#34;,&#34;Hash&#34;:&#34;QmZLRFWaz9Kypt2ACNMDzA5uzACDRiCqwdkNSP1UZsu56D&#34;,&#34;Size&#34;:&#34;11&#34;}&#39;,</span>
</span></span><span class="line"><span class="cl"><span class="c1">#  &#39;{&#34;Name&#34;:&#34;my-files/file2.txt&#34;,&#34;Hash&#34;:&#34;QmfTitKrVzFGbLCG2KaxBg5UMo6F2iYxsQCeC2er59cFTU&#34;,&#34;Size&#34;:&#34;11&#34;}&#39;,</span>
</span></span><span class="line"><span class="cl"><span class="c1">#  &#39;{&#34;Name&#34;:&#34;my-files&#34;,&#34;Hash&#34;:&#34;QmVRFp9xNPRLXXe4qXTwDx5sG3Wm6Y6jcbRJnv7u8EZcQu&#34;,&#34;Size&#34;:&#34;128&#34;}&#39;,</span>
</span></span><span class="line"><span class="cl"><span class="c1"># &#39;&#39;]</span>
</span></span><span class="line"><span class="cl"><span class="c1">#</span>
</span></span><span class="line"><span class="cl"><span class="c1"># It&#39;s the Hash of the directory that we want to point our IPNS</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ID at, so that our IPNS ID always points at the latest version.</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">ipfs_response</span> <span class="o">=</span> <span class="n">response</span><span class="o">.</span><span class="n">text</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="s2">&#34;</span><span class="se">\n</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="n">item</span> <span class="ow">in</span> <span class="n">ipfs_response</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">j</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">loads</span><span class="p">(</span><span class="n">item</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">j</span><span class="p">[</span><span class="s2">&#34;Name&#34;</span><span class="p">]</span> <span class="o">==</span> <span class="n">directory</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">ipfs_hash</span> <span class="o">=</span> <span class="n">j</span><span class="p">[</span><span class="s2">&#34;Hash&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Point our IPNS at the directory&#39;s hash</span>
</span></span><span class="line"><span class="cl">        <span class="n">response</span> <span class="o">=</span> <span class="n">requests</span><span class="o">.</span><span class="n">post</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">url</span><span class="si">}</span><span class="s2">/name/publish?arg=</span><span class="si">{</span><span class="n">ipfs_hash</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">break</span>
</span></span></code></pre></div><h2 id="walkthrough">Walkthrough</h2>
<p>The first thing to notice is the <code>file1_absolute_escaped</code> value: <code>my-files%2Ffile1.txt</code>. That
is the <code>filename</code> sent in the multipart part's <code>Content-Disposition</code> header. It is the
URL-encoded form of the relative path <code>my-files/file1.txt</code>. This is the single line that makes
the whole directory trick work - go-ipfs sees the <code>%2F</code>, decodes it to <code>/</code>, and infers that
these two files belong to a directory called <code>my-files</code>.</p>
<p>The <code>folder_and_files</code> dictionary is structured as <code>requests</code> expects for multipart uploads:
each key is a form field name, and each value is a tuple of <code>(filename, bytes, content_type, extra_headers)</code>. The <code>extra_headers</code> dict with <code>{&quot;filename&quot;: file1_absolute_escaped}</code> is what
actually sets the <code>filename</code> in the <code>Content-Disposition</code> header of that part - this is the
field go-ipfs reads.</p>
<p>The response from <code>/add</code> is not a single JSON object. It is a series of newline-separated JSON
objects, one per file added, followed by one for the directory itself. The example response in
the comments shows the shape: two file entries, then a third entry whose <code>Name</code> is the
directory name (<code>my-files</code>) and whose <code>Hash</code> is the folder CID. The script loops through those
lines, parses each as JSON, and waits for the entry where <code>Name</code> matches the directory name.
That entry's <code>Hash</code> is the CID to publish under IPNS.</p>
<p>The final <code>requests.post(f&quot;{url}/name/publish?arg={ipfs_hash}&quot;)</code> is the IPNS update. It tells
the daemon's IPNS key to point at the new folder CID. From that point on, anyone resolving the
IPNS name gets the latest version of the directory and all the files inside it.</p>
<h2 id="extending-it">Extending it</h2>
<p>The script hardcodes two files for clarity, but the pattern generalises. Read every file in
the directory, build an escaped path for each, construct the <code>folder_and_files</code> dict in a loop,
and POST them all in a single <code>add</code> request. The directory CID comes back as the entry whose
<code>Name</code> is the directory name. Wrap it in a function, call it from a cron job or a systemd
timer whenever the source files change, and you have a self-updating IPFS publication pipeline
with a stable IPNS name that consumers can pin.</p>
<p>If you want a changelog of which CID was published when - useful for a dataset that updates
over time - append the folder CID and a timestamp to a log file before publishing it to IPNS,
then include that log file as one of the files in the directory on the next publish. The
changelog ships inside the directory it describes.</p>
<h2 id="installing-the-ipfs-daemon">Installing the IPFS daemon</h2>
<p>If you need to get go-ipfs onto a Linux server in the first place, I co-maintain an
<a href="https://github.com/madoke/ansible-ipfs-cluster" target="_blank" rel="noopener noreferrer">Ansible role for IPFS</a>
 that handles the
installation and configuration. It is what I used to stand up the node this script talks to.</p>
<h2 id="why-not-the-python-library">Why not the Python library?</h2>
<p>At the time this was written, <code>py-ipfs-http-client</code> did not work reliably with the current
go-ipfs release and appeared semi-abandoned. The HTTP API is stable, well-documented, and
<code>requests</code> is already a dependency in most Python projects that talk to HTTP services. Skipping
the library and talking to the API directly was less code and less risk than pinning to a
specific old version of a library that might break on the next go-ipfs upgrade. That may have
changed since - check the current state of the library before deciding.</p>
<p>This article was adapted from work originally written for my customer
<a href="https://freedom.press/tech/news/us-pft-on-ipfs/" target="_blank" rel="noopener noreferrer">Freedom of the Press Foundation</a>
.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>IPFS CIDs are derived from content, so any change produces a new CID</li>
<li>IPNS provides a stable name that repoints to the latest CID</li>
<li>Directory inference: escape <code>/</code> as <code>%2F</code> in the multipart filename field</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://docs.ipfs.io/reference/http/api/#api-v0-add" target="_blank" rel="noopener noreferrer">IPFS HTTP API reference</a>
</li>
<li><a href="https://docs.ipfs.io/concepts/ipns/" target="_blank" rel="noopener noreferrer">IPNS documentation</a>
</li>
<li><a href="https://freedom.press/tech/news/us-pft-on-ipfs/" target="_blank" rel="noopener noreferrer">Original article on Freedom of the Press Foundation</a>
</li>
<li><a href="https://github.com/madoke/ansible-ipfs-cluster" target="_blank" rel="noopener noreferrer">ansible-ipfs-cluster</a>
</li>
<li><a href="https://docs.python-requests.org/en/latest/" target="_blank" rel="noopener noreferrer">Python requests library</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need help with decentralized publishing?</div>
  <div class="smallprint mb-3">I do contract sysadmin and automation work, including IPFS, Tor, and censorship-resistant infrastructure.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>Sysadmin communication: incident updates, plans, and trust</title>
      <link>https://mig5.net/news/so-what-it-you-exactly-do-part-seven-communication/</link>
      <pubDate>Wed, 16 Sep 2026 15:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/so-what-it-you-exactly-do-part-seven-communication/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-chat-dots"></i><span>From the archives</span></div>
    <h1 class="display-6 fw-semibold mb-2">Sysadmin communication: incident updates, plans, and trust</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-16">September 16, 2026</time></p>
    <p class="lead mb-0">Part seven (the conclusion) of a seven-part series from 2016 on what a contract sysadmin does.
This one covers communication: building trust through proactive updates, the incident report
template, the plan and the rollback plan, and a TODO system borrowed from Tom Limoncelli.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-16T15:00:00&#43;10:00">16 September 2026</time>
        </div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">From the archives - originally published in 2016</div>
  A decade on, I do some of this differently - tools, the industry, and the threat landscape have
all moved on. But the underlying ideas have stayed remarkably relevant even ten years later.
</div>

<p>Admittedly there is an irony in having a massive delay between the last article in this series and this one, aptly to be regarding 'communication' :) Sorry for the silence. It seems some of these articles have inspired some interest in the work I do, and as a result, I have been running off my feet with new business. There are worse problems to have.</p>
<p>Working as a consulting sysadmin kind of flips the sysadmin stereotype on its head. A consultant can't really operate in the basement of some office complex, rarely emerging to see the light of day, and bark at colleagues and customers alike. As a consultant I work for many organisations and so need to be available and responsive to all of them. And I have to (try and) be polite, even if I (often?) fail!</p>
<p>In addition, modern agencies have broken down a lot of those stereotypical office cultures. The sysadmin is a vital member of a team who is far more 'customer-facing' than he or she used to be. For example, large parts of my work involve preparing the technical components of RFPs to assist my customers getting new business themselves.</p>
<p>All of this points to the heightened importance of good communication as a vital skill for sysadmins, or any sort of consultant.</p>
<h2 id="building-trust---proactive-communication-in-a-reactive-role">Building trust - proactive communication in a reactive role</h2>
<p>It's not all beautiful in 'modern agencies'. Despite latest DevOps crazes, the sysadmin still has a largely reactive role, simply because someone has to. Automatic response to issues isn't always possible. Frequently, even post-mortem analysis of an incident requires the subtlety of a human brain to detect patterns or filter through irrelevancies. See the <a href="/news/so-what-it-you-exactly-do-part-five-troubleshooting/">previous article on Troubleshooting</a>
 for more on this.</p>
<p>The reality of a reactive role means that someone has to communicate about an issue. This might be simply notifying constituents that there <em>is</em> a problem. It might be providing an ETA, or regular progress updates if the issue is taking longer than desired to resolve. And it might need an incident report afterward. In all of these cases, the sysadmin would do well to expect questions - sometimes justified, sometimes irrational, sometimes inane. Now the sysadmin has two problems :) Dealing with a technical problem, and dealing with a people problem.</p>
<blockquote>
<p>Picard management tip: Most conflict arises out of confusion. Clarify quickly before weapons power up.</p>
<ul>
<li>Picard Tips (@PicardTips) <a href="https://twitter.com/PicardTips/status/614481334447112192" target="_blank" rel="noopener noreferrer">June 26, 2015</a>
</li>
</ul>
</blockquote>
<p>More and more, I find my work revolves around the ability to communicate my way through these issues.</p>
<p>I see frequent on-boarding of new customers at different agencies, and there often seems an initial period of panic and mistrust by said new customer. They may have been burnt by previous hosting experience. They may simply be a real piece of work and hence why their hosting company dropped them. The slightest issue or even a simple routine task is taken out of proportion almost immediately by the panicky client rep. Or, it's an idea by them that is already whipped into a panicked frenzy before it's even presented to you.</p>
<p>This is all about a lack of trust, largely because not enough time has passed to develop that trust.</p>
<p>It would be nice if customers assumed the benefit of the doubt: that you know what you're doing and that you actually want to help! Sadly this often isn't the case. The customer perhaps feels they can't give the benefit of the doubt because the 'risk' (or really, the personal attachment) to the project feels too great to take. A vicious cycle ensues.</p>
<p>Effective communication can fix all this.</p>
<p>The sysadmin operates at the 'pointy' end of risk, as someone recently described to me, where things can and do go wrong. It only follows that the sysadmin then witnesses or experiences directly, the angst of a client. Optimism, diligence, even humour through frequent communication will reassure the customer. Eventually through a 'broken record player' repeated cycle of tasks with a majority of successful results, strung together with consistent communication, trust is established.</p>
<h2 id="consistency-communicating-about-incidents">Consistency: communicating about incidents</h2>
<p>Cross-reference historical tickets with new issues to link similar or related tasks. This also reassures the customer that you are paying deep attention not just to their application, but to the history shared between you both. It also helps you in future when trying to debug something that has happened before.</p>
<p>I am a big fan of the incident report. It might just be an e-mail, or a ticket, or an entry in a spreadsheet or other system. It need only be based on a consistent template, like the following:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">Start time: YYYY-MM-DD HH:MM
</span></span><span class="line"><span class="cl">End time: YYYY-MM-DD HH:MM
</span></span><span class="line"><span class="cl">Duration: (diff between the above)
</span></span><span class="line"><span class="cl">Discovered by:
</span></span><span class="line"><span class="cl">Resolved by:
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Summary: [ONE-LINE SUMMARY OF ISSUE]
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Details:
</span></span><span class="line"><span class="cl">At HH:MM the monitoring system alerted on-call engineers to an issue with the BLAH BLAH.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Upon investigation, it was apparent that BLAH BLAH had caused a BLAH BLAH due to a misconfiguration in the BLAH.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Engineers applied an on-the-fly change to the BLAH and restarted the service.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Normal service returned shortly thereafter.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Mitigation:
</span></span><span class="line"><span class="cl">Developers and system engineers are investigating the ideal configuration which will be then folded into config management to ensure this issue doesn&#39;t re-occur.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">If you have any queries about this incident, please contact BLAH BLAH.
</span></span></code></pre></div><p>My instinct is to provide as much detail as possible in these reports. However, I learned a year or two into my career that less-technical customers are also easily overwhelmed by the content of incident reports. Try and keep them consistent (this is why a template helps) so they are used to knowing where to jump to if they want a summary or a detailed synopsis. It is OK to provide a technical explanation in the details, but avoid asking questions or placing blame. Cause and effect is the goal.</p>
<p>I also try not to stress about any Mitigation details. We are human. Sometimes problems are hard to understand. Sometimes they even need to occur again but not at 3AM, for us to analyse in real time. It is OK to state that engineers are 'continuing to monitor and investigate the root cause.' Ideally once you figure it out, you can update the incident report later.</p>
<h2 id="finding-the-fine-line-too-much-vs-not-enough">Finding the fine line: too much vs not enough</h2>
<p>You can, of course, get into the opposite sort of trouble if you withhold too much technical info. It can depend on the situation.</p>
<p>One example where <em>more</em> information is useful, is in the wake of some sort of security issue (such as Heartbleed, POODLE, Logjam, Shellshock etc). Even if your systems are not affected or you have quickly mitigated the risk, customers can make public announcements (or statements for auditors) that were simply incorrect because you told them 'everything is fine' when you really mean 'we're going through the process of fixing things up now, I'll let you know when we're all clear'. A customer might proclaim publicly 'Great news, we're not affected by [EVIL AND CATCHY-NAMED VULN]!'. In certain circles, particularly infosec which seems particularly plagued by immaturity, this is like asking for trouble.</p>
<p>In other words: sometimes you need to give more information than you normally would. Think of it as not explaining to your customer, but explaining to the people who are going to ask your customer (i.e their customers, their auditors, the public if this has a significant public image/brand etc). Or simply protecting your customer from themselves...</p>
<p>This is hard, but effective communication takes practice.</p>
<h2 id="the-plan-and-the-rollback-plan">The plan and the rollback plan</h2>
<p>When planning a particularly high risk task like a big deployment or a migration, you know things can go wrong 3/4 of the way through, which require some sort of backing out.</p>
<p>I find it helps to write a step-by-step plan for the task itself, but even more importantly, a rollback plan at the end. When things go wrong, it can be stressful. Your brain doesn't work as well when stressed as it does when calm, because it's receiving conflicting instinctive instructions to deal with the threat. Having a rollback plan doesn't just fix the issue: it allows you to not have to think, taking even more pressure off your brain so it begins to calm down again.</p>
<p>Communicate both the plan and the rollback plan to your team prior to commencement of the task. Get a second opinion on what sort of tests should demonstrate a successful completion of the task. Find out if people need to be notified in the event of rollback.</p>
<p>Other people will have good ideas that you didn't expect, which you can then factor into your plan.</p>
<p>You might just find that this communication has spotted something that, when added or corrected, means not having to rollback at all!</p>
<h2 id="customers-hate-silence">Customers hate silence</h2>
<p>Frequently I see a customer ask a question or lodge a ticket for something to be done, and the engineer or the developer commences work on it but doesn't say so. Any number of issues can later occur (such as being dragged into meetings) that leave the customer feeling that no-one has even noticed their request. Behaviour gets irate (especially if trust is not yet established, and thus expectations and realities are not in proportion).</p>
<p>One customer I know might assign me something to deploy during an agreed routine 'maintenance window' that is at the end of the week. I know I don't have to do anything for 5 days. But the customer would get nervous that I have not even seen the task and that it might be missed. This didn't make them intolerable, but it didn't take much effort or time for me to acknowledge the task in the ticket and put some anxieties at rest. It would probably only work in my favour to have a relaxed client if I had to give them some bad news about some other problem an hour later :)</p>
<p>Without being overly verbose, it can help to leave a quick note &quot;No worries, I'm just jumping into a meeting but I think this is a quick fix. I'll look at it shortly/give it to Alice to take a look while I step out&quot;. Alice might reply &quot;I hit a bit of an obstacle here because [something you didn't even remember as you were thinking about your imminent meeting]&quot;. Comes back to you: &quot;oh, you're right, I forgot about that. Sorry Client, this will take a little longer than I initially thought. I've run out of time today but it's on my TODO for tomorrow.&quot;</p>
<h2 id="flood">Flood</h2>
<p>Excessive communication is of course too much, and creates a 'switch-off' mode where you are either ignored, not taken seriously, or your information is misinterpreted due to sheer volume.</p>
<p>Again, this is just about practice, balance, and being dynamic. Different people require different amounts of communication to either keep them happy or make them understand something important.</p>
<p>I would, however, say that you are less commonly going to run into issues communicating superfluously than you are by not communicating enough.</p>
<h2 id="the-todo-system">The TODO system</h2>
<p>A few years ago I recall being very overworked and quite inefficient at getting through tasks. I was forgetting some tasks, jumping from some tasks to others before finishing them, and generally making myself and the people who depended on me pretty unhappy.</p>
<p><a href="http://everythingsysadmin.com/" target="_blank" rel="noopener noreferrer">Tom Limoncelli&#39;s</a>
 book <em><a href="http://www.amazon.com/Management-System-Administrators-Thomas-Limoncelli/dp/0596007833" target="_blank" rel="noopener noreferrer">&#39;Time Management for System Administrators&#39;</a>
</em>, which is quite small (important for people short on time!) and excellent, features a TODO 'cycle' approach which really helped me rotate through tasks over weeks, moving things I didn't finish one day to the top of tomorrow's list. But also significant was his recommendation to always take a note of any new requests. We all know people have an annoying habit of sidling up to your desk while you're busy and asking for something. Sometimes demanding they use the ticket system is just a waste of your own time: you know these people won't leave til they are convinced you'll help them.</p>
<p>You would be surprised at the difference it can make to respond &quot;OK, sure. I don't have time right this second as I need to finish this other task. But I'll note this down so it doesn't get forgotten.&quot; Ensure the person sees for themselves that you are writing it down. This absurd procedure is validating for them your acknowledgement not just of their issue, but of them personally. It's also a lot less abrasive than barking &quot;why can't you open a TICKET???!&quot;</p>
<p>This is trickier in a distributed team, but that's where your ticket system comes in handy (and you were only going to add that item to the ticket system later right?). Assign yourself a ticket and ensure the requester is notified of it, if your ticket system supports it. Who knows: they might even think they can save some time/effort in future and just make the ticket themselves. Stranger things have and will happen!</p>
<h2 id="summing-up-this-series">Summing up (this series)</h2>
<p>You can take all the above with a grain of salt since it took so long for me to communicate this blog post! What do I know?</p>
<p>Well, I don't know a lot, but I hope this series has been interesting to other sysadmins, as well as anyone working in digital agencies, or even consultants of any sort. I think the nature of consultancy carries a lot of common elements across a wide range of job descriptions, technologies and industries. This is just a snapshot of how it fills up most of my time.</p>
<p>If you have questions or feedback you are welcome to comment here, in any of the previous articles in the series, or via the <a href="/contact/">contact form</a>
.</p>
<p>Thanks!</p>
<h2 id="previous-articles-in-this-series">Previous articles in this series</h2>
<p><a href="/news/so-what-it-you-exactly-do-part-one-continuous-deployment/">Continuous deployment for safer production changes</a>

<a href="/news/so-what-it-you-exactly-do-part-two-config-management/">Linux configuration management: eliminating snowflake servers</a>

<a href="/news/so-what-it-you-exactly-do-part-three-security/">Linux infrastructure security as risk management</a>

<a href="/news/so-what-it-you-exactly-do-part-four-monitoring/">Production Linux monitoring: what to watch and why</a>

<a href="/news/so-what-it-you-exactly-do-part-five-troubleshooting/">Troubleshooting Linux systems: patterns, evidence, and good notes</a>

<a href="/news/so-what-it-you-exactly-do-part-six-high-availability/">High availability: resilience without unnecessary complexity</a>
</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">This series</div>
  <ul>
<li><a href="/news/so-what-it-you-exactly-do-part-one-continuous-deployment/">Part 1 - Continuous deployment</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-two-config-management/">Part 2 - Config management</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-three-security/">Part 3 - Security</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-four-monitoring/">Part 4 - Monitoring</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-five-troubleshooting/">Part 5 - Troubleshooting</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-six-high-availability/">Part 6 - High availability</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-seven-communication/">Part 7 - Communication</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Original</div>
  <ul>
<li><a href="https://old.mig5.net/content/so-what-it-you-exactly-do-part-seven-communication.html" target="_blank" rel="noopener noreferrer">Original post on old.mig5.net</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need a sysadmin?</div>
  <div class="smallprint mb-3">I do contract sysadmin and automation work and can help with deployment, config management, security, monitoring, troubleshooting, high availability, and the communication that glues it all together.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-chat-dots"></i><span>From the archives</span></div>
    <h1 class="display-6 fw-semibold mb-2">Sysadmin communication: incident updates, plans, and trust</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-16">September 16, 2026</time></p>
    <p class="lead mb-0">Part seven (the conclusion) of a seven-part series from 2016 on what a contract sysadmin does.
This one covers communication: building trust through proactive updates, the incident report
template, the plan and the rollback plan, and a TODO system borrowed from Tom Limoncelli.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-16T15:00:00&#43;10:00">16 September 2026</time>
        </div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">From the archives - originally published in 2016</div>
  A decade on, I do some of this differently - tools, the industry, and the threat landscape have
all moved on. But the underlying ideas have stayed remarkably relevant even ten years later.
</div>

<p>Admittedly there is an irony in having a massive delay between the last article in this series and this one, aptly to be regarding 'communication' :) Sorry for the silence. It seems some of these articles have inspired some interest in the work I do, and as a result, I have been running off my feet with new business. There are worse problems to have.</p>
<p>Working as a consulting sysadmin kind of flips the sysadmin stereotype on its head. A consultant can't really operate in the basement of some office complex, rarely emerging to see the light of day, and bark at colleagues and customers alike. As a consultant I work for many organisations and so need to be available and responsive to all of them. And I have to (try and) be polite, even if I (often?) fail!</p>
<p>In addition, modern agencies have broken down a lot of those stereotypical office cultures. The sysadmin is a vital member of a team who is far more 'customer-facing' than he or she used to be. For example, large parts of my work involve preparing the technical components of RFPs to assist my customers getting new business themselves.</p>
<p>All of this points to the heightened importance of good communication as a vital skill for sysadmins, or any sort of consultant.</p>
<h2 id="building-trust---proactive-communication-in-a-reactive-role">Building trust - proactive communication in a reactive role</h2>
<p>It's not all beautiful in 'modern agencies'. Despite latest DevOps crazes, the sysadmin still has a largely reactive role, simply because someone has to. Automatic response to issues isn't always possible. Frequently, even post-mortem analysis of an incident requires the subtlety of a human brain to detect patterns or filter through irrelevancies. See the <a href="/news/so-what-it-you-exactly-do-part-five-troubleshooting/">previous article on Troubleshooting</a>
 for more on this.</p>
<p>The reality of a reactive role means that someone has to communicate about an issue. This might be simply notifying constituents that there <em>is</em> a problem. It might be providing an ETA, or regular progress updates if the issue is taking longer than desired to resolve. And it might need an incident report afterward. In all of these cases, the sysadmin would do well to expect questions - sometimes justified, sometimes irrational, sometimes inane. Now the sysadmin has two problems :) Dealing with a technical problem, and dealing with a people problem.</p>
<blockquote>
<p>Picard management tip: Most conflict arises out of confusion. Clarify quickly before weapons power up.</p>
<ul>
<li>Picard Tips (@PicardTips) <a href="https://twitter.com/PicardTips/status/614481334447112192" target="_blank" rel="noopener noreferrer">June 26, 2015</a>
</li>
</ul>
</blockquote>
<p>More and more, I find my work revolves around the ability to communicate my way through these issues.</p>
<p>I see frequent on-boarding of new customers at different agencies, and there often seems an initial period of panic and mistrust by said new customer. They may have been burnt by previous hosting experience. They may simply be a real piece of work and hence why their hosting company dropped them. The slightest issue or even a simple routine task is taken out of proportion almost immediately by the panicky client rep. Or, it's an idea by them that is already whipped into a panicked frenzy before it's even presented to you.</p>
<p>This is all about a lack of trust, largely because not enough time has passed to develop that trust.</p>
<p>It would be nice if customers assumed the benefit of the doubt: that you know what you're doing and that you actually want to help! Sadly this often isn't the case. The customer perhaps feels they can't give the benefit of the doubt because the 'risk' (or really, the personal attachment) to the project feels too great to take. A vicious cycle ensues.</p>
<p>Effective communication can fix all this.</p>
<p>The sysadmin operates at the 'pointy' end of risk, as someone recently described to me, where things can and do go wrong. It only follows that the sysadmin then witnesses or experiences directly, the angst of a client. Optimism, diligence, even humour through frequent communication will reassure the customer. Eventually through a 'broken record player' repeated cycle of tasks with a majority of successful results, strung together with consistent communication, trust is established.</p>
<h2 id="consistency-communicating-about-incidents">Consistency: communicating about incidents</h2>
<p>Cross-reference historical tickets with new issues to link similar or related tasks. This also reassures the customer that you are paying deep attention not just to their application, but to the history shared between you both. It also helps you in future when trying to debug something that has happened before.</p>
<p>I am a big fan of the incident report. It might just be an e-mail, or a ticket, or an entry in a spreadsheet or other system. It need only be based on a consistent template, like the following:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">Start time: YYYY-MM-DD HH:MM
</span></span><span class="line"><span class="cl">End time: YYYY-MM-DD HH:MM
</span></span><span class="line"><span class="cl">Duration: (diff between the above)
</span></span><span class="line"><span class="cl">Discovered by:
</span></span><span class="line"><span class="cl">Resolved by:
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Summary: [ONE-LINE SUMMARY OF ISSUE]
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Details:
</span></span><span class="line"><span class="cl">At HH:MM the monitoring system alerted on-call engineers to an issue with the BLAH BLAH.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Upon investigation, it was apparent that BLAH BLAH had caused a BLAH BLAH due to a misconfiguration in the BLAH.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Engineers applied an on-the-fly change to the BLAH and restarted the service.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Normal service returned shortly thereafter.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Mitigation:
</span></span><span class="line"><span class="cl">Developers and system engineers are investigating the ideal configuration which will be then folded into config management to ensure this issue doesn&#39;t re-occur.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">If you have any queries about this incident, please contact BLAH BLAH.
</span></span></code></pre></div><p>My instinct is to provide as much detail as possible in these reports. However, I learned a year or two into my career that less-technical customers are also easily overwhelmed by the content of incident reports. Try and keep them consistent (this is why a template helps) so they are used to knowing where to jump to if they want a summary or a detailed synopsis. It is OK to provide a technical explanation in the details, but avoid asking questions or placing blame. Cause and effect is the goal.</p>
<p>I also try not to stress about any Mitigation details. We are human. Sometimes problems are hard to understand. Sometimes they even need to occur again but not at 3AM, for us to analyse in real time. It is OK to state that engineers are 'continuing to monitor and investigate the root cause.' Ideally once you figure it out, you can update the incident report later.</p>
<h2 id="finding-the-fine-line-too-much-vs-not-enough">Finding the fine line: too much vs not enough</h2>
<p>You can, of course, get into the opposite sort of trouble if you withhold too much technical info. It can depend on the situation.</p>
<p>One example where <em>more</em> information is useful, is in the wake of some sort of security issue (such as Heartbleed, POODLE, Logjam, Shellshock etc). Even if your systems are not affected or you have quickly mitigated the risk, customers can make public announcements (or statements for auditors) that were simply incorrect because you told them 'everything is fine' when you really mean 'we're going through the process of fixing things up now, I'll let you know when we're all clear'. A customer might proclaim publicly 'Great news, we're not affected by [EVIL AND CATCHY-NAMED VULN]!'. In certain circles, particularly infosec which seems particularly plagued by immaturity, this is like asking for trouble.</p>
<p>In other words: sometimes you need to give more information than you normally would. Think of it as not explaining to your customer, but explaining to the people who are going to ask your customer (i.e their customers, their auditors, the public if this has a significant public image/brand etc). Or simply protecting your customer from themselves...</p>
<p>This is hard, but effective communication takes practice.</p>
<h2 id="the-plan-and-the-rollback-plan">The plan and the rollback plan</h2>
<p>When planning a particularly high risk task like a big deployment or a migration, you know things can go wrong 3/4 of the way through, which require some sort of backing out.</p>
<p>I find it helps to write a step-by-step plan for the task itself, but even more importantly, a rollback plan at the end. When things go wrong, it can be stressful. Your brain doesn't work as well when stressed as it does when calm, because it's receiving conflicting instinctive instructions to deal with the threat. Having a rollback plan doesn't just fix the issue: it allows you to not have to think, taking even more pressure off your brain so it begins to calm down again.</p>
<p>Communicate both the plan and the rollback plan to your team prior to commencement of the task. Get a second opinion on what sort of tests should demonstrate a successful completion of the task. Find out if people need to be notified in the event of rollback.</p>
<p>Other people will have good ideas that you didn't expect, which you can then factor into your plan.</p>
<p>You might just find that this communication has spotted something that, when added or corrected, means not having to rollback at all!</p>
<h2 id="customers-hate-silence">Customers hate silence</h2>
<p>Frequently I see a customer ask a question or lodge a ticket for something to be done, and the engineer or the developer commences work on it but doesn't say so. Any number of issues can later occur (such as being dragged into meetings) that leave the customer feeling that no-one has even noticed their request. Behaviour gets irate (especially if trust is not yet established, and thus expectations and realities are not in proportion).</p>
<p>One customer I know might assign me something to deploy during an agreed routine 'maintenance window' that is at the end of the week. I know I don't have to do anything for 5 days. But the customer would get nervous that I have not even seen the task and that it might be missed. This didn't make them intolerable, but it didn't take much effort or time for me to acknowledge the task in the ticket and put some anxieties at rest. It would probably only work in my favour to have a relaxed client if I had to give them some bad news about some other problem an hour later :)</p>
<p>Without being overly verbose, it can help to leave a quick note &quot;No worries, I'm just jumping into a meeting but I think this is a quick fix. I'll look at it shortly/give it to Alice to take a look while I step out&quot;. Alice might reply &quot;I hit a bit of an obstacle here because [something you didn't even remember as you were thinking about your imminent meeting]&quot;. Comes back to you: &quot;oh, you're right, I forgot about that. Sorry Client, this will take a little longer than I initially thought. I've run out of time today but it's on my TODO for tomorrow.&quot;</p>
<h2 id="flood">Flood</h2>
<p>Excessive communication is of course too much, and creates a 'switch-off' mode where you are either ignored, not taken seriously, or your information is misinterpreted due to sheer volume.</p>
<p>Again, this is just about practice, balance, and being dynamic. Different people require different amounts of communication to either keep them happy or make them understand something important.</p>
<p>I would, however, say that you are less commonly going to run into issues communicating superfluously than you are by not communicating enough.</p>
<h2 id="the-todo-system">The TODO system</h2>
<p>A few years ago I recall being very overworked and quite inefficient at getting through tasks. I was forgetting some tasks, jumping from some tasks to others before finishing them, and generally making myself and the people who depended on me pretty unhappy.</p>
<p><a href="http://everythingsysadmin.com/" target="_blank" rel="noopener noreferrer">Tom Limoncelli&#39;s</a>
 book <em><a href="http://www.amazon.com/Management-System-Administrators-Thomas-Limoncelli/dp/0596007833" target="_blank" rel="noopener noreferrer">&#39;Time Management for System Administrators&#39;</a>
</em>, which is quite small (important for people short on time!) and excellent, features a TODO 'cycle' approach which really helped me rotate through tasks over weeks, moving things I didn't finish one day to the top of tomorrow's list. But also significant was his recommendation to always take a note of any new requests. We all know people have an annoying habit of sidling up to your desk while you're busy and asking for something. Sometimes demanding they use the ticket system is just a waste of your own time: you know these people won't leave til they are convinced you'll help them.</p>
<p>You would be surprised at the difference it can make to respond &quot;OK, sure. I don't have time right this second as I need to finish this other task. But I'll note this down so it doesn't get forgotten.&quot; Ensure the person sees for themselves that you are writing it down. This absurd procedure is validating for them your acknowledgement not just of their issue, but of them personally. It's also a lot less abrasive than barking &quot;why can't you open a TICKET???!&quot;</p>
<p>This is trickier in a distributed team, but that's where your ticket system comes in handy (and you were only going to add that item to the ticket system later right?). Assign yourself a ticket and ensure the requester is notified of it, if your ticket system supports it. Who knows: they might even think they can save some time/effort in future and just make the ticket themselves. Stranger things have and will happen!</p>
<h2 id="summing-up-this-series">Summing up (this series)</h2>
<p>You can take all the above with a grain of salt since it took so long for me to communicate this blog post! What do I know?</p>
<p>Well, I don't know a lot, but I hope this series has been interesting to other sysadmins, as well as anyone working in digital agencies, or even consultants of any sort. I think the nature of consultancy carries a lot of common elements across a wide range of job descriptions, technologies and industries. This is just a snapshot of how it fills up most of my time.</p>
<p>If you have questions or feedback you are welcome to comment here, in any of the previous articles in the series, or via the <a href="/contact/">contact form</a>
.</p>
<p>Thanks!</p>
<h2 id="previous-articles-in-this-series">Previous articles in this series</h2>
<p><a href="/news/so-what-it-you-exactly-do-part-one-continuous-deployment/">Continuous deployment for safer production changes</a>

<a href="/news/so-what-it-you-exactly-do-part-two-config-management/">Linux configuration management: eliminating snowflake servers</a>

<a href="/news/so-what-it-you-exactly-do-part-three-security/">Linux infrastructure security as risk management</a>

<a href="/news/so-what-it-you-exactly-do-part-four-monitoring/">Production Linux monitoring: what to watch and why</a>

<a href="/news/so-what-it-you-exactly-do-part-five-troubleshooting/">Troubleshooting Linux systems: patterns, evidence, and good notes</a>

<a href="/news/so-what-it-you-exactly-do-part-six-high-availability/">High availability: resilience without unnecessary complexity</a>
</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">This series</div>
  <ul>
<li><a href="/news/so-what-it-you-exactly-do-part-one-continuous-deployment/">Part 1 - Continuous deployment</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-two-config-management/">Part 2 - Config management</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-three-security/">Part 3 - Security</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-four-monitoring/">Part 4 - Monitoring</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-five-troubleshooting/">Part 5 - Troubleshooting</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-six-high-availability/">Part 6 - High availability</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-seven-communication/">Part 7 - Communication</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Original</div>
  <ul>
<li><a href="https://old.mig5.net/content/so-what-it-you-exactly-do-part-seven-communication.html" target="_blank" rel="noopener noreferrer">Original post on old.mig5.net</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need a sysadmin?</div>
  <div class="smallprint mb-3">I do contract sysadmin and automation work and can help with deployment, config management, security, monitoring, troubleshooting, high availability, and the communication that glues it all together.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>High availability: resilience without unnecessary complexity</title>
      <link>https://mig5.net/news/so-what-it-you-exactly-do-part-six-high-availability/</link>
      <pubDate>Wed, 16 Sep 2026 14:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/so-what-it-you-exactly-do-part-six-high-availability/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-diagram-3"></i><span>From the archives</span></div>
    <h1 class="display-6 fw-semibold mb-2">High availability: resilience without unnecessary complexity</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-16">September 16, 2026</time></p>
    <p class="lead mb-0">Part six of a seven-part series from 2016 on what a contract sysadmin does. This one covers high
availability: tiers of service, single points of failure across database/file/load-balancer
layers, quorum and STONITH, and the argument that HA can breed the failure it's meant to prevent.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-16T14:00:00&#43;10:00">16 September 2026</time>
        </div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">From the archives - originally published in 2016</div>
  A decade on, I do some of this differently - tools, the industry, and the threat landscape have
all moved on. But the underlying ideas have stayed remarkably relevant even ten years later.
</div>

<p>In this segment of 'what do you do, sysadmin?' I'll cover the area of high availability, building infrastructure that can withstand failure, and preparing for worst case disasters.</p>
<h2 id="my-clients-site-the-most-important-thing-ever">My (client's) site: the most important thing ever</h2>
<p>It seems High Availability, for me at least, is duking it out with Security as the 'hot area' of sysadmin in recent years. Every agency's customer seems convinced of the importance of their application and how vital it is to the rest of us. As a result, the slightest bit of downtime is viewed as abhorrent. This can be a vicious cycle of having supported an application to the point that it has been very stable, and the customer has got used to the fact that problems are rare. Or it's having been fed 'cloud' FUD from somewhere else.</p>
<p>Fact of the matter is, unless you are operating major payment systems (i.e you are a bank), or it's life-threatening, I don't honestly believe anyone needs high availability. Nonetheless, I seem to be regularly setting such environments up.</p>
<h2 id="tiers-of-tears">Tiers of tears</h2>
<p>High Availability, as you probably know, from a sysadmin perspective is about breaking your infrastructure in to 'tiers' of service (HTTP layer, Database layer, Volatile File layer, Search layer etc), and then trying to ensure that each of those layers can withstand failure (keep running at some level so as to ensure your application continues to behave as expected). Understanding the layers or tiers of service is vital in terms of identifying how your application can scale (though scalability is a separate topic) and where the weak points are.</p>
<p>In understanding how the tiers are structured, it also requires an understanding of the application itself works. Drupal, for example, totally depends on its database layer. The codebase itself tends to be somewhat static (doesn't change except on deployments or upgrades). But it also has volatile storage area (the 'files' directory), where uploaded data goes. Awkwardly, that volatile data tends to live within the same file structure as the static codebase (most people with some level of sophisticated deployment, will ensure that the 'files' area lives outside of the codebase filesystem for this reason).</p>
<p>Wordpress and Magento are very similar with their 'uploads' and 'media' directories respectively (and Magento's 'var' directory also).</p>
<h2 id="single-points-of-failure">Single Points of Failure</h2>
<p>Once you start to understand how your application is structured, you begin to recognise its 'weak' points. We call this the act of identifying single points of failure. A database is a single point of failure in Drupal, because it can't function without it. An inability to access or upload files won't bring the site down, but will probably make a right mess of its appearance, as well as the loss of expected functionality (uploading), so we may as well consider this a single point of failure too.</p>
<p>And of course, at a wider level, the server providing the HTTP service is also a single point of failure.</p>
<p>High availability is about mitigating Single Points of Failure, typically through load-balancing. Ironically, there are often many single points of failure :)</p>
<h3 id="databases">Databases</h3>
<p>Aside from putting load balancers in front of the web apps to distribute the requests and dynamically pull web apps in and out of the cluster when they fail/recover, database replication is probably the next focus of peoples' attention for 'high availability'. Proper database H/A extends beyond creating read-only slaves (which for Drupal, which likes to write a lot of the time, is really only useful for <em>scaling</em> reads, not H/A), and ensuring that you can write to (at least) another master if your primary master goes down.</p>
<p>It is possible to set up multi-master MySQL replication natively - but you have to have a method with which to switch automatically to the other master if the first one fails. You can do this by assigning a 'floating' IP address to the active master, with old stable tools (which, on the other hand, aren't actively developed anymore) like <a href="http://mysql-mmm.org/doku.php" target="_blank" rel="noopener noreferrer">MySQL-MMM</a>
. Or you can be a bit more 'modern' in your approach and look at solutions such as Percona XtraDB Cluster - though these have <a href="https://www.percona.com/doc/percona-xtradb-cluster/5.5/limitation.html" target="_blank" rel="noopener noreferrer">caveats you should be aware of</a>
, especially regarding InnoDB deadlocks.</p>
<h3 id="file-servers">File servers</h3>
<p>The biggest issue I see with H/A solutions, is the file layer. It seems common to just throw the 'volatile files' on NFS which all web apps can mount with an NFS client. This does work, but it leaves the NFS as a single point of failure. If it goes down, so do those mount points, which go stale and drive up a race condition of locked up processes on the web servers (and increasing CPU load).</p>
<p>There are solutions for making your NFS highly available, such as using DRBD with Pacemaker and Corosync as a cluster which can tolerate one of the servers failing (even more if you use a distributed filesystem like OCFS). There's also Gluster, which I have always had inevitable problems with and so have avoided it as much as I can. Other solutions I have yet to look at include Ceph. If you have a lot of money, you can look at something like a NetApp cluster, which I have been 'on the other side of' (never have had direct access to one myself, just the NFS mount point) and it seemed quite stable.</p>
<p>For now, most of the Drupal sites I use have quite a 'dumb' files layer which isn't being abused all that much - just occasional uploads of single, reasonably sized files. For me, a DRBD+Pacemaker+Corosync+NFS solution, so long as I have quorum and a STONITH solution (more on that below), gives me the amount of control and stability I require.</p>
<h3 id="load-balancers">Load balancers</h3>
<p>The same goes for load balancers as it does for file servers: a single loadbalancer gives you H/A at the web app layer, but itself remains a single point of failure. It doesn't matter if your backend web servers are all healthy: they are unreachable if your loadbalancer goes down. So make sure you have H/A at your load balancer layer too. Services like Linode's NodeBalancer and Rackspace's Cloud Load Balancers have this built in.</p>
<p>If you are rolling your own, i.e with <a href="http://www.haproxy.org/" target="_blank" rel="noopener noreferrer">HAproxy</a>
, you can use the 'floating IP' approach again, with something like <a href="http://www.keepalived.org/" target="_blank" rel="noopener noreferrer">keepalived</a>
 to ensure your 'passive' HAproxy LB can become the 'active' LB by claiming the floating IP (which presumably your site resolves to via DNS, or is behind a NAT that does). The beauty, at least in cases where your session management is elsewhere in the stack, is that you don't have to worry about state enforcement or integrity (i.e replication).</p>
<h2 id="majority-rules-the-simple-power-of-quorum">Majority rules: the simple power of quorum</h2>
<p>'Back in the day' we used to roll out DRBD 2-node clusters with Heartbeat. Frequently we saw 'death matches' when connectivity was severed between the two nodes and they fought it out to claim the 'Primary' role. This is quite disastrous for data integrity and I won't go into detail here - google 'STONITH' and you'll see what I mean.</p>
<p>Essentially, STONITH, an acronym for 'Shoot The Other Node In The Head' was invented to help with this problem. A crude process, it was a pluggable way of tearing down your infrastructure in the event of such a disaster, based on the philosophy that it was better to lose high availability than risk integrity issues. And it's true.</p>
<p>STONITH is still vital in such setups, but a major mitigating factor of many of the <em>scenarios</em> that can require STONITH is quorum. In most clusters, the communication layer is regularly making decisions about the health of the environment and whether all parties agree on who is the 'primary', who is the 'secondary' and so on. Pacemaker does this, as does the Percona XtraDB Cluster. Quorum simply means deliberately 'imbalancing' this decision-making behaviour so that there is always a majority consensus.</p>
<p>For example, in the past, if both nodes in a two-node cluster thought the other node was down, it was impossible for the node to know if it was correct or not. With quorum, it should be possible to get consensus on who is right about which node is down.</p>
<p>In the case of Pacemaker - even where it is backing a DRBD two-node cluster - you can accomplish this by having a third node in perpetual 'standby' mode. It will still contribute to quorum, while you don't have to worry about it trying to claim the actual resources (e.g it doesn't have to run DRBD).</p>
<p>Nonetheless, even with Quorum, there can still be scenarios, or more specifically a series of events which can result in a STONITH like situation. So a STONITH device is still necessary and I would not deploy without one, regardless of how crude it is.</p>
<h2 id="plan-for-irony-high-availability-breeds-failure">Plan for irony: high availability breeds failure</h2>
<p>My biggest gripe with H/A, other than the fact that I don't believe most people really need it, is that as we have seen above, it is no quick fix. Proper H/A requires identifying all the weak single points of failure and mitigating it. This inevitably breeds complexity into the overall architecture. Just look again at the tiers of service described above. It very quickly becomes far from being a simple web app environment. The problem with this is that complexity, the amount of moving parts, <em>increases</em> the chance of some component failing.</p>
<p>Sure, this is not be a problem, you might say: we have accounted for single points of failure, therefore we can <em>tolerate</em> such failure. And this is partially true. But even small failure can (should) result in monitoring systems becoming aware, incident reports being generated, investigation and response. It should also be noted that in many cases, failure of a component can result in the loss of H/A, in that should the remaining node (say, the passive HAproxy loadbalancer which is now primary) fail as well, then you lose your H/A.</p>
<p>In other words, in common H/A environments you can only tolerate failure for a short period if you want to reclaim your redundancy. And due to the complexity, you should expect to see such failure occur more often than you might with a single server.</p>
<p>While you are in a mindset of availability and failure, there are, of course, other problematic areas to consider. Power failure at the data centre. Hypervisor compromise. Maybe the Autonomous System (AS) advertising the IP range your infrastructure is on, gets hijacked by rogue BGP and even though your stuff is healthy, you simply can't route to it. This opens up a whole other kettle of fish - that being Disaster Recovery, and having hot spare capacity ideally with a totally other hosting provider. I have <a href="https://old.mig5.net/content/principles-building-basic-disaster-recovery-zone.html" target="_blank" rel="noopener noreferrer">written about some basic principles of disaster recovery design earlier</a>
.</p>
<h2 id="sometimes-read-only-slaves-are-ok">Sometimes read-only slaves are OK</h2>
<p>This article has focused on Drupal-style applications and what is demanded of H/A for it to work. And I dismissed read-only slaves at a database layer as part of this.</p>
<p>However, of course there are systems where this is enough. For example, if you depend on LDAP for your infrastructure, it is pretty trivial to set up LDAP slave replicators in read-only mode - it's largely 'set and forget'. This is enough for me because writes to LDAP (i.e changing of a password, or adding a new user) are less frequent, therefore we might be able to tolerate a period where the LDAP writer (master) is down, while still not losing the availability of the LDAP database in the meantime (thanks to the slaves).</p>
<p>In other words, you have to think about the nature of the application, how it is used, and what level of 'availability' loss you can tolerate.</p>
<h2 id="summing-up-swallow-your-clients-pride">Summing up: swallow your (client's) pride</h2>
<p>When I see really basic websites that aren't really all that important except to the owner's flimsy, needy ego, suffer complexity and increase the likelihood of failure due to a demand for H/A, I get really annoyed. Often it means the narrow-minded project manager or pre-sales are fixated on dredging money out of the client no matter what (or are already frightened of a high-strung panicky client), and therefore don't challenge the client when they make unreasonable requests for H/A during the negotiation phase. It gets bolted into the contract and then it lands in the engineer's lap, just like all the other bad design decisions do for the frontend developer.</p>
<p>Don't misunderstand me: this is not a hatred of H/A - and some solutions really do justify it and should design for it - but a frustration with the typical problems associated with web development agencies: the divide between cash/commission-driven account managers/project managers/pre-sales and the poor lackies who have to actually do the engineering at the end of the day. We need a 'DevOps' for sales and tech teams, basically. CashOps? :)</p>
<p>I would encourage anyone with the ability to influence the decision making process with a client early on, to try and challenge the client to explain their perceived need for H/A. Describe to them how it can breed complexity into an otherwise simple application that can actually result in the problems they are trying to avoid.</p>
<p>Preach the beauty of simplicity and if you can make them see through their pride, they may just realise their site can tolerate the 20 minutes of outage at 3AM (which no-one was around to notice) before the on-call engineer wakes up and restarts Apache before going to back to bed.</p>
<p>You won't always win. But a good client will end up respecting you for challenging them in terms of 'the right thing to do' - even if they disagree.</p>
<h2 id="coming-up">Coming up...</h2>
<p>The concluding piece: <a href="/news/so-what-it-you-exactly-do-part-seven-communication/">Part 7, communication</a>
: the most important part of my job.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">This series</div>
  <ul>
<li><a href="/news/so-what-it-you-exactly-do-part-one-continuous-deployment/">Part 1 - Continuous deployment</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-two-config-management/">Part 2 - Config management</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-three-security/">Part 3 - Security</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-four-monitoring/">Part 4 - Monitoring</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-five-troubleshooting/">Part 5 - Troubleshooting</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-six-high-availability/">Part 6 - High availability</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-seven-communication/">Part 7 - Communication</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Original</div>
  <ul>
<li><a href="https://old.mig5.net/content/so-what-it-you-exactly-do-part-six-high-availability.html" target="_blank" rel="noopener noreferrer">Original post on old.mig5.net</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need a sysadmin?</div>
  <div class="smallprint mb-3">I do contract sysadmin and automation work and can help with deployment, config management, security, monitoring, troubleshooting, high availability, and the communication that glues it all together.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-diagram-3"></i><span>From the archives</span></div>
    <h1 class="display-6 fw-semibold mb-2">High availability: resilience without unnecessary complexity</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-16">September 16, 2026</time></p>
    <p class="lead mb-0">Part six of a seven-part series from 2016 on what a contract sysadmin does. This one covers high
availability: tiers of service, single points of failure across database/file/load-balancer
layers, quorum and STONITH, and the argument that HA can breed the failure it's meant to prevent.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-16T14:00:00&#43;10:00">16 September 2026</time>
        </div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">From the archives - originally published in 2016</div>
  A decade on, I do some of this differently - tools, the industry, and the threat landscape have
all moved on. But the underlying ideas have stayed remarkably relevant even ten years later.
</div>

<p>In this segment of 'what do you do, sysadmin?' I'll cover the area of high availability, building infrastructure that can withstand failure, and preparing for worst case disasters.</p>
<h2 id="my-clients-site-the-most-important-thing-ever">My (client's) site: the most important thing ever</h2>
<p>It seems High Availability, for me at least, is duking it out with Security as the 'hot area' of sysadmin in recent years. Every agency's customer seems convinced of the importance of their application and how vital it is to the rest of us. As a result, the slightest bit of downtime is viewed as abhorrent. This can be a vicious cycle of having supported an application to the point that it has been very stable, and the customer has got used to the fact that problems are rare. Or it's having been fed 'cloud' FUD from somewhere else.</p>
<p>Fact of the matter is, unless you are operating major payment systems (i.e you are a bank), or it's life-threatening, I don't honestly believe anyone needs high availability. Nonetheless, I seem to be regularly setting such environments up.</p>
<h2 id="tiers-of-tears">Tiers of tears</h2>
<p>High Availability, as you probably know, from a sysadmin perspective is about breaking your infrastructure in to 'tiers' of service (HTTP layer, Database layer, Volatile File layer, Search layer etc), and then trying to ensure that each of those layers can withstand failure (keep running at some level so as to ensure your application continues to behave as expected). Understanding the layers or tiers of service is vital in terms of identifying how your application can scale (though scalability is a separate topic) and where the weak points are.</p>
<p>In understanding how the tiers are structured, it also requires an understanding of the application itself works. Drupal, for example, totally depends on its database layer. The codebase itself tends to be somewhat static (doesn't change except on deployments or upgrades). But it also has volatile storage area (the 'files' directory), where uploaded data goes. Awkwardly, that volatile data tends to live within the same file structure as the static codebase (most people with some level of sophisticated deployment, will ensure that the 'files' area lives outside of the codebase filesystem for this reason).</p>
<p>Wordpress and Magento are very similar with their 'uploads' and 'media' directories respectively (and Magento's 'var' directory also).</p>
<h2 id="single-points-of-failure">Single Points of Failure</h2>
<p>Once you start to understand how your application is structured, you begin to recognise its 'weak' points. We call this the act of identifying single points of failure. A database is a single point of failure in Drupal, because it can't function without it. An inability to access or upload files won't bring the site down, but will probably make a right mess of its appearance, as well as the loss of expected functionality (uploading), so we may as well consider this a single point of failure too.</p>
<p>And of course, at a wider level, the server providing the HTTP service is also a single point of failure.</p>
<p>High availability is about mitigating Single Points of Failure, typically through load-balancing. Ironically, there are often many single points of failure :)</p>
<h3 id="databases">Databases</h3>
<p>Aside from putting load balancers in front of the web apps to distribute the requests and dynamically pull web apps in and out of the cluster when they fail/recover, database replication is probably the next focus of peoples' attention for 'high availability'. Proper database H/A extends beyond creating read-only slaves (which for Drupal, which likes to write a lot of the time, is really only useful for <em>scaling</em> reads, not H/A), and ensuring that you can write to (at least) another master if your primary master goes down.</p>
<p>It is possible to set up multi-master MySQL replication natively - but you have to have a method with which to switch automatically to the other master if the first one fails. You can do this by assigning a 'floating' IP address to the active master, with old stable tools (which, on the other hand, aren't actively developed anymore) like <a href="http://mysql-mmm.org/doku.php" target="_blank" rel="noopener noreferrer">MySQL-MMM</a>
. Or you can be a bit more 'modern' in your approach and look at solutions such as Percona XtraDB Cluster - though these have <a href="https://www.percona.com/doc/percona-xtradb-cluster/5.5/limitation.html" target="_blank" rel="noopener noreferrer">caveats you should be aware of</a>
, especially regarding InnoDB deadlocks.</p>
<h3 id="file-servers">File servers</h3>
<p>The biggest issue I see with H/A solutions, is the file layer. It seems common to just throw the 'volatile files' on NFS which all web apps can mount with an NFS client. This does work, but it leaves the NFS as a single point of failure. If it goes down, so do those mount points, which go stale and drive up a race condition of locked up processes on the web servers (and increasing CPU load).</p>
<p>There are solutions for making your NFS highly available, such as using DRBD with Pacemaker and Corosync as a cluster which can tolerate one of the servers failing (even more if you use a distributed filesystem like OCFS). There's also Gluster, which I have always had inevitable problems with and so have avoided it as much as I can. Other solutions I have yet to look at include Ceph. If you have a lot of money, you can look at something like a NetApp cluster, which I have been 'on the other side of' (never have had direct access to one myself, just the NFS mount point) and it seemed quite stable.</p>
<p>For now, most of the Drupal sites I use have quite a 'dumb' files layer which isn't being abused all that much - just occasional uploads of single, reasonably sized files. For me, a DRBD+Pacemaker+Corosync+NFS solution, so long as I have quorum and a STONITH solution (more on that below), gives me the amount of control and stability I require.</p>
<h3 id="load-balancers">Load balancers</h3>
<p>The same goes for load balancers as it does for file servers: a single loadbalancer gives you H/A at the web app layer, but itself remains a single point of failure. It doesn't matter if your backend web servers are all healthy: they are unreachable if your loadbalancer goes down. So make sure you have H/A at your load balancer layer too. Services like Linode's NodeBalancer and Rackspace's Cloud Load Balancers have this built in.</p>
<p>If you are rolling your own, i.e with <a href="http://www.haproxy.org/" target="_blank" rel="noopener noreferrer">HAproxy</a>
, you can use the 'floating IP' approach again, with something like <a href="http://www.keepalived.org/" target="_blank" rel="noopener noreferrer">keepalived</a>
 to ensure your 'passive' HAproxy LB can become the 'active' LB by claiming the floating IP (which presumably your site resolves to via DNS, or is behind a NAT that does). The beauty, at least in cases where your session management is elsewhere in the stack, is that you don't have to worry about state enforcement or integrity (i.e replication).</p>
<h2 id="majority-rules-the-simple-power-of-quorum">Majority rules: the simple power of quorum</h2>
<p>'Back in the day' we used to roll out DRBD 2-node clusters with Heartbeat. Frequently we saw 'death matches' when connectivity was severed between the two nodes and they fought it out to claim the 'Primary' role. This is quite disastrous for data integrity and I won't go into detail here - google 'STONITH' and you'll see what I mean.</p>
<p>Essentially, STONITH, an acronym for 'Shoot The Other Node In The Head' was invented to help with this problem. A crude process, it was a pluggable way of tearing down your infrastructure in the event of such a disaster, based on the philosophy that it was better to lose high availability than risk integrity issues. And it's true.</p>
<p>STONITH is still vital in such setups, but a major mitigating factor of many of the <em>scenarios</em> that can require STONITH is quorum. In most clusters, the communication layer is regularly making decisions about the health of the environment and whether all parties agree on who is the 'primary', who is the 'secondary' and so on. Pacemaker does this, as does the Percona XtraDB Cluster. Quorum simply means deliberately 'imbalancing' this decision-making behaviour so that there is always a majority consensus.</p>
<p>For example, in the past, if both nodes in a two-node cluster thought the other node was down, it was impossible for the node to know if it was correct or not. With quorum, it should be possible to get consensus on who is right about which node is down.</p>
<p>In the case of Pacemaker - even where it is backing a DRBD two-node cluster - you can accomplish this by having a third node in perpetual 'standby' mode. It will still contribute to quorum, while you don't have to worry about it trying to claim the actual resources (e.g it doesn't have to run DRBD).</p>
<p>Nonetheless, even with Quorum, there can still be scenarios, or more specifically a series of events which can result in a STONITH like situation. So a STONITH device is still necessary and I would not deploy without one, regardless of how crude it is.</p>
<h2 id="plan-for-irony-high-availability-breeds-failure">Plan for irony: high availability breeds failure</h2>
<p>My biggest gripe with H/A, other than the fact that I don't believe most people really need it, is that as we have seen above, it is no quick fix. Proper H/A requires identifying all the weak single points of failure and mitigating it. This inevitably breeds complexity into the overall architecture. Just look again at the tiers of service described above. It very quickly becomes far from being a simple web app environment. The problem with this is that complexity, the amount of moving parts, <em>increases</em> the chance of some component failing.</p>
<p>Sure, this is not be a problem, you might say: we have accounted for single points of failure, therefore we can <em>tolerate</em> such failure. And this is partially true. But even small failure can (should) result in monitoring systems becoming aware, incident reports being generated, investigation and response. It should also be noted that in many cases, failure of a component can result in the loss of H/A, in that should the remaining node (say, the passive HAproxy loadbalancer which is now primary) fail as well, then you lose your H/A.</p>
<p>In other words, in common H/A environments you can only tolerate failure for a short period if you want to reclaim your redundancy. And due to the complexity, you should expect to see such failure occur more often than you might with a single server.</p>
<p>While you are in a mindset of availability and failure, there are, of course, other problematic areas to consider. Power failure at the data centre. Hypervisor compromise. Maybe the Autonomous System (AS) advertising the IP range your infrastructure is on, gets hijacked by rogue BGP and even though your stuff is healthy, you simply can't route to it. This opens up a whole other kettle of fish - that being Disaster Recovery, and having hot spare capacity ideally with a totally other hosting provider. I have <a href="https://old.mig5.net/content/principles-building-basic-disaster-recovery-zone.html" target="_blank" rel="noopener noreferrer">written about some basic principles of disaster recovery design earlier</a>
.</p>
<h2 id="sometimes-read-only-slaves-are-ok">Sometimes read-only slaves are OK</h2>
<p>This article has focused on Drupal-style applications and what is demanded of H/A for it to work. And I dismissed read-only slaves at a database layer as part of this.</p>
<p>However, of course there are systems where this is enough. For example, if you depend on LDAP for your infrastructure, it is pretty trivial to set up LDAP slave replicators in read-only mode - it's largely 'set and forget'. This is enough for me because writes to LDAP (i.e changing of a password, or adding a new user) are less frequent, therefore we might be able to tolerate a period where the LDAP writer (master) is down, while still not losing the availability of the LDAP database in the meantime (thanks to the slaves).</p>
<p>In other words, you have to think about the nature of the application, how it is used, and what level of 'availability' loss you can tolerate.</p>
<h2 id="summing-up-swallow-your-clients-pride">Summing up: swallow your (client's) pride</h2>
<p>When I see really basic websites that aren't really all that important except to the owner's flimsy, needy ego, suffer complexity and increase the likelihood of failure due to a demand for H/A, I get really annoyed. Often it means the narrow-minded project manager or pre-sales are fixated on dredging money out of the client no matter what (or are already frightened of a high-strung panicky client), and therefore don't challenge the client when they make unreasonable requests for H/A during the negotiation phase. It gets bolted into the contract and then it lands in the engineer's lap, just like all the other bad design decisions do for the frontend developer.</p>
<p>Don't misunderstand me: this is not a hatred of H/A - and some solutions really do justify it and should design for it - but a frustration with the typical problems associated with web development agencies: the divide between cash/commission-driven account managers/project managers/pre-sales and the poor lackies who have to actually do the engineering at the end of the day. We need a 'DevOps' for sales and tech teams, basically. CashOps? :)</p>
<p>I would encourage anyone with the ability to influence the decision making process with a client early on, to try and challenge the client to explain their perceived need for H/A. Describe to them how it can breed complexity into an otherwise simple application that can actually result in the problems they are trying to avoid.</p>
<p>Preach the beauty of simplicity and if you can make them see through their pride, they may just realise their site can tolerate the 20 minutes of outage at 3AM (which no-one was around to notice) before the on-call engineer wakes up and restarts Apache before going to back to bed.</p>
<p>You won't always win. But a good client will end up respecting you for challenging them in terms of 'the right thing to do' - even if they disagree.</p>
<h2 id="coming-up">Coming up...</h2>
<p>The concluding piece: <a href="/news/so-what-it-you-exactly-do-part-seven-communication/">Part 7, communication</a>
: the most important part of my job.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">This series</div>
  <ul>
<li><a href="/news/so-what-it-you-exactly-do-part-one-continuous-deployment/">Part 1 - Continuous deployment</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-two-config-management/">Part 2 - Config management</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-three-security/">Part 3 - Security</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-four-monitoring/">Part 4 - Monitoring</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-five-troubleshooting/">Part 5 - Troubleshooting</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-six-high-availability/">Part 6 - High availability</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-seven-communication/">Part 7 - Communication</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Original</div>
  <ul>
<li><a href="https://old.mig5.net/content/so-what-it-you-exactly-do-part-six-high-availability.html" target="_blank" rel="noopener noreferrer">Original post on old.mig5.net</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need a sysadmin?</div>
  <div class="smallprint mb-3">I do contract sysadmin and automation work and can help with deployment, config management, security, monitoring, troubleshooting, high availability, and the communication that glues it all together.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>Troubleshooting Linux systems: patterns, evidence, and good notes</title>
      <link>https://mig5.net/news/so-what-it-you-exactly-do-part-five-troubleshooting/</link>
      <pubDate>Wed, 16 Sep 2026 13:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/so-what-it-you-exactly-do-part-five-troubleshooting/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-search"></i><span>From the archives</span></div>
    <h1 class="display-6 fw-semibold mb-2">Troubleshooting Linux systems: patterns, evidence, and good notes</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-16">September 16, 2026</time></p>
    <p class="lead mb-0">Part five of a seven-part series from 2016 on what a contract sysadmin does. This one covers
troubleshooting: pattern recognition, what not to do when an error appears, and the value of a
written 'trouble log'.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-16T13:00:00&#43;10:00">16 September 2026</time>
        </div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">From the archives - originally published in 2016</div>
  A decade on, I do some of this differently - tools, the industry, and the threat landscape have
all moved on. But the underlying ideas have stayed remarkably relevant even ten years later.
</div>

<p>In the <a href="/news/so-what-it-you-exactly-do-part-four-monitoring/">last article of this sysadmin series</a>
, I talked about the importance of monitoring as an insight into infrastructure and application behaviour - something that is hard to overstate. But what good is monitoring if you don't understand what it's telling you? That's where troubleshooting comes in.</p>
<p>I had a hard time trying to work out how to write this piece, precisely because troubleshooting is only part science, and part intuition. The quality of troubleshooting depends on how your brain works. I (of course..) pride myself on my troubleshooting abilities, mostly because I think I'm good at pattern recognition and have a good understanding of how different things behave in certain conditions. This goes beyond, say, counting the number of times an error has appeared in a log, but recognising 'meta' about those entries: what time they appeared in the logs, what else was happening at the time. Whether it coincides with wider problems (e.g upstream issues). What happened after the error and whether that thing was a side-effect of the problem or unrelated. Vice versa for what came before the error and if it caused it.</p>
<h2 id="ghosts-in-the-machine">Ghosts in the machine</h2>
<p>Just today I was troubleshooting a Drupal problem which was reported to me as a file permissions issue, but initially behaved like a Javascript issue. Based on the nature of the error message it became pretty quickly clear to me that</p>
<ul>
<li>a) it was a server-side error being thrown, but</li>
<li>b) it was not permissions, and</li>
<li>c) it was most likely a change in the hosting provider's mod_security rules (false positive on CKeditor actions when publishing a node).</li>
</ul>
<p>I didn't even know the provider employed mod_security, but it was the only thing that I could think of which would explain a 403 on that sort of POST request under those certain conditions. This was all before I had even had the chance to SSH into the server to see the logs that proved it (my client joked that it took a couple hours to get the hosting provider to reset an expired password for me so I could login, but only about 5 minutes for me to determine the cause of the problem).</p>
<p>Some of this is evidence based. Some of it is 'trial and error' or 'process of elimination' technique. But some of it is a vague 'feeling' that I can't quite describe, but that I think simply comes down to a combination of experience and 'thinking right' about an issue.</p>
<p>Another case from last week: a server was reported by Nagios as flapping all over the place - lots of packet loss every 10 minutes or so. I was baffled by the Munin monitoring which seemed to show wild spikes in CPU and RAM for the machine. Suddenly my ghost whispers on a 'hunch' that I am not seeing spikes, but in fact the data of a different server with the same hostname or IP, but a different specification, responding to the Munin request and therefore supplementing its own data onto the same graph. With some ARPing and tcpdump action proving a case of 'martian packets', indeed discovered I had a case of 2 servers on the same network with the same IP assignment (but of course different MAC addresses).</p>
<h2 id="what-not-to-do">What not to do</h2>
<p>Over the years I've seen sysadmins simply not think properly when faced with a problem. Common mistakes end up being:</p>
<ul>
<li>&quot;Maybe it's&quot; - endless hyperbole about theories without actually looking at what the error says</li>
<li>Changing lots of things in an effort to solve the problem - often without checking after making each change, or altering the landscape away from something that can reproduce the problem</li>
<li>Misreading a problem as a different problem because a similar thing has happened before (e.g a network timeout causing backups to fail, so assuming that a corruption occurred &amp; forcing a full backup because that's why the last backup failed)</li>
<li>Solving the problem but misunderstanding how or why - an unusual case that involves not understanding the solution let alone the problem (i.e copy-paste from StackExchange and then going to lunch)</li>
<li>Simply not testing their own theory as to the problem - e.g believing a firewall is blocking a request, but not testing the request in another way to prove it (e.g no tcpdumping on either end to see if packets arrive/ACKs sent back, etc), just giving up instead and escalating the issue</li>
<li>Not keeping or updating a 'Trouble Log' - a searchable diary of problems that occurred and how they were fixed (in case they happen again, as well as to reinforce one's own memory and understanding of the issue by writing it down)</li>
</ul>
<p>Sometimes it's more esoteric and probably down to experience - a junior sysadmin might attempt to run a command and give up waiting if it takes too long, reporting it as a symptom of high load (naturally without backing this up with evidence) or a firewall issue, whereas I can recognise the certain amount of delay characteristic of a DNS issue. It is really hard to teach this sort of thing, and that troubles me because I am trying to do so all the time with junior sysadmins at the agencies I consult to as the 'adhoc senior'!</p>
<p>I have a strong memory of being panicked by a massive network issue at an organisation I worked at when I was a junior. I was all over the place with wild theories as to what was going on, misinterpreting traceroutes, and making dangerous changes to firewalls that only led to other auxiliary problems. The head of the sysadmin team sat with me and forced me to 'think of myself sitting on the packet' (or something along those lines). By trying to imagine moving alongside the packet on its journey to its destination, he was able to get me to find precisely which tier in the stack of routers that was not forwarding the packet as it should have been. This experience was highly influential on my overall approach to troubleshooting, even outside of complex networking (routing and BGP etc), which probably remains my weakest link.</p>
<h2 id="summing-up">Summing up</h2>
<p>I don't have much more to say on this area of sysadmin, but the point of the series is to explain what makes up my day. Effective troubleshooting is an enormous part, and I feel that it, along with communication (which I'll talk about in the concluding piece), are my strongest tools in my sysadmin arsenal, despite neither of those tools being inherently technical. I appreciate the irony of this in what is a very technical profession.</p>
<p>My advice to other sysadmins when it comes to troubleshooting is:</p>
<ul>
<li>Collect evidence: think carefully about what the error is telling you. Don't waste time adding your own theories. This might also avoid the risk of trying solutions found by Googling that are irrelevant and therefore dangerous in their own right</li>
<li>Make small changes in efforts to resolve the issue. If they don't fix the issue, undo your change so as not to introduce new issues</li>
<li>Learn to Google effectively. When I started sysadmin, I didn't have a senior mentor at my dayjob - he hired me and then went on holiday for a couple months. Google was literally all I had</li>
<li>Record everything in a 'Trouble Log' to make next time easier. This can also double as an incident report or addition to your risk register if you have compliance matters to deal with (e.g ISO27001)</li>
</ul>
<p>Could your organisation do with a consultant sysadmin's expert troubleshooting? I work on an ongoing retainer basis, in different capacities (4 to 40 hours a month depending on organisation size), for organisations all round the world who can't justify a full-time sysadmin. <a href="/contact/">Get in touch</a>
.</p>
<h2 id="coming-up">Coming up</h2>
<p>We are nearly done. Two more areas I'd like to talk about:</p>
<p><a href="/news/so-what-it-you-exactly-do-part-six-high-availability/">High availability: resilience without unnecessary complexity</a>
,
<a href="/news/so-what-it-you-exactly-do-part-seven-communication/">Sysadmin communication: incident updates, plans, and trust</a>
.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">This series</div>
  <ul>
<li><a href="/news/so-what-it-you-exactly-do-part-one-continuous-deployment/">Part 1 - Continuous deployment</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-two-config-management/">Part 2 - Config management</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-three-security/">Part 3 - Security</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-four-monitoring/">Part 4 - Monitoring</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-five-troubleshooting/">Part 5 - Troubleshooting</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-six-high-availability/">Part 6 - High availability</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-seven-communication/">Part 7 - Communication</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Original</div>
  <ul>
<li><a href="https://old.mig5.net/content/so-what-it-you-exactly-do-part-five-troubleshooting.html" target="_blank" rel="noopener noreferrer">Original post on old.mig5.net</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need a sysadmin?</div>
  <div class="smallprint mb-3">I do contract sysadmin and automation work and can help with deployment, config management, security, monitoring, troubleshooting, high availability, and the communication that glues it all together.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-search"></i><span>From the archives</span></div>
    <h1 class="display-6 fw-semibold mb-2">Troubleshooting Linux systems: patterns, evidence, and good notes</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-16">September 16, 2026</time></p>
    <p class="lead mb-0">Part five of a seven-part series from 2016 on what a contract sysadmin does. This one covers
troubleshooting: pattern recognition, what not to do when an error appears, and the value of a
written 'trouble log'.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-16T13:00:00&#43;10:00">16 September 2026</time>
        </div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">From the archives - originally published in 2016</div>
  A decade on, I do some of this differently - tools, the industry, and the threat landscape have
all moved on. But the underlying ideas have stayed remarkably relevant even ten years later.
</div>

<p>In the <a href="/news/so-what-it-you-exactly-do-part-four-monitoring/">last article of this sysadmin series</a>
, I talked about the importance of monitoring as an insight into infrastructure and application behaviour - something that is hard to overstate. But what good is monitoring if you don't understand what it's telling you? That's where troubleshooting comes in.</p>
<p>I had a hard time trying to work out how to write this piece, precisely because troubleshooting is only part science, and part intuition. The quality of troubleshooting depends on how your brain works. I (of course..) pride myself on my troubleshooting abilities, mostly because I think I'm good at pattern recognition and have a good understanding of how different things behave in certain conditions. This goes beyond, say, counting the number of times an error has appeared in a log, but recognising 'meta' about those entries: what time they appeared in the logs, what else was happening at the time. Whether it coincides with wider problems (e.g upstream issues). What happened after the error and whether that thing was a side-effect of the problem or unrelated. Vice versa for what came before the error and if it caused it.</p>
<h2 id="ghosts-in-the-machine">Ghosts in the machine</h2>
<p>Just today I was troubleshooting a Drupal problem which was reported to me as a file permissions issue, but initially behaved like a Javascript issue. Based on the nature of the error message it became pretty quickly clear to me that</p>
<ul>
<li>a) it was a server-side error being thrown, but</li>
<li>b) it was not permissions, and</li>
<li>c) it was most likely a change in the hosting provider's mod_security rules (false positive on CKeditor actions when publishing a node).</li>
</ul>
<p>I didn't even know the provider employed mod_security, but it was the only thing that I could think of which would explain a 403 on that sort of POST request under those certain conditions. This was all before I had even had the chance to SSH into the server to see the logs that proved it (my client joked that it took a couple hours to get the hosting provider to reset an expired password for me so I could login, but only about 5 minutes for me to determine the cause of the problem).</p>
<p>Some of this is evidence based. Some of it is 'trial and error' or 'process of elimination' technique. But some of it is a vague 'feeling' that I can't quite describe, but that I think simply comes down to a combination of experience and 'thinking right' about an issue.</p>
<p>Another case from last week: a server was reported by Nagios as flapping all over the place - lots of packet loss every 10 minutes or so. I was baffled by the Munin monitoring which seemed to show wild spikes in CPU and RAM for the machine. Suddenly my ghost whispers on a 'hunch' that I am not seeing spikes, but in fact the data of a different server with the same hostname or IP, but a different specification, responding to the Munin request and therefore supplementing its own data onto the same graph. With some ARPing and tcpdump action proving a case of 'martian packets', indeed discovered I had a case of 2 servers on the same network with the same IP assignment (but of course different MAC addresses).</p>
<h2 id="what-not-to-do">What not to do</h2>
<p>Over the years I've seen sysadmins simply not think properly when faced with a problem. Common mistakes end up being:</p>
<ul>
<li>&quot;Maybe it's&quot; - endless hyperbole about theories without actually looking at what the error says</li>
<li>Changing lots of things in an effort to solve the problem - often without checking after making each change, or altering the landscape away from something that can reproduce the problem</li>
<li>Misreading a problem as a different problem because a similar thing has happened before (e.g a network timeout causing backups to fail, so assuming that a corruption occurred &amp; forcing a full backup because that's why the last backup failed)</li>
<li>Solving the problem but misunderstanding how or why - an unusual case that involves not understanding the solution let alone the problem (i.e copy-paste from StackExchange and then going to lunch)</li>
<li>Simply not testing their own theory as to the problem - e.g believing a firewall is blocking a request, but not testing the request in another way to prove it (e.g no tcpdumping on either end to see if packets arrive/ACKs sent back, etc), just giving up instead and escalating the issue</li>
<li>Not keeping or updating a 'Trouble Log' - a searchable diary of problems that occurred and how they were fixed (in case they happen again, as well as to reinforce one's own memory and understanding of the issue by writing it down)</li>
</ul>
<p>Sometimes it's more esoteric and probably down to experience - a junior sysadmin might attempt to run a command and give up waiting if it takes too long, reporting it as a symptom of high load (naturally without backing this up with evidence) or a firewall issue, whereas I can recognise the certain amount of delay characteristic of a DNS issue. It is really hard to teach this sort of thing, and that troubles me because I am trying to do so all the time with junior sysadmins at the agencies I consult to as the 'adhoc senior'!</p>
<p>I have a strong memory of being panicked by a massive network issue at an organisation I worked at when I was a junior. I was all over the place with wild theories as to what was going on, misinterpreting traceroutes, and making dangerous changes to firewalls that only led to other auxiliary problems. The head of the sysadmin team sat with me and forced me to 'think of myself sitting on the packet' (or something along those lines). By trying to imagine moving alongside the packet on its journey to its destination, he was able to get me to find precisely which tier in the stack of routers that was not forwarding the packet as it should have been. This experience was highly influential on my overall approach to troubleshooting, even outside of complex networking (routing and BGP etc), which probably remains my weakest link.</p>
<h2 id="summing-up">Summing up</h2>
<p>I don't have much more to say on this area of sysadmin, but the point of the series is to explain what makes up my day. Effective troubleshooting is an enormous part, and I feel that it, along with communication (which I'll talk about in the concluding piece), are my strongest tools in my sysadmin arsenal, despite neither of those tools being inherently technical. I appreciate the irony of this in what is a very technical profession.</p>
<p>My advice to other sysadmins when it comes to troubleshooting is:</p>
<ul>
<li>Collect evidence: think carefully about what the error is telling you. Don't waste time adding your own theories. This might also avoid the risk of trying solutions found by Googling that are irrelevant and therefore dangerous in their own right</li>
<li>Make small changes in efforts to resolve the issue. If they don't fix the issue, undo your change so as not to introduce new issues</li>
<li>Learn to Google effectively. When I started sysadmin, I didn't have a senior mentor at my dayjob - he hired me and then went on holiday for a couple months. Google was literally all I had</li>
<li>Record everything in a 'Trouble Log' to make next time easier. This can also double as an incident report or addition to your risk register if you have compliance matters to deal with (e.g ISO27001)</li>
</ul>
<p>Could your organisation do with a consultant sysadmin's expert troubleshooting? I work on an ongoing retainer basis, in different capacities (4 to 40 hours a month depending on organisation size), for organisations all round the world who can't justify a full-time sysadmin. <a href="/contact/">Get in touch</a>
.</p>
<h2 id="coming-up">Coming up</h2>
<p>We are nearly done. Two more areas I'd like to talk about:</p>
<p><a href="/news/so-what-it-you-exactly-do-part-six-high-availability/">High availability: resilience without unnecessary complexity</a>
,
<a href="/news/so-what-it-you-exactly-do-part-seven-communication/">Sysadmin communication: incident updates, plans, and trust</a>
.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">This series</div>
  <ul>
<li><a href="/news/so-what-it-you-exactly-do-part-one-continuous-deployment/">Part 1 - Continuous deployment</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-two-config-management/">Part 2 - Config management</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-three-security/">Part 3 - Security</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-four-monitoring/">Part 4 - Monitoring</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-five-troubleshooting/">Part 5 - Troubleshooting</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-six-high-availability/">Part 6 - High availability</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-seven-communication/">Part 7 - Communication</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Original</div>
  <ul>
<li><a href="https://old.mig5.net/content/so-what-it-you-exactly-do-part-five-troubleshooting.html" target="_blank" rel="noopener noreferrer">Original post on old.mig5.net</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need a sysadmin?</div>
  <div class="smallprint mb-3">I do contract sysadmin and automation work and can help with deployment, config management, security, monitoring, troubleshooting, high availability, and the communication that glues it all together.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>Production Linux monitoring: what to watch and why</title>
      <link>https://mig5.net/news/so-what-it-you-exactly-do-part-four-monitoring/</link>
      <pubDate>Wed, 16 Sep 2026 12:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/so-what-it-you-exactly-do-part-four-monitoring/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-activity"></i><span>From the archives</span></div>
    <h1 class="display-6 fw-semibold mb-2">Production Linux monitoring: what to watch and why</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-16">September 16, 2026</time></p>
    <p class="lead mb-0">Part four of a seven-part series from 2016 on what a contract sysadmin does. This one covers
monitoring: a hypothetical chain of failure, the role of monitoring in security, and the tools
the author actually uses (Nagios, Munin, OSSEC, Scumblr, ELK).</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-16T12:00:00&#43;10:00">16 September 2026</time>
        </div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">From the archives - originally published in 2016</div>
  A decade on, I do some of this differently - tools, the industry, and the threat landscape have
all moved on. But the underlying ideas have stayed remarkably relevant even ten years later.
</div>

<h2 id="heres-a-scenario">Here's a scenario...</h2>
<p>At 4:30AM every Thursday (sysadmin's time), a server's site suddenly spikes in load, because a full backup takes place at such a time, which is not an off-peak time in terms of traffic due to international visitors.</p>
<p>A bunch of users visiting a site on that server receive a flurry of 502 errors trying to load some content - a form of application timeout due to the taxing effect on the CPU related to the backup process.</p>
<p>No-one notices, until two months later, those 502 errors turn into 503s, because a disk has become full (due to said weekly backup not purging old backups), and a database has subsequently become corrupt.</p>
<p>The application doesn't bootstrap the database, but due to a programming oversight, exposes the database credentials in the 503 error page.</p>
<p>A nefarious visitor observes this and manages to leverage their way onto the system through a security vulnerability in the application a few weeks later, once the database corruption has been fixed, and takes a copy of the database using these credentials. The vulnerability was related to a temp folder in the application codebase being <code>chmod 777</code>.</p>
<p>The database is leaked on pastebin.com.</p>
<p>This didn't happen to me, I made the scenario up (phew!) but I think my point is clear already. All of this may have been actionable much sooner with some decent monitoring. Let me explore the ways:</p>
<ul>
<li>The regular CPU spike occurring each Thursday: something that a monitoring tool like Nagios would likely notice and alert people about. The pattern of being woken up by a Nagios push notification weekly at 4:30AM would soon make the sysadmin look into further detail about its nature.</li>
<li>The 502 alerts would be detected by a log analysis monitoring tool such as OSSEC. These would also be alerted via e-mail or in routine OSSEC-generated reports, and cross-referenced with the spike in load</li>
<li>The disk becomes full after a few months: a sign of a growing problem or trend, which humans would recognise easily in a graph form. Something like Munin or other RRD graph-generating interface monitoring the disk usage would show this.</li>
<li>The 503 errors would be noticed by Nagios and OSSEC, as would the database corruption, and probably many other alerts/symptoms if a disk got full</li>
<li>The poor permissions on the application folder would have been alerted to by OSSEC, long before someone exploited it</li>
<li>The intrusion by an attacker leveraging the database credentials would (possibly) be detected by OSSEC</li>
<li>The leak of the database on pastebin.com may have been detectable via a Threat Monitoring tool such as Scumblr, allowing the company to initiate a remediation procedure earlier than being 'caught on the back foot' by the leak being picked up by the mainstream media.</li>
</ul>
<p>It becomes pretty clear that a chain reaction of very unfortunate events can be largely avoided, or caught in time, through effective monitoring.</p>
<h2 id="no-surprise-like-a-bad-surprise">No surprise like a bad surprise</h2>
<p>In 2012 I observed a series of websites get hacked through an application vulnerability. In at least one case, it meant that payment information may have been exposed. We all (myself included) were about 2 weeks late to the discovery from the time that the intrusion occurred. There was not enough monitoring in place to detect it - and that's when I <a href="https://old.mig5.net/content/another-ossec-talk-drupalcamp-sydney-2014.html" target="_blank" rel="noopener noreferrer">discovered OSSEC</a>
. It felt unbearable to me to know that something like that could have happened under my nose. For <em>weeks</em> until a chance encounter performing another task on the server, allowed me to spot it.</p>
<h2 id="monitoring-and-security">Monitoring and security</h2>
<p>Of course, the above scenario, and the 'real life' case I mention, have a focus on security in terms of hacks/leaks. But as I mentioned <a href="/news/so-what-it-you-exactly-do-part-three-security/">in the previous article</a>
, security is also about availability (disks not getting full!), integrity (data not being corrupted, or effective rollover of backup systems so that the backups themselves don't become corrupt), and also trust (the public opinion of the company who got hacked through their negligence). This is something especially important to be considered in the scope of 'security' for ISO27001 certified companies (or those trying to obtain that certification). I believe monitoring can help protect against all of these issues.</p>
<h2 id="some-more-thoughts-on-specific-tools">Some more thoughts on specific tools</h2>
<p>Do I implement and use all of the above tools for the agencies I consult to? Yes indeed I do - some use all or a subset of these depending on their requirements.</p>
<h3 id="nagios">Nagios</h3>
<p>A tool like <a href="http://www.nagios.org/" target="_blank" rel="noopener noreferrer">Nagios</a>
 is the staple for me - it is old, and there is a growing disdain for it by hipsteradmins who pray to the Bleeding Edge gods, but it has plenty of value for me so I use it. I am not saying it is the best, I am saying it does everything I need it to do.</p>
<p>Nagios is notoriously difficult to configure - not so when you define your Nagios configuration through Puppet. 'But you still have to do the Puppet configuration', say the trolls. Not so when you have <a href="https://github.com/mig5/madelon" target="_blank" rel="noopener noreferrer">written bots</a>
 who do it for you on demand. Say I want to spin up a server at Linode or Rackspace that is a LAMP stack: my business is all about providing tools that take that requirement and automate it. This includes not just the provisioning of the server, but the auto-hookup of such a server and its services into Nagios. And all <a href="/news/so-what-it-you-exactly-do-part-two-config-management/">config managed in Puppet</a>
. Welcome to effective systems administration in 2015.</p>
<p>'Nagios looks like crap' - yes indeed, by default it does. It's customisable, though.</p>
<h3 id="munin">Munin</h3>
<p><a href="http://munin-monitoring.org/" target="_blank" rel="noopener noreferrer">Munin</a>
 is also a tool that's vital to me because it records the historic issues visually - a spike in CPU or RAM, a growing disk usage issue, a strange pattern in network traffic. It helps me analyse the past, and predict the future. Most of the time it helps me prove to hosting companies that their disk latency is too high or that there is a 'noisy neighbour' resulting in high CPU steal time on my server, as of the last (say) 16 hours.</p>
<h3 id="scumblr">Scumblr</h3>
<p><a href="https://github.com/Netflix/Scumblr" target="_blank" rel="noopener noreferrer">Scumblr</a>
 is a threat monitoring tool released by Netflix's OSS team, which I have also <a href="https://github.com/Netflix/Scumblr/graphs/contributors" target="_blank" rel="noopener noreferrer">contributed</a>
 fixes and features to. This is really useful, especially for businesses with a recognisable brand or product that needs protection. But I have also used it to find cases where developers have accidentally published Github repositories as 'public' when they were meant to be private - or that they published credentials (e.g <a href="http://www.itnews.com.au/News/375785,aws-urges-developers-to-scrub-github-of-secret-keys.aspx" target="_blank" rel="noopener noreferrer">AWS keys</a>
) in those repositories.</p>
<p>I have written more on Scumblr, specifically with regards to Pastebin monitoring, <a href="https://old.mig5.net/content/monitoring-pastebincom-scumblr.html" target="_blank" rel="noopener noreferrer">on my blog</a>
.</p>
<h3 id="elk-etc">ELK etc</h3>
<p>And of course there are other products that I haven't discussed here, such as use of NewRelic for performance metrics, Pingdom as an additional 'uptime' vector (don't rely on an internal Nagios instance giving accurate indication of your service availability or being able to send notifications, if your upstream router is down!). A friend of mine is working on <a href="https://radalert.io" target="_blank" rel="noopener noreferrer">RadAlert.io</a>
, a next-gen monitoring solution that features predictive metrics.</p>
<p>There's also the <a href="https://www.elastic.co/downloads" target="_blank" rel="noopener noreferrer">&#39;ELK stack&#39;</a>
 comprising Elasticsearch, Logstash and Kibana. This suite can be useful as a centralised logging system with a flexible UI, capable of producing real-time and trend data in a similar fashion to Munin, but of <em>any</em> sort of data you want to aggregate (e.g not just metrics, but log messages). I have used the ELK stack to observe patterns in Wordpress intrusion attempts and SASL dictionary attacks via OSSEC.</p>
<h2 id="security-in-monitoring-third-party-systems">Security in monitoring third party systems</h2>
<p>In the modern era, many businesses use a variety of third party tools in order to operate. You probably use Google Apps or at least some of that suite. If you're in the business of developing software, you might also use Github or Bitbucket. Perhaps you have a third party project management system like Pivotal or Atlassian Jira. Maybe Twitter is an important communication channel between you and your customers. Many a business laments publicly when these services fall down. It's a fact, even if it shouldn't be: these major companies are depended on for a business to operate. So if you depend on that service, shouldn't you be monitoring it? This is something I've seen ISO27001 auditors point out.</p>
<p>Most reputable businesses like those I've listed, will feature a public status page to demonstrate a healthy or degraded service (or subset of services). As part of efforts I have invested in building 'mash up' monitoring dashboards for some of my customers, aggregating that information is something I like to include where I can.</p>
<h2 id="summing-up">Summing up</h2>
<p>The amount of monitoring really depends on the business needs. What seems definite though, is that any form of monitoring can drastically bolster the security, stability, general understanding of, a business's technical internals, for that business. The smallest finger on the lightest pulse, is still an assurance that something's alive and beating. So it should be for the systems you need to be running at their optimal capability.</p>
<p>My observations have consistently been that the addition of OSSEC and/or ELK stacks for logging analysis alone, has exposed previously 'hidden' issues due people simply not checking <code>/var/log/syslog</code> proactively. These can include the discovery of occasional PHP memory limit exhaustion issues (not seen previously by you, but maybe by an end user) on specific pages of an app. Or, it might identify an awareness issue: a developer running <code>sudo chmod -R 777</code> on something, which might involve a bit of re-education. (And a report to your CISO, because why did that developer have sudo anyway? (Someone added him to sudoers on your sick day so you didn't know) ).</p>
<p>The very awareness that 'there happens to be a spike in SSH brute force attacks this week', doesn't necessarily change anything for your business other than it makes you more keenly aware of the threat landscape you're operating in. Which makes you more prepared and more educated as a sysadmin, which means you can make your staff (or customers, if you consult like I do) more prepared, more educated.</p>
<p>And, of course, monitoring is not just about tools: it's not something to 'switch on' and walk away from. No monitoring system is of much use if no-one's able to use or understand what it's saying. That's something I'll cover in part five: troubleshooting, because a <em>massive</em> part of my role is interpreting problems and railing against the irritating sense of 'mysteries' or 'ghosts in the machine', in an industry defined by science. I have seen sysadmins who aren't capable of effective troubleshooting, or making common mistakes in an effort to do so. This can be the difference between a good sysadmin and one who is just costing an organisation money without solving problems.</p>
<h2 id="coming-up">Coming up</h2>
<p><a href="/news/so-what-it-you-exactly-do-part-five-troubleshooting/">Troubleshooting Linux systems: patterns, evidence, and good notes</a>
,
<a href="/news/so-what-it-you-exactly-do-part-six-high-availability/">High availability: resilience without unnecessary complexity</a>
,
<a href="/news/so-what-it-you-exactly-do-part-seven-communication/">Sysadmin communication: incident updates, plans, and trust</a>
.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">This series</div>
  <ul>
<li><a href="/news/so-what-it-you-exactly-do-part-one-continuous-deployment/">Part 1 - Continuous deployment</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-two-config-management/">Part 2 - Config management</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-three-security/">Part 3 - Security</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-four-monitoring/">Part 4 - Monitoring</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-five-troubleshooting/">Part 5 - Troubleshooting</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-six-high-availability/">Part 6 - High availability</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-seven-communication/">Part 7 - Communication</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Original</div>
  <ul>
<li><a href="https://old.mig5.net/content/so-what-it-you-exactly-do-part-four-monitoring.html" target="_blank" rel="noopener noreferrer">Original post on old.mig5.net</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need a sysadmin?</div>
  <div class="smallprint mb-3">I do contract sysadmin and automation work and can help with deployment, config management, security, monitoring, troubleshooting, high availability, and the communication that glues it all together.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-activity"></i><span>From the archives</span></div>
    <h1 class="display-6 fw-semibold mb-2">Production Linux monitoring: what to watch and why</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-16">September 16, 2026</time></p>
    <p class="lead mb-0">Part four of a seven-part series from 2016 on what a contract sysadmin does. This one covers
monitoring: a hypothetical chain of failure, the role of monitoring in security, and the tools
the author actually uses (Nagios, Munin, OSSEC, Scumblr, ELK).</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-16T12:00:00&#43;10:00">16 September 2026</time>
        </div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">From the archives - originally published in 2016</div>
  A decade on, I do some of this differently - tools, the industry, and the threat landscape have
all moved on. But the underlying ideas have stayed remarkably relevant even ten years later.
</div>

<h2 id="heres-a-scenario">Here's a scenario...</h2>
<p>At 4:30AM every Thursday (sysadmin's time), a server's site suddenly spikes in load, because a full backup takes place at such a time, which is not an off-peak time in terms of traffic due to international visitors.</p>
<p>A bunch of users visiting a site on that server receive a flurry of 502 errors trying to load some content - a form of application timeout due to the taxing effect on the CPU related to the backup process.</p>
<p>No-one notices, until two months later, those 502 errors turn into 503s, because a disk has become full (due to said weekly backup not purging old backups), and a database has subsequently become corrupt.</p>
<p>The application doesn't bootstrap the database, but due to a programming oversight, exposes the database credentials in the 503 error page.</p>
<p>A nefarious visitor observes this and manages to leverage their way onto the system through a security vulnerability in the application a few weeks later, once the database corruption has been fixed, and takes a copy of the database using these credentials. The vulnerability was related to a temp folder in the application codebase being <code>chmod 777</code>.</p>
<p>The database is leaked on pastebin.com.</p>
<p>This didn't happen to me, I made the scenario up (phew!) but I think my point is clear already. All of this may have been actionable much sooner with some decent monitoring. Let me explore the ways:</p>
<ul>
<li>The regular CPU spike occurring each Thursday: something that a monitoring tool like Nagios would likely notice and alert people about. The pattern of being woken up by a Nagios push notification weekly at 4:30AM would soon make the sysadmin look into further detail about its nature.</li>
<li>The 502 alerts would be detected by a log analysis monitoring tool such as OSSEC. These would also be alerted via e-mail or in routine OSSEC-generated reports, and cross-referenced with the spike in load</li>
<li>The disk becomes full after a few months: a sign of a growing problem or trend, which humans would recognise easily in a graph form. Something like Munin or other RRD graph-generating interface monitoring the disk usage would show this.</li>
<li>The 503 errors would be noticed by Nagios and OSSEC, as would the database corruption, and probably many other alerts/symptoms if a disk got full</li>
<li>The poor permissions on the application folder would have been alerted to by OSSEC, long before someone exploited it</li>
<li>The intrusion by an attacker leveraging the database credentials would (possibly) be detected by OSSEC</li>
<li>The leak of the database on pastebin.com may have been detectable via a Threat Monitoring tool such as Scumblr, allowing the company to initiate a remediation procedure earlier than being 'caught on the back foot' by the leak being picked up by the mainstream media.</li>
</ul>
<p>It becomes pretty clear that a chain reaction of very unfortunate events can be largely avoided, or caught in time, through effective monitoring.</p>
<h2 id="no-surprise-like-a-bad-surprise">No surprise like a bad surprise</h2>
<p>In 2012 I observed a series of websites get hacked through an application vulnerability. In at least one case, it meant that payment information may have been exposed. We all (myself included) were about 2 weeks late to the discovery from the time that the intrusion occurred. There was not enough monitoring in place to detect it - and that's when I <a href="https://old.mig5.net/content/another-ossec-talk-drupalcamp-sydney-2014.html" target="_blank" rel="noopener noreferrer">discovered OSSEC</a>
. It felt unbearable to me to know that something like that could have happened under my nose. For <em>weeks</em> until a chance encounter performing another task on the server, allowed me to spot it.</p>
<h2 id="monitoring-and-security">Monitoring and security</h2>
<p>Of course, the above scenario, and the 'real life' case I mention, have a focus on security in terms of hacks/leaks. But as I mentioned <a href="/news/so-what-it-you-exactly-do-part-three-security/">in the previous article</a>
, security is also about availability (disks not getting full!), integrity (data not being corrupted, or effective rollover of backup systems so that the backups themselves don't become corrupt), and also trust (the public opinion of the company who got hacked through their negligence). This is something especially important to be considered in the scope of 'security' for ISO27001 certified companies (or those trying to obtain that certification). I believe monitoring can help protect against all of these issues.</p>
<h2 id="some-more-thoughts-on-specific-tools">Some more thoughts on specific tools</h2>
<p>Do I implement and use all of the above tools for the agencies I consult to? Yes indeed I do - some use all or a subset of these depending on their requirements.</p>
<h3 id="nagios">Nagios</h3>
<p>A tool like <a href="http://www.nagios.org/" target="_blank" rel="noopener noreferrer">Nagios</a>
 is the staple for me - it is old, and there is a growing disdain for it by hipsteradmins who pray to the Bleeding Edge gods, but it has plenty of value for me so I use it. I am not saying it is the best, I am saying it does everything I need it to do.</p>
<p>Nagios is notoriously difficult to configure - not so when you define your Nagios configuration through Puppet. 'But you still have to do the Puppet configuration', say the trolls. Not so when you have <a href="https://github.com/mig5/madelon" target="_blank" rel="noopener noreferrer">written bots</a>
 who do it for you on demand. Say I want to spin up a server at Linode or Rackspace that is a LAMP stack: my business is all about providing tools that take that requirement and automate it. This includes not just the provisioning of the server, but the auto-hookup of such a server and its services into Nagios. And all <a href="/news/so-what-it-you-exactly-do-part-two-config-management/">config managed in Puppet</a>
. Welcome to effective systems administration in 2015.</p>
<p>'Nagios looks like crap' - yes indeed, by default it does. It's customisable, though.</p>
<h3 id="munin">Munin</h3>
<p><a href="http://munin-monitoring.org/" target="_blank" rel="noopener noreferrer">Munin</a>
 is also a tool that's vital to me because it records the historic issues visually - a spike in CPU or RAM, a growing disk usage issue, a strange pattern in network traffic. It helps me analyse the past, and predict the future. Most of the time it helps me prove to hosting companies that their disk latency is too high or that there is a 'noisy neighbour' resulting in high CPU steal time on my server, as of the last (say) 16 hours.</p>
<h3 id="scumblr">Scumblr</h3>
<p><a href="https://github.com/Netflix/Scumblr" target="_blank" rel="noopener noreferrer">Scumblr</a>
 is a threat monitoring tool released by Netflix's OSS team, which I have also <a href="https://github.com/Netflix/Scumblr/graphs/contributors" target="_blank" rel="noopener noreferrer">contributed</a>
 fixes and features to. This is really useful, especially for businesses with a recognisable brand or product that needs protection. But I have also used it to find cases where developers have accidentally published Github repositories as 'public' when they were meant to be private - or that they published credentials (e.g <a href="http://www.itnews.com.au/News/375785,aws-urges-developers-to-scrub-github-of-secret-keys.aspx" target="_blank" rel="noopener noreferrer">AWS keys</a>
) in those repositories.</p>
<p>I have written more on Scumblr, specifically with regards to Pastebin monitoring, <a href="https://old.mig5.net/content/monitoring-pastebincom-scumblr.html" target="_blank" rel="noopener noreferrer">on my blog</a>
.</p>
<h3 id="elk-etc">ELK etc</h3>
<p>And of course there are other products that I haven't discussed here, such as use of NewRelic for performance metrics, Pingdom as an additional 'uptime' vector (don't rely on an internal Nagios instance giving accurate indication of your service availability or being able to send notifications, if your upstream router is down!). A friend of mine is working on <a href="https://radalert.io" target="_blank" rel="noopener noreferrer">RadAlert.io</a>
, a next-gen monitoring solution that features predictive metrics.</p>
<p>There's also the <a href="https://www.elastic.co/downloads" target="_blank" rel="noopener noreferrer">&#39;ELK stack&#39;</a>
 comprising Elasticsearch, Logstash and Kibana. This suite can be useful as a centralised logging system with a flexible UI, capable of producing real-time and trend data in a similar fashion to Munin, but of <em>any</em> sort of data you want to aggregate (e.g not just metrics, but log messages). I have used the ELK stack to observe patterns in Wordpress intrusion attempts and SASL dictionary attacks via OSSEC.</p>
<h2 id="security-in-monitoring-third-party-systems">Security in monitoring third party systems</h2>
<p>In the modern era, many businesses use a variety of third party tools in order to operate. You probably use Google Apps or at least some of that suite. If you're in the business of developing software, you might also use Github or Bitbucket. Perhaps you have a third party project management system like Pivotal or Atlassian Jira. Maybe Twitter is an important communication channel between you and your customers. Many a business laments publicly when these services fall down. It's a fact, even if it shouldn't be: these major companies are depended on for a business to operate. So if you depend on that service, shouldn't you be monitoring it? This is something I've seen ISO27001 auditors point out.</p>
<p>Most reputable businesses like those I've listed, will feature a public status page to demonstrate a healthy or degraded service (or subset of services). As part of efforts I have invested in building 'mash up' monitoring dashboards for some of my customers, aggregating that information is something I like to include where I can.</p>
<h2 id="summing-up">Summing up</h2>
<p>The amount of monitoring really depends on the business needs. What seems definite though, is that any form of monitoring can drastically bolster the security, stability, general understanding of, a business's technical internals, for that business. The smallest finger on the lightest pulse, is still an assurance that something's alive and beating. So it should be for the systems you need to be running at their optimal capability.</p>
<p>My observations have consistently been that the addition of OSSEC and/or ELK stacks for logging analysis alone, has exposed previously 'hidden' issues due people simply not checking <code>/var/log/syslog</code> proactively. These can include the discovery of occasional PHP memory limit exhaustion issues (not seen previously by you, but maybe by an end user) on specific pages of an app. Or, it might identify an awareness issue: a developer running <code>sudo chmod -R 777</code> on something, which might involve a bit of re-education. (And a report to your CISO, because why did that developer have sudo anyway? (Someone added him to sudoers on your sick day so you didn't know) ).</p>
<p>The very awareness that 'there happens to be a spike in SSH brute force attacks this week', doesn't necessarily change anything for your business other than it makes you more keenly aware of the threat landscape you're operating in. Which makes you more prepared and more educated as a sysadmin, which means you can make your staff (or customers, if you consult like I do) more prepared, more educated.</p>
<p>And, of course, monitoring is not just about tools: it's not something to 'switch on' and walk away from. No monitoring system is of much use if no-one's able to use or understand what it's saying. That's something I'll cover in part five: troubleshooting, because a <em>massive</em> part of my role is interpreting problems and railing against the irritating sense of 'mysteries' or 'ghosts in the machine', in an industry defined by science. I have seen sysadmins who aren't capable of effective troubleshooting, or making common mistakes in an effort to do so. This can be the difference between a good sysadmin and one who is just costing an organisation money without solving problems.</p>
<h2 id="coming-up">Coming up</h2>
<p><a href="/news/so-what-it-you-exactly-do-part-five-troubleshooting/">Troubleshooting Linux systems: patterns, evidence, and good notes</a>
,
<a href="/news/so-what-it-you-exactly-do-part-six-high-availability/">High availability: resilience without unnecessary complexity</a>
,
<a href="/news/so-what-it-you-exactly-do-part-seven-communication/">Sysadmin communication: incident updates, plans, and trust</a>
.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">This series</div>
  <ul>
<li><a href="/news/so-what-it-you-exactly-do-part-one-continuous-deployment/">Part 1 - Continuous deployment</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-two-config-management/">Part 2 - Config management</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-three-security/">Part 3 - Security</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-four-monitoring/">Part 4 - Monitoring</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-five-troubleshooting/">Part 5 - Troubleshooting</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-six-high-availability/">Part 6 - High availability</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-seven-communication/">Part 7 - Communication</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Original</div>
  <ul>
<li><a href="https://old.mig5.net/content/so-what-it-you-exactly-do-part-four-monitoring.html" target="_blank" rel="noopener noreferrer">Original post on old.mig5.net</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need a sysadmin?</div>
  <div class="smallprint mb-3">I do contract sysadmin and automation work and can help with deployment, config management, security, monitoring, troubleshooting, high availability, and the communication that glues it all together.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>Linux infrastructure security as risk management</title>
      <link>https://mig5.net/news/so-what-it-you-exactly-do-part-three-security/</link>
      <pubDate>Wed, 16 Sep 2026 11:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/so-what-it-you-exactly-do-part-three-security/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-shield-lock"></i><span>From the archives</span></div>
    <h1 class="display-6 fw-semibold mb-2">Linux infrastructure security as risk management</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-16">September 16, 2026</time></p>
    <p class="lead mb-0">Part three of a seven-part series from 2016 on what a contract sysadmin does. This one covers
security: defining it in terms of risk, ISO 27001 and PCI-DSS, the race against time on patching,
common pitfalls, and tools like OSSEC, Yubikeys, Scumblr, and Qubes.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-16T11:00:00&#43;10:00">16 September 2026</time>
        </div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">From the archives - originally published in 2016</div>
  A decade on, I do some of this differently - tools, the industry, and the threat landscape have
all moved on. But the underlying ideas have stayed remarkably relevant even ten years later.
</div>

<p>This article is third in a series of long, windy answers to the inevitable 'but what exactly do you do as a sysadmin consultant?' question. I started writing this because it's hard to give a sufficient short answer.</p>
<p>Back in <a href="/news/so-what-it-you-exactly-do-part-two-config-management/">part two</a>
 I talked about the time I spend with organisations helping them manage their configuration and infrastructure 'state' as declarative code (e.g Puppet). Prior to that, in <a href="/news/so-what-it-you-exactly-do-part-one-continuous-deployment/">part one</a>
, I talked about continuous deployment of applications (particularly Drupal, and Magento). In both these cases I tried to transgress discussions about the technical details, and focus more on how a sysadmin helps organisations mitigate risk and even improve the experience of other colleagues using the company's systems.</p>
<h2 id="defining-security-in-terms-of-risk">Defining security in terms of risk</h2>
<p>Security is no exception to this - and increasingly it seems that dealing with matters of information security is a bigger and bigger part of my role. Most of us are aware of the 'arms race' that is defending public (and even private) facing systems from attackers. Spin up a cloud server somewhere and in just a couple of minutes (or less), it will start receiving intrusion attempts or be scanned for the purposes of information gathering. The internet is not a friendly place for the things that run on it or for those who use it. But attack and defense is only one area of security.</p>
<p>A number of my customers have been going through the rigorous process of achieving <a href="http://en.wikipedia.org/wiki/ISO/IEC_27001:2013" target="_blank" rel="noopener noreferrer">ISO27001 compliance</a>
. This is a security standard that is supposed to demonstrate that a company has a reliable, well-maintained and <em>effective</em> suite of information security processes which are appropriate for their business.</p>
<p>This is a much bigger (or)deal than <a href="https://en.wikipedia.org/wiki/Payment_Card_Industry_Data_Security_Standard" target="_blank" rel="noopener noreferrer">PCI-DSS</a>
 (compliance typically related to ensuring an organisation and its infrastructure is appropriately building systems that can perform payment transactions). ISO27001 has done a good job of educating people about the fact that security is not just about building safe systems or adequately monitoring and responding to compromises. Instead, security is again about risk. A system that is meant to be up and running, but is down (not due to being hacked), is still a security issue, because it has detrimental affects on the business in a myriad of ways (i.e money is being lost, reputations are being damaged, or the nature of the downtime might have also resulted in <em>integrity</em> issues such as data corruption).</p>
<p>As a result, my work as a consultant is not just about typical security technical details (ensuring there are adequate firewalls (inbound and outbound), that there are virus scans, intrusion detection systems, access control, password policies, version header stripping, isolation of systems etc) but designing broader procedures and systems that conform to or make up a customer's <a href="http://en.wikipedia.org/wiki/Information_security_management_system" target="_blank" rel="noopener noreferrer">ISMS</a>
.</p>
<p>This can include the automation of entries into risk registers, introducing threat monitoring systems, and attempting to automate disaster recovery tests. It can also include the implementation of rolling patch cycles (and routine automated checks to ensure services that depend on libraries which have been patched, have been restarted or reboots have taken place).</p>
<h2 id="things-i-like">Things I like</h2>
<p>Having said all that, there are of course some specific technical tools that particularly interest me at the moment in the area of security, some of which I have been deploying for organisations. These include:</p>
<ul>
<li>Threat intelligence (monitoring internet chatter with tools like <a href="https://old.mig5.net/content/monitoring-pastebincom-scumblr.html" target="_blank" rel="noopener noreferrer">Scumblr</a>
)</li>
<li>Hardware 2-factor auth with <a href="https://old.mig5.net/content/adding-yubikey-2-factor-authentication-ssh-and-sudo-debian.html" target="_blank" rel="noopener noreferrer">Yubikeys</a>
</li>
<li>Security by isolation models (such as the <a href="https://www.qubes-os.org/" target="_blank" rel="noopener noreferrer">Qubes</a>
 Linux distribution)</li>
<li>Intrusion detection and integrity checking (e.g, and last but not least, <a href="http://www.ossec.net/" target="_blank" rel="noopener noreferrer">OSSEC</a>
)</li>
</ul>
<p>OSSEC has been especially an asset to me since 2012 after a spate of Drupal vulnerabilities related to CKeditor. I have <a href="https://old.mig5.net/content/another-ossec-talk-drupalcamp-sydney-2014.html" target="_blank" rel="noopener noreferrer">given</a>
 <a href="https://old.mig5.net/content/ossec-and-drupal.html" target="_blank" rel="noopener noreferrer">talks</a>
 about its use and how important rigorous review of its alerts is, in terms of intrusion and pattern detection, and integrity (not just intrusions).</p>
<h2 id="a-race-against-time">A race against time</h2>
<p>A major mitigating factor to the issue of risk and security is timely detection. This includes monitoring not just of the systems you are running for signs of intrusion or human error (<code>chmod -R 777</code> anyone?), but also keeping tabs on the outside world. Most of us run software developed by third parties, and security vulnerabilities are constantly being discovered in them. The time between announcement of a security vulnerability (and its fix) and the mitigation of that threat, is crucial.</p>
<p>In October 2014, a critical <a href="https://www.drupal.org/SA-CORE-2014-005" target="_blank" rel="noopener noreferrer">SQL injection vulnerability in the Drupal CMS</a>
 was being exploited in the wild just 7 hours after its announcement (observed first-hand by myself). Many people didn't patch for some time, mostly because of these main reasons:</p>
<ul>
<li>They weren't subscribed to announcements about security vulnerabilities in Drupal (the dedicated <a href="https://www.drupal.org/mailing-lists" target="_blank" rel="noopener noreferrer">mailing lists</a>
 provided to the Drupal community this purpose, Twitter, RSS feeds etc)</li>
<li>They didn't understand the severity or nature of the threat and so under-reacted</li>
<li>They didn't have effective means of patching swiftly or in an automated fashion</li>
</ul>
<p>All three of these are what I consider to be valuable merits that a Drupal-savvy systems administrator can bring. Stay notified, carefully interpret what you are being notified about, and have a plan for what must be done. I observed similar mistakes by others in the wake of the <a href="http://en.wikipedia.org/wiki/Heartbleed" target="_blank" rel="noopener noreferrer">Heartbleed</a>
 vulnerability.</p>
<h2 id="other-common-pitfalls">Other common pitfalls</h2>
<p>Other areas I regularly see agencies fail at security, other than running vulnerable versions of software or due to lack of monitoring for intrusions/mistakes, include:</p>
<ul>
<li>Poor access control (developers have access to production systems) - this often goes hand-in-hand with the absence of a decent deployment/workflow/'backward sync from prod to stage' system (whereby there is little <em>need</em> to login to production systems)</li>
<li>Poor authentication control (no 2-factor auth, no SSH PKI, no screen locks on desktop environments, shared accounts/passwords etc)</li>
<li>Poor operational security (particularly increasing risk of client-side attacks) - e.g no laptop encryption, leaving sensitive data on laptops which are left in public places or could be susceptible to <a href="https://www.schneier.com/blog/archives/2009/10/evil_maid_attac.html" target="_blank" rel="noopener noreferrer">&#39;Evil Maid&#39;</a>
 attacks), private key misuse</li>
<li>Poor encryption (no use of VPNs, PGP, <em>use</em> of plaintext FTP systems, transmitting of passwords or other sensitive data in cleartext e-mails etc)</li>
<li>Poor firewalls (frequently I see cases of no/slack outbound firewall rules, or default policies of ACCEPT instead of DROP)</li>
<li>Poor implementations of backups, cron jobs (e.g database backup jobs that chew up disk space gradually and eventually crash and corrupt servers or data), or reckless tuning of memory settings etc which can lead to similar crashes</li>
<li>'Legacy' problems (services running that no longer need to be, data that should have been deleted but wasn't, or ex-employee accounts/access remaining active</li>
<li>Information leakages (no stripping of metadata, headers etc)</li>
<li>Poor definition of roles, responsibilities, change management, or chain of command (e.g no establishment of a chief security officer, lack of incident reporting or risk registers. Often it is mistakenly assumed by a development agency that a hosting company is taking care of problems that are not part of the hosting contract at all)</li>
<li>This one I have experienced myself - the paralysing effect of fear (e.g customer knows something is wrong but is worried about acting, in case the act of change breaks something else/makes it worse. Inevitably, it means nothing is done)</li>
</ul>
<p>None of these things are devoid of solutions. The good news is that things can only get better from there!</p>
<h2 id="summing-up">Summing up</h2>
<p>Security is about a lot more than staying on top of software vulnerabilities. It is a constant arms race whereby the 'good guys' are usually on the back foot. It requires constant re-evaluation of procedures, policies, changes to systems, and evaluation of the 'current climate'. Most importantly, security involves making sensible judgment calls by business leaders as to which risks matter the most. No-one can defend against everything or win all the time. Excessive security is usually at the cost of usability, and can make your staff either unhappy or subversive (they will simply find ways around the rules you've enforced, or sacrifice quality and/or speed to conform to the rigidity).</p>
<p>Fortunately, in the wake of major world events such as the <a href="http://www.theguardian.com/world/2013/sep/05/nsa-gchq-encryption-codes-security" target="_blank" rel="noopener noreferrer">&#39;Snowden revelations&#39;</a>
, security issues are receiving a lot more scrutiny by businesses of all sizes than they perhaps used to. Increasingly, areas such as encryption, data privacy etc and are being re-evaluated, and major organisations like Google are starting to play hard-ball by penalising organisations who deploy poorly secured systems (bad SSL certs/algorithms, preference to plaintext traffic etc).</p>
<p>I think the current trend is a good one and may help build a safer internet. In the meantime I hope to continue to help agencies improve the security of their systems, both for the sake of their businesses and the customers/users they serve. <a href="/contact/">Get in touch</a>
 if you are a Drupal shop seeking to improve on any of the above issues I've mentioned.</p>
<h2 id="coming-up">Coming up</h2>
<p>Coming up next, <a href="/news/so-what-it-you-exactly-do-part-four-monitoring/">production Linux monitoring</a>
 - where I'll be talking about effective monitoring solutions for server administration (and, yes, security). Then:</p>
<p><a href="/news/so-what-it-you-exactly-do-part-five-troubleshooting/">Troubleshooting Linux systems: patterns, evidence, and good notes</a>
,
<a href="/news/so-what-it-you-exactly-do-part-six-high-availability/">High availability: resilience without unnecessary complexity</a>
,
<a href="/news/so-what-it-you-exactly-do-part-seven-communication/">Sysadmin communication: incident updates, plans, and trust</a>
.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">This series</div>
  <ul>
<li><a href="/news/so-what-it-you-exactly-do-part-one-continuous-deployment/">Part 1 - Continuous deployment</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-two-config-management/">Part 2 - Config management</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-three-security/">Part 3 - Security</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-four-monitoring/">Part 4 - Monitoring</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-five-troubleshooting/">Part 5 - Troubleshooting</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-six-high-availability/">Part 6 - High availability</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-seven-communication/">Part 7 - Communication</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Original</div>
  <ul>
<li><a href="https://old.mig5.net/content/so-what-it-you-exactly-do-part-three-security.html" target="_blank" rel="noopener noreferrer">Original post on old.mig5.net</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need a sysadmin?</div>
  <div class="smallprint mb-3">I do contract sysadmin and automation work and can help with deployment, config management, security, monitoring, troubleshooting, high availability, and the communication that glues it all together.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-shield-lock"></i><span>From the archives</span></div>
    <h1 class="display-6 fw-semibold mb-2">Linux infrastructure security as risk management</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-16">September 16, 2026</time></p>
    <p class="lead mb-0">Part three of a seven-part series from 2016 on what a contract sysadmin does. This one covers
security: defining it in terms of risk, ISO 27001 and PCI-DSS, the race against time on patching,
common pitfalls, and tools like OSSEC, Yubikeys, Scumblr, and Qubes.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-16T11:00:00&#43;10:00">16 September 2026</time>
        </div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">From the archives - originally published in 2016</div>
  A decade on, I do some of this differently - tools, the industry, and the threat landscape have
all moved on. But the underlying ideas have stayed remarkably relevant even ten years later.
</div>

<p>This article is third in a series of long, windy answers to the inevitable 'but what exactly do you do as a sysadmin consultant?' question. I started writing this because it's hard to give a sufficient short answer.</p>
<p>Back in <a href="/news/so-what-it-you-exactly-do-part-two-config-management/">part two</a>
 I talked about the time I spend with organisations helping them manage their configuration and infrastructure 'state' as declarative code (e.g Puppet). Prior to that, in <a href="/news/so-what-it-you-exactly-do-part-one-continuous-deployment/">part one</a>
, I talked about continuous deployment of applications (particularly Drupal, and Magento). In both these cases I tried to transgress discussions about the technical details, and focus more on how a sysadmin helps organisations mitigate risk and even improve the experience of other colleagues using the company's systems.</p>
<h2 id="defining-security-in-terms-of-risk">Defining security in terms of risk</h2>
<p>Security is no exception to this - and increasingly it seems that dealing with matters of information security is a bigger and bigger part of my role. Most of us are aware of the 'arms race' that is defending public (and even private) facing systems from attackers. Spin up a cloud server somewhere and in just a couple of minutes (or less), it will start receiving intrusion attempts or be scanned for the purposes of information gathering. The internet is not a friendly place for the things that run on it or for those who use it. But attack and defense is only one area of security.</p>
<p>A number of my customers have been going through the rigorous process of achieving <a href="http://en.wikipedia.org/wiki/ISO/IEC_27001:2013" target="_blank" rel="noopener noreferrer">ISO27001 compliance</a>
. This is a security standard that is supposed to demonstrate that a company has a reliable, well-maintained and <em>effective</em> suite of information security processes which are appropriate for their business.</p>
<p>This is a much bigger (or)deal than <a href="https://en.wikipedia.org/wiki/Payment_Card_Industry_Data_Security_Standard" target="_blank" rel="noopener noreferrer">PCI-DSS</a>
 (compliance typically related to ensuring an organisation and its infrastructure is appropriately building systems that can perform payment transactions). ISO27001 has done a good job of educating people about the fact that security is not just about building safe systems or adequately monitoring and responding to compromises. Instead, security is again about risk. A system that is meant to be up and running, but is down (not due to being hacked), is still a security issue, because it has detrimental affects on the business in a myriad of ways (i.e money is being lost, reputations are being damaged, or the nature of the downtime might have also resulted in <em>integrity</em> issues such as data corruption).</p>
<p>As a result, my work as a consultant is not just about typical security technical details (ensuring there are adequate firewalls (inbound and outbound), that there are virus scans, intrusion detection systems, access control, password policies, version header stripping, isolation of systems etc) but designing broader procedures and systems that conform to or make up a customer's <a href="http://en.wikipedia.org/wiki/Information_security_management_system" target="_blank" rel="noopener noreferrer">ISMS</a>
.</p>
<p>This can include the automation of entries into risk registers, introducing threat monitoring systems, and attempting to automate disaster recovery tests. It can also include the implementation of rolling patch cycles (and routine automated checks to ensure services that depend on libraries which have been patched, have been restarted or reboots have taken place).</p>
<h2 id="things-i-like">Things I like</h2>
<p>Having said all that, there are of course some specific technical tools that particularly interest me at the moment in the area of security, some of which I have been deploying for organisations. These include:</p>
<ul>
<li>Threat intelligence (monitoring internet chatter with tools like <a href="https://old.mig5.net/content/monitoring-pastebincom-scumblr.html" target="_blank" rel="noopener noreferrer">Scumblr</a>
)</li>
<li>Hardware 2-factor auth with <a href="https://old.mig5.net/content/adding-yubikey-2-factor-authentication-ssh-and-sudo-debian.html" target="_blank" rel="noopener noreferrer">Yubikeys</a>
</li>
<li>Security by isolation models (such as the <a href="https://www.qubes-os.org/" target="_blank" rel="noopener noreferrer">Qubes</a>
 Linux distribution)</li>
<li>Intrusion detection and integrity checking (e.g, and last but not least, <a href="http://www.ossec.net/" target="_blank" rel="noopener noreferrer">OSSEC</a>
)</li>
</ul>
<p>OSSEC has been especially an asset to me since 2012 after a spate of Drupal vulnerabilities related to CKeditor. I have <a href="https://old.mig5.net/content/another-ossec-talk-drupalcamp-sydney-2014.html" target="_blank" rel="noopener noreferrer">given</a>
 <a href="https://old.mig5.net/content/ossec-and-drupal.html" target="_blank" rel="noopener noreferrer">talks</a>
 about its use and how important rigorous review of its alerts is, in terms of intrusion and pattern detection, and integrity (not just intrusions).</p>
<h2 id="a-race-against-time">A race against time</h2>
<p>A major mitigating factor to the issue of risk and security is timely detection. This includes monitoring not just of the systems you are running for signs of intrusion or human error (<code>chmod -R 777</code> anyone?), but also keeping tabs on the outside world. Most of us run software developed by third parties, and security vulnerabilities are constantly being discovered in them. The time between announcement of a security vulnerability (and its fix) and the mitigation of that threat, is crucial.</p>
<p>In October 2014, a critical <a href="https://www.drupal.org/SA-CORE-2014-005" target="_blank" rel="noopener noreferrer">SQL injection vulnerability in the Drupal CMS</a>
 was being exploited in the wild just 7 hours after its announcement (observed first-hand by myself). Many people didn't patch for some time, mostly because of these main reasons:</p>
<ul>
<li>They weren't subscribed to announcements about security vulnerabilities in Drupal (the dedicated <a href="https://www.drupal.org/mailing-lists" target="_blank" rel="noopener noreferrer">mailing lists</a>
 provided to the Drupal community this purpose, Twitter, RSS feeds etc)</li>
<li>They didn't understand the severity or nature of the threat and so under-reacted</li>
<li>They didn't have effective means of patching swiftly or in an automated fashion</li>
</ul>
<p>All three of these are what I consider to be valuable merits that a Drupal-savvy systems administrator can bring. Stay notified, carefully interpret what you are being notified about, and have a plan for what must be done. I observed similar mistakes by others in the wake of the <a href="http://en.wikipedia.org/wiki/Heartbleed" target="_blank" rel="noopener noreferrer">Heartbleed</a>
 vulnerability.</p>
<h2 id="other-common-pitfalls">Other common pitfalls</h2>
<p>Other areas I regularly see agencies fail at security, other than running vulnerable versions of software or due to lack of monitoring for intrusions/mistakes, include:</p>
<ul>
<li>Poor access control (developers have access to production systems) - this often goes hand-in-hand with the absence of a decent deployment/workflow/'backward sync from prod to stage' system (whereby there is little <em>need</em> to login to production systems)</li>
<li>Poor authentication control (no 2-factor auth, no SSH PKI, no screen locks on desktop environments, shared accounts/passwords etc)</li>
<li>Poor operational security (particularly increasing risk of client-side attacks) - e.g no laptop encryption, leaving sensitive data on laptops which are left in public places or could be susceptible to <a href="https://www.schneier.com/blog/archives/2009/10/evil_maid_attac.html" target="_blank" rel="noopener noreferrer">&#39;Evil Maid&#39;</a>
 attacks), private key misuse</li>
<li>Poor encryption (no use of VPNs, PGP, <em>use</em> of plaintext FTP systems, transmitting of passwords or other sensitive data in cleartext e-mails etc)</li>
<li>Poor firewalls (frequently I see cases of no/slack outbound firewall rules, or default policies of ACCEPT instead of DROP)</li>
<li>Poor implementations of backups, cron jobs (e.g database backup jobs that chew up disk space gradually and eventually crash and corrupt servers or data), or reckless tuning of memory settings etc which can lead to similar crashes</li>
<li>'Legacy' problems (services running that no longer need to be, data that should have been deleted but wasn't, or ex-employee accounts/access remaining active</li>
<li>Information leakages (no stripping of metadata, headers etc)</li>
<li>Poor definition of roles, responsibilities, change management, or chain of command (e.g no establishment of a chief security officer, lack of incident reporting or risk registers. Often it is mistakenly assumed by a development agency that a hosting company is taking care of problems that are not part of the hosting contract at all)</li>
<li>This one I have experienced myself - the paralysing effect of fear (e.g customer knows something is wrong but is worried about acting, in case the act of change breaks something else/makes it worse. Inevitably, it means nothing is done)</li>
</ul>
<p>None of these things are devoid of solutions. The good news is that things can only get better from there!</p>
<h2 id="summing-up">Summing up</h2>
<p>Security is about a lot more than staying on top of software vulnerabilities. It is a constant arms race whereby the 'good guys' are usually on the back foot. It requires constant re-evaluation of procedures, policies, changes to systems, and evaluation of the 'current climate'. Most importantly, security involves making sensible judgment calls by business leaders as to which risks matter the most. No-one can defend against everything or win all the time. Excessive security is usually at the cost of usability, and can make your staff either unhappy or subversive (they will simply find ways around the rules you've enforced, or sacrifice quality and/or speed to conform to the rigidity).</p>
<p>Fortunately, in the wake of major world events such as the <a href="http://www.theguardian.com/world/2013/sep/05/nsa-gchq-encryption-codes-security" target="_blank" rel="noopener noreferrer">&#39;Snowden revelations&#39;</a>
, security issues are receiving a lot more scrutiny by businesses of all sizes than they perhaps used to. Increasingly, areas such as encryption, data privacy etc and are being re-evaluated, and major organisations like Google are starting to play hard-ball by penalising organisations who deploy poorly secured systems (bad SSL certs/algorithms, preference to plaintext traffic etc).</p>
<p>I think the current trend is a good one and may help build a safer internet. In the meantime I hope to continue to help agencies improve the security of their systems, both for the sake of their businesses and the customers/users they serve. <a href="/contact/">Get in touch</a>
 if you are a Drupal shop seeking to improve on any of the above issues I've mentioned.</p>
<h2 id="coming-up">Coming up</h2>
<p>Coming up next, <a href="/news/so-what-it-you-exactly-do-part-four-monitoring/">production Linux monitoring</a>
 - where I'll be talking about effective monitoring solutions for server administration (and, yes, security). Then:</p>
<p><a href="/news/so-what-it-you-exactly-do-part-five-troubleshooting/">Troubleshooting Linux systems: patterns, evidence, and good notes</a>
,
<a href="/news/so-what-it-you-exactly-do-part-six-high-availability/">High availability: resilience without unnecessary complexity</a>
,
<a href="/news/so-what-it-you-exactly-do-part-seven-communication/">Sysadmin communication: incident updates, plans, and trust</a>
.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">This series</div>
  <ul>
<li><a href="/news/so-what-it-you-exactly-do-part-one-continuous-deployment/">Part 1 - Continuous deployment</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-two-config-management/">Part 2 - Config management</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-three-security/">Part 3 - Security</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-four-monitoring/">Part 4 - Monitoring</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-five-troubleshooting/">Part 5 - Troubleshooting</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-six-high-availability/">Part 6 - High availability</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-seven-communication/">Part 7 - Communication</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Original</div>
  <ul>
<li><a href="https://old.mig5.net/content/so-what-it-you-exactly-do-part-three-security.html" target="_blank" rel="noopener noreferrer">Original post on old.mig5.net</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need a sysadmin?</div>
  <div class="smallprint mb-3">I do contract sysadmin and automation work and can help with deployment, config management, security, monitoring, troubleshooting, high availability, and the communication that glues it all together.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>Linux configuration management: eliminating snowflake servers</title>
      <link>https://mig5.net/news/so-what-it-you-exactly-do-part-two-config-management/</link>
      <pubDate>Wed, 16 Sep 2026 10:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/so-what-it-you-exactly-do-part-two-config-management/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-gear"></i><span>From the archives</span></div>
    <h1 class="display-6 fw-semibold mb-2">Linux configuration management: eliminating snowflake servers</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-16">September 16, 2026</time></p>
    <p class="lead mb-0">Part two of a seven-part series from 2016 on what a contract sysadmin does. This one covers
config management: enforcing a known state across servers, the cost-savings of version-controlled
infrastructure as code, and why you probably can't afford not to do it.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-16T10:00:00&#43;10:00">16 September 2026</time>
        </div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">From the archives - originally published in 2016</div>
  A decade on, I do some of this differently - tools, the industry, and the threat landscape have
all moved on. But the underlying ideas have stayed remarkably relevant even ten years later.
</div>

<p>Continuing on from <a href="/news/so-what-it-you-exactly-do-part-one-continuous-deployment/">continuous deployment for safer production changes</a>
, where I discuss the far-ranging benefits of continuous deployment, today I'll cover off another large part of the 'what do I do as a sysadmin' question: that being, config management.</p>
<p>Config management doesn't just mean capturing or templating config files for services. Put more broadly, the art of config management allows you to enforce a known state about your system, such as what is running, how it should run, and what it takes to make it run. Proper config management results in a suite of information that <em>describes</em> what your infrastructure does or how it should work.</p>
<h2 id="a-tool-a-ball-of-wool">A tool, a ball of wool</h2>
<p>I touched on a pattern in Part One, in that many areas of systems administration start out sounding like a simple task or tool to solve one problem. Continuous Deployment might mean automating making changes to a website. Config management might mean making sure the file <code>/etc/sudoers</code> is identical across a cluster of servers. But as we saw in Part One, those small changes or additions as part of achieving Continuous Deployment might also mean speeding up the application development process, making a business more profitable - or even making someone's job less risky and therefore more enjoyable (and therefore making a human being more healthy). In the same way, config management is a ball of wool that inevitably leads to other benefits.</p>
<p>Config management can pave the way for automating the provisioning process should you build more systems, scale out existing systems, or are forced to rebuild (which means it also can <a href="https://old.mig5.net/content/principles-building-basic-disaster-recovery-zone.html" target="_blank" rel="noopener noreferrer">aid the Disaster Recovery process</a>
).</p>
<p>It can enforce consistency across development, staging and production environments. Very significantly, it can enhance the quality of making changes to systems, because config management is code, and can be version controlled, peer reviewed, tested through syntax checkers and simulations, or staged out, before being applied in critical production environments. Less mistakes happen - therefore money is saved.</p>
<p>It can speed up the on-boarding process when a new developer joins a project (I make developers' Vagrant instances pull the same configuration from a Puppet master that the stage/prod servers do). It is common sense that you should not develop applications in PHP 5.5 if you are deploying to a production PHP 5.4 system. Developers love to roll their eyes about this, but this will make sense to business owners who aren't infatuated with the idea of a workstation somehow representing a personality: <em>your customer should not be paying money for you to troubleshoot version-inconsistent problems that shouldn't have happened in the first place</em>. You wouldn't pay a mechanic to try and reproduce a problem you've reported with your car's transmission, in a lawnmower. It is a question of professionalism as much as anything else. If you don't understand this, you don't deserve to be asking for money.</p>
<h2 id="but-config-management-takes-time-and-costs-money">But Config Management takes time and costs money!</h2>
<p>In 2008, I was working for a development agency as the 'ops guy' interfacing with the ops team of the upstream hosting department, for a major international charity site. We were in the process of scoping out the staging and production environment. A Hosting Ops guy was very keen to take advantage of this cool new tool called <a href="https://puppetlabs.com/" target="_blank" rel="noopener noreferrer">Puppet</a>
. The budget impact of writing Puppet manifests was deemed too much, and the idea was canned. We all remember the pain of having to scale out the infrastructure during the peak period of that charity, while <em>hundreds</em> of thousands of dollars were being lost by the hour as the site couldn't withstand traffic. Or spending hours trying to troubleshoot some 'random' problem that turned out to be a typo in a file on just one server in a cluster, thanks to the manual effort.</p>
<p>Suffice to say, Puppet was used on all such subsequent projects. Now I can't imagine <em>not</em> using it. And in 2015, many many public Puppet modules have been released for reuse by others, thus reducing some of that cost factor. The tech community tries to help itself.</p>
<h2 id="to-summarise">To summarise</h2>
<p>There's not much else to say here - this is just part of the answer to the open-ended question 'what exactly do you do, sysadmin?'. I spend a lot of time writing Puppet modules even for basic stuff - often even reverse-engineering what's already been done 'on the fly' back into code, in case it has to be reused later. To the uninitiated - and sometimes to those who are paying for it - it seems like a lot of overkill. To those of us who've lived through any of the above experiences, we know it pays for itself.</p>
<p>How has config management helped your business or job?</p>
<h2 id="coming-up">Coming up...</h2>
<p>In <a href="/news/so-what-it-you-exactly-do-part-three-security/">Linux infrastructure security as risk management</a>
 I'll talk about security in systems administration. This will include everything from audits, hardening, intrusion detection, to helping businesses achieve PCI-DSS compliance and ISO27001 certification.</p>
<p>After that:</p>
<p><a href="/news/so-what-it-you-exactly-do-part-four-monitoring/">Production Linux monitoring: what to watch and why</a>
,
<a href="/news/so-what-it-you-exactly-do-part-five-troubleshooting/">Troubleshooting Linux systems: patterns, evidence, and good notes</a>
,
<a href="/news/so-what-it-you-exactly-do-part-six-high-availability/">High availability: resilience without unnecessary complexity</a>
,
<a href="/news/so-what-it-you-exactly-do-part-seven-communication/">Sysadmin communication: incident updates, plans, and trust</a>
.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">This series</div>
  <ul>
<li><a href="/news/so-what-it-you-exactly-do-part-one-continuous-deployment/">Part 1 - Continuous deployment</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-two-config-management/">Part 2 - Config management</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-three-security/">Part 3 - Security</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-four-monitoring/">Part 4 - Monitoring</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-five-troubleshooting/">Part 5 - Troubleshooting</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-six-high-availability/">Part 6 - High availability</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-seven-communication/">Part 7 - Communication</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Original</div>
  <ul>
<li><a href="https://old.mig5.net/content/so-what-it-you-exactly-do-part-two-config-management.html" target="_blank" rel="noopener noreferrer">Original post on old.mig5.net</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need a sysadmin?</div>
  <div class="smallprint mb-3">I do contract sysadmin and automation work and can help with deployment, config management, security, monitoring, troubleshooting, high availability, and the communication that glues it all together.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-gear"></i><span>From the archives</span></div>
    <h1 class="display-6 fw-semibold mb-2">Linux configuration management: eliminating snowflake servers</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-16">September 16, 2026</time></p>
    <p class="lead mb-0">Part two of a seven-part series from 2016 on what a contract sysadmin does. This one covers
config management: enforcing a known state across servers, the cost-savings of version-controlled
infrastructure as code, and why you probably can't afford not to do it.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-16T10:00:00&#43;10:00">16 September 2026</time>
        </div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">From the archives - originally published in 2016</div>
  A decade on, I do some of this differently - tools, the industry, and the threat landscape have
all moved on. But the underlying ideas have stayed remarkably relevant even ten years later.
</div>

<p>Continuing on from <a href="/news/so-what-it-you-exactly-do-part-one-continuous-deployment/">continuous deployment for safer production changes</a>
, where I discuss the far-ranging benefits of continuous deployment, today I'll cover off another large part of the 'what do I do as a sysadmin' question: that being, config management.</p>
<p>Config management doesn't just mean capturing or templating config files for services. Put more broadly, the art of config management allows you to enforce a known state about your system, such as what is running, how it should run, and what it takes to make it run. Proper config management results in a suite of information that <em>describes</em> what your infrastructure does or how it should work.</p>
<h2 id="a-tool-a-ball-of-wool">A tool, a ball of wool</h2>
<p>I touched on a pattern in Part One, in that many areas of systems administration start out sounding like a simple task or tool to solve one problem. Continuous Deployment might mean automating making changes to a website. Config management might mean making sure the file <code>/etc/sudoers</code> is identical across a cluster of servers. But as we saw in Part One, those small changes or additions as part of achieving Continuous Deployment might also mean speeding up the application development process, making a business more profitable - or even making someone's job less risky and therefore more enjoyable (and therefore making a human being more healthy). In the same way, config management is a ball of wool that inevitably leads to other benefits.</p>
<p>Config management can pave the way for automating the provisioning process should you build more systems, scale out existing systems, or are forced to rebuild (which means it also can <a href="https://old.mig5.net/content/principles-building-basic-disaster-recovery-zone.html" target="_blank" rel="noopener noreferrer">aid the Disaster Recovery process</a>
).</p>
<p>It can enforce consistency across development, staging and production environments. Very significantly, it can enhance the quality of making changes to systems, because config management is code, and can be version controlled, peer reviewed, tested through syntax checkers and simulations, or staged out, before being applied in critical production environments. Less mistakes happen - therefore money is saved.</p>
<p>It can speed up the on-boarding process when a new developer joins a project (I make developers' Vagrant instances pull the same configuration from a Puppet master that the stage/prod servers do). It is common sense that you should not develop applications in PHP 5.5 if you are deploying to a production PHP 5.4 system. Developers love to roll their eyes about this, but this will make sense to business owners who aren't infatuated with the idea of a workstation somehow representing a personality: <em>your customer should not be paying money for you to troubleshoot version-inconsistent problems that shouldn't have happened in the first place</em>. You wouldn't pay a mechanic to try and reproduce a problem you've reported with your car's transmission, in a lawnmower. It is a question of professionalism as much as anything else. If you don't understand this, you don't deserve to be asking for money.</p>
<h2 id="but-config-management-takes-time-and-costs-money">But Config Management takes time and costs money!</h2>
<p>In 2008, I was working for a development agency as the 'ops guy' interfacing with the ops team of the upstream hosting department, for a major international charity site. We were in the process of scoping out the staging and production environment. A Hosting Ops guy was very keen to take advantage of this cool new tool called <a href="https://puppetlabs.com/" target="_blank" rel="noopener noreferrer">Puppet</a>
. The budget impact of writing Puppet manifests was deemed too much, and the idea was canned. We all remember the pain of having to scale out the infrastructure during the peak period of that charity, while <em>hundreds</em> of thousands of dollars were being lost by the hour as the site couldn't withstand traffic. Or spending hours trying to troubleshoot some 'random' problem that turned out to be a typo in a file on just one server in a cluster, thanks to the manual effort.</p>
<p>Suffice to say, Puppet was used on all such subsequent projects. Now I can't imagine <em>not</em> using it. And in 2015, many many public Puppet modules have been released for reuse by others, thus reducing some of that cost factor. The tech community tries to help itself.</p>
<h2 id="to-summarise">To summarise</h2>
<p>There's not much else to say here - this is just part of the answer to the open-ended question 'what exactly do you do, sysadmin?'. I spend a lot of time writing Puppet modules even for basic stuff - often even reverse-engineering what's already been done 'on the fly' back into code, in case it has to be reused later. To the uninitiated - and sometimes to those who are paying for it - it seems like a lot of overkill. To those of us who've lived through any of the above experiences, we know it pays for itself.</p>
<p>How has config management helped your business or job?</p>
<h2 id="coming-up">Coming up...</h2>
<p>In <a href="/news/so-what-it-you-exactly-do-part-three-security/">Linux infrastructure security as risk management</a>
 I'll talk about security in systems administration. This will include everything from audits, hardening, intrusion detection, to helping businesses achieve PCI-DSS compliance and ISO27001 certification.</p>
<p>After that:</p>
<p><a href="/news/so-what-it-you-exactly-do-part-four-monitoring/">Production Linux monitoring: what to watch and why</a>
,
<a href="/news/so-what-it-you-exactly-do-part-five-troubleshooting/">Troubleshooting Linux systems: patterns, evidence, and good notes</a>
,
<a href="/news/so-what-it-you-exactly-do-part-six-high-availability/">High availability: resilience without unnecessary complexity</a>
,
<a href="/news/so-what-it-you-exactly-do-part-seven-communication/">Sysadmin communication: incident updates, plans, and trust</a>
.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">This series</div>
  <ul>
<li><a href="/news/so-what-it-you-exactly-do-part-one-continuous-deployment/">Part 1 - Continuous deployment</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-two-config-management/">Part 2 - Config management</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-three-security/">Part 3 - Security</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-four-monitoring/">Part 4 - Monitoring</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-five-troubleshooting/">Part 5 - Troubleshooting</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-six-high-availability/">Part 6 - High availability</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-seven-communication/">Part 7 - Communication</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Original</div>
  <ul>
<li><a href="https://old.mig5.net/content/so-what-it-you-exactly-do-part-two-config-management.html" target="_blank" rel="noopener noreferrer">Original post on old.mig5.net</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need a sysadmin?</div>
  <div class="smallprint mb-3">I do contract sysadmin and automation work and can help with deployment, config management, security, monitoring, troubleshooting, high availability, and the communication that glues it all together.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>Continuous deployment for safer production changes</title>
      <link>https://mig5.net/news/so-what-it-you-exactly-do-part-one-continuous-deployment/</link>
      <pubDate>Wed, 16 Sep 2026 09:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/so-what-it-you-exactly-do-part-one-continuous-deployment/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-rocket-takeoff"></i><span>From the archives</span></div>
    <h1 class="display-6 fw-semibold mb-2">Continuous deployment for safer production changes</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-16">September 16, 2026</time></p>
    <p class="lead mb-0">Part one of a seven-part series from 2016 on what a contract sysadmin actually does. This one
covers continuous deployment: how automating change reduces risk, shifts developer culture, and
makes deploying something to look forward to rather than fear.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-16T09:00:00&#43;10:00">16 September 2026</time>
        </div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">From the archives - originally published in 2016</div>
  A decade on, I do some of this differently - tools, the industry, and the threat landscape have
all moved on. But the underlying ideas have stayed remarkably relevant even ten years later.
</div>

<p>I hear this question a lot - both from non-technical folk, as well as agencies who know they are 'missing something' in their approach to deploying, securing and scaling applications, but aren't sure if a sysadmin will solve it. 'What is it that you (a sysadmin) actually does (e.g the day-to-day, or in general)?'</p>
<p>Sysadmin work is, in my opinion, an unusual role because it traverses a huge range of technologies, problems, risks, and there are often many solutions to the one task. In addition, the role can be both innovative as well as reactive. Things need to be built, and those things break. The sysadmin usually deals with both.</p>
<p>Despite years of working with agencies and their developers to help design tools that developers want to use, the sysadmin often has to work wonders and is frequently a 'lone ranger' with a complex peer group that is sometimes reached out to by simply 'Googling it'.</p>
<p>I thought I'd describe some of the things I've been doing, especially in the last couple of years. There seems no better place to start than with a whopper, and what allowed me to take the leap to full-time consulting: continuous deployment.</p>
<h2 id="anxiety-sucks">Anxiety sucks</h2>
<p>As many in the Drupal community know, around 2009/2010 I helped pioneer solutions to <a href="https://old.mig5.net/content/drupal-deployments-workflows-version-control-drushmake-and-aegir.html" target="_blank" rel="noopener noreferrer">the famous &#39;dev/stage/live&#39; workflow problem</a>
 when deploying Drupal apps across such chained environments - particularly with a focus on Aegir, but the paradigm was more general than that - more general, even, than Drupal. My interest has always been in automating the procedure of delivering change (that's how I found Aegir). This began because I'm simply lazy. It ended up being more altruistic, after seeing firsthand that once that process is easier or automatic, the 'change' becomes not only easier, but enjoyable.</p>
<p>Change is risky - not just technical change. Things might go wrong. No-one likes to break things that weren't meant to be broken. People are scared of getting in trouble or causing problems. We often would rather go out of our way to avoid upsetting the 'norm' or what we think could wind up being a worse experience than we expected it to be. The inevitable side-effect of this is that necessary changes get put off, procrastinated, and build up in a queue. It's ironic that sometimes things break only <em>because</em> of the sheer amount of 'change' that's built up in this procrastination - that the very accumulation itself sometimes introduces new ways to fail that weren't apparent as those little bits and pieces were made one at a time. Ever put off going to the dentist?</p>
<p>The crucial aim of automating change through effective change management, is to reduce risk. But looking beyond trying to stop things 'going wrong', the aim should also be to make dealing with things going wrong, an easier process. Things will always go wrong. What happens next is what matters.</p>
<p>Much of my work building (usually bespoke these days, not Aegir*) deployment solutions is automating the deployment, but also handling rollback, notification and management of the situation gone wrong. A simple example: a bad <code>hook_update()</code> can break your site, and reverting your git commit isn't going to change your database. What then? Build systems based on a sensible methodology: back up first, attempt upgrade, rollback if it fails, alert people. Try to be quiet about success, so that it becomes 'the norm'. As you can see, this goes beyond 'what my awesome shell script can do'. This is about changing attitudes, and trying to make people feel less ill about what they have to do. Deployment anxiety (or any anxiety, for that matter) sucks, and it doesn't have to exist.</p>
<p>A fascinating phenomenon that I've observed in agencies is that once developers recognise the convenience of the deployment process ('what? I just push to Git and that's it?'), they seem to become naturally compelled to practice the famous mantra of 'commit early, commit often'. A pain point of development (deploying) is suddenly gone, so it actually becomes enjoyable to practice it - and you practice it by pushing your bite-size chunks of change more frequently. Developers are smart - they figure this out for themselves. No need for a training session on 'how often we're gonna deploy now' - just show them the new deployment system and they will do this themselves.</p>
<p>As a result, the changes start to become simpler and more frequent. This has a multitude of effects: the first is that quality improves, because it's harder to make big mistakes in small amounts. The 'bank-up queue' of change is gone, so many unexpected side-effects simply don't exist. Another effect is that developers become more confident in the process. Morale improves. On-boarding of new developers, or re-arranging of roles, becomes easier (because git history becomes easier to understand). And when the occasional mistake is made and the system detects and backs it out or alerts about it, much time and frustration is saved that would normally be consumed trying to find out exactly what's gone wrong.</p>
<p>Multiply these effects N times if you use a deployment methodology (not just tool!) that is abstracted and consistent across all N projects.</p>
<h2 id="to-summarise">To summarise</h2>
<p>The work of 'sysadmin' is not just a reactive <em>or</em> innovative role for solving <em>technical</em> problems. The sysadmin doesn't have to live in a basement and hate everybody. Designing and implementing automated deployment systems also can have a sizeable effect on the culture of an organisation. It can help people enjoy coming to work. It can result in higher quality of work. And that can result in a better reputation for a company, in the eyes of its customers. And that can result in more customers - and therefore, more money. All from the desire to make a pain point less painful.</p>
<p>What other benefits has automating continuous deployment offered your business? I am also happy to answer questions (or <a href="/contact/">offer engagement</a>
) if you are trying to work out a way to do what I've described. Why not hire a consultant who's in the business of helping make that happen?</p>
<h2 id="coming-up">Coming up...</h2>
<p><a href="/news/so-what-it-you-exactly-do-part-two-config-management/">In Part Two</a>
 of what I do as a sysadmin, I'll talk some more about automation and enforcing state as part of change management (think: Puppet).</p>
<p><a href="/news/so-what-it-you-exactly-do-part-three-security/">Linux infrastructure security as risk management</a>
 will talk about security,
<a href="/news/so-what-it-you-exactly-do-part-four-monitoring/">Production Linux monitoring: what to watch and why</a>
,
<a href="/news/so-what-it-you-exactly-do-part-five-troubleshooting/">Troubleshooting Linux systems: patterns, evidence, and good notes</a>
,
<a href="/news/so-what-it-you-exactly-do-part-six-high-availability/">High availability: resilience without unnecessary complexity</a>
,
<a href="/news/so-what-it-you-exactly-do-part-seven-communication/">Sysadmin communication: incident updates, plans, and trust</a>
.</p>
<p>* Lots of people are often surprised to discover I don't do a lot of Aegir work now. Most of my customers now have more bespoke needs (such as Drupal/Magento hybrids, 'unusual' Drupal deployments, complex integrations/inter-communications, high-availability clusters etc - stuff that Aegir can be a bit inflexible about). Inevitably I am in business, and it is often more viable time-wise (and therefore commercially) to build bespoke tools to solve such problems. Fabric-driven Jenkins deployment is my main solution these days. Feel free to <a href="/contact/">get in touch</a>
 if you think it would be something you might need.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">This series</div>
  <ul>
<li><a href="/news/so-what-it-you-exactly-do-part-one-continuous-deployment/">Part 1 - Continuous deployment</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-two-config-management/">Part 2 - Config management</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-three-security/">Part 3 - Security</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-four-monitoring/">Part 4 - Monitoring</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-five-troubleshooting/">Part 5 - Troubleshooting</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-six-high-availability/">Part 6 - High availability</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-seven-communication/">Part 7 - Communication</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Original</div>
  <ul>
<li><a href="https://old.mig5.net/content/so-what-it-you-exactly-do-part-one-continuous-deployment.html" target="_blank" rel="noopener noreferrer">Original post on old.mig5.net</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need a sysadmin?</div>
  <div class="smallprint mb-3">I do contract sysadmin and automation work and can help with deployment, config management, security, monitoring, troubleshooting, high availability, and the communication that glues it all together.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-rocket-takeoff"></i><span>From the archives</span></div>
    <h1 class="display-6 fw-semibold mb-2">Continuous deployment for safer production changes</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-16">September 16, 2026</time></p>
    <p class="lead mb-0">Part one of a seven-part series from 2016 on what a contract sysadmin actually does. This one
covers continuous deployment: how automating change reduces risk, shifts developer culture, and
makes deploying something to look forward to rather than fear.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-16T09:00:00&#43;10:00">16 September 2026</time>
        </div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">From the archives - originally published in 2016</div>
  A decade on, I do some of this differently - tools, the industry, and the threat landscape have
all moved on. But the underlying ideas have stayed remarkably relevant even ten years later.
</div>

<p>I hear this question a lot - both from non-technical folk, as well as agencies who know they are 'missing something' in their approach to deploying, securing and scaling applications, but aren't sure if a sysadmin will solve it. 'What is it that you (a sysadmin) actually does (e.g the day-to-day, or in general)?'</p>
<p>Sysadmin work is, in my opinion, an unusual role because it traverses a huge range of technologies, problems, risks, and there are often many solutions to the one task. In addition, the role can be both innovative as well as reactive. Things need to be built, and those things break. The sysadmin usually deals with both.</p>
<p>Despite years of working with agencies and their developers to help design tools that developers want to use, the sysadmin often has to work wonders and is frequently a 'lone ranger' with a complex peer group that is sometimes reached out to by simply 'Googling it'.</p>
<p>I thought I'd describe some of the things I've been doing, especially in the last couple of years. There seems no better place to start than with a whopper, and what allowed me to take the leap to full-time consulting: continuous deployment.</p>
<h2 id="anxiety-sucks">Anxiety sucks</h2>
<p>As many in the Drupal community know, around 2009/2010 I helped pioneer solutions to <a href="https://old.mig5.net/content/drupal-deployments-workflows-version-control-drushmake-and-aegir.html" target="_blank" rel="noopener noreferrer">the famous &#39;dev/stage/live&#39; workflow problem</a>
 when deploying Drupal apps across such chained environments - particularly with a focus on Aegir, but the paradigm was more general than that - more general, even, than Drupal. My interest has always been in automating the procedure of delivering change (that's how I found Aegir). This began because I'm simply lazy. It ended up being more altruistic, after seeing firsthand that once that process is easier or automatic, the 'change' becomes not only easier, but enjoyable.</p>
<p>Change is risky - not just technical change. Things might go wrong. No-one likes to break things that weren't meant to be broken. People are scared of getting in trouble or causing problems. We often would rather go out of our way to avoid upsetting the 'norm' or what we think could wind up being a worse experience than we expected it to be. The inevitable side-effect of this is that necessary changes get put off, procrastinated, and build up in a queue. It's ironic that sometimes things break only <em>because</em> of the sheer amount of 'change' that's built up in this procrastination - that the very accumulation itself sometimes introduces new ways to fail that weren't apparent as those little bits and pieces were made one at a time. Ever put off going to the dentist?</p>
<p>The crucial aim of automating change through effective change management, is to reduce risk. But looking beyond trying to stop things 'going wrong', the aim should also be to make dealing with things going wrong, an easier process. Things will always go wrong. What happens next is what matters.</p>
<p>Much of my work building (usually bespoke these days, not Aegir*) deployment solutions is automating the deployment, but also handling rollback, notification and management of the situation gone wrong. A simple example: a bad <code>hook_update()</code> can break your site, and reverting your git commit isn't going to change your database. What then? Build systems based on a sensible methodology: back up first, attempt upgrade, rollback if it fails, alert people. Try to be quiet about success, so that it becomes 'the norm'. As you can see, this goes beyond 'what my awesome shell script can do'. This is about changing attitudes, and trying to make people feel less ill about what they have to do. Deployment anxiety (or any anxiety, for that matter) sucks, and it doesn't have to exist.</p>
<p>A fascinating phenomenon that I've observed in agencies is that once developers recognise the convenience of the deployment process ('what? I just push to Git and that's it?'), they seem to become naturally compelled to practice the famous mantra of 'commit early, commit often'. A pain point of development (deploying) is suddenly gone, so it actually becomes enjoyable to practice it - and you practice it by pushing your bite-size chunks of change more frequently. Developers are smart - they figure this out for themselves. No need for a training session on 'how often we're gonna deploy now' - just show them the new deployment system and they will do this themselves.</p>
<p>As a result, the changes start to become simpler and more frequent. This has a multitude of effects: the first is that quality improves, because it's harder to make big mistakes in small amounts. The 'bank-up queue' of change is gone, so many unexpected side-effects simply don't exist. Another effect is that developers become more confident in the process. Morale improves. On-boarding of new developers, or re-arranging of roles, becomes easier (because git history becomes easier to understand). And when the occasional mistake is made and the system detects and backs it out or alerts about it, much time and frustration is saved that would normally be consumed trying to find out exactly what's gone wrong.</p>
<p>Multiply these effects N times if you use a deployment methodology (not just tool!) that is abstracted and consistent across all N projects.</p>
<h2 id="to-summarise">To summarise</h2>
<p>The work of 'sysadmin' is not just a reactive <em>or</em> innovative role for solving <em>technical</em> problems. The sysadmin doesn't have to live in a basement and hate everybody. Designing and implementing automated deployment systems also can have a sizeable effect on the culture of an organisation. It can help people enjoy coming to work. It can result in higher quality of work. And that can result in a better reputation for a company, in the eyes of its customers. And that can result in more customers - and therefore, more money. All from the desire to make a pain point less painful.</p>
<p>What other benefits has automating continuous deployment offered your business? I am also happy to answer questions (or <a href="/contact/">offer engagement</a>
) if you are trying to work out a way to do what I've described. Why not hire a consultant who's in the business of helping make that happen?</p>
<h2 id="coming-up">Coming up...</h2>
<p><a href="/news/so-what-it-you-exactly-do-part-two-config-management/">In Part Two</a>
 of what I do as a sysadmin, I'll talk some more about automation and enforcing state as part of change management (think: Puppet).</p>
<p><a href="/news/so-what-it-you-exactly-do-part-three-security/">Linux infrastructure security as risk management</a>
 will talk about security,
<a href="/news/so-what-it-you-exactly-do-part-four-monitoring/">Production Linux monitoring: what to watch and why</a>
,
<a href="/news/so-what-it-you-exactly-do-part-five-troubleshooting/">Troubleshooting Linux systems: patterns, evidence, and good notes</a>
,
<a href="/news/so-what-it-you-exactly-do-part-six-high-availability/">High availability: resilience without unnecessary complexity</a>
,
<a href="/news/so-what-it-you-exactly-do-part-seven-communication/">Sysadmin communication: incident updates, plans, and trust</a>
.</p>
<p>* Lots of people are often surprised to discover I don't do a lot of Aegir work now. Most of my customers now have more bespoke needs (such as Drupal/Magento hybrids, 'unusual' Drupal deployments, complex integrations/inter-communications, high-availability clusters etc - stuff that Aegir can be a bit inflexible about). Inevitably I am in business, and it is often more viable time-wise (and therefore commercially) to build bespoke tools to solve such problems. Fabric-driven Jenkins deployment is my main solution these days. Feel free to <a href="/contact/">get in touch</a>
 if you think it would be something you might need.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">This series</div>
  <ul>
<li><a href="/news/so-what-it-you-exactly-do-part-one-continuous-deployment/">Part 1 - Continuous deployment</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-two-config-management/">Part 2 - Config management</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-three-security/">Part 3 - Security</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-four-monitoring/">Part 4 - Monitoring</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-five-troubleshooting/">Part 5 - Troubleshooting</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-six-high-availability/">Part 6 - High availability</a>
</li>
<li><a href="/news/so-what-it-you-exactly-do-part-seven-communication/">Part 7 - Communication</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Original</div>
  <ul>
<li><a href="https://old.mig5.net/content/so-what-it-you-exactly-do-part-one-continuous-deployment.html" target="_blank" rel="noopener noreferrer">Original post on old.mig5.net</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need a sysadmin?</div>
  <div class="smallprint mb-3">I do contract sysadmin and automation work and can help with deployment, config management, security, monitoring, troubleshooting, high availability, and the communication that glues it all together.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>Starting a Qube and its dependencies in Qubes OS</title>
      <link>https://mig5.net/news/qubes-start-deps/</link>
      <pubDate>Sat, 12 Sep 2026 00:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/qubes-start-deps/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-diagram-3"></i><span>Qubes OS</span></div>
    <h1 class="display-6 fw-semibold mb-2">Starting a Qube and its dependencies in Qubes OS</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-12">September 12, 2026</time></p>
    <p class="lead mb-0">A howto for qvm-start-deps: a dom0 script that recursively starts a qube's declared
dependencies - Split SSH vaults, service qubes, custom cross-qube relationships - before the
target, using qvm-features as dependency tags.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-12T00:00:00&#43;10:00">12 September 2026</time>
        </div>

<h2 id="the-scenario">The scenario</h2>
<p>You have a qube - call it <code>work</code> - that needs more than a network connection to be useful. Its
SSH agent lives in a vault qube via Split SSH. Its GPG key lives in another vault via Split GPG.
A third qube runs a local service it connects to over qrexec. When you start <code>work</code>, you want
all of those started first, in the right order, without thinking about it.</p>
<p>Qubes starts the network chain for you. If <code>work</code>'s netvm is <code>sys-firewall</code> and
<code>sys-firewall</code> isn't running, <code>qvm-start work</code> will start it (and <code>sys-net</code> underneath it)
automatically. But anything beyond that is your problem. Split SSH providers, Split GPG vaults,
service-hosting qubes - Qubes doesn't know about those relationships. You either start them by
hand each time, or you write a script.</p>
<h2 id="the-dependency-tag-approach">The dependency-tag approach</h2>
<p><code>qvm-start-deps</code> is a small Python script that runs in dom0. It reads a custom feature -
<code>x-dep.requires</code> by default - from the target qube, recursively resolves all declared
dependencies, topologically sorts them, and starts them in order before starting the target.</p>
<p>Qubes calls these &quot;features&quot; - key-value pairs stored on each qube via <code>qvm-features</code>. They're
not the same as Qubes tags (set with <code>qvm-tags</code>), which are simple labels with no value. We're
using a custom feature, <code>x-dep.requires</code>, as a dependency tag: its value is a whitespace-separated
list of qube names that must be started before this qube.</p>
<p>Setting <code>x-dep.requires</code> on <code>work</code> to <code>split-ssh-vault split-gpg-vault build-server</code> means:
before starting <code>work</code>, start those three (and their own dependencies, recursively) first.</p>
<p>The script uses the <code>qubesadmin</code> Python library - the same one that backs the <code>qvm-*</code> CLI
tools - so it runs in dom0 with no extra dependencies.</p>
<h2 id="installing-the-script">Installing the script</h2>
<p>Save the script to dom0 as <code>qvm-start-deps</code> and make it executable:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">chmod +x qvm-start-deps
</span></span></code></pre></div><p>Place it somewhere in <code>$PATH</code> - <code>/usr/local/bin/</code> is the usual spot for dom0 scripts that
aren't managed by the package manager.</p>
<p>The full script:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="ch">#!/usr/bin/python3</span>
</span></span><span class="line"><span class="cl"><span class="s2">&#34;&#34;&#34;Start Qubes domains after recursively starting declared dependencies.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">__future__</span> <span class="kn">import</span> <span class="n">annotations</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">argparse</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">fcntl</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">os</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">sys</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">pathlib</span> <span class="kn">import</span> <span class="n">Path</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">typing</span> <span class="kn">import</span> <span class="n">Iterable</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">qubesadmin</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">qubesadmin.exc</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">DEFAULT_FEATURE</span> <span class="o">=</span> <span class="s2">&#34;x-dep.requires&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">RUNTIME_DIR</span> <span class="o">=</span> <span class="n">Path</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="n">os</span><span class="o">.</span><span class="n">environ</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;XDG_RUNTIME_DIR&#34;</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;/run/user/</span><span class="si">{</span><span class="n">os</span><span class="o">.</span><span class="n">getuid</span><span class="p">()</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">LOCK_PATH</span> <span class="o">=</span> <span class="n">RUNTIME_DIR</span> <span class="o">/</span> <span class="s2">&#34;qvm-start-deps.lock&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">DependencyError</span><span class="p">(</span><span class="ne">RuntimeError</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;The configured dependency graph is invalid.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">dependency_names</span><span class="p">(</span><span class="n">vm</span><span class="p">,</span> <span class="n">feature</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]:</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;Return the unique, ordered dependencies declared on a qube.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">value</span> <span class="o">=</span> <span class="n">vm</span><span class="o">.</span><span class="n">features</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">feature</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">value</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Qube names cannot contain whitespace, so a whitespace-separated</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># feature value is sufficient.</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">list</span><span class="p">(</span><span class="nb">dict</span><span class="o">.</span><span class="n">fromkeys</span><span class="p">(</span><span class="nb">str</span><span class="p">(</span><span class="n">value</span><span class="p">)</span><span class="o">.</span><span class="n">split</span><span class="p">()))</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">resolve_order</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="n">app</span><span class="p">:</span> <span class="n">qubesadmin</span><span class="o">.</span><span class="n">Qubes</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">targets</span><span class="p">:</span> <span class="n">Iterable</span><span class="p">[</span><span class="nb">str</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">    <span class="n">feature</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]:</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;Topologically sort targets and their custom dependencies.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">order</span><span class="p">:</span> <span class="n">List</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl">    <span class="n">state</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">int</span><span class="p">]</span> <span class="o">=</span> <span class="p">{}</span>
</span></span><span class="line"><span class="cl">    <span class="n">stack</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">visit</span><span class="p">(</span><span class="n">name</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">mark</span> <span class="o">=</span> <span class="n">state</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">name</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">mark</span> <span class="o">==</span> <span class="mi">2</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">mark</span> <span class="o">==</span> <span class="mi">1</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">cycle_at</span> <span class="o">=</span> <span class="n">stack</span><span class="o">.</span><span class="n">index</span><span class="p">(</span><span class="n">name</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="n">cycle</span> <span class="o">=</span> <span class="n">stack</span><span class="p">[</span><span class="n">cycle_at</span><span class="p">:]</span> <span class="o">+</span> <span class="p">[</span><span class="n">name</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">            <span class="k">raise</span> <span class="n">DependencyError</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">                <span class="s2">&#34;dependency cycle: &#34;</span> <span class="o">+</span> <span class="s2">&#34; -&gt; &#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">cycle</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">vm</span> <span class="o">=</span> <span class="n">app</span><span class="o">.</span><span class="n">domains</span><span class="p">[</span><span class="n">name</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">        <span class="k">except</span> <span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="ne">KeyError</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">qubesadmin</span><span class="o">.</span><span class="n">exc</span><span class="o">.</span><span class="n">QubesVMNotFoundError</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span> <span class="k">as</span> <span class="n">exc</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">parent</span> <span class="o">=</span> <span class="n">stack</span><span class="p">[</span><span class="o">-</span><span class="mi">1</span><span class="p">]</span> <span class="k">if</span> <span class="n">stack</span> <span class="k">else</span> <span class="s2">&#34;&lt;command line&gt;&#34;</span>
</span></span><span class="line"><span class="cl">            <span class="k">raise</span> <span class="n">DependencyError</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">                <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">parent</span><span class="si">!r}</span><span class="s2"> requires missing qube </span><span class="si">{</span><span class="n">name</span><span class="si">!r}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">            <span class="p">)</span> <span class="kn">from</span> <span class="nn">exc</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">vm</span><span class="o">.</span><span class="n">klass</span> <span class="o">==</span> <span class="s2">&#34;AdminVM&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">raise</span> <span class="n">DependencyError</span><span class="p">(</span><span class="s2">&#34;dom0 cannot be a start dependency&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="n">state</span><span class="p">[</span><span class="n">name</span><span class="p">]</span> <span class="o">=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">        <span class="n">stack</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">name</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">for</span> <span class="n">dependency</span> <span class="ow">in</span> <span class="n">dependency_names</span><span class="p">(</span><span class="n">vm</span><span class="p">,</span> <span class="n">feature</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="n">visit</span><span class="p">(</span><span class="n">dependency</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="n">stack</span><span class="o">.</span><span class="n">pop</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">        <span class="n">state</span><span class="p">[</span><span class="n">name</span><span class="p">]</span> <span class="o">=</span> <span class="mi">2</span>
</span></span><span class="line"><span class="cl">        <span class="n">order</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">name</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">target</span> <span class="ow">in</span> <span class="n">targets</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">visit</span><span class="p">(</span><span class="n">target</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">order</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">start_in_order</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="n">app</span><span class="p">:</span> <span class="n">qubesadmin</span><span class="o">.</span><span class="n">Qubes</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">order</span><span class="p">:</span> <span class="n">Iterable</span><span class="p">[</span><span class="nb">str</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">    <span class="n">dry_run</span><span class="p">:</span> <span class="nb">bool</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;Start every qube in the resolved order.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">name</span> <span class="ow">in</span> <span class="n">order</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">vm</span> <span class="o">=</span> <span class="n">app</span><span class="o">.</span><span class="n">domains</span><span class="p">[</span><span class="n">name</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">        <span class="n">power_state</span> <span class="o">=</span> <span class="n">vm</span><span class="o">.</span><span class="n">get_power_state</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">power_state</span> <span class="o">==</span> <span class="s2">&#34;Running&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;already running: </span><span class="si">{</span><span class="n">name</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="k">continue</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">power_state</span> <span class="o">==</span> <span class="s2">&#34;Halted&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">action</span> <span class="o">=</span> <span class="s2">&#34;would start&#34;</span> <span class="k">if</span> <span class="n">dry_run</span> <span class="k">else</span> <span class="s2">&#34;starting&#34;</span>
</span></span><span class="line"><span class="cl">            <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">action</span><span class="si">}</span><span class="s2">: </span><span class="si">{</span><span class="n">name</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="ow">not</span> <span class="n">dry_run</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">                <span class="n">vm</span><span class="o">.</span><span class="n">start</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">            <span class="k">continue</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">raise</span> <span class="n">DependencyError</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">name</span><span class="si">!r}</span><span class="s2"> is in state </span><span class="si">{</span><span class="n">power_state</span><span class="si">!r}</span><span class="s2">; &#34;</span>
</span></span><span class="line"><span class="cl">            <span class="s2">&#34;refusing to treat it as ready.&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">parse_args</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="n">argparse</span><span class="o">.</span><span class="n">Namespace</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span> <span class="o">=</span> <span class="n">argparse</span><span class="o">.</span><span class="n">ArgumentParser</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">description</span><span class="o">=</span><span class="s2">&#34;Start qubes recursively in dependency order.&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;qubes&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">nargs</span><span class="o">=</span><span class="s2">&#34;+&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">help</span><span class="o">=</span><span class="s2">&#34;target qube or qubes&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;--feature&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">default</span><span class="o">=</span><span class="n">DEFAULT_FEATURE</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">help</span><span class="o">=</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="s2">&#34;feature containing whitespace-separated dependencies &#34;</span>
</span></span><span class="line"><span class="cl">            <span class="sa">f</span><span class="s2">&#34;(default: </span><span class="si">{</span><span class="n">DEFAULT_FEATURE</span><span class="si">}</span><span class="s2">)&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;--dry-run&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">action</span><span class="o">=</span><span class="s2">&#34;store_true&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">help</span><span class="o">=</span><span class="s2">&#34;show the resolved order without starting anything&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">parser</span><span class="o">.</span><span class="n">parse_args</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">main</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="nb">int</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">args</span> <span class="o">=</span> <span class="n">parse_args</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">RUNTIME_DIR</span><span class="o">.</span><span class="n">mkdir</span><span class="p">(</span><span class="n">parents</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">exist_ok</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">with</span> <span class="n">LOCK_PATH</span><span class="o">.</span><span class="n">open</span><span class="p">(</span><span class="s2">&#34;w&#34;</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="s2">&#34;utf-8&#34;</span><span class="p">)</span> <span class="k">as</span> <span class="n">lock_file</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="c1"># Avoids races between two instances of this launcher.</span>
</span></span><span class="line"><span class="cl">            <span class="n">fcntl</span><span class="o">.</span><span class="n">flock</span><span class="p">(</span><span class="n">lock_file</span><span class="p">,</span> <span class="n">fcntl</span><span class="o">.</span><span class="n">LOCK_EX</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">            <span class="n">app</span> <span class="o">=</span> <span class="n">qubesadmin</span><span class="o">.</span><span class="n">Qubes</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">            <span class="n">order</span> <span class="o">=</span> <span class="n">resolve_order</span><span class="p">(</span><span class="n">app</span><span class="p">,</span> <span class="n">args</span><span class="o">.</span><span class="n">qubes</span><span class="p">,</span> <span class="n">args</span><span class="o">.</span><span class="n">feature</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">            <span class="nb">print</span><span class="p">(</span><span class="s2">&#34;resolved order: &#34;</span> <span class="o">+</span> <span class="s2">&#34; -&gt; &#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">order</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">            <span class="n">start_in_order</span><span class="p">(</span><span class="n">app</span><span class="p">,</span> <span class="n">order</span><span class="p">,</span> <span class="n">args</span><span class="o">.</span><span class="n">dry_run</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">except</span> <span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">DependencyError</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">qubesadmin</span><span class="o">.</span><span class="n">exc</span><span class="o">.</span><span class="n">QubesException</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ne">OSError</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span> <span class="k">as</span> <span class="n">exc</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;qvm-start-deps: </span><span class="si">{</span><span class="n">exc</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="n">file</span><span class="o">=</span><span class="n">sys</span><span class="o">.</span><span class="n">stderr</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="vm">__name__</span> <span class="o">==</span> <span class="s2">&#34;__main__&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">raise</span> <span class="ne">SystemExit</span><span class="p">(</span><span class="n">main</span><span class="p">())</span>
</span></span></code></pre></div><h2 id="declaring-dependencies">Declaring dependencies</h2>
<p>Dependencies are stored as a feature on the qube. The tool for setting features is
<code>qvm-features</code> - not <code>qvm-prefs</code>, which manages properties like <code>netvm</code> and <code>memory</code>, but the
separate <code>qvm-features</code> command that manages the key-value feature store:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-features work x-dep.requires <span class="s2">&#34;split-ssh-vault split-gpg-vault build-server&#34;</span>
</span></span></code></pre></div><p>Read it back to verify:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-features work x-dep.requires
</span></span></code></pre></div><p>List all features on a qube to see it in context:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-features work
</span></span></code></pre></div><p>Dependencies are recursive. If <code>split-ssh-vault</code> itself declares a dependency - say it needs a
backup vault running first:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-features split-ssh-vault x-dep.requires <span class="s2">&#34;backup-vault&#34;</span>
</span></span></code></pre></div><p>then <code>qvm-start-deps work</code> starts <code>backup-vault</code>, then <code>split-ssh-vault</code>, then the rest, then
<code>work</code>. The script does a topological sort and starts everything in order - leaves before
roots.</p>
<p>To remove a dependency declaration:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-features -D work x-dep.requires
</span></span></code></pre></div><h2 id="a-worked-example">A worked example</h2>
<p>Suppose you have this setup:</p>
<ul>
<li><code>work</code> - your daily-driver AppVM. Declares <code>x-dep.requires: split-ssh-vault build-server</code>.</li>
<li><code>split-ssh-vault</code> - holds your SSH agent via Split SSH. Declares
<code>x-dep.requires: backup-vault</code>.</li>
<li><code>backup-vault</code> - holds key material. No custom dependencies.</li>
<li><code>build-server</code> - runs a build service <code>work</code> connects to. No custom dependencies (its netvm
chain is handled by Qubes natively).</li>
</ul>
<p>The dependency graph looks like:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">backup-vault
</span></span><span class="line"><span class="cl">    |
</span></span><span class="line"><span class="cl">    v
</span></span><span class="line"><span class="cl">split-ssh-vault          build-server
</span></span><span class="line"><span class="cl">         \               /
</span></span><span class="line"><span class="cl">          \             /
</span></span><span class="line"><span class="cl">           v           v
</span></span><span class="line"><span class="cl">              work
</span></span></code></pre></div><p>Start <code>work</code> with the script:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-start-deps work
</span></span></code></pre></div><p>Output:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">resolved order: backup-vault -&gt; split-ssh-vault -&gt; build-server -&gt; work
</span></span><span class="line"><span class="cl">starting: backup-vault
</span></span><span class="line"><span class="cl">starting: split-ssh-vault
</span></span><span class="line"><span class="cl">starting: build-server
</span></span><span class="line"><span class="cl">starting: work
</span></span></code></pre></div><p>Already-running qubes are skipped silently:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">resolved order: backup-vault -&gt; split-ssh-vault -&gt; build-server -&gt; work
</span></span><span class="line"><span class="cl">already running: backup-vault
</span></span><span class="line"><span class="cl">already running: split-ssh-vault
</span></span><span class="line"><span class="cl">starting: build-server
</span></span><span class="line"><span class="cl">starting: work
</span></span></code></pre></div><h2 id="dry-run">Dry run</h2>
<p>Before wiring a new qube into the graph, check the resolved order without starting anything:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-start-deps --dry-run work
</span></span></code></pre></div><p>Output:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">resolved order: backup-vault -&gt; split-ssh-vault -&gt; build-server -&gt; work
</span></span><span class="line"><span class="cl">would start: backup-vault
</span></span><span class="line"><span class="cl">would start: split-ssh-vault
</span></span><span class="line"><span class="cl">would start: build-server
</span></span><span class="line"><span class="cl">would start: work
</span></span></code></pre></div><h2 id="multiple-targets">Multiple targets</h2>
<p>The script accepts one or more qube names. Dependencies shared between targets are started
once:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-start-deps work personal banking
</span></span></code></pre></div><h2 id="what-it-catches-for-you">What it catches for you</h2>
<p>The script refuses to start a broken dependency graph. Three classes of error are caught
before anything is started.</p>
<p><strong>Cycles.</strong> If <code>a</code> requires <code>b</code> and <code>b</code> requires <code>a</code>, the topological sort detects the
back-edge and reports the cycle path:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-features a x-dep.requires <span class="s2">&#34;b&#34;</span>
</span></span><span class="line"><span class="cl">qvm-features b x-dep.requires <span class="s2">&#34;a&#34;</span>
</span></span><span class="line"><span class="cl">qvm-start-deps a
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">qvm-start-deps: dependency cycle: a -&gt; b -&gt; a
</span></span></code></pre></div><p><strong>Missing qubes.</strong> A typo in a dependency name, or a qube that was renamed or deleted, is
caught before any qube is started. The error names the parent that declared the bad dependency:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-features work x-dep.requires <span class="s2">&#34;nonexistent-qube&#34;</span>
</span></span><span class="line"><span class="cl">qvm-start-deps work
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-start-deps: <span class="s1">&#39;work&#39;</span> requires missing qube <span class="s1">&#39;nonexistent-qube&#39;</span>
</span></span></code></pre></div><p><strong>dom0 as a dependency.</strong> The script refuses to treat dom0 (<code>AdminVM</code>) as a start dependency.
Declaring <code>dom0</code> in a <code>x-dep.requires</code> value is a configuration error, not a request to start
dom0.</p>
<p><strong>Transient states.</strong> If a qube in the resolved order is neither <code>Halted</code> nor <code>Running</code> - it's
<code>Transient</code>, <code>Dying</code>, or similar - the script stops and reports it. Starting the next qube
against a dependency that's mid-transition is a race; the script won't guess.</p>
<p><strong>Concurrent runs.</strong> A file lock (<code>fcntl.flock</code> on <code>$XDG_RUNTIME_DIR/qvm-start-deps.lock</code>)
prevents two invocations from racing each other. If one is mid-start, the other waits.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Language: Python 3 (uses <code>qubesadmin</code>, no extra deps)</li>
<li>Dependency tag: <code>x-dep.requires</code> feature (whitespace-separated qube names)</li>
<li>Set with: <code>qvm-features VMNAME x-dep.requires &quot;dep1 dep2 ...&quot;</code></li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://doc.qubes-os.org/projects/core-admin-client/en/latest/manpages/qvm-features.html" target="_blank" rel="noopener noreferrer">qvm-features man page</a>
</li>
<li><a href="https://github.com/QubesOS/qubes-core-admin-client" target="_blank" rel="noopener noreferrer">qubesadmin Python API (source)</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need help automating your Qubes OS workflow?</div>
  <div class="smallprint mb-3">I do contract sysadmin and privacy-infrastructure work and for a time, sysadminned the QubesOS onion apt/yum repositories. I built CI a workflow system for SecureDrop Workstation on QubesOS. Let&#39;s talk compartmentalisation!</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-diagram-3"></i><span>Qubes OS</span></div>
    <h1 class="display-6 fw-semibold mb-2">Starting a Qube and its dependencies in Qubes OS</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-12">September 12, 2026</time></p>
    <p class="lead mb-0">A howto for qvm-start-deps: a dom0 script that recursively starts a qube's declared
dependencies - Split SSH vaults, service qubes, custom cross-qube relationships - before the
target, using qvm-features as dependency tags.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-12T00:00:00&#43;10:00">12 September 2026</time>
        </div>

<h2 id="the-scenario">The scenario</h2>
<p>You have a qube - call it <code>work</code> - that needs more than a network connection to be useful. Its
SSH agent lives in a vault qube via Split SSH. Its GPG key lives in another vault via Split GPG.
A third qube runs a local service it connects to over qrexec. When you start <code>work</code>, you want
all of those started first, in the right order, without thinking about it.</p>
<p>Qubes starts the network chain for you. If <code>work</code>'s netvm is <code>sys-firewall</code> and
<code>sys-firewall</code> isn't running, <code>qvm-start work</code> will start it (and <code>sys-net</code> underneath it)
automatically. But anything beyond that is your problem. Split SSH providers, Split GPG vaults,
service-hosting qubes - Qubes doesn't know about those relationships. You either start them by
hand each time, or you write a script.</p>
<h2 id="the-dependency-tag-approach">The dependency-tag approach</h2>
<p><code>qvm-start-deps</code> is a small Python script that runs in dom0. It reads a custom feature -
<code>x-dep.requires</code> by default - from the target qube, recursively resolves all declared
dependencies, topologically sorts them, and starts them in order before starting the target.</p>
<p>Qubes calls these &quot;features&quot; - key-value pairs stored on each qube via <code>qvm-features</code>. They're
not the same as Qubes tags (set with <code>qvm-tags</code>), which are simple labels with no value. We're
using a custom feature, <code>x-dep.requires</code>, as a dependency tag: its value is a whitespace-separated
list of qube names that must be started before this qube.</p>
<p>Setting <code>x-dep.requires</code> on <code>work</code> to <code>split-ssh-vault split-gpg-vault build-server</code> means:
before starting <code>work</code>, start those three (and their own dependencies, recursively) first.</p>
<p>The script uses the <code>qubesadmin</code> Python library - the same one that backs the <code>qvm-*</code> CLI
tools - so it runs in dom0 with no extra dependencies.</p>
<h2 id="installing-the-script">Installing the script</h2>
<p>Save the script to dom0 as <code>qvm-start-deps</code> and make it executable:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">chmod +x qvm-start-deps
</span></span></code></pre></div><p>Place it somewhere in <code>$PATH</code> - <code>/usr/local/bin/</code> is the usual spot for dom0 scripts that
aren't managed by the package manager.</p>
<p>The full script:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="ch">#!/usr/bin/python3</span>
</span></span><span class="line"><span class="cl"><span class="s2">&#34;&#34;&#34;Start Qubes domains after recursively starting declared dependencies.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">__future__</span> <span class="kn">import</span> <span class="n">annotations</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">argparse</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">fcntl</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">os</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">sys</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">pathlib</span> <span class="kn">import</span> <span class="n">Path</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">typing</span> <span class="kn">import</span> <span class="n">Iterable</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">qubesadmin</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">qubesadmin.exc</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">DEFAULT_FEATURE</span> <span class="o">=</span> <span class="s2">&#34;x-dep.requires&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">RUNTIME_DIR</span> <span class="o">=</span> <span class="n">Path</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="n">os</span><span class="o">.</span><span class="n">environ</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;XDG_RUNTIME_DIR&#34;</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;/run/user/</span><span class="si">{</span><span class="n">os</span><span class="o">.</span><span class="n">getuid</span><span class="p">()</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">LOCK_PATH</span> <span class="o">=</span> <span class="n">RUNTIME_DIR</span> <span class="o">/</span> <span class="s2">&#34;qvm-start-deps.lock&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">DependencyError</span><span class="p">(</span><span class="ne">RuntimeError</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;The configured dependency graph is invalid.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">dependency_names</span><span class="p">(</span><span class="n">vm</span><span class="p">,</span> <span class="n">feature</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]:</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;Return the unique, ordered dependencies declared on a qube.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">value</span> <span class="o">=</span> <span class="n">vm</span><span class="o">.</span><span class="n">features</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">feature</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">value</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Qube names cannot contain whitespace, so a whitespace-separated</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># feature value is sufficient.</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">list</span><span class="p">(</span><span class="nb">dict</span><span class="o">.</span><span class="n">fromkeys</span><span class="p">(</span><span class="nb">str</span><span class="p">(</span><span class="n">value</span><span class="p">)</span><span class="o">.</span><span class="n">split</span><span class="p">()))</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">resolve_order</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="n">app</span><span class="p">:</span> <span class="n">qubesadmin</span><span class="o">.</span><span class="n">Qubes</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">targets</span><span class="p">:</span> <span class="n">Iterable</span><span class="p">[</span><span class="nb">str</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">    <span class="n">feature</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]:</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;Topologically sort targets and their custom dependencies.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">order</span><span class="p">:</span> <span class="n">List</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl">    <span class="n">state</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">int</span><span class="p">]</span> <span class="o">=</span> <span class="p">{}</span>
</span></span><span class="line"><span class="cl">    <span class="n">stack</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">visit</span><span class="p">(</span><span class="n">name</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">mark</span> <span class="o">=</span> <span class="n">state</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">name</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">mark</span> <span class="o">==</span> <span class="mi">2</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">mark</span> <span class="o">==</span> <span class="mi">1</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">cycle_at</span> <span class="o">=</span> <span class="n">stack</span><span class="o">.</span><span class="n">index</span><span class="p">(</span><span class="n">name</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="n">cycle</span> <span class="o">=</span> <span class="n">stack</span><span class="p">[</span><span class="n">cycle_at</span><span class="p">:]</span> <span class="o">+</span> <span class="p">[</span><span class="n">name</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">            <span class="k">raise</span> <span class="n">DependencyError</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">                <span class="s2">&#34;dependency cycle: &#34;</span> <span class="o">+</span> <span class="s2">&#34; -&gt; &#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">cycle</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">vm</span> <span class="o">=</span> <span class="n">app</span><span class="o">.</span><span class="n">domains</span><span class="p">[</span><span class="n">name</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">        <span class="k">except</span> <span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="ne">KeyError</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">qubesadmin</span><span class="o">.</span><span class="n">exc</span><span class="o">.</span><span class="n">QubesVMNotFoundError</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span> <span class="k">as</span> <span class="n">exc</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">parent</span> <span class="o">=</span> <span class="n">stack</span><span class="p">[</span><span class="o">-</span><span class="mi">1</span><span class="p">]</span> <span class="k">if</span> <span class="n">stack</span> <span class="k">else</span> <span class="s2">&#34;&lt;command line&gt;&#34;</span>
</span></span><span class="line"><span class="cl">            <span class="k">raise</span> <span class="n">DependencyError</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">                <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">parent</span><span class="si">!r}</span><span class="s2"> requires missing qube </span><span class="si">{</span><span class="n">name</span><span class="si">!r}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">            <span class="p">)</span> <span class="kn">from</span> <span class="nn">exc</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">vm</span><span class="o">.</span><span class="n">klass</span> <span class="o">==</span> <span class="s2">&#34;AdminVM&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">raise</span> <span class="n">DependencyError</span><span class="p">(</span><span class="s2">&#34;dom0 cannot be a start dependency&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="n">state</span><span class="p">[</span><span class="n">name</span><span class="p">]</span> <span class="o">=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">        <span class="n">stack</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">name</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">for</span> <span class="n">dependency</span> <span class="ow">in</span> <span class="n">dependency_names</span><span class="p">(</span><span class="n">vm</span><span class="p">,</span> <span class="n">feature</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="n">visit</span><span class="p">(</span><span class="n">dependency</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="n">stack</span><span class="o">.</span><span class="n">pop</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">        <span class="n">state</span><span class="p">[</span><span class="n">name</span><span class="p">]</span> <span class="o">=</span> <span class="mi">2</span>
</span></span><span class="line"><span class="cl">        <span class="n">order</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">name</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">target</span> <span class="ow">in</span> <span class="n">targets</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">visit</span><span class="p">(</span><span class="n">target</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">order</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">start_in_order</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="n">app</span><span class="p">:</span> <span class="n">qubesadmin</span><span class="o">.</span><span class="n">Qubes</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">order</span><span class="p">:</span> <span class="n">Iterable</span><span class="p">[</span><span class="nb">str</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">    <span class="n">dry_run</span><span class="p">:</span> <span class="nb">bool</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;Start every qube in the resolved order.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">name</span> <span class="ow">in</span> <span class="n">order</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">vm</span> <span class="o">=</span> <span class="n">app</span><span class="o">.</span><span class="n">domains</span><span class="p">[</span><span class="n">name</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">        <span class="n">power_state</span> <span class="o">=</span> <span class="n">vm</span><span class="o">.</span><span class="n">get_power_state</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">power_state</span> <span class="o">==</span> <span class="s2">&#34;Running&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;already running: </span><span class="si">{</span><span class="n">name</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="k">continue</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">power_state</span> <span class="o">==</span> <span class="s2">&#34;Halted&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">action</span> <span class="o">=</span> <span class="s2">&#34;would start&#34;</span> <span class="k">if</span> <span class="n">dry_run</span> <span class="k">else</span> <span class="s2">&#34;starting&#34;</span>
</span></span><span class="line"><span class="cl">            <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">action</span><span class="si">}</span><span class="s2">: </span><span class="si">{</span><span class="n">name</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="ow">not</span> <span class="n">dry_run</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">                <span class="n">vm</span><span class="o">.</span><span class="n">start</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">            <span class="k">continue</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">raise</span> <span class="n">DependencyError</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">name</span><span class="si">!r}</span><span class="s2"> is in state </span><span class="si">{</span><span class="n">power_state</span><span class="si">!r}</span><span class="s2">; &#34;</span>
</span></span><span class="line"><span class="cl">            <span class="s2">&#34;refusing to treat it as ready.&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">parse_args</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="n">argparse</span><span class="o">.</span><span class="n">Namespace</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span> <span class="o">=</span> <span class="n">argparse</span><span class="o">.</span><span class="n">ArgumentParser</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">description</span><span class="o">=</span><span class="s2">&#34;Start qubes recursively in dependency order.&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;qubes&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">nargs</span><span class="o">=</span><span class="s2">&#34;+&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">help</span><span class="o">=</span><span class="s2">&#34;target qube or qubes&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;--feature&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">default</span><span class="o">=</span><span class="n">DEFAULT_FEATURE</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">help</span><span class="o">=</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="s2">&#34;feature containing whitespace-separated dependencies &#34;</span>
</span></span><span class="line"><span class="cl">            <span class="sa">f</span><span class="s2">&#34;(default: </span><span class="si">{</span><span class="n">DEFAULT_FEATURE</span><span class="si">}</span><span class="s2">)&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;--dry-run&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">action</span><span class="o">=</span><span class="s2">&#34;store_true&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">help</span><span class="o">=</span><span class="s2">&#34;show the resolved order without starting anything&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">parser</span><span class="o">.</span><span class="n">parse_args</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">main</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="nb">int</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">args</span> <span class="o">=</span> <span class="n">parse_args</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">RUNTIME_DIR</span><span class="o">.</span><span class="n">mkdir</span><span class="p">(</span><span class="n">parents</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">exist_ok</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">with</span> <span class="n">LOCK_PATH</span><span class="o">.</span><span class="n">open</span><span class="p">(</span><span class="s2">&#34;w&#34;</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="s2">&#34;utf-8&#34;</span><span class="p">)</span> <span class="k">as</span> <span class="n">lock_file</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="c1"># Avoids races between two instances of this launcher.</span>
</span></span><span class="line"><span class="cl">            <span class="n">fcntl</span><span class="o">.</span><span class="n">flock</span><span class="p">(</span><span class="n">lock_file</span><span class="p">,</span> <span class="n">fcntl</span><span class="o">.</span><span class="n">LOCK_EX</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">            <span class="n">app</span> <span class="o">=</span> <span class="n">qubesadmin</span><span class="o">.</span><span class="n">Qubes</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">            <span class="n">order</span> <span class="o">=</span> <span class="n">resolve_order</span><span class="p">(</span><span class="n">app</span><span class="p">,</span> <span class="n">args</span><span class="o">.</span><span class="n">qubes</span><span class="p">,</span> <span class="n">args</span><span class="o">.</span><span class="n">feature</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">            <span class="nb">print</span><span class="p">(</span><span class="s2">&#34;resolved order: &#34;</span> <span class="o">+</span> <span class="s2">&#34; -&gt; &#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">order</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">            <span class="n">start_in_order</span><span class="p">(</span><span class="n">app</span><span class="p">,</span> <span class="n">order</span><span class="p">,</span> <span class="n">args</span><span class="o">.</span><span class="n">dry_run</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">except</span> <span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">DependencyError</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">qubesadmin</span><span class="o">.</span><span class="n">exc</span><span class="o">.</span><span class="n">QubesException</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ne">OSError</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span> <span class="k">as</span> <span class="n">exc</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;qvm-start-deps: </span><span class="si">{</span><span class="n">exc</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="n">file</span><span class="o">=</span><span class="n">sys</span><span class="o">.</span><span class="n">stderr</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="vm">__name__</span> <span class="o">==</span> <span class="s2">&#34;__main__&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">raise</span> <span class="ne">SystemExit</span><span class="p">(</span><span class="n">main</span><span class="p">())</span>
</span></span></code></pre></div><h2 id="declaring-dependencies">Declaring dependencies</h2>
<p>Dependencies are stored as a feature on the qube. The tool for setting features is
<code>qvm-features</code> - not <code>qvm-prefs</code>, which manages properties like <code>netvm</code> and <code>memory</code>, but the
separate <code>qvm-features</code> command that manages the key-value feature store:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-features work x-dep.requires <span class="s2">&#34;split-ssh-vault split-gpg-vault build-server&#34;</span>
</span></span></code></pre></div><p>Read it back to verify:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-features work x-dep.requires
</span></span></code></pre></div><p>List all features on a qube to see it in context:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-features work
</span></span></code></pre></div><p>Dependencies are recursive. If <code>split-ssh-vault</code> itself declares a dependency - say it needs a
backup vault running first:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-features split-ssh-vault x-dep.requires <span class="s2">&#34;backup-vault&#34;</span>
</span></span></code></pre></div><p>then <code>qvm-start-deps work</code> starts <code>backup-vault</code>, then <code>split-ssh-vault</code>, then the rest, then
<code>work</code>. The script does a topological sort and starts everything in order - leaves before
roots.</p>
<p>To remove a dependency declaration:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-features -D work x-dep.requires
</span></span></code></pre></div><h2 id="a-worked-example">A worked example</h2>
<p>Suppose you have this setup:</p>
<ul>
<li><code>work</code> - your daily-driver AppVM. Declares <code>x-dep.requires: split-ssh-vault build-server</code>.</li>
<li><code>split-ssh-vault</code> - holds your SSH agent via Split SSH. Declares
<code>x-dep.requires: backup-vault</code>.</li>
<li><code>backup-vault</code> - holds key material. No custom dependencies.</li>
<li><code>build-server</code> - runs a build service <code>work</code> connects to. No custom dependencies (its netvm
chain is handled by Qubes natively).</li>
</ul>
<p>The dependency graph looks like:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">backup-vault
</span></span><span class="line"><span class="cl">    |
</span></span><span class="line"><span class="cl">    v
</span></span><span class="line"><span class="cl">split-ssh-vault          build-server
</span></span><span class="line"><span class="cl">         \               /
</span></span><span class="line"><span class="cl">          \             /
</span></span><span class="line"><span class="cl">           v           v
</span></span><span class="line"><span class="cl">              work
</span></span></code></pre></div><p>Start <code>work</code> with the script:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-start-deps work
</span></span></code></pre></div><p>Output:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">resolved order: backup-vault -&gt; split-ssh-vault -&gt; build-server -&gt; work
</span></span><span class="line"><span class="cl">starting: backup-vault
</span></span><span class="line"><span class="cl">starting: split-ssh-vault
</span></span><span class="line"><span class="cl">starting: build-server
</span></span><span class="line"><span class="cl">starting: work
</span></span></code></pre></div><p>Already-running qubes are skipped silently:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">resolved order: backup-vault -&gt; split-ssh-vault -&gt; build-server -&gt; work
</span></span><span class="line"><span class="cl">already running: backup-vault
</span></span><span class="line"><span class="cl">already running: split-ssh-vault
</span></span><span class="line"><span class="cl">starting: build-server
</span></span><span class="line"><span class="cl">starting: work
</span></span></code></pre></div><h2 id="dry-run">Dry run</h2>
<p>Before wiring a new qube into the graph, check the resolved order without starting anything:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-start-deps --dry-run work
</span></span></code></pre></div><p>Output:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">resolved order: backup-vault -&gt; split-ssh-vault -&gt; build-server -&gt; work
</span></span><span class="line"><span class="cl">would start: backup-vault
</span></span><span class="line"><span class="cl">would start: split-ssh-vault
</span></span><span class="line"><span class="cl">would start: build-server
</span></span><span class="line"><span class="cl">would start: work
</span></span></code></pre></div><h2 id="multiple-targets">Multiple targets</h2>
<p>The script accepts one or more qube names. Dependencies shared between targets are started
once:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-start-deps work personal banking
</span></span></code></pre></div><h2 id="what-it-catches-for-you">What it catches for you</h2>
<p>The script refuses to start a broken dependency graph. Three classes of error are caught
before anything is started.</p>
<p><strong>Cycles.</strong> If <code>a</code> requires <code>b</code> and <code>b</code> requires <code>a</code>, the topological sort detects the
back-edge and reports the cycle path:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-features a x-dep.requires <span class="s2">&#34;b&#34;</span>
</span></span><span class="line"><span class="cl">qvm-features b x-dep.requires <span class="s2">&#34;a&#34;</span>
</span></span><span class="line"><span class="cl">qvm-start-deps a
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">qvm-start-deps: dependency cycle: a -&gt; b -&gt; a
</span></span></code></pre></div><p><strong>Missing qubes.</strong> A typo in a dependency name, or a qube that was renamed or deleted, is
caught before any qube is started. The error names the parent that declared the bad dependency:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-features work x-dep.requires <span class="s2">&#34;nonexistent-qube&#34;</span>
</span></span><span class="line"><span class="cl">qvm-start-deps work
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qvm-start-deps: <span class="s1">&#39;work&#39;</span> requires missing qube <span class="s1">&#39;nonexistent-qube&#39;</span>
</span></span></code></pre></div><p><strong>dom0 as a dependency.</strong> The script refuses to treat dom0 (<code>AdminVM</code>) as a start dependency.
Declaring <code>dom0</code> in a <code>x-dep.requires</code> value is a configuration error, not a request to start
dom0.</p>
<p><strong>Transient states.</strong> If a qube in the resolved order is neither <code>Halted</code> nor <code>Running</code> - it's
<code>Transient</code>, <code>Dying</code>, or similar - the script stops and reports it. Starting the next qube
against a dependency that's mid-transition is a race; the script won't guess.</p>
<p><strong>Concurrent runs.</strong> A file lock (<code>fcntl.flock</code> on <code>$XDG_RUNTIME_DIR/qvm-start-deps.lock</code>)
prevents two invocations from racing each other. If one is mid-start, the other waits.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Language: Python 3 (uses <code>qubesadmin</code>, no extra deps)</li>
<li>Dependency tag: <code>x-dep.requires</code> feature (whitespace-separated qube names)</li>
<li>Set with: <code>qvm-features VMNAME x-dep.requires &quot;dep1 dep2 ...&quot;</code></li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://doc.qubes-os.org/projects/core-admin-client/en/latest/manpages/qvm-features.html" target="_blank" rel="noopener noreferrer">qvm-features man page</a>
</li>
<li><a href="https://github.com/QubesOS/qubes-core-admin-client" target="_blank" rel="noopener noreferrer">qubesadmin Python API (source)</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need help automating your Qubes OS workflow?</div>
  <div class="smallprint mb-3">I do contract sysadmin and privacy-infrastructure work and for a time, sysadminned the QubesOS onion apt/yum repositories. I built CI a workflow system for SecureDrop Workstation on QubesOS. Let&#39;s talk compartmentalisation!</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>A private AI assistant over Signal</title>
      <link>https://mig5.net/news/signal-ai-assistant/</link>
      <pubDate>Sat, 05 Sep 2026 00:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/signal-ai-assistant/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-chat-dots"></i><span>Node-RED</span></div>
    <h1 class="display-6 fw-semibold mb-2">A private AI assistant over Signal</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-05">September 5, 2026</time></p>
    <p class="lead mb-0">A walkthrough of building a private AI assistant you can text over Signal - with conversation
memory, an emoji-driven context reset, a sender allowlist, and a choice between local Ollama
inference and a cloud API.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-05T00:00:00&#43;10:00">5 September 2026</time>
        </div>

<h2 id="the-idea">The idea</h2>
<p>The goal: an AI assistant you can text. Not through a cloud chatbot, not through yet another
app, but through Signal - end-to-end encrypted, reachable from your phone the same way any
contact is.</p>
<p>The pieces are all there.
<a href="https://github.com/bbernhard/signal-cli-rest-api" target="_blank" rel="noopener noreferrer">signal-cli-rest-api</a>
 wraps the Signal CLI in
a REST and WebSocket API. Node-RED can run in a Docker container on the same host.
<a href="https://ollama.com" target="_blank" rel="noopener noreferrer">Ollama</a>
 provides local inference, and
<a href="https://regolo.ai" target="_blank" rel="noopener noreferrer">Regolo</a>
 offers an OpenAI-compatible cloud API. The work is wiring them
together.</p>
<div class="mermaid">

sequenceDiagram
    participant U as User (Signal app)
    participant S as signal-cli-rest-api
    participant N as Node-RED
    participant R as Regolo API
    U->>S: Signal message
    S->>N: WebSocket envelope (JSON)
    N->>N: Allow-list check (UUID)
    alt Reaction emoji
        N->>N: Clear sender context
    else Text message
        N->>N: Build context (15-min TTL, 8 max)
        N->>S: PUT typing indicator
        N->>R: POST /v1/chat/completions
        R-->>N: Assistant reply
        N->>N: Strip HTML, store in context
        N->>S: POST /v2/send
        S-->>U: Signal reply
    end

</div>

<h2 id="signal-cli-rest-api-the-bridge">signal-cli-rest-api: the bridge</h2>
<p>signal-cli-rest-api runs as a Docker container (<code>bbernhard/signal-cli-rest-api</code>) in
<code>json-rpc</code> mode. Before anything else, you link a real Signal account to it - a dedicated
number that becomes the bot's identity. Once linked, the container exposes two key endpoints: a
WebSocket receive path that streams incoming messages, and a REST send path for outgoing
replies.</p>
<p>The WebSocket endpoint is the interesting one. It long-polls Signal's servers with a
configurable timeout - in this case, five seconds - and pushes any received envelopes down the
WebSocket as JSON. Each envelope contains the sender's UUID, the message body, a timestamp, and
any reactions (emoji responses to previous messages).</p>
<h2 id="the-receive-path">The receive path</h2>
<p>In Node-RED, a <code>websocket-client</code> node connects to
<code>ws://signal_cli:8080/v1/receive/&lt;bot-number&gt;?timeout=5</code>. Every time someone texts the bot, a
JSON envelope lands in the flow. The first thing the flow does is check who sent it.</p>
<h2 id="who-gets-to-talk">Who gets to talk</h2>
<p>The allowlist is a function node with a hardcoded array of Signal UUIDs - not phone numbers.
Signal identifies users by UUID internally; <code>sourceUuid</code> is stable and doesn't change if
someone changes their number. The list might contain a handful of UUIDs - your own, a
colleague's, anyone you want to grant access. If the sender isn't on the list, the message is
dropped silently. No error reply, no acknowledgement - the bot simply doesn't exist for them.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gdscript3" data-lang="gdscript3"><span class="line"><span class="cl"><span class="o">//</span> <span class="n">Allow</span><span class="o">-</span><span class="n">list</span> <span class="n">of</span> <span class="n">senders</span>
</span></span><span class="line"><span class="cl"><span class="n">let</span> <span class="n">validSignalUsers</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;&lt;your-signal-uuid&gt;&#34;</span><span class="p">,</span>       <span class="o">//</span> <span class="n">Me</span> <span class="n">main</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;&lt;colleague-uuid-1&gt;&#34;</span><span class="p">,</span>       <span class="o">//</span> <span class="n">Me</span> <span class="n">work</span>
</span></span><span class="line"><span class="cl">  <span class="o">//</span> <span class="o">...</span>
</span></span><span class="line"><span class="cl"><span class="p">];</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="n">validSignalUsers</span><span class="o">.</span><span class="n">includes</span><span class="p">(</span><span class="n">msg</span><span class="o">.</span><span class="n">payload</span><span class="o">.</span><span class="n">envelope</span><span class="o">.</span><span class="n">sourceUuid</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="n">msg</span><span class="o">.</span><span class="n">sender</span> <span class="o">=</span> <span class="n">msg</span><span class="o">.</span><span class="n">payload</span><span class="o">.</span><span class="n">envelope</span><span class="o">.</span><span class="n">sourceUuid</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="n">msg</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="n">node</span><span class="o">.</span><span class="n">error</span><span class="p">(</span><span class="err">`</span><span class="n">Sender</span> <span class="o">$</span><span class="p">{</span><span class="n">msg</span><span class="o">.</span><span class="n">payload</span><span class="o">.</span><span class="n">envelope</span><span class="o">.</span><span class="n">sourceUuid</span><span class="p">}</span> <span class="ow">not</span> <span class="n">allowed</span><span class="err">`</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="n">null</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><h2 id="the-emoji-reaction-router">The emoji reaction router</h2>
<p>This is where it gets fun. Signal supports emoji reactions - you can react to a message with
any emoji. The flow checks whether an incoming envelope contains a reaction. If it does, it
checks whether the emoji is 🤯 (the exploding head).</p>
<p>If it is, the flow clears that sender's conversation history. All of it. Gone. The next message
starts a fresh conversation. This is a surprisingly natural UX: you don't need to type a command
like <code>/reset</code> - you just react to the bot's last message with the exploding head emoji and the
context is wiped.</p>
<p>If the reaction is any other emoji, it's ignored. And if there's no reaction at all - just a
regular text message - the flow continues to the main processing path.</p>
<h2 id="conversation-memory">Conversation memory</h2>
<p>Each sender gets their own conversation history, stored in Node-RED's global context under a
key called <code>SIGNAL_AI_ASSISTANT</code>. The structure is a map of sender UUID to an array of
messages, each with a content string and a timestamp.</p>
<p>Two limits keep things bounded. Messages older than 15 minutes are evicted - if you haven't
talked to the bot in a quarter of an hour, it starts fresh. And the array is capped at 8
messages total, so even a fast back-and-forth won't grow unbounded.</p>
<p>When a new message arrives, the flow builds an XML-tagged context block for the LLM. Previous
messages are wrapped in <code>&lt;context_message&gt;</code> tags inside a <code>&lt;context&gt;</code> block. The new message is
wrapped in a <code>&lt;latest_message&gt;</code> tag. This separation matters: the LLM is told explicitly that
the context block is background only - use it for consistency and memory - while the latest
message is the one to answer. This prevents the model from re-answering old questions or getting
confused about which message is current.</p>
<p>After the LLM replies, the assistant's response is also stored in the context array, so the next
exchange has the full back-and-forth.</p>
<p>The context-building function enforces both limits and assembles the XML-tagged prompt:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gdscript3" data-lang="gdscript3"><span class="line"><span class="cl"><span class="k">const</span> <span class="n">MAX_CONTEXT_AGE_MS</span> <span class="o">=</span> <span class="mi">15</span> <span class="o">*</span> <span class="mi">60</span> <span class="o">*</span> <span class="mi">1000</span><span class="p">;</span> <span class="o">//</span> <span class="mi">15</span> <span class="n">minutes</span>
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">MAX_MESSAGES</span> <span class="o">=</span> <span class="mi">8</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">let</span> <span class="n">ctx</span> <span class="o">=</span> <span class="n">global</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s1">&#39;SIGNAL_AI_ASSISTANT&#39;</span><span class="p">)</span> <span class="o">||</span> <span class="p">{};</span>
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">sender</span> <span class="o">=</span> <span class="n">msg</span><span class="o">.</span><span class="n">sender</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="n">let</span> <span class="n">messages</span> <span class="o">=</span> <span class="p">(</span><span class="n">ctx</span><span class="p">[</span><span class="n">sender</span><span class="p">]</span> <span class="o">&amp;&amp;</span> <span class="ne">Array</span><span class="o">.</span><span class="n">isArray</span><span class="p">(</span><span class="n">ctx</span><span class="p">[</span><span class="n">sender</span><span class="p">]</span><span class="o">.</span><span class="n">messages</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">  <span class="err">?</span> <span class="p">[</span><span class="o">...</span><span class="n">ctx</span><span class="p">[</span><span class="n">sender</span><span class="p">]</span><span class="o">.</span><span class="n">messages</span><span class="p">]</span> <span class="p">:</span> <span class="p">[];</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="o">//</span> <span class="n">Drop</span> <span class="n">messages</span> <span class="n">older</span> <span class="n">than</span> <span class="mi">15</span> <span class="n">minutes</span>
</span></span><span class="line"><span class="cl"><span class="n">messages</span> <span class="o">=</span> <span class="n">messages</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span><span class="n">m</span> <span class="o">=&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">(</span><span class="n">newMessageTimestamp</span> <span class="o">-</span> <span class="p">(</span><span class="n">m</span><span class="o">.</span><span class="n">timestamp</span> <span class="o">||</span> <span class="mi">0</span><span class="p">))</span> <span class="o">&lt;=</span> <span class="n">MAX_CONTEXT_AGE_MS</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="o">//</span> <span class="n">Cap</span> <span class="n">at</span> <span class="n">MAX_MESSAGES</span> <span class="o">-</span> <span class="mi">1</span> <span class="n">before</span> <span class="n">adding</span> <span class="n">the</span> <span class="n">new</span> <span class="n">one</span>
</span></span><span class="line"><span class="cl"><span class="k">while</span> <span class="p">(</span><span class="n">messages</span><span class="o">.</span><span class="n">length</span> <span class="o">&gt;</span> <span class="n">MAX_MESSAGES</span> <span class="o">-</span> <span class="mi">1</span><span class="p">)</span> <span class="n">messages</span><span class="o">.</span><span class="n">shift</span><span class="p">();</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">messages</span><span class="o">.</span><span class="n">push</span><span class="p">({</span> <span class="n">content</span><span class="p">:</span> <span class="ne">String</span><span class="p">(</span><span class="n">newMessageContent</span><span class="p">),</span> <span class="n">timestamp</span><span class="p">:</span> <span class="n">newMessageTimestamp</span> <span class="p">});</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">ctx</span><span class="p">[</span><span class="n">sender</span><span class="p">]</span> <span class="o">=</span> <span class="p">{</span> <span class="n">messages</span> <span class="p">};</span>
</span></span><span class="line"><span class="cl"><span class="n">global</span><span class="o">.</span><span class="n">set</span><span class="p">(</span><span class="s1">&#39;SIGNAL_AI_ASSISTANT&#39;</span><span class="p">,</span> <span class="n">ctx</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="o">//</span> <span class="n">Build</span> <span class="n">XML</span><span class="o">-</span><span class="n">tagged</span> <span class="n">prompt</span><span class="p">:</span> <span class="n">context</span> <span class="n">block</span> <span class="o">+</span> <span class="n">latest</span> <span class="n">message</span>
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">previous</span> <span class="o">=</span> <span class="n">messages</span><span class="o">.</span><span class="n">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="o">-</span><span class="mi">1</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">latest</span>   <span class="o">=</span> <span class="n">messages</span><span class="p">[</span><span class="n">messages</span><span class="o">.</span><span class="n">length</span> <span class="o">-</span> <span class="mi">1</span><span class="p">];</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">contextBlock</span> <span class="o">=</span> <span class="n">previous</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="n">m</span> <span class="o">=&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="err">`</span><span class="o">&lt;</span><span class="n">context_message</span> <span class="n">ts</span><span class="o">=</span><span class="s2">&#34;${m.timestamp}&#34;</span><span class="o">&gt;$</span><span class="p">{</span><span class="n">m</span><span class="o">.</span><span class="n">content</span><span class="p">}</span><span class="o">&lt;/</span><span class="n">context_message</span><span class="o">&gt;</span><span class="err">`</span><span class="p">)</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="s1">&#39;</span><span class="se">\n</span><span class="s1">&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">merged</span> <span class="o">=</span> <span class="p">(</span><span class="n">previous</span><span class="o">.</span><span class="n">length</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="err">?</span> <span class="err">`</span><span class="o">&lt;</span><span class="n">context</span> <span class="p">(</span><span class="n">oldest</span><span class="o">-&gt;</span><span class="n">newest</span><span class="p">)</span><span class="o">&gt;</span>\<span class="n">n</span><span class="o">$</span><span class="p">{</span><span class="n">contextBlock</span><span class="p">}</span>\<span class="n">n</span><span class="o">&lt;/</span><span class="n">context</span><span class="o">&gt;</span>\<span class="n">n</span>\<span class="n">n</span><span class="err">`</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">    <span class="err">`</span><span class="o">&lt;</span><span class="n">latest_message</span> <span class="n">ts</span><span class="o">=</span><span class="s2">&#34;${latest.timestamp}&#34;</span><span class="o">&gt;$</span><span class="p">{</span><span class="n">latest</span><span class="o">.</span><span class="n">content</span><span class="p">}</span><span class="o">&lt;/</span><span class="n">latest_message</span><span class="o">&gt;</span><span class="err">`</span>
</span></span><span class="line"><span class="cl">  <span class="p">:</span> <span class="err">`</span><span class="o">&lt;</span><span class="n">latest_message</span> <span class="n">ts</span><span class="o">=</span><span class="s2">&#34;${latest.timestamp}&#34;</span><span class="o">&gt;$</span><span class="p">{</span><span class="n">latest</span><span class="o">.</span><span class="n">content</span><span class="p">}</span><span class="o">&lt;/</span><span class="n">latest_message</span><span class="o">&gt;</span><span class="err">`</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">msg</span><span class="o">.</span><span class="n">context</span> <span class="o">=</span> <span class="n">merged</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">return</span> <span class="n">msg</span><span class="p">;</span>
</span></span></code></pre></div><h2 id="two-ways-to-query">Two ways to query</h2>
<p>The flow defines two subflows for talking to an LLM, but only one is wired in.</p>
<p>In this example, the active subflow, <strong>Query Regolo</strong>, calls an OpenAI-compatible API at <code>api.regolo.ai</code> with
the model <code>mistral-small-4-119b</code> at temperature 0.3. Regolo is a cloud API - fast, capable, but
your messages leave your network. The subflow sends the system prompt and context block as a
chat completion request and extracts the assistant's reply from the response.</p>
<p>The other subflow, <strong>Query Ollama with Tools</strong>, is defined but not wired up - I've included it
as an example (and it was the original flow I used to use). It talks to a local Ollama instance running
an LLM of your choice (in this example, I had been using <code>mistral-nemo:12b-instruct-2407-q4_K_M</code>)</p>
<ul>
<li>everything stays on the host, no data leaves the network.</li>
</ul>
<p>But the interesting part is the &quot;with Tools&quot; bit: the subflow's system prompt
instructs the model to use web search tools to improve its answers and to cite the URLs it
found. The prompt is detailed - rules about when to use context versus the latest message, how
to handle code requests, how to handle decisions, and a requirement to search the web when it
would help.</p>
<p>This subflow isn't wired in by default. The idea is to switch to it once the tool-calling
pipeline is solid, so the assistant can look things up in real time instead of only knowing
what its training data taught it.</p>
<h2 id="the-web-search-tool">The web search tool</h2>
<p>The &quot;Query Ollama with Tools&quot; subflow needs a web search backend. When the LLM emits a
<code>tool_call</code> for <code>private_web_search</code>, the subflow builds a command line and hands it to an
<code>exec</code> node that runs a small Python script, <code>tools.py</code>. The script queries a
<a href="https://searxng.com" target="_blank" rel="noopener noreferrer">SearXNG</a>
 instance running as a Docker service on the same host,
extracts the top result, strips HTML, and prints a single line: the result title and URL
followed by a content snippet. That stdout is fed back into the Ollama chat completion as a
<code>tool</code> message, so the model can incorporate what it found and cite the URL.</p>
<p>The script is deliberately small - one function, one HTTP GET, one <code>print</code>. It passes a
spoofed <code>X-Forwarded-For</code> header to SearXNG (which trusts the reverse proxy in front of it)
and returns the first result as <code>title - url</code> on the first line, with the content snippet on
the second:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="ch">#!/usr/bin/env python3</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">argparse</span><span class="o">,</span> <span class="nn">requests</span><span class="o">,</span> <span class="nn">html</span><span class="o">,</span> <span class="nn">re</span><span class="o">,</span> <span class="nn">sys</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">SEARXNG_URL</span> <span class="o">=</span> <span class="s2">&#34;http://searxng:8080/search&#34;</span>  <span class="c1"># Docker service name:port</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">top_result</span><span class="p">(</span><span class="n">query</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">xff</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">,</span> <span class="n">xri</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">params</span> <span class="o">=</span> <span class="p">{</span><span class="s2">&#34;q&#34;</span><span class="p">:</span> <span class="n">query</span><span class="p">,</span> <span class="s2">&#34;format&#34;</span><span class="p">:</span> <span class="s2">&#34;json&#34;</span><span class="p">,</span> <span class="s2">&#34;language&#34;</span><span class="p">:</span> <span class="s2">&#34;en&#34;</span><span class="p">,</span> <span class="s2">&#34;safesearch&#34;</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span> <span class="s2">&#34;categories&#34;</span><span class="p">:</span> <span class="s2">&#34;general&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="n">headers</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;User-Agent&#34;</span><span class="p">:</span> <span class="s2">&#34;Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 &#34;</span>
</span></span><span class="line"><span class="cl">                      <span class="s2">&#34;(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;Accept&#34;</span><span class="p">:</span> <span class="s2">&#34;application/json,text/plain,*/*&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;Accept-Language&#34;</span><span class="p">:</span> <span class="s2">&#34;en-US,en;q=0.9&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;Accept-Encoding&#34;</span><span class="p">:</span> <span class="s2">&#34;gzip, deflate&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;Connection&#34;</span><span class="p">:</span> <span class="s2">&#34;keep-alive&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;Referer&#34;</span><span class="p">:</span> <span class="s2">&#34;http://searxng:8080/&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;X-Forwarded-For&#34;</span><span class="p">:</span> <span class="s2">&#34;&lt;spoofed-client-ip&gt;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">r</span> <span class="o">=</span> <span class="n">requests</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">SEARXNG_URL</span><span class="p">,</span> <span class="n">params</span><span class="o">=</span><span class="n">params</span><span class="p">,</span> <span class="n">headers</span><span class="o">=</span><span class="n">headers</span><span class="p">,</span> <span class="n">timeout</span><span class="o">=</span><span class="mi">12</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">r</span><span class="o">.</span><span class="n">status_code</span> <span class="o">==</span> <span class="mi">403</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">r</span><span class="o">.</span><span class="n">text</span>
</span></span><span class="line"><span class="cl">    <span class="n">r</span><span class="o">.</span><span class="n">raise_for_status</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">data</span> <span class="o">=</span> <span class="n">r</span><span class="o">.</span><span class="n">json</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">results</span> <span class="o">=</span> <span class="n">data</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;results&#34;</span><span class="p">,</span> <span class="p">[])</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">results</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="s2">&#34;No result found.&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">top</span> <span class="o">=</span> <span class="n">results</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="n">title</span> <span class="o">=</span> <span class="p">(</span><span class="n">top</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;title&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="s2">&#34;&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">url</span>   <span class="o">=</span> <span class="p">(</span><span class="n">top</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;url&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="s2">&#34;&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">content</span> <span class="o">=</span> <span class="p">(</span><span class="n">top</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;content&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="s2">&#34;&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">content</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">sub</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;&lt;[^&gt;]+&gt;&#34;</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">,</span> <span class="n">html</span><span class="o">.</span><span class="n">unescape</span><span class="p">(</span><span class="n">content</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">    <span class="n">content</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">sub</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;\s+&#34;</span><span class="p">,</span> <span class="s2">&#34; &#34;</span><span class="p">,</span> <span class="n">content</span><span class="p">)</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">piece</span> <span class="o">=</span> <span class="s2">&#34; - &#34;</span><span class="o">.</span><span class="n">join</span><span class="p">([</span><span class="n">p</span> <span class="k">for</span> <span class="n">p</span> <span class="ow">in</span> <span class="p">[</span><span class="n">title</span><span class="p">,</span> <span class="n">url</span><span class="p">]</span> <span class="k">if</span> <span class="n">p</span><span class="p">])</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">piece</span><span class="si">}</span><span class="se">\n</span><span class="si">{</span><span class="n">content</span><span class="si">}</span><span class="s2">&#34;</span> <span class="k">if</span> <span class="n">content</span> <span class="k">else</span> <span class="p">(</span><span class="n">piece</span> <span class="ow">or</span> <span class="s2">&#34;No result found.&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">main</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">    <span class="n">ap</span> <span class="o">=</span> <span class="n">argparse</span><span class="o">.</span><span class="n">ArgumentParser</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">ap</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s2">&#34;--query&#34;</span><span class="p">,</span> <span class="n">required</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">ap</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s2">&#34;--xff&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">ap</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s2">&#34;--xri&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">args</span> <span class="o">=</span> <span class="n">ap</span><span class="o">.</span><span class="n">parse_args</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="n">top_result</span><span class="p">(</span><span class="n">args</span><span class="o">.</span><span class="n">query</span><span class="o">.</span><span class="n">strip</span><span class="p">(),</span> <span class="n">args</span><span class="o">.</span><span class="n">xff</span><span class="p">,</span> <span class="n">args</span><span class="o">.</span><span class="n">xri</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="vm">__name__</span> <span class="o">==</span> <span class="s2">&#34;__main__&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">main</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="k">except</span> <span class="ne">Exception</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;Error: </span><span class="si">{</span><span class="n">e</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">sys</span><span class="o">.</span><span class="n">exit</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</span></span></code></pre></div><p>The <code>--xff</code> and <code>--xri</code> arguments are wired through from the Node-RED flow but not currently
used by the SearXNG query - they're there for future use if the search backend needs a real
client IP for rate-limiting or region detection. The exec node calls it as:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gdscript3" data-lang="gdscript3"><span class="line"><span class="cl"><span class="o">/</span><span class="n">data</span><span class="o">/</span><span class="n">tools</span><span class="o">/</span><span class="n">venv</span><span class="o">/</span><span class="n">bin</span><span class="o">/</span><span class="n">python3</span> <span class="o">/</span><span class="n">data</span><span class="o">/</span><span class="n">tools</span><span class="o">/</span><span class="n">tools</span><span class="o">.</span><span class="n">py</span> <span class="o">--</span><span class="n">query</span> <span class="s2">&#34;&lt;search terms&gt;&#34;</span>
</span></span></code></pre></div><p>with a 15-second timeout. If the script exits non-zero or times out, the subflow's tool-call
handler receives empty stdout, which becomes the tool message content - the model is told
&quot;No output&quot; and carries on without a citation.</p>
<h2 id="the-send-path">The send path</h2>
<p>Before the LLM is even queried, the flow fires off a typing indicator - a <code>PUT</code> to the
signal-cli REST API. This makes the bot show &quot;typing…&quot; in Signal while the model is generating.
It's a small touch, but it makes the difference between a bot that feels alive and one that
feels like it hung.</p>
<p>Once the LLM responds, the flow strips all HTML tags from the reply. LLMs love to emit markdown
and HTML; Signal renders neither. Autolinks like <code>&lt;https://example.com&gt;</code> are unwrapped to plain
URLs, then all tags are stripped. The cleaned text is posted to <code>/v2/send</code> with the bot's
number and the sender's UUID as the recipient.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gdscript3" data-lang="gdscript3"><span class="line"><span class="cl"><span class="k">const</span> <span class="n">unwrapAutoLinks</span> <span class="o">=</span> <span class="n">s</span> <span class="o">=&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="n">s</span><span class="o">.</span><span class="n">replace</span><span class="p">(</span><span class="o">/&lt;</span><span class="p">((</span><span class="err">?</span><span class="p">:</span><span class="n">https</span><span class="err">?</span><span class="p">:</span>\<span class="o">/</span>\<span class="o">/|</span><span class="n">mailto</span><span class="p">:)[</span><span class="o">^&gt;</span><span class="p">]</span><span class="o">+</span><span class="p">)</span><span class="o">&gt;/</span><span class="n">gi</span><span class="p">,</span> <span class="s1">&#39;$1&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">stripHtmlTags</span> <span class="o">=</span> <span class="n">s</span> <span class="o">=&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="n">s</span><span class="o">.</span><span class="n">replace</span><span class="p">(</span><span class="o">/&lt;</span>\<span class="o">/</span><span class="err">?</span><span class="p">[</span><span class="n">a</span><span class="o">-</span><span class="n">z</span><span class="p">][</span>\<span class="n">w</span><span class="p">:</span><span class="o">-</span><span class="p">]</span><span class="o">*</span>\<span class="n">b</span><span class="p">[</span><span class="o">^&gt;</span><span class="p">]</span><span class="o">*&gt;/</span><span class="n">gi</span><span class="p">,</span> <span class="s1">&#39;&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">let</span> <span class="n">unwrapped_response</span> <span class="o">=</span> <span class="n">unwrapAutoLinks</span><span class="p">(</span><span class="n">msg</span><span class="o">.</span><span class="n">payload</span><span class="o">.</span><span class="n">assistant</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="n">let</span> <span class="n">final_response</span> <span class="o">=</span> <span class="n">stripHtmlTags</span><span class="p">(</span><span class="n">unwrapped_response</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">msg</span><span class="o">.</span><span class="n">payload</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;message&#34;</span><span class="p">:</span> <span class="n">final_response</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;number&#34;</span><span class="p">:</span> <span class="s2">&#34;&lt;bot-number&gt;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;recipients&#34;</span><span class="p">:</span> <span class="p">[</span> <span class="n">msg</span><span class="o">.</span><span class="n">sender</span> <span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">};</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">msg</span><span class="o">.</span><span class="n">method</span> <span class="o">=</span> <span class="s2">&#34;POST&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="n">msg</span><span class="o">.</span><span class="n">url</span> <span class="o">=</span> <span class="s2">&#34;http://signal_cli:8080/v2/send&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">return</span> <span class="n">msg</span><span class="p">;</span>
</span></span></code></pre></div><h2 id="import-the-flow">Import the flow</h2>
<p>The complete flow - tab, both subflows (Query Regolo and Query Ollama with Tools), and the
websocket-client config node - is available as a sanitised JSON file with all secrets replaced
by placeholders (<code>&lt;bot-number&gt;</code>, <code>&lt;your-regolo-api-key&gt;</code>, <code>&lt;your-signal-uuid&gt;</code>, etc.):</p>
<p><a href="/flows/signal-ai-assistant.json">Download signal-ai-assistant.json</a>
</p>
<p>In Node-RED: hamburger menu → Import → select the file. After importing, edit the
websocket-client config node to point at your signal-cli-rest-api instance, fill in the
Regolo API key in the Query Regolo subflow's environment variables, and replace the UUID
placeholders in the allow-list function.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Bridge: signal-cli-rest-api (json-rpc mode)</li>
<li>Glue: Node-RED (Docker)</li>
<li>Cloud model: mistral-small-4-119b via Regolo</li>
<li>Local model: mistral-nemo:12b via Ollama</li>
<li>Context: 15-min TTL, 8 messages max</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://github.com/bbernhard/signal-cli-rest-api" target="_blank" rel="noopener noreferrer">signal-cli-rest-api</a>
</li>
<li><a href="https://nodered.org" target="_blank" rel="noopener noreferrer">Node-RED</a>
</li>
<li><a href="https://ollama.com" target="_blank" rel="noopener noreferrer">Ollama</a>
</li>
<li><a href="https://regolo.ai" target="_blank" rel="noopener noreferrer">Regolo</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Want something similar?</div>
  <div class="smallprint mb-3">I do contract sysadmin and automation work and can help wire up Signal bots, Node-RED flows, or local LLM infrastructure.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-chat-dots"></i><span>Node-RED</span></div>
    <h1 class="display-6 fw-semibold mb-2">A private AI assistant over Signal</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-05">September 5, 2026</time></p>
    <p class="lead mb-0">A walkthrough of building a private AI assistant you can text over Signal - with conversation
memory, an emoji-driven context reset, a sender allowlist, and a choice between local Ollama
inference and a cloud API.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-05T00:00:00&#43;10:00">5 September 2026</time>
        </div>

<h2 id="the-idea">The idea</h2>
<p>The goal: an AI assistant you can text. Not through a cloud chatbot, not through yet another
app, but through Signal - end-to-end encrypted, reachable from your phone the same way any
contact is.</p>
<p>The pieces are all there.
<a href="https://github.com/bbernhard/signal-cli-rest-api" target="_blank" rel="noopener noreferrer">signal-cli-rest-api</a>
 wraps the Signal CLI in
a REST and WebSocket API. Node-RED can run in a Docker container on the same host.
<a href="https://ollama.com" target="_blank" rel="noopener noreferrer">Ollama</a>
 provides local inference, and
<a href="https://regolo.ai" target="_blank" rel="noopener noreferrer">Regolo</a>
 offers an OpenAI-compatible cloud API. The work is wiring them
together.</p>
<div class="mermaid">

sequenceDiagram
    participant U as User (Signal app)
    participant S as signal-cli-rest-api
    participant N as Node-RED
    participant R as Regolo API
    U->>S: Signal message
    S->>N: WebSocket envelope (JSON)
    N->>N: Allow-list check (UUID)
    alt Reaction emoji
        N->>N: Clear sender context
    else Text message
        N->>N: Build context (15-min TTL, 8 max)
        N->>S: PUT typing indicator
        N->>R: POST /v1/chat/completions
        R-->>N: Assistant reply
        N->>N: Strip HTML, store in context
        N->>S: POST /v2/send
        S-->>U: Signal reply
    end

</div>

<h2 id="signal-cli-rest-api-the-bridge">signal-cli-rest-api: the bridge</h2>
<p>signal-cli-rest-api runs as a Docker container (<code>bbernhard/signal-cli-rest-api</code>) in
<code>json-rpc</code> mode. Before anything else, you link a real Signal account to it - a dedicated
number that becomes the bot's identity. Once linked, the container exposes two key endpoints: a
WebSocket receive path that streams incoming messages, and a REST send path for outgoing
replies.</p>
<p>The WebSocket endpoint is the interesting one. It long-polls Signal's servers with a
configurable timeout - in this case, five seconds - and pushes any received envelopes down the
WebSocket as JSON. Each envelope contains the sender's UUID, the message body, a timestamp, and
any reactions (emoji responses to previous messages).</p>
<h2 id="the-receive-path">The receive path</h2>
<p>In Node-RED, a <code>websocket-client</code> node connects to
<code>ws://signal_cli:8080/v1/receive/&lt;bot-number&gt;?timeout=5</code>. Every time someone texts the bot, a
JSON envelope lands in the flow. The first thing the flow does is check who sent it.</p>
<h2 id="who-gets-to-talk">Who gets to talk</h2>
<p>The allowlist is a function node with a hardcoded array of Signal UUIDs - not phone numbers.
Signal identifies users by UUID internally; <code>sourceUuid</code> is stable and doesn't change if
someone changes their number. The list might contain a handful of UUIDs - your own, a
colleague's, anyone you want to grant access. If the sender isn't on the list, the message is
dropped silently. No error reply, no acknowledgement - the bot simply doesn't exist for them.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gdscript3" data-lang="gdscript3"><span class="line"><span class="cl"><span class="o">//</span> <span class="n">Allow</span><span class="o">-</span><span class="n">list</span> <span class="n">of</span> <span class="n">senders</span>
</span></span><span class="line"><span class="cl"><span class="n">let</span> <span class="n">validSignalUsers</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;&lt;your-signal-uuid&gt;&#34;</span><span class="p">,</span>       <span class="o">//</span> <span class="n">Me</span> <span class="n">main</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;&lt;colleague-uuid-1&gt;&#34;</span><span class="p">,</span>       <span class="o">//</span> <span class="n">Me</span> <span class="n">work</span>
</span></span><span class="line"><span class="cl">  <span class="o">//</span> <span class="o">...</span>
</span></span><span class="line"><span class="cl"><span class="p">];</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="n">validSignalUsers</span><span class="o">.</span><span class="n">includes</span><span class="p">(</span><span class="n">msg</span><span class="o">.</span><span class="n">payload</span><span class="o">.</span><span class="n">envelope</span><span class="o">.</span><span class="n">sourceUuid</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="n">msg</span><span class="o">.</span><span class="n">sender</span> <span class="o">=</span> <span class="n">msg</span><span class="o">.</span><span class="n">payload</span><span class="o">.</span><span class="n">envelope</span><span class="o">.</span><span class="n">sourceUuid</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="n">msg</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="n">node</span><span class="o">.</span><span class="n">error</span><span class="p">(</span><span class="err">`</span><span class="n">Sender</span> <span class="o">$</span><span class="p">{</span><span class="n">msg</span><span class="o">.</span><span class="n">payload</span><span class="o">.</span><span class="n">envelope</span><span class="o">.</span><span class="n">sourceUuid</span><span class="p">}</span> <span class="ow">not</span> <span class="n">allowed</span><span class="err">`</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="n">null</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><h2 id="the-emoji-reaction-router">The emoji reaction router</h2>
<p>This is where it gets fun. Signal supports emoji reactions - you can react to a message with
any emoji. The flow checks whether an incoming envelope contains a reaction. If it does, it
checks whether the emoji is 🤯 (the exploding head).</p>
<p>If it is, the flow clears that sender's conversation history. All of it. Gone. The next message
starts a fresh conversation. This is a surprisingly natural UX: you don't need to type a command
like <code>/reset</code> - you just react to the bot's last message with the exploding head emoji and the
context is wiped.</p>
<p>If the reaction is any other emoji, it's ignored. And if there's no reaction at all - just a
regular text message - the flow continues to the main processing path.</p>
<h2 id="conversation-memory">Conversation memory</h2>
<p>Each sender gets their own conversation history, stored in Node-RED's global context under a
key called <code>SIGNAL_AI_ASSISTANT</code>. The structure is a map of sender UUID to an array of
messages, each with a content string and a timestamp.</p>
<p>Two limits keep things bounded. Messages older than 15 minutes are evicted - if you haven't
talked to the bot in a quarter of an hour, it starts fresh. And the array is capped at 8
messages total, so even a fast back-and-forth won't grow unbounded.</p>
<p>When a new message arrives, the flow builds an XML-tagged context block for the LLM. Previous
messages are wrapped in <code>&lt;context_message&gt;</code> tags inside a <code>&lt;context&gt;</code> block. The new message is
wrapped in a <code>&lt;latest_message&gt;</code> tag. This separation matters: the LLM is told explicitly that
the context block is background only - use it for consistency and memory - while the latest
message is the one to answer. This prevents the model from re-answering old questions or getting
confused about which message is current.</p>
<p>After the LLM replies, the assistant's response is also stored in the context array, so the next
exchange has the full back-and-forth.</p>
<p>The context-building function enforces both limits and assembles the XML-tagged prompt:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gdscript3" data-lang="gdscript3"><span class="line"><span class="cl"><span class="k">const</span> <span class="n">MAX_CONTEXT_AGE_MS</span> <span class="o">=</span> <span class="mi">15</span> <span class="o">*</span> <span class="mi">60</span> <span class="o">*</span> <span class="mi">1000</span><span class="p">;</span> <span class="o">//</span> <span class="mi">15</span> <span class="n">minutes</span>
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">MAX_MESSAGES</span> <span class="o">=</span> <span class="mi">8</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">let</span> <span class="n">ctx</span> <span class="o">=</span> <span class="n">global</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s1">&#39;SIGNAL_AI_ASSISTANT&#39;</span><span class="p">)</span> <span class="o">||</span> <span class="p">{};</span>
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">sender</span> <span class="o">=</span> <span class="n">msg</span><span class="o">.</span><span class="n">sender</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="n">let</span> <span class="n">messages</span> <span class="o">=</span> <span class="p">(</span><span class="n">ctx</span><span class="p">[</span><span class="n">sender</span><span class="p">]</span> <span class="o">&amp;&amp;</span> <span class="ne">Array</span><span class="o">.</span><span class="n">isArray</span><span class="p">(</span><span class="n">ctx</span><span class="p">[</span><span class="n">sender</span><span class="p">]</span><span class="o">.</span><span class="n">messages</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">  <span class="err">?</span> <span class="p">[</span><span class="o">...</span><span class="n">ctx</span><span class="p">[</span><span class="n">sender</span><span class="p">]</span><span class="o">.</span><span class="n">messages</span><span class="p">]</span> <span class="p">:</span> <span class="p">[];</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="o">//</span> <span class="n">Drop</span> <span class="n">messages</span> <span class="n">older</span> <span class="n">than</span> <span class="mi">15</span> <span class="n">minutes</span>
</span></span><span class="line"><span class="cl"><span class="n">messages</span> <span class="o">=</span> <span class="n">messages</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span><span class="n">m</span> <span class="o">=&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">(</span><span class="n">newMessageTimestamp</span> <span class="o">-</span> <span class="p">(</span><span class="n">m</span><span class="o">.</span><span class="n">timestamp</span> <span class="o">||</span> <span class="mi">0</span><span class="p">))</span> <span class="o">&lt;=</span> <span class="n">MAX_CONTEXT_AGE_MS</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="o">//</span> <span class="n">Cap</span> <span class="n">at</span> <span class="n">MAX_MESSAGES</span> <span class="o">-</span> <span class="mi">1</span> <span class="n">before</span> <span class="n">adding</span> <span class="n">the</span> <span class="n">new</span> <span class="n">one</span>
</span></span><span class="line"><span class="cl"><span class="k">while</span> <span class="p">(</span><span class="n">messages</span><span class="o">.</span><span class="n">length</span> <span class="o">&gt;</span> <span class="n">MAX_MESSAGES</span> <span class="o">-</span> <span class="mi">1</span><span class="p">)</span> <span class="n">messages</span><span class="o">.</span><span class="n">shift</span><span class="p">();</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">messages</span><span class="o">.</span><span class="n">push</span><span class="p">({</span> <span class="n">content</span><span class="p">:</span> <span class="ne">String</span><span class="p">(</span><span class="n">newMessageContent</span><span class="p">),</span> <span class="n">timestamp</span><span class="p">:</span> <span class="n">newMessageTimestamp</span> <span class="p">});</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">ctx</span><span class="p">[</span><span class="n">sender</span><span class="p">]</span> <span class="o">=</span> <span class="p">{</span> <span class="n">messages</span> <span class="p">};</span>
</span></span><span class="line"><span class="cl"><span class="n">global</span><span class="o">.</span><span class="n">set</span><span class="p">(</span><span class="s1">&#39;SIGNAL_AI_ASSISTANT&#39;</span><span class="p">,</span> <span class="n">ctx</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="o">//</span> <span class="n">Build</span> <span class="n">XML</span><span class="o">-</span><span class="n">tagged</span> <span class="n">prompt</span><span class="p">:</span> <span class="n">context</span> <span class="n">block</span> <span class="o">+</span> <span class="n">latest</span> <span class="n">message</span>
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">previous</span> <span class="o">=</span> <span class="n">messages</span><span class="o">.</span><span class="n">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="o">-</span><span class="mi">1</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">latest</span>   <span class="o">=</span> <span class="n">messages</span><span class="p">[</span><span class="n">messages</span><span class="o">.</span><span class="n">length</span> <span class="o">-</span> <span class="mi">1</span><span class="p">];</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">contextBlock</span> <span class="o">=</span> <span class="n">previous</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="n">m</span> <span class="o">=&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="err">`</span><span class="o">&lt;</span><span class="n">context_message</span> <span class="n">ts</span><span class="o">=</span><span class="s2">&#34;${m.timestamp}&#34;</span><span class="o">&gt;$</span><span class="p">{</span><span class="n">m</span><span class="o">.</span><span class="n">content</span><span class="p">}</span><span class="o">&lt;/</span><span class="n">context_message</span><span class="o">&gt;</span><span class="err">`</span><span class="p">)</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="s1">&#39;</span><span class="se">\n</span><span class="s1">&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">merged</span> <span class="o">=</span> <span class="p">(</span><span class="n">previous</span><span class="o">.</span><span class="n">length</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="err">?</span> <span class="err">`</span><span class="o">&lt;</span><span class="n">context</span> <span class="p">(</span><span class="n">oldest</span><span class="o">-&gt;</span><span class="n">newest</span><span class="p">)</span><span class="o">&gt;</span>\<span class="n">n</span><span class="o">$</span><span class="p">{</span><span class="n">contextBlock</span><span class="p">}</span>\<span class="n">n</span><span class="o">&lt;/</span><span class="n">context</span><span class="o">&gt;</span>\<span class="n">n</span>\<span class="n">n</span><span class="err">`</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">    <span class="err">`</span><span class="o">&lt;</span><span class="n">latest_message</span> <span class="n">ts</span><span class="o">=</span><span class="s2">&#34;${latest.timestamp}&#34;</span><span class="o">&gt;$</span><span class="p">{</span><span class="n">latest</span><span class="o">.</span><span class="n">content</span><span class="p">}</span><span class="o">&lt;/</span><span class="n">latest_message</span><span class="o">&gt;</span><span class="err">`</span>
</span></span><span class="line"><span class="cl">  <span class="p">:</span> <span class="err">`</span><span class="o">&lt;</span><span class="n">latest_message</span> <span class="n">ts</span><span class="o">=</span><span class="s2">&#34;${latest.timestamp}&#34;</span><span class="o">&gt;$</span><span class="p">{</span><span class="n">latest</span><span class="o">.</span><span class="n">content</span><span class="p">}</span><span class="o">&lt;/</span><span class="n">latest_message</span><span class="o">&gt;</span><span class="err">`</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">msg</span><span class="o">.</span><span class="n">context</span> <span class="o">=</span> <span class="n">merged</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">return</span> <span class="n">msg</span><span class="p">;</span>
</span></span></code></pre></div><h2 id="two-ways-to-query">Two ways to query</h2>
<p>The flow defines two subflows for talking to an LLM, but only one is wired in.</p>
<p>In this example, the active subflow, <strong>Query Regolo</strong>, calls an OpenAI-compatible API at <code>api.regolo.ai</code> with
the model <code>mistral-small-4-119b</code> at temperature 0.3. Regolo is a cloud API - fast, capable, but
your messages leave your network. The subflow sends the system prompt and context block as a
chat completion request and extracts the assistant's reply from the response.</p>
<p>The other subflow, <strong>Query Ollama with Tools</strong>, is defined but not wired up - I've included it
as an example (and it was the original flow I used to use). It talks to a local Ollama instance running
an LLM of your choice (in this example, I had been using <code>mistral-nemo:12b-instruct-2407-q4_K_M</code>)</p>
<ul>
<li>everything stays on the host, no data leaves the network.</li>
</ul>
<p>But the interesting part is the &quot;with Tools&quot; bit: the subflow's system prompt
instructs the model to use web search tools to improve its answers and to cite the URLs it
found. The prompt is detailed - rules about when to use context versus the latest message, how
to handle code requests, how to handle decisions, and a requirement to search the web when it
would help.</p>
<p>This subflow isn't wired in by default. The idea is to switch to it once the tool-calling
pipeline is solid, so the assistant can look things up in real time instead of only knowing
what its training data taught it.</p>
<h2 id="the-web-search-tool">The web search tool</h2>
<p>The &quot;Query Ollama with Tools&quot; subflow needs a web search backend. When the LLM emits a
<code>tool_call</code> for <code>private_web_search</code>, the subflow builds a command line and hands it to an
<code>exec</code> node that runs a small Python script, <code>tools.py</code>. The script queries a
<a href="https://searxng.com" target="_blank" rel="noopener noreferrer">SearXNG</a>
 instance running as a Docker service on the same host,
extracts the top result, strips HTML, and prints a single line: the result title and URL
followed by a content snippet. That stdout is fed back into the Ollama chat completion as a
<code>tool</code> message, so the model can incorporate what it found and cite the URL.</p>
<p>The script is deliberately small - one function, one HTTP GET, one <code>print</code>. It passes a
spoofed <code>X-Forwarded-For</code> header to SearXNG (which trusts the reverse proxy in front of it)
and returns the first result as <code>title - url</code> on the first line, with the content snippet on
the second:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="ch">#!/usr/bin/env python3</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">argparse</span><span class="o">,</span> <span class="nn">requests</span><span class="o">,</span> <span class="nn">html</span><span class="o">,</span> <span class="nn">re</span><span class="o">,</span> <span class="nn">sys</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">SEARXNG_URL</span> <span class="o">=</span> <span class="s2">&#34;http://searxng:8080/search&#34;</span>  <span class="c1"># Docker service name:port</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">top_result</span><span class="p">(</span><span class="n">query</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">xff</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">,</span> <span class="n">xri</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">params</span> <span class="o">=</span> <span class="p">{</span><span class="s2">&#34;q&#34;</span><span class="p">:</span> <span class="n">query</span><span class="p">,</span> <span class="s2">&#34;format&#34;</span><span class="p">:</span> <span class="s2">&#34;json&#34;</span><span class="p">,</span> <span class="s2">&#34;language&#34;</span><span class="p">:</span> <span class="s2">&#34;en&#34;</span><span class="p">,</span> <span class="s2">&#34;safesearch&#34;</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span> <span class="s2">&#34;categories&#34;</span><span class="p">:</span> <span class="s2">&#34;general&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="n">headers</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;User-Agent&#34;</span><span class="p">:</span> <span class="s2">&#34;Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 &#34;</span>
</span></span><span class="line"><span class="cl">                      <span class="s2">&#34;(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;Accept&#34;</span><span class="p">:</span> <span class="s2">&#34;application/json,text/plain,*/*&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;Accept-Language&#34;</span><span class="p">:</span> <span class="s2">&#34;en-US,en;q=0.9&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;Accept-Encoding&#34;</span><span class="p">:</span> <span class="s2">&#34;gzip, deflate&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;Connection&#34;</span><span class="p">:</span> <span class="s2">&#34;keep-alive&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;Referer&#34;</span><span class="p">:</span> <span class="s2">&#34;http://searxng:8080/&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;X-Forwarded-For&#34;</span><span class="p">:</span> <span class="s2">&#34;&lt;spoofed-client-ip&gt;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">r</span> <span class="o">=</span> <span class="n">requests</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">SEARXNG_URL</span><span class="p">,</span> <span class="n">params</span><span class="o">=</span><span class="n">params</span><span class="p">,</span> <span class="n">headers</span><span class="o">=</span><span class="n">headers</span><span class="p">,</span> <span class="n">timeout</span><span class="o">=</span><span class="mi">12</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">r</span><span class="o">.</span><span class="n">status_code</span> <span class="o">==</span> <span class="mi">403</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">r</span><span class="o">.</span><span class="n">text</span>
</span></span><span class="line"><span class="cl">    <span class="n">r</span><span class="o">.</span><span class="n">raise_for_status</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">data</span> <span class="o">=</span> <span class="n">r</span><span class="o">.</span><span class="n">json</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">results</span> <span class="o">=</span> <span class="n">data</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;results&#34;</span><span class="p">,</span> <span class="p">[])</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">results</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="s2">&#34;No result found.&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">top</span> <span class="o">=</span> <span class="n">results</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="n">title</span> <span class="o">=</span> <span class="p">(</span><span class="n">top</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;title&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="s2">&#34;&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">url</span>   <span class="o">=</span> <span class="p">(</span><span class="n">top</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;url&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="s2">&#34;&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">content</span> <span class="o">=</span> <span class="p">(</span><span class="n">top</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;content&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="s2">&#34;&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">content</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">sub</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;&lt;[^&gt;]+&gt;&#34;</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">,</span> <span class="n">html</span><span class="o">.</span><span class="n">unescape</span><span class="p">(</span><span class="n">content</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">    <span class="n">content</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">sub</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;\s+&#34;</span><span class="p">,</span> <span class="s2">&#34; &#34;</span><span class="p">,</span> <span class="n">content</span><span class="p">)</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">piece</span> <span class="o">=</span> <span class="s2">&#34; - &#34;</span><span class="o">.</span><span class="n">join</span><span class="p">([</span><span class="n">p</span> <span class="k">for</span> <span class="n">p</span> <span class="ow">in</span> <span class="p">[</span><span class="n">title</span><span class="p">,</span> <span class="n">url</span><span class="p">]</span> <span class="k">if</span> <span class="n">p</span><span class="p">])</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">piece</span><span class="si">}</span><span class="se">\n</span><span class="si">{</span><span class="n">content</span><span class="si">}</span><span class="s2">&#34;</span> <span class="k">if</span> <span class="n">content</span> <span class="k">else</span> <span class="p">(</span><span class="n">piece</span> <span class="ow">or</span> <span class="s2">&#34;No result found.&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">main</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">    <span class="n">ap</span> <span class="o">=</span> <span class="n">argparse</span><span class="o">.</span><span class="n">ArgumentParser</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">ap</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s2">&#34;--query&#34;</span><span class="p">,</span> <span class="n">required</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">ap</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s2">&#34;--xff&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">ap</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s2">&#34;--xri&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">args</span> <span class="o">=</span> <span class="n">ap</span><span class="o">.</span><span class="n">parse_args</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="n">top_result</span><span class="p">(</span><span class="n">args</span><span class="o">.</span><span class="n">query</span><span class="o">.</span><span class="n">strip</span><span class="p">(),</span> <span class="n">args</span><span class="o">.</span><span class="n">xff</span><span class="p">,</span> <span class="n">args</span><span class="o">.</span><span class="n">xri</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="vm">__name__</span> <span class="o">==</span> <span class="s2">&#34;__main__&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">main</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="k">except</span> <span class="ne">Exception</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;Error: </span><span class="si">{</span><span class="n">e</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">sys</span><span class="o">.</span><span class="n">exit</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</span></span></code></pre></div><p>The <code>--xff</code> and <code>--xri</code> arguments are wired through from the Node-RED flow but not currently
used by the SearXNG query - they're there for future use if the search backend needs a real
client IP for rate-limiting or region detection. The exec node calls it as:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gdscript3" data-lang="gdscript3"><span class="line"><span class="cl"><span class="o">/</span><span class="n">data</span><span class="o">/</span><span class="n">tools</span><span class="o">/</span><span class="n">venv</span><span class="o">/</span><span class="n">bin</span><span class="o">/</span><span class="n">python3</span> <span class="o">/</span><span class="n">data</span><span class="o">/</span><span class="n">tools</span><span class="o">/</span><span class="n">tools</span><span class="o">.</span><span class="n">py</span> <span class="o">--</span><span class="n">query</span> <span class="s2">&#34;&lt;search terms&gt;&#34;</span>
</span></span></code></pre></div><p>with a 15-second timeout. If the script exits non-zero or times out, the subflow's tool-call
handler receives empty stdout, which becomes the tool message content - the model is told
&quot;No output&quot; and carries on without a citation.</p>
<h2 id="the-send-path">The send path</h2>
<p>Before the LLM is even queried, the flow fires off a typing indicator - a <code>PUT</code> to the
signal-cli REST API. This makes the bot show &quot;typing…&quot; in Signal while the model is generating.
It's a small touch, but it makes the difference between a bot that feels alive and one that
feels like it hung.</p>
<p>Once the LLM responds, the flow strips all HTML tags from the reply. LLMs love to emit markdown
and HTML; Signal renders neither. Autolinks like <code>&lt;https://example.com&gt;</code> are unwrapped to plain
URLs, then all tags are stripped. The cleaned text is posted to <code>/v2/send</code> with the bot's
number and the sender's UUID as the recipient.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gdscript3" data-lang="gdscript3"><span class="line"><span class="cl"><span class="k">const</span> <span class="n">unwrapAutoLinks</span> <span class="o">=</span> <span class="n">s</span> <span class="o">=&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="n">s</span><span class="o">.</span><span class="n">replace</span><span class="p">(</span><span class="o">/&lt;</span><span class="p">((</span><span class="err">?</span><span class="p">:</span><span class="n">https</span><span class="err">?</span><span class="p">:</span>\<span class="o">/</span>\<span class="o">/|</span><span class="n">mailto</span><span class="p">:)[</span><span class="o">^&gt;</span><span class="p">]</span><span class="o">+</span><span class="p">)</span><span class="o">&gt;/</span><span class="n">gi</span><span class="p">,</span> <span class="s1">&#39;$1&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">stripHtmlTags</span> <span class="o">=</span> <span class="n">s</span> <span class="o">=&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="n">s</span><span class="o">.</span><span class="n">replace</span><span class="p">(</span><span class="o">/&lt;</span>\<span class="o">/</span><span class="err">?</span><span class="p">[</span><span class="n">a</span><span class="o">-</span><span class="n">z</span><span class="p">][</span>\<span class="n">w</span><span class="p">:</span><span class="o">-</span><span class="p">]</span><span class="o">*</span>\<span class="n">b</span><span class="p">[</span><span class="o">^&gt;</span><span class="p">]</span><span class="o">*&gt;/</span><span class="n">gi</span><span class="p">,</span> <span class="s1">&#39;&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">let</span> <span class="n">unwrapped_response</span> <span class="o">=</span> <span class="n">unwrapAutoLinks</span><span class="p">(</span><span class="n">msg</span><span class="o">.</span><span class="n">payload</span><span class="o">.</span><span class="n">assistant</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="n">let</span> <span class="n">final_response</span> <span class="o">=</span> <span class="n">stripHtmlTags</span><span class="p">(</span><span class="n">unwrapped_response</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">msg</span><span class="o">.</span><span class="n">payload</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;message&#34;</span><span class="p">:</span> <span class="n">final_response</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;number&#34;</span><span class="p">:</span> <span class="s2">&#34;&lt;bot-number&gt;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;recipients&#34;</span><span class="p">:</span> <span class="p">[</span> <span class="n">msg</span><span class="o">.</span><span class="n">sender</span> <span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">};</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">msg</span><span class="o">.</span><span class="n">method</span> <span class="o">=</span> <span class="s2">&#34;POST&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="n">msg</span><span class="o">.</span><span class="n">url</span> <span class="o">=</span> <span class="s2">&#34;http://signal_cli:8080/v2/send&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">return</span> <span class="n">msg</span><span class="p">;</span>
</span></span></code></pre></div><h2 id="import-the-flow">Import the flow</h2>
<p>The complete flow - tab, both subflows (Query Regolo and Query Ollama with Tools), and the
websocket-client config node - is available as a sanitised JSON file with all secrets replaced
by placeholders (<code>&lt;bot-number&gt;</code>, <code>&lt;your-regolo-api-key&gt;</code>, <code>&lt;your-signal-uuid&gt;</code>, etc.):</p>
<p><a href="/flows/signal-ai-assistant.json">Download signal-ai-assistant.json</a>
</p>
<p>In Node-RED: hamburger menu → Import → select the file. After importing, edit the
websocket-client config node to point at your signal-cli-rest-api instance, fill in the
Regolo API key in the Query Regolo subflow's environment variables, and replace the UUID
placeholders in the allow-list function.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Bridge: signal-cli-rest-api (json-rpc mode)</li>
<li>Glue: Node-RED (Docker)</li>
<li>Cloud model: mistral-small-4-119b via Regolo</li>
<li>Local model: mistral-nemo:12b via Ollama</li>
<li>Context: 15-min TTL, 8 messages max</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://github.com/bbernhard/signal-cli-rest-api" target="_blank" rel="noopener noreferrer">signal-cli-rest-api</a>
</li>
<li><a href="https://nodered.org" target="_blank" rel="noopener noreferrer">Node-RED</a>
</li>
<li><a href="https://ollama.com" target="_blank" rel="noopener noreferrer">Ollama</a>
</li>
<li><a href="https://regolo.ai" target="_blank" rel="noopener noreferrer">Regolo</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Want something similar?</div>
  <div class="smallprint mb-3">I do contract sysadmin and automation work and can help wire up Signal bots, Node-RED flows, or local LLM infrastructure.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>BoatRules.au: a marine licence practice test that keeps you on your toes</title>
      <link>https://mig5.net/news/boatrules-au-marine-licence-practice/</link>
      <pubDate>Wed, 02 Sep 2026 00:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/boatrules-au-marine-licence-practice/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-water"></i><span>Study tool</span></div>
    <h1 class="display-6 fw-semibold mb-2">BoatRules.au: a marine licence quiz that won&#39;t let you memorise the test</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-02">September 2, 2026</time></p>
    <p class="lead mb-0"><p>I sat my Victorian marine licence test recently and passed (yay).</p>
<p>The Safe Transport Victoria site has a bunch of 'practice' quizzes that, I admit, are quite similar
to the actual test. However, their design, in my humble opinion, isn't the greatest:
it's the same static set of questions, same answers in the same slots, every time. So I ended up building
<a href="https://boatrules.au" target="_blank" rel="noopener noreferrer">BoatRules.au</a>
: a study quiz that does things slightly differently. It helped me a lot!</p>
</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-02T00:00:00&#43;10:00">2 September 2026</time>
        </div>

<h2 id="learning-how-to-learn">Learning how to learn</h2>
<p>After a recent visit to an ancestral homeland with a strong connection to the sea, sailing and
fishing, I rediscovered my interest in all things nautical.</p>
<p>Once I got back home, I sat my Victorian marine licence test, and passed. Happy with that!</p>
<p>Passing the marine license test in Victoria involves learning a lot of rules from a handbook,
then passing 26 out of 30 multiple choice questions.</p>
<p>I initially had been studying with the <a href="https://safetransport.vic.gov.au/on-the-water/marine-licence-and-vessel-registration/marine-licence-practice-tests/" target="_blank" rel="noopener noreferrer">Safe Transport Victoria&#39;s own practice
tests</a>
.</p>
<p>They're okay, but I think they could be better!</p>
<p>The same handful of questions, in the same order, with the answers in the same
positions, every single time. Sit the practice test twice and by the second pass you're not
answering questions any more - you're recognising a pattern. Question 7 is C. Question 12 is the
starboard-hand mark. You stop retrieving the rule and start retrieving the slot.</p>
<p>That's <strong>positional cueing</strong> at work - a flavour of <strong>testwiseness</strong> where the test-taker picks up
structural cues (where the answer sits) instead of actually knowing the content. Studies have found
position-bias ratios as high as 3 or 4 to 1 toward middle slots in four-option tests.</p>
<p>All of which is to say, pretty quickly I found myself nerd-swiped out of studying boat rules and instead studying how to study :)</p>
<p>So in the end, I built a thing.</p>
<h2 id="boatrulesau">BoatRules.au</h2>
<p>It's called <a href="https://boatrules.au" target="_blank" rel="noopener noreferrer">BoatRules.au</a>
. No backend, no account, no ads, no tracking.
Single page, runs in any modern browser, on a desktop or phone.</p>
<p>It draws from a bank of about 160 questions covering the full <a href="https://handbook.safetransport.vic.gov.au/" target="_blank" rel="noopener noreferrer">Victorian Recreational Boating
Safety Handbook</a>
 - licensing, lifejackets, speed and
distance, towing, collision rules, buoyage, navigation lights, day shapes, sound signals, safety
equipment, emergency communications, weather, ocean bars, the lot. Every sitting generates a fresh
30-question test, 26 to pass, no per-question feedback until you submit. Same shape as the real
exam.</p>
<p>The difference is that nothing stays put.</p>
<h2 id="nothing-stays-put">Nothing stays put</h2>
<p>Every sitting shuffles the question order. Every question shuffles its answer positions
independently. Positional cueing gets you nothing. You can't memorise &quot;B&quot; because B is a moving
target.</p>
<p>The tool also prefers questions your browser hasn't seen before, only dipping back into seen
questions when the unseen pool runs low. That's a gentle nudge toward the <strong>spacing effect</strong> - one
of the more stubbornly robust findings in learning research, that distributing practice across
sessions beats cramming the same material into one sitting. You come back tomorrow, you see new
questions, you don't just re-answer the thirty you saw today. Learning languages tends to favour a
'spaced repetition system' like this too.</p>
<p>Welcome to what is known as <strong>desirable difficulties</strong> - features
of practice that make learning feel harder in the short term but produce more durable retention.
Shuffled order, spaced exposure. How many of you find 'theory' boring but prefer 'hands on' learning?</p>
<h2 id="no-help-until-the-end">No help until the end</h2>
<p>The tool withholds per-question feedback until you submit. That's stolen straight from the real
exam format, and it lines up with another well-established finding: <strong>retrieval practice</strong>,
sometimes called <strong>test-enhanced learning</strong>. The act of pulling information out of memory - not
re-reading it, not hearing it again, but actively retrieving it - is itself a powerful learning
event. Each question in a sitting is a retrieval attempt. Withholding feedback until the end stops
the feedback from short-circuiting the retrieval. You have to actually try.</p>
<p>After you submit you get the full breakdown - score, correct answers, explanations - so the
feedback arrives, just not mid-retrieval where it would do the work for you.</p>
<h2 id="hear-the-horn-see-the-mark">Hear the horn, see the mark</h2>
<p>This is the part I had the most fun with.</p>
<p>The official practice tests are mostly text-only. But the handbook talks about light sequences
for cardinal marks, horn blasts and their duration, and so on.</p>
<p>So BoatRules.au has three kinds of questions:</p>
<ul>
<li><strong>Text questions</strong> - the standard format, drawn from the handbook rules.</li>
<li><strong>SVG schematics</strong> - original simplified diagrams for every mark type in IALA System A: lateral,
cardinal, isolated danger, safe-water, special, new-danger. Plus navigation light configurations
<ul>
<li>anchored, power-driven ahead, sailing ahead, trawler, fishing, not-under-command, restricted
in ability to manoeuvre, aground, pilot vessel, constrained-by-draught, dredge, long tow. Plus
day shapes. You see the diagram, you identify what it means.</li>
</ul>
</li>
<li><strong>Playable horn audio</strong> - real audio files for every sound signal in the handbook. One short
blast means &quot;I am altering my course to starboard.&quot; Two short means &quot;altering to port.&quot; Three
means &quot;operating astern propulsion.&quot; Five or more rapid short blasts means doubt or danger. One
prolonged means a blind bend in a narrow channel. The narrow-channel overtaking and agreement
signals are there too. Play the sound, identify the manoeuvre.</li>
</ul>
<p>This is where Allan Paivio's <strong>dual coding theory</strong> earns its keep: verbal and visual
information get processed through separate cognitive channels, and material encoded through both
is more robustly retained than material encoded through one.</p>
<h2 id="want-to-try-it">Want to try it?</h2>
<p>This is super niche, so I don't expect many readers to want to go trying for their Victorian
marine license (if you do, though, hope to see you out there one day!). But in case you're
interested in 'learning how to learn', you might still find it interesting.</p>
<p><a href="https://boatrules.au" target="_blank" rel="noopener noreferrer">BoatRules.au</a>
 is free, has no account, no ads, no tracking.
It's an independent study aid, not the official exam - before you sit the real test, you should
still read the <a href="https://handbook.safetransport.vic.gov.au/" target="_blank" rel="noopener noreferrer">Victorian Recreational Boating Safety Handbook</a>
 and, hey
why not (for all my criticism, they still helped me!), check out those <a href="https://safetransport.vic.gov.au/on-the-water/marine-licence-and-vessel-registration/marine-licence-practice-tests/" target="_blank" rel="noopener noreferrer">Safe Transport Victoria
practice tests</a>
.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Type: browser-based study quiz</li>
<li>Coverage: ~160 questions</li>
<li>Format: 30-question tests, pass target 26/30</li>
<li>Modalities: text, SVG schematics, playable horn-signal audio</li>
<li>Cost: free, no account, no tracking</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://boatrules.au" target="_blank" rel="noopener noreferrer">Try BoatRules.au</a>
</li>
<li><a href="https://handbook.safetransport.vic.gov.au/" target="_blank" rel="noopener noreferrer">Victorian Recreational Boating Safety Handbook</a>
</li>
<li><a href="https://safetransport.vic.gov.au/on-the-water/marine-licence-and-vessel-registration/marine-licence-practice-tests/" target="_blank" rel="noopener noreferrer">Safe Transport Victoria practice tests</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Interested in how I think?</div>
  <div class="smallprint mb-3">Let me puzzle over any technical problems plaguing the systems at your organisation.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-water"></i><span>Study tool</span></div>
    <h1 class="display-6 fw-semibold mb-2">BoatRules.au: a marine licence quiz that won&#39;t let you memorise the test</h1>
    <p class="text-muted mb-3"><time datetime="2026-09-02">September 2, 2026</time></p>
    <p class="lead mb-0"><p>I sat my Victorian marine licence test recently and passed (yay).</p>
<p>The Safe Transport Victoria site has a bunch of 'practice' quizzes that, I admit, are quite similar
to the actual test. However, their design, in my humble opinion, isn't the greatest:
it's the same static set of questions, same answers in the same slots, every time. So I ended up building
<a href="https://boatrules.au" target="_blank" rel="noopener noreferrer">BoatRules.au</a>
: a study quiz that does things slightly differently. It helped me a lot!</p>
</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-09-02T00:00:00&#43;10:00">2 September 2026</time>
        </div>

<h2 id="learning-how-to-learn">Learning how to learn</h2>
<p>After a recent visit to an ancestral homeland with a strong connection to the sea, sailing and
fishing, I rediscovered my interest in all things nautical.</p>
<p>Once I got back home, I sat my Victorian marine licence test, and passed. Happy with that!</p>
<p>Passing the marine license test in Victoria involves learning a lot of rules from a handbook,
then passing 26 out of 30 multiple choice questions.</p>
<p>I initially had been studying with the <a href="https://safetransport.vic.gov.au/on-the-water/marine-licence-and-vessel-registration/marine-licence-practice-tests/" target="_blank" rel="noopener noreferrer">Safe Transport Victoria&#39;s own practice
tests</a>
.</p>
<p>They're okay, but I think they could be better!</p>
<p>The same handful of questions, in the same order, with the answers in the same
positions, every single time. Sit the practice test twice and by the second pass you're not
answering questions any more - you're recognising a pattern. Question 7 is C. Question 12 is the
starboard-hand mark. You stop retrieving the rule and start retrieving the slot.</p>
<p>That's <strong>positional cueing</strong> at work - a flavour of <strong>testwiseness</strong> where the test-taker picks up
structural cues (where the answer sits) instead of actually knowing the content. Studies have found
position-bias ratios as high as 3 or 4 to 1 toward middle slots in four-option tests.</p>
<p>All of which is to say, pretty quickly I found myself nerd-swiped out of studying boat rules and instead studying how to study :)</p>
<p>So in the end, I built a thing.</p>
<h2 id="boatrulesau">BoatRules.au</h2>
<p>It's called <a href="https://boatrules.au" target="_blank" rel="noopener noreferrer">BoatRules.au</a>
. No backend, no account, no ads, no tracking.
Single page, runs in any modern browser, on a desktop or phone.</p>
<p>It draws from a bank of about 160 questions covering the full <a href="https://handbook.safetransport.vic.gov.au/" target="_blank" rel="noopener noreferrer">Victorian Recreational Boating
Safety Handbook</a>
 - licensing, lifejackets, speed and
distance, towing, collision rules, buoyage, navigation lights, day shapes, sound signals, safety
equipment, emergency communications, weather, ocean bars, the lot. Every sitting generates a fresh
30-question test, 26 to pass, no per-question feedback until you submit. Same shape as the real
exam.</p>
<p>The difference is that nothing stays put.</p>
<h2 id="nothing-stays-put">Nothing stays put</h2>
<p>Every sitting shuffles the question order. Every question shuffles its answer positions
independently. Positional cueing gets you nothing. You can't memorise &quot;B&quot; because B is a moving
target.</p>
<p>The tool also prefers questions your browser hasn't seen before, only dipping back into seen
questions when the unseen pool runs low. That's a gentle nudge toward the <strong>spacing effect</strong> - one
of the more stubbornly robust findings in learning research, that distributing practice across
sessions beats cramming the same material into one sitting. You come back tomorrow, you see new
questions, you don't just re-answer the thirty you saw today. Learning languages tends to favour a
'spaced repetition system' like this too.</p>
<p>Welcome to what is known as <strong>desirable difficulties</strong> - features
of practice that make learning feel harder in the short term but produce more durable retention.
Shuffled order, spaced exposure. How many of you find 'theory' boring but prefer 'hands on' learning?</p>
<h2 id="no-help-until-the-end">No help until the end</h2>
<p>The tool withholds per-question feedback until you submit. That's stolen straight from the real
exam format, and it lines up with another well-established finding: <strong>retrieval practice</strong>,
sometimes called <strong>test-enhanced learning</strong>. The act of pulling information out of memory - not
re-reading it, not hearing it again, but actively retrieving it - is itself a powerful learning
event. Each question in a sitting is a retrieval attempt. Withholding feedback until the end stops
the feedback from short-circuiting the retrieval. You have to actually try.</p>
<p>After you submit you get the full breakdown - score, correct answers, explanations - so the
feedback arrives, just not mid-retrieval where it would do the work for you.</p>
<h2 id="hear-the-horn-see-the-mark">Hear the horn, see the mark</h2>
<p>This is the part I had the most fun with.</p>
<p>The official practice tests are mostly text-only. But the handbook talks about light sequences
for cardinal marks, horn blasts and their duration, and so on.</p>
<p>So BoatRules.au has three kinds of questions:</p>
<ul>
<li><strong>Text questions</strong> - the standard format, drawn from the handbook rules.</li>
<li><strong>SVG schematics</strong> - original simplified diagrams for every mark type in IALA System A: lateral,
cardinal, isolated danger, safe-water, special, new-danger. Plus navigation light configurations
<ul>
<li>anchored, power-driven ahead, sailing ahead, trawler, fishing, not-under-command, restricted
in ability to manoeuvre, aground, pilot vessel, constrained-by-draught, dredge, long tow. Plus
day shapes. You see the diagram, you identify what it means.</li>
</ul>
</li>
<li><strong>Playable horn audio</strong> - real audio files for every sound signal in the handbook. One short
blast means &quot;I am altering my course to starboard.&quot; Two short means &quot;altering to port.&quot; Three
means &quot;operating astern propulsion.&quot; Five or more rapid short blasts means doubt or danger. One
prolonged means a blind bend in a narrow channel. The narrow-channel overtaking and agreement
signals are there too. Play the sound, identify the manoeuvre.</li>
</ul>
<p>This is where Allan Paivio's <strong>dual coding theory</strong> earns its keep: verbal and visual
information get processed through separate cognitive channels, and material encoded through both
is more robustly retained than material encoded through one.</p>
<h2 id="want-to-try-it">Want to try it?</h2>
<p>This is super niche, so I don't expect many readers to want to go trying for their Victorian
marine license (if you do, though, hope to see you out there one day!). But in case you're
interested in 'learning how to learn', you might still find it interesting.</p>
<p><a href="https://boatrules.au" target="_blank" rel="noopener noreferrer">BoatRules.au</a>
 is free, has no account, no ads, no tracking.
It's an independent study aid, not the official exam - before you sit the real test, you should
still read the <a href="https://handbook.safetransport.vic.gov.au/" target="_blank" rel="noopener noreferrer">Victorian Recreational Boating Safety Handbook</a>
 and, hey
why not (for all my criticism, they still helped me!), check out those <a href="https://safetransport.vic.gov.au/on-the-water/marine-licence-and-vessel-registration/marine-licence-practice-tests/" target="_blank" rel="noopener noreferrer">Safe Transport Victoria
practice tests</a>
.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Type: browser-based study quiz</li>
<li>Coverage: ~160 questions</li>
<li>Format: 30-question tests, pass target 26/30</li>
<li>Modalities: text, SVG schematics, playable horn-signal audio</li>
<li>Cost: free, no account, no tracking</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://boatrules.au" target="_blank" rel="noopener noreferrer">Try BoatRules.au</a>
</li>
<li><a href="https://handbook.safetransport.vic.gov.au/" target="_blank" rel="noopener noreferrer">Victorian Recreational Boating Safety Handbook</a>
</li>
<li><a href="https://safetransport.vic.gov.au/on-the-water/marine-licence-and-vessel-registration/marine-licence-practice-tests/" target="_blank" rel="noopener noreferrer">Safe Transport Victoria practice tests</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Interested in how I think?</div>
  <div class="smallprint mb-3">Let me puzzle over any technical problems plaguing the systems at your organisation.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>SYN City: a networking arcade game (just for fun)</title>
      <link>https://mig5.net/news/syncity-just-for-fun/</link>
      <pubDate>Mon, 31 Aug 2026 00:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/syncity-just-for-fun/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-controller"></i><span>Just for fun</span></div>
    <h1 class="display-6 fw-semibold mb-2">SYN City: pushing packets, arcade-style</h1>
    <p class="text-muted mb-3"><time datetime="2026-08-31">August 31, 2026</time></p>
    <p class="lead mb-0">A side project I built just for fun. SYN City is a hold-and-release arcade jumper where every
level is a different networking or sysadmin idea - the TCP handshake, a TLS negotiation, a
split-brain failover, a wardrive across the neighbourhood's Wi-Fi. It started as a derivation of
100 Jumps, with its author's blessing, and grew into 27 levels of packet-pushing nonsense.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-08-31T00:00:00&#43;10:00">31 August 2026</time>
        </div>

<h2 id="not-everything-has-to-be-work">Not everything has to be work</h2>
<p>Most of what I write about here is earnest: CSPs, config management, high availability, the
usual sysadmin serial. This post is not that. This post is about a game I made on the weekends
just for fun.</p>
<p>It's called <strong>SYN City</strong>, and you can go play it right now at
<a href="https://syn.mig5.net" target="_blank" rel="noopener noreferrer">syn.mig5.net</a>
. No install, no account, no cookies, no ads, no framework,
no game engine.</p>
<p>It runs in any modern browser and installs as a PWA on your phone if you want it to.</p>
<figure class="text-center my-4">
  <a href="/news/syncity-just-for-fun/00_menu_hu12703806227490005402.jpg" class="image-lightbox" title="Click to view larger">
    <img src="/news/syncity-just-for-fun/00_menu_hu7474192819610531161.jpg" alt="SYN City&#39;s level select screen - a neon city skyline with the level picker open" width="320" height="670" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">The level picker - 27 protocols to pick from.</figcaption>
</figure>

<h2 id="where-it-came-from">Where it came from</h2>
<p>The seed was <a href="https://boredzebra.com/100jumps" target="_blank" rel="noopener noreferrer">100 Jumps</a>
, a deceptively simple one-touch
platformer by the good people at Bored Zebra: hold to charge your jump, release to leap, land on
the next platform, count down from 100 to 0. That's it. That's the whole game.</p>
<p>What stayed with me was the core mechanic - hold to build power, release to hop, land on the
pad. There's a purity to it that feels like the arcade games I grew up on. I started wondering
what would happen if you kept that core and dressed it in something I think about all day anyway:
networking. Hold to build send power. Release to transmit. Land on the next host. Beat the
retransmit timer. Linger long enough and you might just learn something about networking.</p>
<p>I reached out to the author of 100 Jumps to make sure I wasn't being a jerk about it, and they
generously gave their blessing to make a derivation based on some of the same mechanics. So:
thank you, Bored Zebra. SYN City owes its jump to 100 Jumps. Go play that one too.</p>
<h2 id="how-it-plays">How it plays</h2>
<p>One thumb is enough for most levels, which is the whole point.</p>
<ol>
<li><strong>Hold</strong> anywhere on the screen to charge the shot. The longer you hold, the farther the
packet flies.</li>
<li><strong>Release</strong> to transmit. Tap the left or right edge of the pad you're on to tilt your next
hop backward or forward - useful for recovering a pad you overshot, or choosing a branch when
the route forks.</li>
<li><strong>Beat the RTO.</strong> A retransmit-timer bar is always draining. Land the next pad before it
empties, or the packet is retransmitted and you lose a life. TTL is your lives; checksum is
your score, and clean landings build a streak multiplier.</li>
<li><strong>Stealth.</strong> IDS, MITM and WIDS scanners sweep the route. Get caught waiting inside a beam
and you're flagged and knocked back. Hold STEALTH to go invisible while a beam passes
overhead - but Stealth drains faster, so don't sit in it.</li>
</ol>
<p>That's the loop. Everything else - the timers, the scanners, the hazards, the litany of ways to
lose - is a networking idea wearing an arcade costume.</p>
<h2 id="every-level-is-a-different-protocol">Every level is a different protocol</h2>
<p>There are 27 of them, each unlocking the next, and each one rebuilds a different corner of the
stack. A few of my favourites:</p>
<ul>
<li><strong>ACKrobatics</strong> - the three-way handshake as a side-scroller. Open the connection, dodge RST
firewalls, filter Martian packets, land the FIN gate.</li>
</ul>
<figure class="text-center my-4">
  <a href="/news/syncity-just-for-fun/01_ackrobatics_hu13578681013957770491.jpg" class="image-lightbox" title="Click to view larger">
    <img src="/news/syncity-just-for-fun/01_ackrobatics_hu6143765920170814048.jpg" alt="ACKrobatics - a packet mid-flight over the city skyline, hopping between pads" width="320" height="670" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">Level 1, ACKrobatics: the three-way handshake as a side-scroller.</figcaption>
</figure>

<ul>
<li><strong>Tell me a secret</strong> - negotiate a TLS 1.3 session a MITM can't crack. Resist downgrade traps,
wait for the certificate VERIFY gate, complete ephemeral key exchange for Perfect Forward
Secrecy. Fall once and you lose PFS.</li>
<li><strong>Onion Skin</strong> - you're an onion packet in three layers of encryption, six hops, two circuits,
zero IPs leaked. Fetch the descriptor, plant a secret at the rendezvous, reach the hidden
.onion service.</li>
<li><strong>Kernel Panic</strong> - the first boss level. You're an open file handle. Hold to float the FD up,
release to sink. Shred crawls up the lane behind you, BOFH sysadmins hurl chmod/rm/tar from
above, and only <code>fsync()</code> and <code>close()</code> commit you cleanly.</li>
</ul>
<figure class="text-center my-4">
  <a href="/news/syncity-just-for-fun/09_kernel_hu12245122226978492574.jpg" class="image-lightbox" title="Click to view larger">
    <img src="/news/syncity-just-for-fun/09_kernel_hu15843703211685484618.jpg" alt="Kernel Panic - the file descriptor floats up the lane while Shred crawls behind" width="320" height="670" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">Level 9, Kernel Panic: you&#39;re an open file handle. Shred is right behind you.</figcaption>
</figure>

<ul>
<li><strong>Tunnel Temper Trap</strong> - a padless VPN rush. Forward motion is automatic and ramps up toward
the exit; hold to steer upward, release to sink. Touch the tunnel wall and you're demasked for
five seconds, opening a window where MITM probes can reach you through the tunnel. Thread the
mssfix/PMUD squeeze gates, dodge replay packets that knock you backward with an AEAD
bad-packet-ID error, and reach FIN at the tunnel exit with the data-channel cover intact.</li>
</ul>
<figure class="text-center my-4">
  <a href="/news/syncity-just-for-fun/16_tunnel_hu5771930871693098907.jpg" class="image-lightbox" title="Click to view larger">
    <img src="/news/syncity-just-for-fun/16_tunnel_hu18030500878996342506.jpg" alt="Tunnel Temper Trap - a packet floats through a VPN tunnel, squeeze gates ahead" width="320" height="670" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">Level 16, Tunnel Temper Trap: a padless rush through the VPN tunnel. Don&#39;t touch the walls.</figcaption>
</figure>

<ul>
<li><strong>Everything is a DNS Problem</strong> - because it's always DNS. This time you're the resolver.
Hold XFER to push the zone to secondaries, then serve a split-horizon route where every fork
randomly demands the INTERNAL row or the EXTERNAL bridge.</li>
<li><strong>In the balance</strong> - a role reversal: you're not the packet, you're the load balancer. Drag the
two backend pads - one with each thumb on a phone - so falling requests land safely. Round
robin accepts either backend; when least-connections mode is ready to flip, a striped
pass-through and a route beam show which narrower pad must catch the next request. Keep the
connection alive until the keepalive expires.</li>
</ul>
<figure class="text-center my-4">
  <a href="/news/syncity-just-for-fun/08_balance_hu11307530761673323656.jpg" class="image-lightbox" title="Click to view larger">
    <img src="/news/syncity-just-for-fun/08_balance_hu12595959581089261433.jpg" alt="In the balance - two backend pads being dragged apart to catch falling requests" width="320" height="670" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">Level 8, In the balance: you&#39;re the load balancer, not the packet. Two thumbs, two backends.</figcaption>
</figure>

<ul>
<li><strong>Next Hop</strong> - a traceroute where the road doesn't exist until you probe it. Hold to charge a
THROW: release and the next pad is flung ahead of you, and you ride the probe onto it in the
same motion. You get 29 pads for the whole trace. Throw too far and the probe answers
<code>* * *</code>.</li>
<li><strong>Logical Volume</strong> - the route is a volume group and you are its allocator. Flex sets how far
the next pad lands and how big it grows, billed in physical extents from one shared pool.
Spend the pool short of <code>/dev/vg/root</code> and it's <em>insufficient free extents</em>.</li>
</ul>
<p>And, naturally, there's a level where you're a humble little <code>pico</code> editor lost in a scrolling
maze while roaming vim and emacs zealots chase you home. The vim-vs-emacs holy war has gone hot,
and only <code>^X</code> can save you.</p>
<h2 id="go-play-it">Go play it!</h2>
<p>It's free, it runs anywhere, and it'll happily install to your phone's home screen and play
offline. If you make it through all 27 levels, write in and tell me which one broke you - my
money's on Kernel Panic or Grep Gunner :)</p>
<p>Go push that packet: <strong><a href="https://syn.mig5.net" target="_blank" rel="noopener noreferrer">syn.mig5.net</a>
</strong>.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Type: browser arcade game (PWA)</li>
<li>Theme: networking &amp; sysadmin</li>
<li>Levels: 27, each a different protocol</li>
<li>Cost: free, no install, no account</li>
<li>Plays: any modern browser, mobile-first</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://syn.mig5.net" target="_blank" rel="noopener noreferrer">Play SYN City</a>
</li>
<li><a href="https://boredzebra.com/100jumps" target="_blank" rel="noopener noreferrer">Inspired by 100 Jumps</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need something slightly more serious?</div>
  <div class="smallprint mb-3">When you&#39;re done pushing packets, I do sysadmin, networking and DevSecOps consulting for real.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-controller"></i><span>Just for fun</span></div>
    <h1 class="display-6 fw-semibold mb-2">SYN City: pushing packets, arcade-style</h1>
    <p class="text-muted mb-3"><time datetime="2026-08-31">August 31, 2026</time></p>
    <p class="lead mb-0">A side project I built just for fun. SYN City is a hold-and-release arcade jumper where every
level is a different networking or sysadmin idea - the TCP handshake, a TLS negotiation, a
split-brain failover, a wardrive across the neighbourhood's Wi-Fi. It started as a derivation of
100 Jumps, with its author's blessing, and grew into 27 levels of packet-pushing nonsense.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-08-31T00:00:00&#43;10:00">31 August 2026</time>
        </div>

<h2 id="not-everything-has-to-be-work">Not everything has to be work</h2>
<p>Most of what I write about here is earnest: CSPs, config management, high availability, the
usual sysadmin serial. This post is not that. This post is about a game I made on the weekends
just for fun.</p>
<p>It's called <strong>SYN City</strong>, and you can go play it right now at
<a href="https://syn.mig5.net" target="_blank" rel="noopener noreferrer">syn.mig5.net</a>
. No install, no account, no cookies, no ads, no framework,
no game engine.</p>
<p>It runs in any modern browser and installs as a PWA on your phone if you want it to.</p>
<figure class="text-center my-4">
  <a href="/news/syncity-just-for-fun/00_menu_hu12703806227490005402.jpg" class="image-lightbox" title="Click to view larger">
    <img src="/news/syncity-just-for-fun/00_menu_hu7474192819610531161.jpg" alt="SYN City&#39;s level select screen - a neon city skyline with the level picker open" width="320" height="670" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">The level picker - 27 protocols to pick from.</figcaption>
</figure>

<h2 id="where-it-came-from">Where it came from</h2>
<p>The seed was <a href="https://boredzebra.com/100jumps" target="_blank" rel="noopener noreferrer">100 Jumps</a>
, a deceptively simple one-touch
platformer by the good people at Bored Zebra: hold to charge your jump, release to leap, land on
the next platform, count down from 100 to 0. That's it. That's the whole game.</p>
<p>What stayed with me was the core mechanic - hold to build power, release to hop, land on the
pad. There's a purity to it that feels like the arcade games I grew up on. I started wondering
what would happen if you kept that core and dressed it in something I think about all day anyway:
networking. Hold to build send power. Release to transmit. Land on the next host. Beat the
retransmit timer. Linger long enough and you might just learn something about networking.</p>
<p>I reached out to the author of 100 Jumps to make sure I wasn't being a jerk about it, and they
generously gave their blessing to make a derivation based on some of the same mechanics. So:
thank you, Bored Zebra. SYN City owes its jump to 100 Jumps. Go play that one too.</p>
<h2 id="how-it-plays">How it plays</h2>
<p>One thumb is enough for most levels, which is the whole point.</p>
<ol>
<li><strong>Hold</strong> anywhere on the screen to charge the shot. The longer you hold, the farther the
packet flies.</li>
<li><strong>Release</strong> to transmit. Tap the left or right edge of the pad you're on to tilt your next
hop backward or forward - useful for recovering a pad you overshot, or choosing a branch when
the route forks.</li>
<li><strong>Beat the RTO.</strong> A retransmit-timer bar is always draining. Land the next pad before it
empties, or the packet is retransmitted and you lose a life. TTL is your lives; checksum is
your score, and clean landings build a streak multiplier.</li>
<li><strong>Stealth.</strong> IDS, MITM and WIDS scanners sweep the route. Get caught waiting inside a beam
and you're flagged and knocked back. Hold STEALTH to go invisible while a beam passes
overhead - but Stealth drains faster, so don't sit in it.</li>
</ol>
<p>That's the loop. Everything else - the timers, the scanners, the hazards, the litany of ways to
lose - is a networking idea wearing an arcade costume.</p>
<h2 id="every-level-is-a-different-protocol">Every level is a different protocol</h2>
<p>There are 27 of them, each unlocking the next, and each one rebuilds a different corner of the
stack. A few of my favourites:</p>
<ul>
<li><strong>ACKrobatics</strong> - the three-way handshake as a side-scroller. Open the connection, dodge RST
firewalls, filter Martian packets, land the FIN gate.</li>
</ul>
<figure class="text-center my-4">
  <a href="/news/syncity-just-for-fun/01_ackrobatics_hu13578681013957770491.jpg" class="image-lightbox" title="Click to view larger">
    <img src="/news/syncity-just-for-fun/01_ackrobatics_hu6143765920170814048.jpg" alt="ACKrobatics - a packet mid-flight over the city skyline, hopping between pads" width="320" height="670" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">Level 1, ACKrobatics: the three-way handshake as a side-scroller.</figcaption>
</figure>

<ul>
<li><strong>Tell me a secret</strong> - negotiate a TLS 1.3 session a MITM can't crack. Resist downgrade traps,
wait for the certificate VERIFY gate, complete ephemeral key exchange for Perfect Forward
Secrecy. Fall once and you lose PFS.</li>
<li><strong>Onion Skin</strong> - you're an onion packet in three layers of encryption, six hops, two circuits,
zero IPs leaked. Fetch the descriptor, plant a secret at the rendezvous, reach the hidden
.onion service.</li>
<li><strong>Kernel Panic</strong> - the first boss level. You're an open file handle. Hold to float the FD up,
release to sink. Shred crawls up the lane behind you, BOFH sysadmins hurl chmod/rm/tar from
above, and only <code>fsync()</code> and <code>close()</code> commit you cleanly.</li>
</ul>
<figure class="text-center my-4">
  <a href="/news/syncity-just-for-fun/09_kernel_hu12245122226978492574.jpg" class="image-lightbox" title="Click to view larger">
    <img src="/news/syncity-just-for-fun/09_kernel_hu15843703211685484618.jpg" alt="Kernel Panic - the file descriptor floats up the lane while Shred crawls behind" width="320" height="670" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">Level 9, Kernel Panic: you&#39;re an open file handle. Shred is right behind you.</figcaption>
</figure>

<ul>
<li><strong>Tunnel Temper Trap</strong> - a padless VPN rush. Forward motion is automatic and ramps up toward
the exit; hold to steer upward, release to sink. Touch the tunnel wall and you're demasked for
five seconds, opening a window where MITM probes can reach you through the tunnel. Thread the
mssfix/PMUD squeeze gates, dodge replay packets that knock you backward with an AEAD
bad-packet-ID error, and reach FIN at the tunnel exit with the data-channel cover intact.</li>
</ul>
<figure class="text-center my-4">
  <a href="/news/syncity-just-for-fun/16_tunnel_hu5771930871693098907.jpg" class="image-lightbox" title="Click to view larger">
    <img src="/news/syncity-just-for-fun/16_tunnel_hu18030500878996342506.jpg" alt="Tunnel Temper Trap - a packet floats through a VPN tunnel, squeeze gates ahead" width="320" height="670" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">Level 16, Tunnel Temper Trap: a padless rush through the VPN tunnel. Don&#39;t touch the walls.</figcaption>
</figure>

<ul>
<li><strong>Everything is a DNS Problem</strong> - because it's always DNS. This time you're the resolver.
Hold XFER to push the zone to secondaries, then serve a split-horizon route where every fork
randomly demands the INTERNAL row or the EXTERNAL bridge.</li>
<li><strong>In the balance</strong> - a role reversal: you're not the packet, you're the load balancer. Drag the
two backend pads - one with each thumb on a phone - so falling requests land safely. Round
robin accepts either backend; when least-connections mode is ready to flip, a striped
pass-through and a route beam show which narrower pad must catch the next request. Keep the
connection alive until the keepalive expires.</li>
</ul>
<figure class="text-center my-4">
  <a href="/news/syncity-just-for-fun/08_balance_hu11307530761673323656.jpg" class="image-lightbox" title="Click to view larger">
    <img src="/news/syncity-just-for-fun/08_balance_hu12595959581089261433.jpg" alt="In the balance - two backend pads being dragged apart to catch falling requests" width="320" height="670" class="img-fluid rounded shadow-sm" loading="lazy" />
  </a>
  <figcaption class="figure-caption mt-2 text-muted small">Level 8, In the balance: you&#39;re the load balancer, not the packet. Two thumbs, two backends.</figcaption>
</figure>

<ul>
<li><strong>Next Hop</strong> - a traceroute where the road doesn't exist until you probe it. Hold to charge a
THROW: release and the next pad is flung ahead of you, and you ride the probe onto it in the
same motion. You get 29 pads for the whole trace. Throw too far and the probe answers
<code>* * *</code>.</li>
<li><strong>Logical Volume</strong> - the route is a volume group and you are its allocator. Flex sets how far
the next pad lands and how big it grows, billed in physical extents from one shared pool.
Spend the pool short of <code>/dev/vg/root</code> and it's <em>insufficient free extents</em>.</li>
</ul>
<p>And, naturally, there's a level where you're a humble little <code>pico</code> editor lost in a scrolling
maze while roaming vim and emacs zealots chase you home. The vim-vs-emacs holy war has gone hot,
and only <code>^X</code> can save you.</p>
<h2 id="go-play-it">Go play it!</h2>
<p>It's free, it runs anywhere, and it'll happily install to your phone's home screen and play
offline. If you make it through all 27 levels, write in and tell me which one broke you - my
money's on Kernel Panic or Grep Gunner :)</p>
<p>Go push that packet: <strong><a href="https://syn.mig5.net" target="_blank" rel="noopener noreferrer">syn.mig5.net</a>
</strong>.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Type: browser arcade game (PWA)</li>
<li>Theme: networking &amp; sysadmin</li>
<li>Levels: 27, each a different protocol</li>
<li>Cost: free, no install, no account</li>
<li>Plays: any modern browser, mobile-first</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://syn.mig5.net" target="_blank" rel="noopener noreferrer">Play SYN City</a>
</li>
<li><a href="https://boredzebra.com/100jumps" target="_blank" rel="noopener noreferrer">Inspired by 100 Jumps</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need something slightly more serious?</div>
  <div class="smallprint mb-3">When you&#39;re done pushing packets, I do sysadmin, networking and DevSecOps consulting for real.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>Running OnionShare CLI as a systemd service</title>
      <link>https://mig5.net/news/onionshare-cli-systemd/</link>
      <pubDate>Sat, 29 Aug 2026 00:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/onionshare-cli-systemd/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-hdd-network"></i><span>OnionShare</span></div>
    <h1 class="display-6 fw-semibold mb-2">Running OnionShare CLI as a systemd service</h1>
    <p class="text-muted mb-3"><time datetime="2026-08-29">August 29, 2026</time></p>
    <p class="lead mb-0">A worked example of running OnionShare CLI as a boot-time systemd service: a persistent Tor
onion address for sharing or receiving files, with no torrc edits and no web server to write.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-08-29T00:00:00&#43;10:00">29 August 2026</time>
        </div>

<h2 id="the-scenario">The scenario</h2>
<p>You want a persistent Tor onion address that serves files to people over Tor - or receives
files from them - and you want it to come back on its own after a reboot. The host is a
headless server with no desktop, so the <a href="https://onionshare.org" target="_blank" rel="noopener noreferrer">OnionShare GUI</a>
 isn't an option.</p>
<p>You could configure a traditional Tor onion service in <code>torrc</code>, point it at a local web server,
and write the upload/download handling yourself. OnionShare's CLI mode is the shorter path:
it bundles the web server, the upload form, the download page, client auth, and the Tor connection
into one process, and a single systemd unit file keeps it alive across reboots.</p>
<h2 id="why-not-a-traditional-tor-onion-service">Why not a traditional Tor onion service?</h2>
<p>A traditional Tor onion service is the lower-level building block. You configure
<code>HiddenServiceDir</code> and <code>HiddenServicePort</code> in <code>torrc</code>, restart Tor, and the onion address is
derived from a private key Tor stores on disk. That onion address then forwards to a local
host and port - and what's listening on that port is your problem. For a static website,
that means running nginx or another web server. For receiving files, it means writing an
upload handler yourself, with all the security review that implies.</p>
<p>OnionShare already does all of this. In <strong>Share mode</strong>, it serves a download page with
compression and an optional auto-stop after the first complete download. In <strong>Receive mode</strong>,
it serves an upload form that writes submitted files to a directory you choose, optionally
POSTing a webhook on each submission. In <strong>Website mode</strong>, it serves a directory of static
files with a strict Content Security Policy by default. Client authentication (Tor's &quot;client
auth&quot;) is on by default - the recipient needs a private key in addition to the onion address.
And OnionShare can use its own bundled Tor, so you don't have to manage a separate Tor
instance if you don't want to.</p>
<p>The trade-off is that OnionShare is opinionated. If you need a dynamic web application, a
database, or a long-running onion service that does something OnionShare doesn't, the
traditional <code>torrc</code> approach is still the right answer. For a file share, an anonymous
dropbox, or a static site, OnionShare is the simpler path - and the CLI mode lets you run it
on a server.</p>
<h2 id="installing-the-cli">Installing the CLI</h2>
<p>The cleanest install on a headless server is via pip - the distro <code>onionshare</code> package pulls
in Qt GUI dependencies you don't need here:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">sudo apt install tor python3-pip
</span></span><span class="line"><span class="cl">pip3 install --user onionshare-cli
</span></span></code></pre></div><p>The <code>onionshare-cli</code> binary lands in <code>~/.local/bin/</code>. Verify it runs:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">onionshare-cli --help
</span></span></code></pre></div><h2 id="the-persistent-session">The persistent session</h2>
<p>A non-persistent OnionShare service generates a fresh onion address on every start. For a
boot-time service, that defeats the point: you want the same address and the same client auth
key every time, so the people you've shared them with can reach the service after a reboot.</p>
<p>OnionShare handles this with a <strong>persistent session JSON file</strong>. The file stores the onion's
private key, the client auth keypair, the service ID (the 56-character address body), the
mode (share, receive, website, or chat), and the mode-specific settings. Once it exists,
OnionShare reads it on start and serves the same onion.</p>
<p>The easiest way to create the persistent file is to start the share once in the OnionShare
desktop app, check &quot;Always open this tab when OnionShare is started&quot;, start the service, and
stop it. This writes a JSON file with a random name (e.g. <code>share.json</code>) into
<code>~/.config/onionshare/persistent/</code>. Copy that file to your server. If you don't have the
desktop app handy, start the CLI with <code>--persistent</code> pointing at a path that doesn't exist
yet; OnionShare will generate a fresh identity, write it to that path, and reuse it from then
on. You'll see the onion address and client auth private key printed to the journal on that
first run - capture them then, because that's when you share them with your recipients.</p>
<p>A persistent session file for a Share mode service looks like this. Substitute your own values
for the keys, the <code>service_id</code>, and the filename - the values below are placeholders, not a
real identity:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;onion&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;private_key&#34;</span><span class="p">:</span> <span class="s2">&#34;&lt;base64-encoded onion service private key&gt;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;client_auth_priv_key&#34;</span><span class="p">:</span> <span class="s2">&#34;&lt;52-char base32 private key to give to recipients&gt;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;client_auth_pub_key&#34;</span><span class="p">:</span> <span class="s2">&#34;&lt;52-char base32 public key, kept server-side&gt;&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;persistent&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;mode&#34;</span><span class="p">:</span> <span class="s2">&#34;share&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;enabled&#34;</span><span class="p">:</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;general&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;title&#34;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;public&#34;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;autostart_timer&#34;</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;autostop_timer&#34;</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;service_id&#34;</span><span class="p">:</span> <span class="s2">&#34;&lt;56-character onion address body&gt;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;qr&#34;</span><span class="p">:</span> <span class="kc">false</span>
</span></span><span class="line"><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;share&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;autostop_sharing&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;filenames&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">      <span class="s2">&#34;/home/onion/share/release-archive.tar.gz&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;receive&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;data_dir&#34;</span><span class="p">:</span> <span class="s2">&#34;/home/onion/OnionShare&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;webhook_url&#34;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;disable_text&#34;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;disable_files&#34;</span><span class="p">:</span> <span class="kc">false</span>
</span></span><span class="line"><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;website&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;disable_csp&#34;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;custom_csp&#34;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;filenames&#34;</span><span class="p">:</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;chat&#34;</span><span class="p">:</span> <span class="p">{}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>Store this at, say, <code>/home/onion/.config/onionshare/persistent/share.json</code>. Lock the file
down - it contains the onion service private key:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">chmod 0600 /home/onion/.config/onionshare/persistent/share.json
</span></span></code></pre></div><p>The main OnionShare config at <code>~/.config/onionshare/onionshare.json</code> also needs to know about
the persistent tab. Its <code>persistent_tabs</code> list should contain the filename (without the
<code>.json</code> extension) of your persistent session:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;version&#34;</span><span class="p">:</span> <span class="s2">&#34;2.6.2&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;connection_type&#34;</span><span class="p">:</span> <span class="s2">&#34;bundled&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;control_port_address&#34;</span><span class="p">:</span> <span class="s2">&#34;127.0.0.1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;control_port_port&#34;</span><span class="p">:</span> <span class="mi">9051</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;socks_address&#34;</span><span class="p">:</span> <span class="s2">&#34;127.0.0.1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;socks_port&#34;</span><span class="p">:</span> <span class="mi">9050</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;socket_file_path&#34;</span><span class="p">:</span> <span class="s2">&#34;/var/run/tor/control&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;auth_type&#34;</span><span class="p">:</span> <span class="s2">&#34;no_auth&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;auth_password&#34;</span><span class="p">:</span> <span class="s2">&#34;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;auto_connect&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;use_autoupdate&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;autoupdate_timestamp&#34;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;bridges_enabled&#34;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;bridges_type&#34;</span><span class="p">:</span> <span class="s2">&#34;built-in&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;bridges_builtin_pt&#34;</span><span class="p">:</span> <span class="s2">&#34;obfs4&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;bridges_moat&#34;</span><span class="p">:</span> <span class="s2">&#34;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;bridges_custom&#34;</span><span class="p">:</span> <span class="s2">&#34;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;bridges_builtin&#34;</span><span class="p">:</span> <span class="p">{},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;persistent_tabs&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;share&#34;</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;locale&#34;</span><span class="p">:</span> <span class="s2">&#34;en&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;theme&#34;</span><span class="p">:</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>The defaults above are what OnionShare writes for itself; the only thing you typically need to
edit is <code>persistent_tabs</code>. If you'd rather use the system Tor instead of the bundled one, set
<code>connection_type</code> to <code>&quot;socket_file&quot;</code> (and make sure the OnionShare user can read
<code>/var/run/tor/control</code>) or <code>&quot;control_port&quot;</code> (with a matching <code>auth_password</code> if you've set one
in your <code>torrc</code>).</p>
<h2 id="the-systemd-unit-file">The systemd unit file</h2>
<p>With the configs in place, create <code>/etc/systemd/system/onionshare-cli.service</code>. Adjust the
<code>User</code>, <code>Group</code>, the path to the <code>onionshare-cli</code> binary, the persistent session path, and
the shared file path to match your setup:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="k">[Unit]</span>
</span></span><span class="line"><span class="cl"><span class="na">Description</span><span class="o">=</span><span class="s">OnionShare CLI</span>
</span></span><span class="line"><span class="cl"><span class="na">After</span><span class="o">=</span><span class="s">network.target</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="cl"><span class="na">ExecStart</span><span class="o">=</span><span class="s">/home/onion/.local/bin/onionshare-cli --persistent /home/onion/.config/onionshare/persistent/share.json /home/onion/share/release-archive.tar.gz</span>
</span></span><span class="line"><span class="cl"><span class="na">Restart</span><span class="o">=</span><span class="s">on-failure</span>
</span></span><span class="line"><span class="cl"><span class="na">User</span><span class="o">=</span><span class="s">onion</span>
</span></span><span class="line"><span class="cl"><span class="na">Group</span><span class="o">=</span><span class="s">onion</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Install]</span>
</span></span><span class="line"><span class="cl"><span class="na">WantedBy</span><span class="o">=</span><span class="s">multi-user.target</span>
</span></span></code></pre></div><p>Two things to notice. First, the path to the shared file (<code>/home/onion/share/release-archive.tar.gz</code>)
is passed both in the <code>filenames</code> list inside <code>share.json</code> <em>and</em> as a positional argument on
the <code>ExecStart</code> line. This is required: OnionShare reads the persistent settings from the
JSON, but the CLI still expects the share files on the command line. Second, <code>User</code> and
<code>Group</code> should be the unprivileged account that owns the OnionShare config - not root. The
bundled Tor and the onion service both run as that user.</p>
<p>Reload systemd, enable the service to start at boot, and start it:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gdscript3" data-lang="gdscript3"><span class="line"><span class="cl"><span class="n">sudo</span> <span class="n">systemctl</span> <span class="n">daemon</span><span class="o">-</span><span class="n">reload</span>
</span></span><span class="line"><span class="cl"><span class="n">sudo</span> <span class="n">systemctl</span> <span class="n">enable</span> <span class="o">--</span><span class="n">now</span> <span class="n">onionshare</span><span class="o">-</span><span class="n">cli</span><span class="o">.</span><span class="n">service</span>
</span></span></code></pre></div><h2 id="verifying-its-running">Verifying it's running</h2>
<p>Follow the journal:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">sudo journalctl -f -u onionshare-cli.service
</span></span></code></pre></div><p>On a clean start, you'll see OnionShare boot Tor, build the onion service, and print the
onion address and the client auth private key:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">Feb 09 10:14:09 onion-host onionshare-cli[18852]: [6.5K blob data]
</span></span><span class="line"><span class="cl">Feb 09 10:14:18 onion-host onionshare-cli[18852]: Compressing files.
</span></span><span class="line"><span class="cl">Feb 09 10:14:18 onion-host onionshare-cli[18852]: Give this address and private key to the recipient:
</span></span><span class="line"><span class="cl">Feb 09 10:14:18 onion-host onionshare-cli[18852]: http://niktadkcp6z7rym3r5o3j2hnmis53mno5ughvur357xo7jkjvmqrchid.onion
</span></span><span class="line"><span class="cl">Feb 09 10:14:18 onion-host onionshare-cli[18852]: Private key: RHJSN4VI3NKGDSIWK45CCWTLYOJHA6DQQRQXUID3FXMAILYXWVUQ
</span></span><span class="line"><span class="cl">Feb 09 10:14:18 onion-host onionshare-cli[18852]: Press Ctrl+C to stop the server
</span></span></code></pre></div><p>Load the <code>.onion</code> address in Tor Browser, enter the private key when prompted, and the
download page should appear. After a reboot, <code>systemctl status onionshare-cli.service</code> should
show the unit active (running), and the same onion address should still resolve - that's the
persistence doing its job.</p>
<p>If you don't want recipients to need a private key, set <code>&quot;public&quot;: true</code> in the <code>general</code>
section of <code>share.json</code>. The onion address is then loadable by anyone who has it, with no key
prompt. Use this only if the address itself is the secret.</p>
<h2 id="switching-to-receive-mode">Switching to Receive mode</h2>
<p>The same unit file works for Receive mode with two changes to <code>share.json</code>. Set
<code>persistent.mode</code> to <code>&quot;receive&quot;</code> instead of <code>&quot;share&quot;</code>, and configure the <code>receive</code> block to
point at the directory where uploads should land:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="s2">&#34;persistent&#34;</span><span class="err">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;mode&#34;</span><span class="p">:</span> <span class="s2">&#34;receive&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;enabled&#34;</span><span class="p">:</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span><span class="err">,</span>
</span></span><span class="line"><span class="cl"><span class="s2">&#34;receive&#34;</span><span class="err">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;data_dir&#34;</span><span class="p">:</span> <span class="s2">&#34;/home/onion/uploads&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;webhook_url&#34;</span><span class="p">:</span> <span class="s2">&#34;https://example.net/hooks/onionshare&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;disable_text&#34;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;disable_files&#34;</span><span class="p">:</span> <span class="kc">false</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>Then drop the positional filename argument from the <code>ExecStart</code> line - there's nothing to
share in Receive mode:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="na">ExecStart</span><span class="o">=</span><span class="s">/home/onion/.local/bin/onionshare-cli --persistent /home/onion/.config/onionshare/persistent/share.json</span>
</span></span></code></pre></div><p>Reload systemd and restart the service. The same onion address and the same client auth key
now serve an upload form. Files land in <code>/home/onion/uploads</code>, organised into timestamped
subfolders; text submissions land there too. The optional <code>webhook_url</code> is POSTed to on each
submission, which is useful for piping into a notification pipeline - a Node-RED flow, a
Signal bot, anything that accepts an HTTP POST.</p>
<h2 id="a-note-on-the-private-key">A note on the private key</h2>
<p>The persistent JSON contains the onion service private key and the client auth private key.
Treat the file the way you'd treat an SSH private key: <code>chmod 0600</code>, owned by the OnionShare
user only, and never committed to a repository. If the file leaks, the onion address can be
impersonated by anyone who holds the key - they can stand up a look-alike service on the same
address. Rotating means deleting the JSON, letting OnionShare generate a new one, and
re-sharing the new address and client auth key with your recipients.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Modes: Share, Receive, Website, Chat</li>
<li>Persistence: same onion address across reboots</li>
<li>Auth: client auth (private key) on by default</li>
<li>Tor: bundled, or system Tor via control_port / socket_file</li>
<li>Service: <code>onionshare-cli.service</code> (systemd, multi-user.target)</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://onionshare.org" target="_blank" rel="noopener noreferrer">OnionShare website</a>
</li>
<li><a href="https://docs.onionshare.org/2.6.5/en/advanced.html#running-the-cli-as-a-systemd-unit-file" target="_blank" rel="noopener noreferrer">Advanced Usage docs (systemd section)</a>
</li>
<li><a href="https://docs.onionshare.org/2.6.5/en/features.html" target="_blank" rel="noopener noreferrer">How OnionShare works</a>
</li>
<li><a href="https://github.com/onionshare/onionshare/blob/develop/cli/README.md" target="_blank" rel="noopener noreferrer">CLI README</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need a Tor-facing service set up?</div>
  <div class="smallprint mb-3">I do contract sysadmin and privacy-infrastructure work, and am one of the core developers of OnionShare.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-hdd-network"></i><span>OnionShare</span></div>
    <h1 class="display-6 fw-semibold mb-2">Running OnionShare CLI as a systemd service</h1>
    <p class="text-muted mb-3"><time datetime="2026-08-29">August 29, 2026</time></p>
    <p class="lead mb-0">A worked example of running OnionShare CLI as a boot-time systemd service: a persistent Tor
onion address for sharing or receiving files, with no torrc edits and no web server to write.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-08-29T00:00:00&#43;10:00">29 August 2026</time>
        </div>

<h2 id="the-scenario">The scenario</h2>
<p>You want a persistent Tor onion address that serves files to people over Tor - or receives
files from them - and you want it to come back on its own after a reboot. The host is a
headless server with no desktop, so the <a href="https://onionshare.org" target="_blank" rel="noopener noreferrer">OnionShare GUI</a>
 isn't an option.</p>
<p>You could configure a traditional Tor onion service in <code>torrc</code>, point it at a local web server,
and write the upload/download handling yourself. OnionShare's CLI mode is the shorter path:
it bundles the web server, the upload form, the download page, client auth, and the Tor connection
into one process, and a single systemd unit file keeps it alive across reboots.</p>
<h2 id="why-not-a-traditional-tor-onion-service">Why not a traditional Tor onion service?</h2>
<p>A traditional Tor onion service is the lower-level building block. You configure
<code>HiddenServiceDir</code> and <code>HiddenServicePort</code> in <code>torrc</code>, restart Tor, and the onion address is
derived from a private key Tor stores on disk. That onion address then forwards to a local
host and port - and what's listening on that port is your problem. For a static website,
that means running nginx or another web server. For receiving files, it means writing an
upload handler yourself, with all the security review that implies.</p>
<p>OnionShare already does all of this. In <strong>Share mode</strong>, it serves a download page with
compression and an optional auto-stop after the first complete download. In <strong>Receive mode</strong>,
it serves an upload form that writes submitted files to a directory you choose, optionally
POSTing a webhook on each submission. In <strong>Website mode</strong>, it serves a directory of static
files with a strict Content Security Policy by default. Client authentication (Tor's &quot;client
auth&quot;) is on by default - the recipient needs a private key in addition to the onion address.
And OnionShare can use its own bundled Tor, so you don't have to manage a separate Tor
instance if you don't want to.</p>
<p>The trade-off is that OnionShare is opinionated. If you need a dynamic web application, a
database, or a long-running onion service that does something OnionShare doesn't, the
traditional <code>torrc</code> approach is still the right answer. For a file share, an anonymous
dropbox, or a static site, OnionShare is the simpler path - and the CLI mode lets you run it
on a server.</p>
<h2 id="installing-the-cli">Installing the CLI</h2>
<p>The cleanest install on a headless server is via pip - the distro <code>onionshare</code> package pulls
in Qt GUI dependencies you don't need here:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">sudo apt install tor python3-pip
</span></span><span class="line"><span class="cl">pip3 install --user onionshare-cli
</span></span></code></pre></div><p>The <code>onionshare-cli</code> binary lands in <code>~/.local/bin/</code>. Verify it runs:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">onionshare-cli --help
</span></span></code></pre></div><h2 id="the-persistent-session">The persistent session</h2>
<p>A non-persistent OnionShare service generates a fresh onion address on every start. For a
boot-time service, that defeats the point: you want the same address and the same client auth
key every time, so the people you've shared them with can reach the service after a reboot.</p>
<p>OnionShare handles this with a <strong>persistent session JSON file</strong>. The file stores the onion's
private key, the client auth keypair, the service ID (the 56-character address body), the
mode (share, receive, website, or chat), and the mode-specific settings. Once it exists,
OnionShare reads it on start and serves the same onion.</p>
<p>The easiest way to create the persistent file is to start the share once in the OnionShare
desktop app, check &quot;Always open this tab when OnionShare is started&quot;, start the service, and
stop it. This writes a JSON file with a random name (e.g. <code>share.json</code>) into
<code>~/.config/onionshare/persistent/</code>. Copy that file to your server. If you don't have the
desktop app handy, start the CLI with <code>--persistent</code> pointing at a path that doesn't exist
yet; OnionShare will generate a fresh identity, write it to that path, and reuse it from then
on. You'll see the onion address and client auth private key printed to the journal on that
first run - capture them then, because that's when you share them with your recipients.</p>
<p>A persistent session file for a Share mode service looks like this. Substitute your own values
for the keys, the <code>service_id</code>, and the filename - the values below are placeholders, not a
real identity:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;onion&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;private_key&#34;</span><span class="p">:</span> <span class="s2">&#34;&lt;base64-encoded onion service private key&gt;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;client_auth_priv_key&#34;</span><span class="p">:</span> <span class="s2">&#34;&lt;52-char base32 private key to give to recipients&gt;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;client_auth_pub_key&#34;</span><span class="p">:</span> <span class="s2">&#34;&lt;52-char base32 public key, kept server-side&gt;&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;persistent&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;mode&#34;</span><span class="p">:</span> <span class="s2">&#34;share&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;enabled&#34;</span><span class="p">:</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;general&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;title&#34;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;public&#34;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;autostart_timer&#34;</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;autostop_timer&#34;</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;service_id&#34;</span><span class="p">:</span> <span class="s2">&#34;&lt;56-character onion address body&gt;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;qr&#34;</span><span class="p">:</span> <span class="kc">false</span>
</span></span><span class="line"><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;share&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;autostop_sharing&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;filenames&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">      <span class="s2">&#34;/home/onion/share/release-archive.tar.gz&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;receive&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;data_dir&#34;</span><span class="p">:</span> <span class="s2">&#34;/home/onion/OnionShare&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;webhook_url&#34;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;disable_text&#34;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;disable_files&#34;</span><span class="p">:</span> <span class="kc">false</span>
</span></span><span class="line"><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;website&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;disable_csp&#34;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;custom_csp&#34;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;filenames&#34;</span><span class="p">:</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;chat&#34;</span><span class="p">:</span> <span class="p">{}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>Store this at, say, <code>/home/onion/.config/onionshare/persistent/share.json</code>. Lock the file
down - it contains the onion service private key:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">chmod 0600 /home/onion/.config/onionshare/persistent/share.json
</span></span></code></pre></div><p>The main OnionShare config at <code>~/.config/onionshare/onionshare.json</code> also needs to know about
the persistent tab. Its <code>persistent_tabs</code> list should contain the filename (without the
<code>.json</code> extension) of your persistent session:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;version&#34;</span><span class="p">:</span> <span class="s2">&#34;2.6.2&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;connection_type&#34;</span><span class="p">:</span> <span class="s2">&#34;bundled&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;control_port_address&#34;</span><span class="p">:</span> <span class="s2">&#34;127.0.0.1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;control_port_port&#34;</span><span class="p">:</span> <span class="mi">9051</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;socks_address&#34;</span><span class="p">:</span> <span class="s2">&#34;127.0.0.1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;socks_port&#34;</span><span class="p">:</span> <span class="mi">9050</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;socket_file_path&#34;</span><span class="p">:</span> <span class="s2">&#34;/var/run/tor/control&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;auth_type&#34;</span><span class="p">:</span> <span class="s2">&#34;no_auth&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;auth_password&#34;</span><span class="p">:</span> <span class="s2">&#34;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;auto_connect&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;use_autoupdate&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;autoupdate_timestamp&#34;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;bridges_enabled&#34;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;bridges_type&#34;</span><span class="p">:</span> <span class="s2">&#34;built-in&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;bridges_builtin_pt&#34;</span><span class="p">:</span> <span class="s2">&#34;obfs4&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;bridges_moat&#34;</span><span class="p">:</span> <span class="s2">&#34;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;bridges_custom&#34;</span><span class="p">:</span> <span class="s2">&#34;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;bridges_builtin&#34;</span><span class="p">:</span> <span class="p">{},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;persistent_tabs&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;share&#34;</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;locale&#34;</span><span class="p">:</span> <span class="s2">&#34;en&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;theme&#34;</span><span class="p">:</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>The defaults above are what OnionShare writes for itself; the only thing you typically need to
edit is <code>persistent_tabs</code>. If you'd rather use the system Tor instead of the bundled one, set
<code>connection_type</code> to <code>&quot;socket_file&quot;</code> (and make sure the OnionShare user can read
<code>/var/run/tor/control</code>) or <code>&quot;control_port&quot;</code> (with a matching <code>auth_password</code> if you've set one
in your <code>torrc</code>).</p>
<h2 id="the-systemd-unit-file">The systemd unit file</h2>
<p>With the configs in place, create <code>/etc/systemd/system/onionshare-cli.service</code>. Adjust the
<code>User</code>, <code>Group</code>, the path to the <code>onionshare-cli</code> binary, the persistent session path, and
the shared file path to match your setup:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="k">[Unit]</span>
</span></span><span class="line"><span class="cl"><span class="na">Description</span><span class="o">=</span><span class="s">OnionShare CLI</span>
</span></span><span class="line"><span class="cl"><span class="na">After</span><span class="o">=</span><span class="s">network.target</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="cl"><span class="na">ExecStart</span><span class="o">=</span><span class="s">/home/onion/.local/bin/onionshare-cli --persistent /home/onion/.config/onionshare/persistent/share.json /home/onion/share/release-archive.tar.gz</span>
</span></span><span class="line"><span class="cl"><span class="na">Restart</span><span class="o">=</span><span class="s">on-failure</span>
</span></span><span class="line"><span class="cl"><span class="na">User</span><span class="o">=</span><span class="s">onion</span>
</span></span><span class="line"><span class="cl"><span class="na">Group</span><span class="o">=</span><span class="s">onion</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Install]</span>
</span></span><span class="line"><span class="cl"><span class="na">WantedBy</span><span class="o">=</span><span class="s">multi-user.target</span>
</span></span></code></pre></div><p>Two things to notice. First, the path to the shared file (<code>/home/onion/share/release-archive.tar.gz</code>)
is passed both in the <code>filenames</code> list inside <code>share.json</code> <em>and</em> as a positional argument on
the <code>ExecStart</code> line. This is required: OnionShare reads the persistent settings from the
JSON, but the CLI still expects the share files on the command line. Second, <code>User</code> and
<code>Group</code> should be the unprivileged account that owns the OnionShare config - not root. The
bundled Tor and the onion service both run as that user.</p>
<p>Reload systemd, enable the service to start at boot, and start it:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gdscript3" data-lang="gdscript3"><span class="line"><span class="cl"><span class="n">sudo</span> <span class="n">systemctl</span> <span class="n">daemon</span><span class="o">-</span><span class="n">reload</span>
</span></span><span class="line"><span class="cl"><span class="n">sudo</span> <span class="n">systemctl</span> <span class="n">enable</span> <span class="o">--</span><span class="n">now</span> <span class="n">onionshare</span><span class="o">-</span><span class="n">cli</span><span class="o">.</span><span class="n">service</span>
</span></span></code></pre></div><h2 id="verifying-its-running">Verifying it's running</h2>
<p>Follow the journal:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">sudo journalctl -f -u onionshare-cli.service
</span></span></code></pre></div><p>On a clean start, you'll see OnionShare boot Tor, build the onion service, and print the
onion address and the client auth private key:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">Feb 09 10:14:09 onion-host onionshare-cli[18852]: [6.5K blob data]
</span></span><span class="line"><span class="cl">Feb 09 10:14:18 onion-host onionshare-cli[18852]: Compressing files.
</span></span><span class="line"><span class="cl">Feb 09 10:14:18 onion-host onionshare-cli[18852]: Give this address and private key to the recipient:
</span></span><span class="line"><span class="cl">Feb 09 10:14:18 onion-host onionshare-cli[18852]: http://niktadkcp6z7rym3r5o3j2hnmis53mno5ughvur357xo7jkjvmqrchid.onion
</span></span><span class="line"><span class="cl">Feb 09 10:14:18 onion-host onionshare-cli[18852]: Private key: RHJSN4VI3NKGDSIWK45CCWTLYOJHA6DQQRQXUID3FXMAILYXWVUQ
</span></span><span class="line"><span class="cl">Feb 09 10:14:18 onion-host onionshare-cli[18852]: Press Ctrl+C to stop the server
</span></span></code></pre></div><p>Load the <code>.onion</code> address in Tor Browser, enter the private key when prompted, and the
download page should appear. After a reboot, <code>systemctl status onionshare-cli.service</code> should
show the unit active (running), and the same onion address should still resolve - that's the
persistence doing its job.</p>
<p>If you don't want recipients to need a private key, set <code>&quot;public&quot;: true</code> in the <code>general</code>
section of <code>share.json</code>. The onion address is then loadable by anyone who has it, with no key
prompt. Use this only if the address itself is the secret.</p>
<h2 id="switching-to-receive-mode">Switching to Receive mode</h2>
<p>The same unit file works for Receive mode with two changes to <code>share.json</code>. Set
<code>persistent.mode</code> to <code>&quot;receive&quot;</code> instead of <code>&quot;share&quot;</code>, and configure the <code>receive</code> block to
point at the directory where uploads should land:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="s2">&#34;persistent&#34;</span><span class="err">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;mode&#34;</span><span class="p">:</span> <span class="s2">&#34;receive&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;enabled&#34;</span><span class="p">:</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span><span class="err">,</span>
</span></span><span class="line"><span class="cl"><span class="s2">&#34;receive&#34;</span><span class="err">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;data_dir&#34;</span><span class="p">:</span> <span class="s2">&#34;/home/onion/uploads&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;webhook_url&#34;</span><span class="p">:</span> <span class="s2">&#34;https://example.net/hooks/onionshare&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;disable_text&#34;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;disable_files&#34;</span><span class="p">:</span> <span class="kc">false</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>Then drop the positional filename argument from the <code>ExecStart</code> line - there's nothing to
share in Receive mode:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="na">ExecStart</span><span class="o">=</span><span class="s">/home/onion/.local/bin/onionshare-cli --persistent /home/onion/.config/onionshare/persistent/share.json</span>
</span></span></code></pre></div><p>Reload systemd and restart the service. The same onion address and the same client auth key
now serve an upload form. Files land in <code>/home/onion/uploads</code>, organised into timestamped
subfolders; text submissions land there too. The optional <code>webhook_url</code> is POSTed to on each
submission, which is useful for piping into a notification pipeline - a Node-RED flow, a
Signal bot, anything that accepts an HTTP POST.</p>
<h2 id="a-note-on-the-private-key">A note on the private key</h2>
<p>The persistent JSON contains the onion service private key and the client auth private key.
Treat the file the way you'd treat an SSH private key: <code>chmod 0600</code>, owned by the OnionShare
user only, and never committed to a repository. If the file leaks, the onion address can be
impersonated by anyone who holds the key - they can stand up a look-alike service on the same
address. Rotating means deleting the JSON, letting OnionShare generate a new one, and
re-sharing the new address and client auth key with your recipients.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Modes: Share, Receive, Website, Chat</li>
<li>Persistence: same onion address across reboots</li>
<li>Auth: client auth (private key) on by default</li>
<li>Tor: bundled, or system Tor via control_port / socket_file</li>
<li>Service: <code>onionshare-cli.service</code> (systemd, multi-user.target)</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://onionshare.org" target="_blank" rel="noopener noreferrer">OnionShare website</a>
</li>
<li><a href="https://docs.onionshare.org/2.6.5/en/advanced.html#running-the-cli-as-a-systemd-unit-file" target="_blank" rel="noopener noreferrer">Advanced Usage docs (systemd section)</a>
</li>
<li><a href="https://docs.onionshare.org/2.6.5/en/features.html" target="_blank" rel="noopener noreferrer">How OnionShare works</a>
</li>
<li><a href="https://github.com/onionshare/onionshare/blob/develop/cli/README.md" target="_blank" rel="noopener noreferrer">CLI README</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need a Tor-facing service set up?</div>
  <div class="smallprint mb-3">I do contract sysadmin and privacy-infrastructure work, and am one of the core developers of OnionShare.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>Secure WebSocket push notifications from Node-RED</title>
      <link>https://mig5.net/news/nodered-websocket-push/</link>
      <pubDate>Sat, 22 Aug 2026 00:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/nodered-websocket-push/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-broadcast"></i><span>Node-RED</span></div>
    <h1 class="display-6 fw-semibold mb-2">Secure WebSocket push notifications</h1>
    <p class="text-muted mb-3"><time datetime="2026-08-22">August 22, 2026</time></p>
    <p class="lead mb-0">A Node-RED flow for pushing real-time updates to browser clients over WebSocket, with JWT or
HMAC token auth, multi-tab support, and targeted fan-out by user identity.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-08-22T00:00:00&#43;10:00">22 August 2026</time>
        </div>

<h2 id="the-problem">The problem</h2>
<p><a href="https://nodered.org" target="_blank" rel="noopener noreferrer">Node-RED</a>
 has built-in WebSocket support - listener nodes, input/output nodes, the works.
Getting a message from the server to a browser is trivial. The hard part is everything around
it: who is on the other end of that WebSocket? How do you authenticate them? What happens when
they open a second tab? How do you push a message to one specific user without broadcasting to
everyone?</p>
<p>The tempting shortcut is to put a token in the WebSocket URL query string:
<code>ws://host/ws?token=...</code>. Don't. Query strings end up in proxy logs, access logs, and browser
history. The token should travel inside the WebSocket, not beside it.</p>
<div class="mermaid">

sequenceDiagram
    participant B as Browser
    participant P as PHP app (token issuer)
    participant N as Node-RED
    participant API as Backend API
    B->>P: Login (cookie session)
    P-->>B: Short-lived WS token (JWT/HMAC)
    B->>N: WebSocket connect /ws/simple
    B->>N: hello + token
    N->>N: Verify token, map identity to wsId
    N-->>B: welcome
    API->>N: POST /push (X-API-Key, toToken)
    N->>N: Fan-out to all wsIds for token
    N-->>B: Pushed message (all tabs)

</div>

<h2 id="the-secure-hello-pattern">The secure hello pattern</h2>
<p>The flow uses a &quot;secure hello&quot; pattern. The client connects to the WebSocket with
no credentials in the URL. Immediately after connecting, it sends a JSON message:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">{&#34;type&#34;: &#34;hello&#34;, &#34;token&#34;: &#34;&lt;short-lived-token&gt;&#34;}
</span></span></code></pre></div><p>The server receives this on a <code>websocket in</code> node and passes it to a function node called
&quot;identify &amp; map&quot;. That function verifies the token and, if valid, registers the connection as
authenticated. If the token is invalid or missing, the function returns <code>null</code> - the connection
is silently dropped. No welcome, no error, just silence.</p>
<p>If the token is valid, the function extracts a stable identity from it (a user ID or username),
maps that identity to the WebSocket connection ID in flow context, and sends back a
<code>{&quot;type&quot;: &quot;welcome&quot;}</code> message so the client knows it's connected and authenticated.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gdscript3" data-lang="gdscript3"><span class="line"><span class="cl"><span class="k">const</span> <span class="n">crypto</span> <span class="o">=</span> <span class="n">require</span><span class="p">(</span><span class="s1">&#39;crypto&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">function</span> <span class="n">timingSafeEq</span><span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">const</span> <span class="n">A</span> <span class="o">=</span> <span class="n">Buffer</span><span class="o">.</span><span class="n">from</span><span class="p">(</span><span class="n">a</span> <span class="o">||</span> <span class="s1">&#39;&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="k">const</span> <span class="n">B</span> <span class="o">=</span> <span class="n">Buffer</span><span class="o">.</span><span class="n">from</span><span class="p">(</span><span class="n">b</span> <span class="o">||</span> <span class="s1">&#39;&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="n">A</span><span class="o">.</span><span class="n">length</span> <span class="o">!==</span> <span class="n">B</span><span class="o">.</span><span class="n">length</span><span class="p">)</span> <span class="k">return</span> <span class="bp">false</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="n">crypto</span><span class="o">.</span><span class="n">timingSafeEqual</span><span class="p">(</span><span class="n">A</span><span class="p">,</span> <span class="n">B</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="o">//</span> <span class="n">Verify</span> <span class="n">JWT</span><span class="p">:</span> <span class="n">split</span> <span class="n">header</span><span class="o">.</span><span class="n">payload</span><span class="o">.</span><span class="n">signature</span><span class="p">,</span> <span class="n">re</span><span class="o">-</span><span class="nb">sign</span> <span class="n">with</span> <span class="n">HMAC</span><span class="o">-</span><span class="n">SHA256</span><span class="p">,</span> <span class="n">compare</span>
</span></span><span class="line"><span class="cl"><span class="n">function</span> <span class="n">verifyJWT</span><span class="p">(</span><span class="n">token</span><span class="p">,</span> <span class="n">secret</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">const</span> <span class="n">parts</span> <span class="o">=</span> <span class="p">(</span><span class="n">token</span> <span class="o">||</span> <span class="s1">&#39;&#39;</span><span class="p">)</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="s1">&#39;.&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="n">parts</span><span class="o">.</span><span class="n">length</span> <span class="o">!==</span> <span class="mi">3</span><span class="p">)</span> <span class="k">return</span> <span class="n">null</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="k">const</span> <span class="p">[</span><span class="n">h</span><span class="p">,</span> <span class="n">p</span><span class="p">,</span> <span class="n">s</span><span class="p">]</span> <span class="o">=</span> <span class="n">parts</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="k">const</span> <span class="n">data</span> <span class="o">=</span> <span class="n">h</span> <span class="o">+</span> <span class="s1">&#39;.&#39;</span> <span class="o">+</span> <span class="n">p</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="k">const</span> <span class="n">expSig</span> <span class="o">=</span> <span class="n">b64u</span><span class="o">.</span><span class="n">encode</span><span class="p">(</span><span class="n">crypto</span><span class="o">.</span><span class="n">createHmac</span><span class="p">(</span><span class="s1">&#39;sha256&#39;</span><span class="p">,</span> <span class="n">secret</span><span class="p">)</span><span class="o">.</span><span class="n">update</span><span class="p">(</span><span class="n">data</span><span class="p">)</span><span class="o">.</span><span class="n">digest</span><span class="p">());</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">timingSafeEq</span><span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="n">expSig</span><span class="p">))</span> <span class="k">return</span> <span class="n">null</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="k">const</span> <span class="n">payload</span> <span class="o">=</span> <span class="n">JSON</span><span class="o">.</span><span class="n">parse</span><span class="p">(</span><span class="n">b64u</span><span class="o">.</span><span class="n">decode</span><span class="p">(</span><span class="n">p</span><span class="p">));</span>
</span></span><span class="line"><span class="cl">  <span class="k">const</span> <span class="n">now</span> <span class="o">=</span> <span class="n">Math</span><span class="o">.</span><span class="n">floor</span><span class="p">(</span><span class="n">Date</span><span class="o">.</span><span class="n">now</span><span class="p">()</span> <span class="o">/</span> <span class="mi">1000</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="n">payload</span><span class="o">.</span><span class="n">exp</span> <span class="o">&amp;&amp;</span> <span class="n">now</span> <span class="o">&gt;</span> <span class="n">payload</span><span class="o">.</span><span class="n">exp</span><span class="p">)</span> <span class="k">return</span> <span class="n">null</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="n">payload</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">SECRET</span> <span class="o">=</span> <span class="n">env</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s1">&#39;AUTH_SECRET&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="n">let</span> <span class="n">p</span> <span class="o">=</span> <span class="n">msg</span><span class="o">.</span><span class="n">payload</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">p</span> <span class="o">||</span> <span class="n">p</span><span class="o">.</span><span class="n">type</span> <span class="o">!==</span> <span class="s2">&#34;hello&#34;</span> <span class="o">||</span> <span class="o">!</span><span class="n">p</span><span class="o">.</span><span class="n">token</span> <span class="o">||</span> <span class="o">!</span><span class="n">SECRET</span><span class="p">)</span> <span class="k">return</span> <span class="n">null</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">claims</span> <span class="o">=</span> <span class="n">verifyJWT</span><span class="p">(</span><span class="n">p</span><span class="o">.</span><span class="n">token</span><span class="p">,</span> <span class="n">SECRET</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">claims</span><span class="p">)</span> <span class="k">return</span> <span class="n">null</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">ident</span> <span class="o">=</span> <span class="ne">String</span><span class="p">(</span><span class="n">claims</span><span class="o">.</span><span class="n">sub</span> <span class="o">||</span> <span class="n">claims</span><span class="o">.</span><span class="n">uid</span> <span class="o">||</span> <span class="s1">&#39;&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="o">//</span> <span class="n">Map</span> <span class="n">identity</span> <span class="n">to</span> <span class="n">array</span> <span class="n">of</span> <span class="n">wsIds</span> <span class="p">(</span><span class="n">multi</span><span class="o">-</span><span class="n">tab</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">wsId</span> <span class="o">=</span> <span class="n">msg</span><span class="o">.</span><span class="n">_session</span><span class="o">.</span><span class="n">id</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">map</span> <span class="o">=</span> <span class="n">flow</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s1">&#39;wsByToken&#39;</span><span class="p">)</span> <span class="o">||</span> <span class="p">{};</span>
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">arr</span> <span class="o">=</span> <span class="ne">Array</span><span class="o">.</span><span class="n">isArray</span><span class="p">(</span><span class="n">map</span><span class="p">[</span><span class="n">ident</span><span class="p">])</span> <span class="err">?</span> <span class="n">map</span><span class="p">[</span><span class="n">ident</span><span class="p">]</span> <span class="p">:</span> <span class="p">[];</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">arr</span><span class="o">.</span><span class="n">includes</span><span class="p">(</span><span class="n">wsId</span><span class="p">))</span> <span class="n">arr</span><span class="o">.</span><span class="n">push</span><span class="p">(</span><span class="n">wsId</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="n">map</span><span class="p">[</span><span class="n">ident</span><span class="p">]</span> <span class="o">=</span> <span class="n">arr</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="n">flow</span><span class="o">.</span><span class="n">set</span><span class="p">(</span><span class="s1">&#39;wsByToken&#39;</span><span class="p">,</span> <span class="n">map</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">return</span> <span class="p">{</span> <span class="n">_session</span><span class="p">:</span> <span class="n">msg</span><span class="o">.</span><span class="n">_session</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">         <span class="n">payload</span><span class="p">:</span> <span class="n">JSON</span><span class="o">.</span><span class="n">stringify</span><span class="p">({</span> <span class="n">type</span><span class="p">:</span> <span class="s1">&#39;welcome&#39;</span><span class="p">,</span> <span class="n">wsId</span><span class="p">,</span> <span class="n">sessionsForIdentity</span><span class="p">:</span> <span class="n">arr</span><span class="o">.</span><span class="n">length</span> <span class="p">})</span> <span class="p">};</span>
</span></span></code></pre></div><h2 id="two-token-modes-jwt-or-hmac">Two token modes: JWT or HMAC</h2>
<p>The function supports two token formats, selected by the <code>AUTH_MODE</code> environment variable. Both
share a single <code>AUTH_SECRET</code> and both check an <code>exp</code> claim so tokens are short-lived.</p>
<p>In <strong>JWT mode</strong>, the token is a standard three-part JWT signed with HMAC-SHA256. The function
verifies the signature, parses the payload, checks the expiry, and extracts the identity from
the <code>sub</code>, <code>uid</code>, or <code>sid</code> claim.</p>
<p>In <strong>HMAC mode</strong>, the token is simpler: a base64url-encoded JSON payload, a dot, and a
base64url-encoded HMAC-SHA256 signature. Same verification logic, same expiry check, same
identity extraction. This is useful when you don't need the full JWT header overhead and just
want a signed, expiring token.</p>
<p>Both modes use <code>crypto.timingSafeEqual</code> for the signature comparison, so the verification is
constant-time. The function requires <code>functionExternalModules: true</code> in Node-RED's
<code>settings.js</code> to access Node's <code>crypto</code> module.</p>
<h2 id="multi-tab-support">Multi-tab support</h2>
<p>A user opens a second browser tab. The PHP app issues the same token (or a new one for the same
identity). The new tab connects, sends its hello, and gets verified. Now there are two WebSocket
connections for the same user.</p>
<p>The flow handles this with a flow-context map called <code>wsByToken</code>. Each token maps to an array of
WebSocket connection IDs. When a new connection is authenticated, its ID is pushed onto the
array. When a connection closes, its ID is removed. Pushing a message to a user means iterating
over their connection IDs and sending to each one.</p>
<p>This is the difference between &quot;push to user X&quot; and &quot;push to the one WebSocket for user X&quot;. Real
users have multiple tabs. The flow accounts for that.</p>
<h2 id="the-push-endpoint">The /push endpoint</h2>
<p>A separate HTTP endpoint, <code>POST /push</code>, lets the backend push messages to connected clients.
Authentication is a simple API key checked against the <code>PUSH_API_KEY</code> environment variable via
the <code>X-API-Key</code> header.</p>
<p>The push function supports three delivery modes. If the request body contains a <code>toToken</code> field
(a string or array), the message is fanned out to every WebSocket connection mapped to that
token - all of the user's tabs. If it contains a <code>toWs</code> field, the message goes to one specific
WebSocket by ID. If neither is present, the message is broadcast to every connected client.</p>
<p>The response is a JSON object with diagnostics: <code>ok</code>, <code>targeted</code>, <code>delivered</code>, <code>targets</code>
(tokens that were online), and <code>missing</code> (tokens that had no connected WebSocket). This lets the
caller know whether the push actually reached anyone.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gdscript3" data-lang="gdscript3"><span class="line"><span class="cl"><span class="k">const</span> <span class="n">API_KEY</span> <span class="o">=</span> <span class="n">env</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s1">&#39;PUSH_API_KEY&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">hdr</span> <span class="o">=</span> <span class="p">(</span><span class="n">msg</span><span class="o">.</span><span class="n">req</span> <span class="o">&amp;&amp;</span> <span class="n">msg</span><span class="o">.</span><span class="n">req</span><span class="o">.</span><span class="n">headers</span><span class="p">)</span> <span class="err">?</span> <span class="n">msg</span><span class="o">.</span><span class="n">req</span><span class="o">.</span><span class="n">headers</span> <span class="p">:</span> <span class="p">{};</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">API_KEY</span> <span class="o">||</span> <span class="n">hdr</span><span class="p">[</span><span class="s1">&#39;x-api-key&#39;</span><span class="p">]</span> <span class="o">!==</span> <span class="n">API_KEY</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">msg</span><span class="o">.</span><span class="n">statusCode</span> <span class="o">=</span> <span class="mi">401</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="p">[</span><span class="n">null</span><span class="p">,</span> <span class="p">{</span> <span class="n">payload</span><span class="p">:</span> <span class="p">{</span> <span class="n">ok</span><span class="p">:</span> <span class="bp">false</span><span class="p">,</span> <span class="n">error</span><span class="p">:</span> <span class="s1">&#39;unauthorized&#39;</span> <span class="p">}</span> <span class="p">}];</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">body</span> <span class="o">=</span> <span class="n">msg</span><span class="o">.</span><span class="n">payload</span> <span class="o">||</span> <span class="p">{};</span>
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">payload</span> <span class="o">=</span> <span class="p">(</span><span class="n">body</span><span class="o">.</span><span class="n">payload</span> <span class="o">!==</span> <span class="n">undefined</span><span class="p">)</span> <span class="err">?</span> <span class="n">body</span><span class="o">.</span><span class="n">payload</span> <span class="p">:</span> <span class="n">body</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">map</span> <span class="o">=</span> <span class="n">flow</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s1">&#39;wsByToken&#39;</span><span class="p">)</span> <span class="o">||</span> <span class="p">{};</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">mk</span> <span class="o">=</span> <span class="p">(</span><span class="n">wsId</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">({</span> <span class="n">payload</span><span class="p">,</span> <span class="n">_session</span><span class="p">:</span> <span class="p">{</span> <span class="n">type</span><span class="p">:</span> <span class="s1">&#39;websocket&#39;</span><span class="p">,</span> <span class="n">id</span><span class="p">:</span> <span class="n">wsId</span> <span class="p">}</span> <span class="p">});</span>
</span></span><span class="line"><span class="cl"><span class="n">let</span> <span class="n">sendMsgs</span> <span class="o">=</span> <span class="p">[];</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="n">body</span><span class="o">.</span><span class="n">toToken</span> <span class="o">!==</span> <span class="n">undefined</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="o">//</span> <span class="n">Fan</span><span class="o">-</span><span class="n">out</span><span class="p">:</span> <span class="n">send</span> <span class="n">to</span> <span class="n">every</span> <span class="n">wsId</span> <span class="n">mapped</span> <span class="n">to</span> <span class="n">this</span> <span class="n">token</span> <span class="p">(</span><span class="n">all</span> <span class="n">of</span> <span class="n">the</span> <span class="n">user</span><span class="s1">&#39;s tabs)</span>
</span></span><span class="line"><span class="cl">  <span class="k">const</span> <span class="n">tokens</span> <span class="o">=</span> <span class="ne">Array</span><span class="o">.</span><span class="n">isArray</span><span class="p">(</span><span class="n">body</span><span class="o">.</span><span class="n">toToken</span><span class="p">)</span> <span class="err">?</span> <span class="n">body</span><span class="o">.</span><span class="n">toToken</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="ne">String</span><span class="p">)</span> <span class="p">:</span> <span class="p">[</span><span class="ne">String</span><span class="p">(</span><span class="n">body</span><span class="o">.</span><span class="n">toToken</span><span class="p">)];</span>
</span></span><span class="line"><span class="cl">  <span class="k">for</span> <span class="p">(</span><span class="k">const</span> <span class="n">t</span> <span class="n">of</span> <span class="n">tokens</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">const</span> <span class="n">ids</span> <span class="o">=</span> <span class="ne">Array</span><span class="o">.</span><span class="n">isArray</span><span class="p">(</span><span class="n">map</span><span class="p">[</span><span class="n">t</span><span class="p">])</span> <span class="err">?</span> <span class="n">map</span><span class="p">[</span><span class="n">t</span><span class="p">]</span> <span class="p">:</span> <span class="p">[];</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="p">(</span><span class="k">const</span> <span class="n">id</span> <span class="n">of</span> <span class="n">ids</span><span class="p">)</span> <span class="n">sendMsgs</span><span class="o">.</span><span class="n">push</span><span class="p">(</span><span class="n">mk</span><span class="p">(</span><span class="n">id</span><span class="p">));</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="n">body</span><span class="o">.</span><span class="n">toWs</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="o">//</span> <span class="n">Direct</span><span class="p">:</span> <span class="n">one</span> <span class="n">specific</span> <span class="n">WebSocket</span>
</span></span><span class="line"><span class="cl">  <span class="n">sendMsgs</span><span class="o">.</span><span class="n">push</span><span class="p">(</span><span class="n">mk</span><span class="p">(</span><span class="ne">String</span><span class="p">(</span><span class="n">body</span><span class="o">.</span><span class="n">toWs</span><span class="p">)));</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="o">//</span> <span class="n">Broadcast</span><span class="p">:</span> <span class="n">no</span> <span class="n">_session</span> <span class="n">means</span> <span class="n">all</span> <span class="n">clients</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="p">[{</span> <span class="n">payload</span> <span class="p">},</span> <span class="p">{</span> <span class="n">payload</span><span class="p">:</span> <span class="p">{</span> <span class="n">ok</span><span class="p">:</span> <span class="bp">true</span><span class="p">,</span> <span class="n">targeted</span><span class="p">:</span> <span class="bp">false</span><span class="p">,</span> <span class="n">delivered</span><span class="p">:</span> <span class="mi">0</span> <span class="p">}</span> <span class="p">}];</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">delivered</span> <span class="o">=</span> <span class="n">sendMsgs</span><span class="o">.</span><span class="n">length</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">return</span> <span class="p">[</span><span class="n">sendMsgs</span><span class="p">,</span> <span class="p">{</span> <span class="n">payload</span><span class="p">:</span> <span class="p">{</span> <span class="n">ok</span><span class="p">:</span> <span class="n">delivered</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">,</span> <span class="n">targeted</span><span class="p">:</span> <span class="bp">true</span><span class="p">,</span> <span class="n">delivered</span> <span class="p">}</span> <span class="p">}];</span>
</span></span></code></pre></div><h2 id="the-token-issuer">The token issuer</h2>
<p>The WebSocket flow doesn't issue tokens itself. That's the job of an external PHP application.
The PHP app authenticates the user with a traditional cookie session, then issues a short-lived
token (JWT or HMAC, signed with the same <code>AUTH_SECRET</code>) that the browser uses for WebSocket
authentication.</p>
<p>This separation is deliberate. The PHP app owns the session, the login form, the password
check. Node-RED owns the real-time transport. The token is the handoff - short-lived, signed,
and carrying just enough identity (a user ID) to route WebSocket messages.</p>
<h2 id="the-simple-demo-page">The /simple demo page</h2>
<p>The flow includes a <code>GET /simple</code> endpoint that serves a minimal HTML page demonstrating the full
cycle. The page's JavaScript fetches a token from the <code>WS_TOKEN_URL</code> endpoint (configured via
environment variable, defaulting to <code>/ws-token</code> on the PHP app), using <code>credentials: 'include'</code>
so the cookie session travels with the request.</p>
<p>Once it has the token, it opens the WebSocket, sends the hello, waits for the welcome, and then
displays any pushed messages. The token can come back from the PHP app as JSON
(<code>{&quot;token&quot;: &quot;...&quot;}</code>) or as an <code>X-WS-Token</code> response header - the page handles both.</p>
<p>It's a demo, but it's a working end-to-end test: cookie auth, token issuance, WebSocket
connection, secure hello, and message receipt.</p>
<h2 id="import-the-flow">Import the flow</h2>
<p>The complete flow - websocket listener, /push endpoint, /simple demo page, and the
websocket-listener config node - is available as a sanitised JSON file:</p>
<p><a href="/flows/nodered-websocket-push.json">Download nodered-websocket-push.json</a>
</p>
<p>In Node-RED: hamburger menu → Import → select the file. After importing, set the
<code>AUTH_SECRET</code>, <code>AUTH_MODE</code> (<code>jwt</code> or <code>hmac</code>), and <code>PUSH_API_KEY</code> environment variables on the
flow tab (or in Node-RED's environment), and point <code>WS_TOKEN_URL</code> at your token-issuing
endpoint.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Transport: WebSocket</li>
<li>Token auth: JWT or HMAC (timing-safe)</li>
<li>Multi-tab: yes (wsByToken map)</li>
<li>Endpoints: /ws/simple, POST /push, GET /simple</li>
<li>Token issuer: external PHP app (cookie session)</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://nodered.org" target="_blank" rel="noopener noreferrer">Node-RED</a>
</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API" target="_blank" rel="noopener noreferrer">WebSocket API (MDN)</a>
</li>
<li><a href="https://datatracker.ietf.org/doc/html/rfc7519" target="_blank" rel="noopener noreferrer">JWT (RFC 7519)</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need real-time push?</div>
  <div class="smallprint mb-3">I do contract solutions architecture and can help design event-driven automation.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-broadcast"></i><span>Node-RED</span></div>
    <h1 class="display-6 fw-semibold mb-2">Secure WebSocket push notifications</h1>
    <p class="text-muted mb-3"><time datetime="2026-08-22">August 22, 2026</time></p>
    <p class="lead mb-0">A Node-RED flow for pushing real-time updates to browser clients over WebSocket, with JWT or
HMAC token auth, multi-tab support, and targeted fan-out by user identity.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-08-22T00:00:00&#43;10:00">22 August 2026</time>
        </div>

<h2 id="the-problem">The problem</h2>
<p><a href="https://nodered.org" target="_blank" rel="noopener noreferrer">Node-RED</a>
 has built-in WebSocket support - listener nodes, input/output nodes, the works.
Getting a message from the server to a browser is trivial. The hard part is everything around
it: who is on the other end of that WebSocket? How do you authenticate them? What happens when
they open a second tab? How do you push a message to one specific user without broadcasting to
everyone?</p>
<p>The tempting shortcut is to put a token in the WebSocket URL query string:
<code>ws://host/ws?token=...</code>. Don't. Query strings end up in proxy logs, access logs, and browser
history. The token should travel inside the WebSocket, not beside it.</p>
<div class="mermaid">

sequenceDiagram
    participant B as Browser
    participant P as PHP app (token issuer)
    participant N as Node-RED
    participant API as Backend API
    B->>P: Login (cookie session)
    P-->>B: Short-lived WS token (JWT/HMAC)
    B->>N: WebSocket connect /ws/simple
    B->>N: hello + token
    N->>N: Verify token, map identity to wsId
    N-->>B: welcome
    API->>N: POST /push (X-API-Key, toToken)
    N->>N: Fan-out to all wsIds for token
    N-->>B: Pushed message (all tabs)

</div>

<h2 id="the-secure-hello-pattern">The secure hello pattern</h2>
<p>The flow uses a &quot;secure hello&quot; pattern. The client connects to the WebSocket with
no credentials in the URL. Immediately after connecting, it sends a JSON message:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">{&#34;type&#34;: &#34;hello&#34;, &#34;token&#34;: &#34;&lt;short-lived-token&gt;&#34;}
</span></span></code></pre></div><p>The server receives this on a <code>websocket in</code> node and passes it to a function node called
&quot;identify &amp; map&quot;. That function verifies the token and, if valid, registers the connection as
authenticated. If the token is invalid or missing, the function returns <code>null</code> - the connection
is silently dropped. No welcome, no error, just silence.</p>
<p>If the token is valid, the function extracts a stable identity from it (a user ID or username),
maps that identity to the WebSocket connection ID in flow context, and sends back a
<code>{&quot;type&quot;: &quot;welcome&quot;}</code> message so the client knows it's connected and authenticated.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gdscript3" data-lang="gdscript3"><span class="line"><span class="cl"><span class="k">const</span> <span class="n">crypto</span> <span class="o">=</span> <span class="n">require</span><span class="p">(</span><span class="s1">&#39;crypto&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">function</span> <span class="n">timingSafeEq</span><span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">const</span> <span class="n">A</span> <span class="o">=</span> <span class="n">Buffer</span><span class="o">.</span><span class="n">from</span><span class="p">(</span><span class="n">a</span> <span class="o">||</span> <span class="s1">&#39;&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="k">const</span> <span class="n">B</span> <span class="o">=</span> <span class="n">Buffer</span><span class="o">.</span><span class="n">from</span><span class="p">(</span><span class="n">b</span> <span class="o">||</span> <span class="s1">&#39;&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="n">A</span><span class="o">.</span><span class="n">length</span> <span class="o">!==</span> <span class="n">B</span><span class="o">.</span><span class="n">length</span><span class="p">)</span> <span class="k">return</span> <span class="bp">false</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="n">crypto</span><span class="o">.</span><span class="n">timingSafeEqual</span><span class="p">(</span><span class="n">A</span><span class="p">,</span> <span class="n">B</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="o">//</span> <span class="n">Verify</span> <span class="n">JWT</span><span class="p">:</span> <span class="n">split</span> <span class="n">header</span><span class="o">.</span><span class="n">payload</span><span class="o">.</span><span class="n">signature</span><span class="p">,</span> <span class="n">re</span><span class="o">-</span><span class="nb">sign</span> <span class="n">with</span> <span class="n">HMAC</span><span class="o">-</span><span class="n">SHA256</span><span class="p">,</span> <span class="n">compare</span>
</span></span><span class="line"><span class="cl"><span class="n">function</span> <span class="n">verifyJWT</span><span class="p">(</span><span class="n">token</span><span class="p">,</span> <span class="n">secret</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">const</span> <span class="n">parts</span> <span class="o">=</span> <span class="p">(</span><span class="n">token</span> <span class="o">||</span> <span class="s1">&#39;&#39;</span><span class="p">)</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="s1">&#39;.&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="n">parts</span><span class="o">.</span><span class="n">length</span> <span class="o">!==</span> <span class="mi">3</span><span class="p">)</span> <span class="k">return</span> <span class="n">null</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="k">const</span> <span class="p">[</span><span class="n">h</span><span class="p">,</span> <span class="n">p</span><span class="p">,</span> <span class="n">s</span><span class="p">]</span> <span class="o">=</span> <span class="n">parts</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="k">const</span> <span class="n">data</span> <span class="o">=</span> <span class="n">h</span> <span class="o">+</span> <span class="s1">&#39;.&#39;</span> <span class="o">+</span> <span class="n">p</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="k">const</span> <span class="n">expSig</span> <span class="o">=</span> <span class="n">b64u</span><span class="o">.</span><span class="n">encode</span><span class="p">(</span><span class="n">crypto</span><span class="o">.</span><span class="n">createHmac</span><span class="p">(</span><span class="s1">&#39;sha256&#39;</span><span class="p">,</span> <span class="n">secret</span><span class="p">)</span><span class="o">.</span><span class="n">update</span><span class="p">(</span><span class="n">data</span><span class="p">)</span><span class="o">.</span><span class="n">digest</span><span class="p">());</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">timingSafeEq</span><span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="n">expSig</span><span class="p">))</span> <span class="k">return</span> <span class="n">null</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="k">const</span> <span class="n">payload</span> <span class="o">=</span> <span class="n">JSON</span><span class="o">.</span><span class="n">parse</span><span class="p">(</span><span class="n">b64u</span><span class="o">.</span><span class="n">decode</span><span class="p">(</span><span class="n">p</span><span class="p">));</span>
</span></span><span class="line"><span class="cl">  <span class="k">const</span> <span class="n">now</span> <span class="o">=</span> <span class="n">Math</span><span class="o">.</span><span class="n">floor</span><span class="p">(</span><span class="n">Date</span><span class="o">.</span><span class="n">now</span><span class="p">()</span> <span class="o">/</span> <span class="mi">1000</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="n">payload</span><span class="o">.</span><span class="n">exp</span> <span class="o">&amp;&amp;</span> <span class="n">now</span> <span class="o">&gt;</span> <span class="n">payload</span><span class="o">.</span><span class="n">exp</span><span class="p">)</span> <span class="k">return</span> <span class="n">null</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="n">payload</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">SECRET</span> <span class="o">=</span> <span class="n">env</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s1">&#39;AUTH_SECRET&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="n">let</span> <span class="n">p</span> <span class="o">=</span> <span class="n">msg</span><span class="o">.</span><span class="n">payload</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">p</span> <span class="o">||</span> <span class="n">p</span><span class="o">.</span><span class="n">type</span> <span class="o">!==</span> <span class="s2">&#34;hello&#34;</span> <span class="o">||</span> <span class="o">!</span><span class="n">p</span><span class="o">.</span><span class="n">token</span> <span class="o">||</span> <span class="o">!</span><span class="n">SECRET</span><span class="p">)</span> <span class="k">return</span> <span class="n">null</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">claims</span> <span class="o">=</span> <span class="n">verifyJWT</span><span class="p">(</span><span class="n">p</span><span class="o">.</span><span class="n">token</span><span class="p">,</span> <span class="n">SECRET</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">claims</span><span class="p">)</span> <span class="k">return</span> <span class="n">null</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">ident</span> <span class="o">=</span> <span class="ne">String</span><span class="p">(</span><span class="n">claims</span><span class="o">.</span><span class="n">sub</span> <span class="o">||</span> <span class="n">claims</span><span class="o">.</span><span class="n">uid</span> <span class="o">||</span> <span class="s1">&#39;&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="o">//</span> <span class="n">Map</span> <span class="n">identity</span> <span class="n">to</span> <span class="n">array</span> <span class="n">of</span> <span class="n">wsIds</span> <span class="p">(</span><span class="n">multi</span><span class="o">-</span><span class="n">tab</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">wsId</span> <span class="o">=</span> <span class="n">msg</span><span class="o">.</span><span class="n">_session</span><span class="o">.</span><span class="n">id</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">map</span> <span class="o">=</span> <span class="n">flow</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s1">&#39;wsByToken&#39;</span><span class="p">)</span> <span class="o">||</span> <span class="p">{};</span>
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">arr</span> <span class="o">=</span> <span class="ne">Array</span><span class="o">.</span><span class="n">isArray</span><span class="p">(</span><span class="n">map</span><span class="p">[</span><span class="n">ident</span><span class="p">])</span> <span class="err">?</span> <span class="n">map</span><span class="p">[</span><span class="n">ident</span><span class="p">]</span> <span class="p">:</span> <span class="p">[];</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">arr</span><span class="o">.</span><span class="n">includes</span><span class="p">(</span><span class="n">wsId</span><span class="p">))</span> <span class="n">arr</span><span class="o">.</span><span class="n">push</span><span class="p">(</span><span class="n">wsId</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="n">map</span><span class="p">[</span><span class="n">ident</span><span class="p">]</span> <span class="o">=</span> <span class="n">arr</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="n">flow</span><span class="o">.</span><span class="n">set</span><span class="p">(</span><span class="s1">&#39;wsByToken&#39;</span><span class="p">,</span> <span class="n">map</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">return</span> <span class="p">{</span> <span class="n">_session</span><span class="p">:</span> <span class="n">msg</span><span class="o">.</span><span class="n">_session</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">         <span class="n">payload</span><span class="p">:</span> <span class="n">JSON</span><span class="o">.</span><span class="n">stringify</span><span class="p">({</span> <span class="n">type</span><span class="p">:</span> <span class="s1">&#39;welcome&#39;</span><span class="p">,</span> <span class="n">wsId</span><span class="p">,</span> <span class="n">sessionsForIdentity</span><span class="p">:</span> <span class="n">arr</span><span class="o">.</span><span class="n">length</span> <span class="p">})</span> <span class="p">};</span>
</span></span></code></pre></div><h2 id="two-token-modes-jwt-or-hmac">Two token modes: JWT or HMAC</h2>
<p>The function supports two token formats, selected by the <code>AUTH_MODE</code> environment variable. Both
share a single <code>AUTH_SECRET</code> and both check an <code>exp</code> claim so tokens are short-lived.</p>
<p>In <strong>JWT mode</strong>, the token is a standard three-part JWT signed with HMAC-SHA256. The function
verifies the signature, parses the payload, checks the expiry, and extracts the identity from
the <code>sub</code>, <code>uid</code>, or <code>sid</code> claim.</p>
<p>In <strong>HMAC mode</strong>, the token is simpler: a base64url-encoded JSON payload, a dot, and a
base64url-encoded HMAC-SHA256 signature. Same verification logic, same expiry check, same
identity extraction. This is useful when you don't need the full JWT header overhead and just
want a signed, expiring token.</p>
<p>Both modes use <code>crypto.timingSafeEqual</code> for the signature comparison, so the verification is
constant-time. The function requires <code>functionExternalModules: true</code> in Node-RED's
<code>settings.js</code> to access Node's <code>crypto</code> module.</p>
<h2 id="multi-tab-support">Multi-tab support</h2>
<p>A user opens a second browser tab. The PHP app issues the same token (or a new one for the same
identity). The new tab connects, sends its hello, and gets verified. Now there are two WebSocket
connections for the same user.</p>
<p>The flow handles this with a flow-context map called <code>wsByToken</code>. Each token maps to an array of
WebSocket connection IDs. When a new connection is authenticated, its ID is pushed onto the
array. When a connection closes, its ID is removed. Pushing a message to a user means iterating
over their connection IDs and sending to each one.</p>
<p>This is the difference between &quot;push to user X&quot; and &quot;push to the one WebSocket for user X&quot;. Real
users have multiple tabs. The flow accounts for that.</p>
<h2 id="the-push-endpoint">The /push endpoint</h2>
<p>A separate HTTP endpoint, <code>POST /push</code>, lets the backend push messages to connected clients.
Authentication is a simple API key checked against the <code>PUSH_API_KEY</code> environment variable via
the <code>X-API-Key</code> header.</p>
<p>The push function supports three delivery modes. If the request body contains a <code>toToken</code> field
(a string or array), the message is fanned out to every WebSocket connection mapped to that
token - all of the user's tabs. If it contains a <code>toWs</code> field, the message goes to one specific
WebSocket by ID. If neither is present, the message is broadcast to every connected client.</p>
<p>The response is a JSON object with diagnostics: <code>ok</code>, <code>targeted</code>, <code>delivered</code>, <code>targets</code>
(tokens that were online), and <code>missing</code> (tokens that had no connected WebSocket). This lets the
caller know whether the push actually reached anyone.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gdscript3" data-lang="gdscript3"><span class="line"><span class="cl"><span class="k">const</span> <span class="n">API_KEY</span> <span class="o">=</span> <span class="n">env</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s1">&#39;PUSH_API_KEY&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">hdr</span> <span class="o">=</span> <span class="p">(</span><span class="n">msg</span><span class="o">.</span><span class="n">req</span> <span class="o">&amp;&amp;</span> <span class="n">msg</span><span class="o">.</span><span class="n">req</span><span class="o">.</span><span class="n">headers</span><span class="p">)</span> <span class="err">?</span> <span class="n">msg</span><span class="o">.</span><span class="n">req</span><span class="o">.</span><span class="n">headers</span> <span class="p">:</span> <span class="p">{};</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">API_KEY</span> <span class="o">||</span> <span class="n">hdr</span><span class="p">[</span><span class="s1">&#39;x-api-key&#39;</span><span class="p">]</span> <span class="o">!==</span> <span class="n">API_KEY</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">msg</span><span class="o">.</span><span class="n">statusCode</span> <span class="o">=</span> <span class="mi">401</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="p">[</span><span class="n">null</span><span class="p">,</span> <span class="p">{</span> <span class="n">payload</span><span class="p">:</span> <span class="p">{</span> <span class="n">ok</span><span class="p">:</span> <span class="bp">false</span><span class="p">,</span> <span class="n">error</span><span class="p">:</span> <span class="s1">&#39;unauthorized&#39;</span> <span class="p">}</span> <span class="p">}];</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">body</span> <span class="o">=</span> <span class="n">msg</span><span class="o">.</span><span class="n">payload</span> <span class="o">||</span> <span class="p">{};</span>
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">payload</span> <span class="o">=</span> <span class="p">(</span><span class="n">body</span><span class="o">.</span><span class="n">payload</span> <span class="o">!==</span> <span class="n">undefined</span><span class="p">)</span> <span class="err">?</span> <span class="n">body</span><span class="o">.</span><span class="n">payload</span> <span class="p">:</span> <span class="n">body</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">map</span> <span class="o">=</span> <span class="n">flow</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s1">&#39;wsByToken&#39;</span><span class="p">)</span> <span class="o">||</span> <span class="p">{};</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">mk</span> <span class="o">=</span> <span class="p">(</span><span class="n">wsId</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">({</span> <span class="n">payload</span><span class="p">,</span> <span class="n">_session</span><span class="p">:</span> <span class="p">{</span> <span class="n">type</span><span class="p">:</span> <span class="s1">&#39;websocket&#39;</span><span class="p">,</span> <span class="n">id</span><span class="p">:</span> <span class="n">wsId</span> <span class="p">}</span> <span class="p">});</span>
</span></span><span class="line"><span class="cl"><span class="n">let</span> <span class="n">sendMsgs</span> <span class="o">=</span> <span class="p">[];</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="n">body</span><span class="o">.</span><span class="n">toToken</span> <span class="o">!==</span> <span class="n">undefined</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="o">//</span> <span class="n">Fan</span><span class="o">-</span><span class="n">out</span><span class="p">:</span> <span class="n">send</span> <span class="n">to</span> <span class="n">every</span> <span class="n">wsId</span> <span class="n">mapped</span> <span class="n">to</span> <span class="n">this</span> <span class="n">token</span> <span class="p">(</span><span class="n">all</span> <span class="n">of</span> <span class="n">the</span> <span class="n">user</span><span class="s1">&#39;s tabs)</span>
</span></span><span class="line"><span class="cl">  <span class="k">const</span> <span class="n">tokens</span> <span class="o">=</span> <span class="ne">Array</span><span class="o">.</span><span class="n">isArray</span><span class="p">(</span><span class="n">body</span><span class="o">.</span><span class="n">toToken</span><span class="p">)</span> <span class="err">?</span> <span class="n">body</span><span class="o">.</span><span class="n">toToken</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="ne">String</span><span class="p">)</span> <span class="p">:</span> <span class="p">[</span><span class="ne">String</span><span class="p">(</span><span class="n">body</span><span class="o">.</span><span class="n">toToken</span><span class="p">)];</span>
</span></span><span class="line"><span class="cl">  <span class="k">for</span> <span class="p">(</span><span class="k">const</span> <span class="n">t</span> <span class="n">of</span> <span class="n">tokens</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">const</span> <span class="n">ids</span> <span class="o">=</span> <span class="ne">Array</span><span class="o">.</span><span class="n">isArray</span><span class="p">(</span><span class="n">map</span><span class="p">[</span><span class="n">t</span><span class="p">])</span> <span class="err">?</span> <span class="n">map</span><span class="p">[</span><span class="n">t</span><span class="p">]</span> <span class="p">:</span> <span class="p">[];</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="p">(</span><span class="k">const</span> <span class="n">id</span> <span class="n">of</span> <span class="n">ids</span><span class="p">)</span> <span class="n">sendMsgs</span><span class="o">.</span><span class="n">push</span><span class="p">(</span><span class="n">mk</span><span class="p">(</span><span class="n">id</span><span class="p">));</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="n">body</span><span class="o">.</span><span class="n">toWs</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="o">//</span> <span class="n">Direct</span><span class="p">:</span> <span class="n">one</span> <span class="n">specific</span> <span class="n">WebSocket</span>
</span></span><span class="line"><span class="cl">  <span class="n">sendMsgs</span><span class="o">.</span><span class="n">push</span><span class="p">(</span><span class="n">mk</span><span class="p">(</span><span class="ne">String</span><span class="p">(</span><span class="n">body</span><span class="o">.</span><span class="n">toWs</span><span class="p">)));</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="o">//</span> <span class="n">Broadcast</span><span class="p">:</span> <span class="n">no</span> <span class="n">_session</span> <span class="n">means</span> <span class="n">all</span> <span class="n">clients</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="p">[{</span> <span class="n">payload</span> <span class="p">},</span> <span class="p">{</span> <span class="n">payload</span><span class="p">:</span> <span class="p">{</span> <span class="n">ok</span><span class="p">:</span> <span class="bp">true</span><span class="p">,</span> <span class="n">targeted</span><span class="p">:</span> <span class="bp">false</span><span class="p">,</span> <span class="n">delivered</span><span class="p">:</span> <span class="mi">0</span> <span class="p">}</span> <span class="p">}];</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">delivered</span> <span class="o">=</span> <span class="n">sendMsgs</span><span class="o">.</span><span class="n">length</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">return</span> <span class="p">[</span><span class="n">sendMsgs</span><span class="p">,</span> <span class="p">{</span> <span class="n">payload</span><span class="p">:</span> <span class="p">{</span> <span class="n">ok</span><span class="p">:</span> <span class="n">delivered</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">,</span> <span class="n">targeted</span><span class="p">:</span> <span class="bp">true</span><span class="p">,</span> <span class="n">delivered</span> <span class="p">}</span> <span class="p">}];</span>
</span></span></code></pre></div><h2 id="the-token-issuer">The token issuer</h2>
<p>The WebSocket flow doesn't issue tokens itself. That's the job of an external PHP application.
The PHP app authenticates the user with a traditional cookie session, then issues a short-lived
token (JWT or HMAC, signed with the same <code>AUTH_SECRET</code>) that the browser uses for WebSocket
authentication.</p>
<p>This separation is deliberate. The PHP app owns the session, the login form, the password
check. Node-RED owns the real-time transport. The token is the handoff - short-lived, signed,
and carrying just enough identity (a user ID) to route WebSocket messages.</p>
<h2 id="the-simple-demo-page">The /simple demo page</h2>
<p>The flow includes a <code>GET /simple</code> endpoint that serves a minimal HTML page demonstrating the full
cycle. The page's JavaScript fetches a token from the <code>WS_TOKEN_URL</code> endpoint (configured via
environment variable, defaulting to <code>/ws-token</code> on the PHP app), using <code>credentials: 'include'</code>
so the cookie session travels with the request.</p>
<p>Once it has the token, it opens the WebSocket, sends the hello, waits for the welcome, and then
displays any pushed messages. The token can come back from the PHP app as JSON
(<code>{&quot;token&quot;: &quot;...&quot;}</code>) or as an <code>X-WS-Token</code> response header - the page handles both.</p>
<p>It's a demo, but it's a working end-to-end test: cookie auth, token issuance, WebSocket
connection, secure hello, and message receipt.</p>
<h2 id="import-the-flow">Import the flow</h2>
<p>The complete flow - websocket listener, /push endpoint, /simple demo page, and the
websocket-listener config node - is available as a sanitised JSON file:</p>
<p><a href="/flows/nodered-websocket-push.json">Download nodered-websocket-push.json</a>
</p>
<p>In Node-RED: hamburger menu → Import → select the file. After importing, set the
<code>AUTH_SECRET</code>, <code>AUTH_MODE</code> (<code>jwt</code> or <code>hmac</code>), and <code>PUSH_API_KEY</code> environment variables on the
flow tab (or in Node-RED's environment), and point <code>WS_TOKEN_URL</code> at your token-issuing
endpoint.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Transport: WebSocket</li>
<li>Token auth: JWT or HMAC (timing-safe)</li>
<li>Multi-tab: yes (wsByToken map)</li>
<li>Endpoints: /ws/simple, POST /push, GET /simple</li>
<li>Token issuer: external PHP app (cookie session)</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://nodered.org" target="_blank" rel="noopener noreferrer">Node-RED</a>
</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API" target="_blank" rel="noopener noreferrer">WebSocket API (MDN)</a>
</li>
<li><a href="https://datatracker.ietf.org/doc/html/rfc7519" target="_blank" rel="noopener noreferrer">JWT (RFC 7519)</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need real-time push?</div>
  <div class="smallprint mb-3">I do contract solutions architecture and can help design event-driven automation.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>php-sqlcipher: encrypted SQLite for PHP, done properly</title>
      <link>https://mig5.net/news/php-sqlcipher-history/</link>
      <pubDate>Sat, 15 Aug 2026 00:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/php-sqlcipher-history/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-database-lock"></i><span>php-sqlcipher</span></div>
    <h1 class="display-6 fw-semibold mb-2">Encrypted SQLite for PHP, done properly</h1>
    <p class="text-muted mb-3"><time datetime="2026-08-15">August 15, 2026</time></p>
    <p class="lead mb-0">From a sed-and-compile script to a hardened Docker-and-Qubes packaging pipeline tracking
SQLCipher across seven PHP versions and five Linux distributions.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-08-15T00:00:00&#43;10:00">15 August 2026</time>
        </div>

<h2 id="the-beginning">The beginning</h2>
<p>This project began in early 2013, originally for
<a href="https://mydex.org" target="_blank" rel="noopener noreferrer">Mydex Data Services CIC</a>
. The goal was simple: build PHP's <code>pdo_sqlite</code>
(and later <code>sqlite3</code>) extensions linked against
<a href="https://www.zetetic.net/sqlcipher/" target="_blank" rel="noopener noreferrer">SQLCipher</a>
 instead of stock SQLite, so PHP applications
could use encrypted databases without changing their database driver code.</p>
<p>A key design decision that suits my needs but may not for you: the packages are <strong>drop-in
replacements</strong>. The driver name doesn't change - it's still <code>sqlite3</code> and <code>pdo_sqlite</code>.</p>
<p>The benefit is that applications that open an SQLite PDO connection don't need modification;
they just need to send <code>PRAGMA key</code> to enable SQLCipher encryption. Regular unencrypted SQLite
databases still work.</p>
<p>If instead you want it to install a <code>sqlcipher</code> driver name, you may have to make some tweaks.</p>
<p>Managing the compilation steps (and the building of the deb packages) used to be so painful,
I actively avoided it. But eventually I pulled my finger out, and now it is quite easy!</p>
<h2 id="the-build-pipeline">The build pipeline</h2>
<p>Because this is a security-focused package, the build process itself needed a security model.
The way I do it:</p>
<ul>
<li>Docker builds using the <a href="https://gvisor.dev" target="_blank" rel="noopener noreferrer">gvisor/runsc</a>
 hardened container runtime.</li>
<li>The actual compile and deb build steps run as an <strong>unprivileged user</strong> inside the container,
with <strong>no network access</strong> (<code>--network=none</code>). Network is only enabled to install build
dependencies.</li>
<li>The Docker daemon runs inside an ephemeral, disposable <a href="https://qubes-os.org" target="_blank" rel="noopener noreferrer">QubesOS</a>
 VM with
only outbound port 80/443 access.</li>
<li>The GPG signing key lives on a Yubikey, accessed via Qubes
<a href="https://doc.qubes-os.org/en/latest/user/security-in-qubes/split-gpg.html" target="_blank" rel="noopener noreferrer">split GPG</a>
 - the
build VM has no filesystem access to the private key.</li>
<li>Signing and APT repo preparation happen in a <strong>separate Qubes VM</strong> with <strong>no network access at
all</strong>.</li>
</ul>
<p>The <code>Dockerfile</code> uses a multi-stage build. The build stage installs dependencies, clones
SQLCipher at the target version tag, fetches the PHP source via <code>apt-get source</code>, then switches
to an unprivileged <code>builder</code> user to run <code>dpkg-buildpackage</code> and <code>lintian</code> with
<code>--network=none</code>. An <code>autopkgtest</code> run validates the package can actually open an encrypted
database. The final stage is <code>FROM scratch</code>, containing only the built <code>.deb</code>, <code>.buildinfo</code>, and
<code>.changes</code> files.</p>
<p><code>scripts/package.sh</code> orchestrates the matrix: at time of writing, it iterates over five base
images (Debian 13 trixie, Debian 12 bookworm, Ubuntu 26.04 resolute, Ubuntu 24.04 noble, Ubuntu
22.04 jammy) and seven PHP versions (7.4 through 8.5), building a tagged image for each combination
and exporting the artifacts to the host.</p>
<h2 id="why-it-matters">Why it matters</h2>
<p>SQLCipher gives you transparent, AES-256 encryption at the file level - the database is
encrypted on disk, and the key never touches storage. For PHP applications that already use
SQLite (and there are many), this is the cheapest path to encryption-at-rest without changing the
database driver or migrating to a server-based database.</p>
<p>The packages are published at <code>apt.mig5.net</code>, but the entire build process is in the open at
<a href="https://git.mig5.net/mig5/php-sqlcipher" target="_blank" rel="noopener noreferrer">git.mig5.net/mig5/php-sqlcipher</a>
. If you don't trust my
repository, you can build them yourself - that's the point.</p>
<p>Have fun adding encryption to your SQLite databases!</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Started: early 2013</li>
<li>PHP: 7.4, 8.0–8.5</li>
<li>Distros: Debian 12/13, Ubuntu 22.04/24.04/26.04</li>
<li>Build: Docker + gvisor/runsc in QubesOS</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://git.mig5.net/mig5/php-sqlcipher" target="_blank" rel="noopener noreferrer">Source repo</a>
</li>
<li><a href="https://git.mig5.net/mig5/pdo_sqlcipher" target="_blank" rel="noopener noreferrer">Predecessor (pdo_sqlcipher)</a>
</li>
<li><a href="https://www.zetetic.net/sqlcipher/" target="_blank" rel="noopener noreferrer">SQLCipher</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need SQLCipher?</div>
  <div class="smallprint mb-3">I can help with encryption-at-rest, packaging, and hardened build pipelines for your SQLite-based tools.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-database-lock"></i><span>php-sqlcipher</span></div>
    <h1 class="display-6 fw-semibold mb-2">Encrypted SQLite for PHP, done properly</h1>
    <p class="text-muted mb-3"><time datetime="2026-08-15">August 15, 2026</time></p>
    <p class="lead mb-0">From a sed-and-compile script to a hardened Docker-and-Qubes packaging pipeline tracking
SQLCipher across seven PHP versions and five Linux distributions.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-08-15T00:00:00&#43;10:00">15 August 2026</time>
        </div>

<h2 id="the-beginning">The beginning</h2>
<p>This project began in early 2013, originally for
<a href="https://mydex.org" target="_blank" rel="noopener noreferrer">Mydex Data Services CIC</a>
. The goal was simple: build PHP's <code>pdo_sqlite</code>
(and later <code>sqlite3</code>) extensions linked against
<a href="https://www.zetetic.net/sqlcipher/" target="_blank" rel="noopener noreferrer">SQLCipher</a>
 instead of stock SQLite, so PHP applications
could use encrypted databases without changing their database driver code.</p>
<p>A key design decision that suits my needs but may not for you: the packages are <strong>drop-in
replacements</strong>. The driver name doesn't change - it's still <code>sqlite3</code> and <code>pdo_sqlite</code>.</p>
<p>The benefit is that applications that open an SQLite PDO connection don't need modification;
they just need to send <code>PRAGMA key</code> to enable SQLCipher encryption. Regular unencrypted SQLite
databases still work.</p>
<p>If instead you want it to install a <code>sqlcipher</code> driver name, you may have to make some tweaks.</p>
<p>Managing the compilation steps (and the building of the deb packages) used to be so painful,
I actively avoided it. But eventually I pulled my finger out, and now it is quite easy!</p>
<h2 id="the-build-pipeline">The build pipeline</h2>
<p>Because this is a security-focused package, the build process itself needed a security model.
The way I do it:</p>
<ul>
<li>Docker builds using the <a href="https://gvisor.dev" target="_blank" rel="noopener noreferrer">gvisor/runsc</a>
 hardened container runtime.</li>
<li>The actual compile and deb build steps run as an <strong>unprivileged user</strong> inside the container,
with <strong>no network access</strong> (<code>--network=none</code>). Network is only enabled to install build
dependencies.</li>
<li>The Docker daemon runs inside an ephemeral, disposable <a href="https://qubes-os.org" target="_blank" rel="noopener noreferrer">QubesOS</a>
 VM with
only outbound port 80/443 access.</li>
<li>The GPG signing key lives on a Yubikey, accessed via Qubes
<a href="https://doc.qubes-os.org/en/latest/user/security-in-qubes/split-gpg.html" target="_blank" rel="noopener noreferrer">split GPG</a>
 - the
build VM has no filesystem access to the private key.</li>
<li>Signing and APT repo preparation happen in a <strong>separate Qubes VM</strong> with <strong>no network access at
all</strong>.</li>
</ul>
<p>The <code>Dockerfile</code> uses a multi-stage build. The build stage installs dependencies, clones
SQLCipher at the target version tag, fetches the PHP source via <code>apt-get source</code>, then switches
to an unprivileged <code>builder</code> user to run <code>dpkg-buildpackage</code> and <code>lintian</code> with
<code>--network=none</code>. An <code>autopkgtest</code> run validates the package can actually open an encrypted
database. The final stage is <code>FROM scratch</code>, containing only the built <code>.deb</code>, <code>.buildinfo</code>, and
<code>.changes</code> files.</p>
<p><code>scripts/package.sh</code> orchestrates the matrix: at time of writing, it iterates over five base
images (Debian 13 trixie, Debian 12 bookworm, Ubuntu 26.04 resolute, Ubuntu 24.04 noble, Ubuntu
22.04 jammy) and seven PHP versions (7.4 through 8.5), building a tagged image for each combination
and exporting the artifacts to the host.</p>
<h2 id="why-it-matters">Why it matters</h2>
<p>SQLCipher gives you transparent, AES-256 encryption at the file level - the database is
encrypted on disk, and the key never touches storage. For PHP applications that already use
SQLite (and there are many), this is the cheapest path to encryption-at-rest without changing the
database driver or migrating to a server-based database.</p>
<p>The packages are published at <code>apt.mig5.net</code>, but the entire build process is in the open at
<a href="https://git.mig5.net/mig5/php-sqlcipher" target="_blank" rel="noopener noreferrer">git.mig5.net/mig5/php-sqlcipher</a>
. If you don't trust my
repository, you can build them yourself - that's the point.</p>
<p>Have fun adding encryption to your SQLite databases!</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Started: early 2013</li>
<li>PHP: 7.4, 8.0–8.5</li>
<li>Distros: Debian 12/13, Ubuntu 22.04/24.04/26.04</li>
<li>Build: Docker + gvisor/runsc in QubesOS</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://git.mig5.net/mig5/php-sqlcipher" target="_blank" rel="noopener noreferrer">Source repo</a>
</li>
<li><a href="https://git.mig5.net/mig5/pdo_sqlcipher" target="_blank" rel="noopener noreferrer">Predecessor (pdo_sqlcipher)</a>
</li>
<li><a href="https://www.zetetic.net/sqlcipher/" target="_blank" rel="noopener noreferrer">SQLCipher</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need SQLCipher?</div>
  <div class="smallprint mb-3">I can help with encryption-at-rest, packaging, and hardened build pipelines for your SQLite-based tools.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>Why you should be using SysMD&#39;s GuardUtils</title>
      <link>https://mig5.net/news/guardutils/</link>
      <pubDate>Sat, 08 Aug 2026 00:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/guardutils/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-shield-lock"></i><span>GuardUtils</span></div>
    <h1 class="display-6 fw-semibold mb-2">Why you should be using SysMD&#39;s GuardUtils</h1>
    <p class="text-muted mb-3"><time datetime="2026-08-08">August 8, 2026</time></p>
    <p class="lead mb-0">Four small, safety-first command-line tools that each solve a classic sysadmin jam: botched
<code>chmod</code>, unbacked edits, unrecoverable <code>rm</code>, and dev cruft. By Marco D'Aleo, a colleague whose
work I trust and recommend.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-08-08T00:00:00&#43;10:00">8 August 2026</time>
        </div>

<h2 id="the-common-thread">The common thread</h2>
<p>Every sysadmin has a set of commands they run on muscle memory: <code>chmod</code>, <code>chown</code>, <code>rm</code>, editing
config files in place. They're the workhorses of the command line. They share one uncomfortable
property: they're irreversible. There's no <code>chmod --undo</code>. There's no <code>rm --restore</code>. There's no
way to get back the version of the file you had before you hit save in <code>vi</code> at 3 a.m.</p>
<p>Sure, you take backups (you <em>do</em>, right?) but those are only as useful as the intervals at which
they run. I don't know about you, but I make more than one mistake within a single window wherein
no backup ran to save me!</p>
<p>Enter GuardUtils: a suite of four small Python tools by
<a href="https://www.sysmd.uk/docs/guardutils/" target="_blank" rel="noopener noreferrer">SysMD</a>
 that each take one of those irreversible, mistake-prone
operations and make it reversible, previewable, or - at minimum - recoverable. They don't try to
replace the tools you know. They wrap them, or sit alongside them, with a safety net underneath.</p>
<h2 id="chguard-undo-for-chmod-and-chown">chguard: undo for chmod and chown</h2>
<p>The classic sysadmin nightmare: a recursive <code>chmod -R</code> or <code>chown -R</code> against the wrong tree, and
suddenly a service, a home directory, or <code>/etc</code> is hosed. Unix gives you no way back.</p>
<p>chguard snapshots filesystem ownership and permission bits before you act, previews any restore
as a colourised diff table, and applies changes only after explicit confirmation. Its standout
feature is wrapper mode - <code>chguard -- chmod 755 file</code> automatically snapshots the affected paths
<em>before</em> running the command, so a one-liner gives you a free rollback point. It never creates,
deletes, or moves files; it never follows symlinks; it refuses to run non-interactively without
an explicit <code>--yes</code>.</p>
<p>The best part of that 'wrapper' mode is it is easy to alias in your shell, so that your usual
<code>chown foo thing</code> becomes, under the hood, <code>chguard -- chown foo thing</code> automatically.</p>
<h2 id="mirro-the-editing-habit-you-should-already-have">mirro: the editing habit you should already have</h2>
<p>Marco puts it plainly: <em>&quot;have you ever been the 'ugh, I forgot to back this up first'
situation? No? Stop lying.&quot;</em> Every sysadmin has edited a config file in place and then wished
they had a copy of the original.</p>
<p>mirro wraps <code>$EDITOR</code> so that editing a file always goes through a temp-copy, compare,
backup-before-overwrite pipeline. You type <code>mirro /etc/fstab</code> instead of <code>vim /etc/fstab</code>. If you
change nothing, nothing happens - no spurious backup, no overwrite. If you do change something,
the original is saved to <code>~/.local/share/mirro/</code> with a timestamp <em>before</em> the target is
touched. Restore, diff, listing, and pruning are all built in. It's one extra character to type,
and it eliminates an entire class of regret.</p>
<h2 id="resrm-rm-that-you-can-take-back">resrm: rm that you can take back</h2>
<p>Desktop environments have had trash semantics for decades. The command line - where most server
file management actually happens - still has <code>rm</code>, and <code>rm</code> is a one-way door.</p>
<p>resrm is a drop-in replacement for <code>rm</code> that moves files to a per-user trash area instead of
unlinking them. The familiar flags (<code>-r</code>, <code>-f</code>, <code>-i</code>) behave as expected. You can list, inspect,
and restore trashed files by short ID or basename, with automatic pruning on a configurable
retention window. Permanent deletion is opt-in via <code>--skip-trash</code>. When run under <code>sudo</code>, it puts
files in their owner's trash, not root's - a small touch that makes natural
<code>sudo resrm ~alice/somefile</code> workflows work without root acting as intermediary.</p>
<h2 id="filedust-autoremove-for-files">filedust: autoremove for files</h2>
<p>The slow jam: <code>__pycache__</code> directories, <code>.pytest_cache</code>, <code>build/</code> and <code>dist/</code> outputs, Vim swap
files, <code>.DS_Store</code> - the cruft that silently fills a developer's home directory and clutters
searches and backups.</p>
<p>filedust scans under <code>$HOME</code> for unambiguous dev junk, renders a table showing exactly what it
found and why, asks one confirmation, and deletes files-then-directories. It refuses any path
outside <code>$HOME</code>, never follows symlinks, and treats user excludes as the always-wins escape
hatch. User-configured include patterns let you extend the built-in rules for your own workflow.
It's the cleanup pass you'd do by hand with <code>find</code> and <code>-delete</code>, except safe, previewable, and
repeatable.</p>
<p>I actually run filedust all the time. It's quite useful in build scripts and workflows where
you want to wipe out cruft before shipping something, or to stay conservative in terms of artifact
sizes.</p>
<h2 id="what-unites-them">What unites them</h2>
<p>The four tools share a design philosophy that is really why I wanted to write about it, because
I think it's rare in small CLI utilities:</p>
<ul>
<li><strong>Reversible by default, irreversible on demand.</strong> Trash, not unlink. Snapshot, not blind
apply. Backup, not overwrite. The dangerous path is always opt-in.</li>
<li><strong>Preview before mutate.</strong> Every tool shows you what it will do before it does it. <code>--dry-run</code>
and confirmation prompts are first-class, not afterthoughts.</li>
<li><strong>Fail-closed.</strong> If something can't be done safely, the tool refuses rather than guessing. No
TTY? Abort. No write access? Abort, and let the operator decide on <code>sudo</code>.</li>
<li><strong>Never auto-escalate.</strong> None of these tools will run <code>sudo</code> for you. If elevated privileges
are needed, they tell you and let you re-run. The operator stays in control.</li>
<li><strong>Narrow scope.</strong> Each tool does one thing and explicitly documents what it does <em>not</em> do.</li>
</ul>
<h2 id="marco-the-magnificent">Marco the magnificent</h2>
<p>GuardUtils is the work of Marco D'Aleo, who runs
<a href="https://sysadmin.marcodaleo.com" target="_blank" rel="noopener noreferrer">MD Sysadmin</a>
. Full disclosure! Marco is a colleague and another competent
sysadmin consultant, who I've had the pleasure of mentoring and teaching a lot about 'my way' (yet
he has developed his <em>own</em> way, which is a great thing). He has my backing for anyone who needs
a contract sysadmin in the European timezone and is happy to trust someone I trust.</p>
<p>The GuardUtils project reflects the person: careful, precise, and honest.</p>
<h2 id="where-to-find-them">Where to find them</h2>
<p>The full suite - source, packages, and documentation - is at
<a href="https://www.sysmd.uk/docs/guardutils/" target="_blank" rel="noopener noreferrer">sysmd.uk</a>
. If you spend your day on the command line,
install all four. The cost is a few megabytes of Python. The upside is never losing a file, a
permission set, or a pre-edit config again!</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Author: Marco D'Aleo (SysMD)</li>
<li><strong>chguard</strong> - undo for chmod/chown</li>
<li><strong>mirro</strong> - safe file editing with backups</li>
<li><strong>resrm</strong> - rm with trash and restore</li>
<li><strong>filedust</strong> - dev cruft cleaner</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://www.sysmd.uk/docs/guardutils/" target="_blank" rel="noopener noreferrer">GuardUtils (SysMD)</a>
</li>
<li><a href="https://sysadmin.marcodaleo.com/" target="_blank" rel="noopener noreferrer">MD Sysadmin consultancy</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need a sysadmin in Europe?</div>
  <div class="smallprint mb-3">If you need someone in the European timezone, I can introduce you to Marco, or visit his site!</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-shield-lock"></i><span>GuardUtils</span></div>
    <h1 class="display-6 fw-semibold mb-2">Why you should be using SysMD&#39;s GuardUtils</h1>
    <p class="text-muted mb-3"><time datetime="2026-08-08">August 8, 2026</time></p>
    <p class="lead mb-0">Four small, safety-first command-line tools that each solve a classic sysadmin jam: botched
<code>chmod</code>, unbacked edits, unrecoverable <code>rm</code>, and dev cruft. By Marco D'Aleo, a colleague whose
work I trust and recommend.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-08-08T00:00:00&#43;10:00">8 August 2026</time>
        </div>

<h2 id="the-common-thread">The common thread</h2>
<p>Every sysadmin has a set of commands they run on muscle memory: <code>chmod</code>, <code>chown</code>, <code>rm</code>, editing
config files in place. They're the workhorses of the command line. They share one uncomfortable
property: they're irreversible. There's no <code>chmod --undo</code>. There's no <code>rm --restore</code>. There's no
way to get back the version of the file you had before you hit save in <code>vi</code> at 3 a.m.</p>
<p>Sure, you take backups (you <em>do</em>, right?) but those are only as useful as the intervals at which
they run. I don't know about you, but I make more than one mistake within a single window wherein
no backup ran to save me!</p>
<p>Enter GuardUtils: a suite of four small Python tools by
<a href="https://www.sysmd.uk/docs/guardutils/" target="_blank" rel="noopener noreferrer">SysMD</a>
 that each take one of those irreversible, mistake-prone
operations and make it reversible, previewable, or - at minimum - recoverable. They don't try to
replace the tools you know. They wrap them, or sit alongside them, with a safety net underneath.</p>
<h2 id="chguard-undo-for-chmod-and-chown">chguard: undo for chmod and chown</h2>
<p>The classic sysadmin nightmare: a recursive <code>chmod -R</code> or <code>chown -R</code> against the wrong tree, and
suddenly a service, a home directory, or <code>/etc</code> is hosed. Unix gives you no way back.</p>
<p>chguard snapshots filesystem ownership and permission bits before you act, previews any restore
as a colourised diff table, and applies changes only after explicit confirmation. Its standout
feature is wrapper mode - <code>chguard -- chmod 755 file</code> automatically snapshots the affected paths
<em>before</em> running the command, so a one-liner gives you a free rollback point. It never creates,
deletes, or moves files; it never follows symlinks; it refuses to run non-interactively without
an explicit <code>--yes</code>.</p>
<p>The best part of that 'wrapper' mode is it is easy to alias in your shell, so that your usual
<code>chown foo thing</code> becomes, under the hood, <code>chguard -- chown foo thing</code> automatically.</p>
<h2 id="mirro-the-editing-habit-you-should-already-have">mirro: the editing habit you should already have</h2>
<p>Marco puts it plainly: <em>&quot;have you ever been the 'ugh, I forgot to back this up first'
situation? No? Stop lying.&quot;</em> Every sysadmin has edited a config file in place and then wished
they had a copy of the original.</p>
<p>mirro wraps <code>$EDITOR</code> so that editing a file always goes through a temp-copy, compare,
backup-before-overwrite pipeline. You type <code>mirro /etc/fstab</code> instead of <code>vim /etc/fstab</code>. If you
change nothing, nothing happens - no spurious backup, no overwrite. If you do change something,
the original is saved to <code>~/.local/share/mirro/</code> with a timestamp <em>before</em> the target is
touched. Restore, diff, listing, and pruning are all built in. It's one extra character to type,
and it eliminates an entire class of regret.</p>
<h2 id="resrm-rm-that-you-can-take-back">resrm: rm that you can take back</h2>
<p>Desktop environments have had trash semantics for decades. The command line - where most server
file management actually happens - still has <code>rm</code>, and <code>rm</code> is a one-way door.</p>
<p>resrm is a drop-in replacement for <code>rm</code> that moves files to a per-user trash area instead of
unlinking them. The familiar flags (<code>-r</code>, <code>-f</code>, <code>-i</code>) behave as expected. You can list, inspect,
and restore trashed files by short ID or basename, with automatic pruning on a configurable
retention window. Permanent deletion is opt-in via <code>--skip-trash</code>. When run under <code>sudo</code>, it puts
files in their owner's trash, not root's - a small touch that makes natural
<code>sudo resrm ~alice/somefile</code> workflows work without root acting as intermediary.</p>
<h2 id="filedust-autoremove-for-files">filedust: autoremove for files</h2>
<p>The slow jam: <code>__pycache__</code> directories, <code>.pytest_cache</code>, <code>build/</code> and <code>dist/</code> outputs, Vim swap
files, <code>.DS_Store</code> - the cruft that silently fills a developer's home directory and clutters
searches and backups.</p>
<p>filedust scans under <code>$HOME</code> for unambiguous dev junk, renders a table showing exactly what it
found and why, asks one confirmation, and deletes files-then-directories. It refuses any path
outside <code>$HOME</code>, never follows symlinks, and treats user excludes as the always-wins escape
hatch. User-configured include patterns let you extend the built-in rules for your own workflow.
It's the cleanup pass you'd do by hand with <code>find</code> and <code>-delete</code>, except safe, previewable, and
repeatable.</p>
<p>I actually run filedust all the time. It's quite useful in build scripts and workflows where
you want to wipe out cruft before shipping something, or to stay conservative in terms of artifact
sizes.</p>
<h2 id="what-unites-them">What unites them</h2>
<p>The four tools share a design philosophy that is really why I wanted to write about it, because
I think it's rare in small CLI utilities:</p>
<ul>
<li><strong>Reversible by default, irreversible on demand.</strong> Trash, not unlink. Snapshot, not blind
apply. Backup, not overwrite. The dangerous path is always opt-in.</li>
<li><strong>Preview before mutate.</strong> Every tool shows you what it will do before it does it. <code>--dry-run</code>
and confirmation prompts are first-class, not afterthoughts.</li>
<li><strong>Fail-closed.</strong> If something can't be done safely, the tool refuses rather than guessing. No
TTY? Abort. No write access? Abort, and let the operator decide on <code>sudo</code>.</li>
<li><strong>Never auto-escalate.</strong> None of these tools will run <code>sudo</code> for you. If elevated privileges
are needed, they tell you and let you re-run. The operator stays in control.</li>
<li><strong>Narrow scope.</strong> Each tool does one thing and explicitly documents what it does <em>not</em> do.</li>
</ul>
<h2 id="marco-the-magnificent">Marco the magnificent</h2>
<p>GuardUtils is the work of Marco D'Aleo, who runs
<a href="https://sysadmin.marcodaleo.com" target="_blank" rel="noopener noreferrer">MD Sysadmin</a>
. Full disclosure! Marco is a colleague and another competent
sysadmin consultant, who I've had the pleasure of mentoring and teaching a lot about 'my way' (yet
he has developed his <em>own</em> way, which is a great thing). He has my backing for anyone who needs
a contract sysadmin in the European timezone and is happy to trust someone I trust.</p>
<p>The GuardUtils project reflects the person: careful, precise, and honest.</p>
<h2 id="where-to-find-them">Where to find them</h2>
<p>The full suite - source, packages, and documentation - is at
<a href="https://www.sysmd.uk/docs/guardutils/" target="_blank" rel="noopener noreferrer">sysmd.uk</a>
. If you spend your day on the command line,
install all four. The cost is a few megabytes of Python. The upside is never losing a file, a
permission set, or a pre-edit config again!</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Author: Marco D'Aleo (SysMD)</li>
<li><strong>chguard</strong> - undo for chmod/chown</li>
<li><strong>mirro</strong> - safe file editing with backups</li>
<li><strong>resrm</strong> - rm with trash and restore</li>
<li><strong>filedust</strong> - dev cruft cleaner</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://www.sysmd.uk/docs/guardutils/" target="_blank" rel="noopener noreferrer">GuardUtils (SysMD)</a>
</li>
<li><a href="https://sysadmin.marcodaleo.com/" target="_blank" rel="noopener noreferrer">MD Sysadmin consultancy</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Need a sysadmin in Europe?</div>
  <div class="smallprint mb-3">If you need someone in the European timezone, I can introduce you to Marco, or visit his site!</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>CSPresso: brewing Content Security Policies from real page loads</title>
      <link>https://mig5.net/news/cspresso-history/</link>
      <pubDate>Sat, 01 Aug 2026 00:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/cspresso-history/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-shield-check"></i><span>CSPresso</span></div>
    <h1 class="display-6 fw-semibold mb-2">Brewing Content Security Policies from real page loads</h1>
    <p class="text-muted mb-3"><time datetime="2026-08-01">August 1, 2026</time></p>
    <p class="lead mb-0">cspresso uses Playwright's headless Chromium to crawl a site, observe what loads, and emit a
draft Content-Security-Policy header. Over its first few releases it gained CSP bypass, policy
evaluation, and non-HTML filtering.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-08-01T00:00:00&#43;10:00">1 August 2026</time>
        </div>

<h2 id="the-csp-problem">The CSP problem</h2>
<p>Content Security Policy is one of the most effective browser-side defences against XSS and data
exfiltration, but it's notoriously fiddly to get right. You need to know every origin your app
loads from - scripts, styles, images, fonts, frames, WebSocket endpoints, API calls. Miss one
and the policy blocks legitimate traffic; add <code>'unsafe-inline'</code> and you've defeated the point.</p>
<p>There are tools out there (and baked into the CSP standard itself) to run it in 'report-only'
mode and see what violations occur. That's still definitely a good idea, but it means you've
needed to know where to start. It can also take a lot of time to analyse the results and determine
what to do next to 'fix' a CSP that was never right to begin with.</p>
<p>The honest way to discover what your app loads is to let a real browser execute it and watch.
That's what cspresso does. It launches headless Chromium via
<a href="https://playwright.dev" target="_blank" rel="noopener noreferrer">Playwright</a>
, crawls same-origin links up to a page limit, and distils
the observed origins into CSP directives. The output is a draft - a starting point to review
and tighten before enforcing.</p>
<h2 id="the-beginning">The beginning</h2>
<p>The first release was simple: launch Chromium, crawl, print a CSP header. Playwright's browser
binaries were auto-installed if missing. A <code>--ignore-non-html</code> option followed quickly, to skip
pages that weren't HTML and could pollute the output.</p>
<h2 id="what-makes-it-useful">What makes it useful</h2>
<p>Two features have since turned cspresso from a crawler into an actually useful CSP rollout tool:</p>
<ul>
<li><strong><code>--bypass-csp</code></strong> strips any existing CSP or CSP-Report-Only headers from the crawled HTML, so
an already-enforcing policy doesn't skew the results. This lets you re-discover the full set of
origins your app <em>could</em> load, not just those your current policy permits.</li>
<li><strong><code>--evaluate CSP</code></strong> injects a proposed CSP as <code>Content-Security-Policy-Report-Only</code> on
crawled documents, reports any violations, and exits with code 1 if violations are detected.
This turns cspresso into a CI-checkable tool: propose a policy, evaluate it against your live
site, fail the pipeline if it would break anything.</li>
</ul>
<p>The recommendation is to use them together: <code>--bypass-csp --evaluate</code> so the evaluation isn't
influenced by the existing enforcement.</p>
<p>Keep in mind the security implications of <code>--bypass-csp</code>: if your site is compromised, bypassing
the CSP and executing the page in a browser - even headless - could evaluate malicious JavaScript
or CSS. This is a tool for analysing your own sites, not for pointing at untrusted URLs.</p>
<h2 id="correctness-and-polish">Correctness and polish</h2>
<p>A few bugs have needed fixing over time to date. The <code>--evaluate</code> mode was initially injecting the
proposed CSP into third-party domains as well, causing them to throw violations and trip the
result - the fix ensured the proposed policy was only applied to same-origin documents. The wait
condition was also relaxed: instead of waiting for <code>networkidle</code> (which some pages never reach),
cspresso now proceeds if the <code>load</code> event fires, even if <code>networkidle</code> doesn't arrive. This
fixed hangs on pages with persistent connections or long-polling.</p>
<h2 id="where-it-stands-today">Where it stands today</h2>
<p>cspresso is a small, focused tool. It doesn't try to be a full CSP linter or a security scanner.
It does one thing well: lets a real browser show you what your app loads, then turns that into a
policy you can review. The <code>--evaluate</code> mode makes it CI-friendly for staged CSP rollouts.</p>
<p>I have to admit I don't spend a lot of time developing cspresso compared to some of my other tools.
It probably deserves some more love!</p>
<p>If nerding out over CSPs is your thing, I also recommend having a look at Cory Myers'
<a href="https://github.com/cfm/webcat-csp-soundness" target="_blank" rel="noopener noreferrer">webcat-csp-soundness</a>
 project, and more generally
at <a href="https://webcat.tech" target="_blank" rel="noopener noreferrer">WEBCAT</a>
 itself.</p>
<p>The source and full changelog are at
<a href="https://git.mig5.net/mig5/cspresso" target="_blank" rel="noopener noreferrer">git.mig5.net/mig5/cspresso</a>
, with documentation at
<a href="https://cspresso.cafe" target="_blank" rel="noopener noreferrer">cspresso.cafe</a>
.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Language: Python</li>
<li>Browser: headless Chromium (Playwright)</li>
<li>Distribution: pip/pipx, AppImage</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://cspresso.cafe" target="_blank" rel="noopener noreferrer">Website / docs</a>
</li>
<li><a href="https://git.mig5.net/mig5/cspresso" target="_blank" rel="noopener noreferrer">Source repo</a>
</li>
<li><a href="https://pypi.org/project/cspresso/" target="_blank" rel="noopener noreferrer">PyPI</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Rolling out CSP?</div>
  <div class="smallprint mb-3">I can help you roll out CSP safely with monitoring, reporting, and staged enforcement.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-shield-check"></i><span>CSPresso</span></div>
    <h1 class="display-6 fw-semibold mb-2">Brewing Content Security Policies from real page loads</h1>
    <p class="text-muted mb-3"><time datetime="2026-08-01">August 1, 2026</time></p>
    <p class="lead mb-0">cspresso uses Playwright's headless Chromium to crawl a site, observe what loads, and emit a
draft Content-Security-Policy header. Over its first few releases it gained CSP bypass, policy
evaluation, and non-HTML filtering.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-08-01T00:00:00&#43;10:00">1 August 2026</time>
        </div>

<h2 id="the-csp-problem">The CSP problem</h2>
<p>Content Security Policy is one of the most effective browser-side defences against XSS and data
exfiltration, but it's notoriously fiddly to get right. You need to know every origin your app
loads from - scripts, styles, images, fonts, frames, WebSocket endpoints, API calls. Miss one
and the policy blocks legitimate traffic; add <code>'unsafe-inline'</code> and you've defeated the point.</p>
<p>There are tools out there (and baked into the CSP standard itself) to run it in 'report-only'
mode and see what violations occur. That's still definitely a good idea, but it means you've
needed to know where to start. It can also take a lot of time to analyse the results and determine
what to do next to 'fix' a CSP that was never right to begin with.</p>
<p>The honest way to discover what your app loads is to let a real browser execute it and watch.
That's what cspresso does. It launches headless Chromium via
<a href="https://playwright.dev" target="_blank" rel="noopener noreferrer">Playwright</a>
, crawls same-origin links up to a page limit, and distils
the observed origins into CSP directives. The output is a draft - a starting point to review
and tighten before enforcing.</p>
<h2 id="the-beginning">The beginning</h2>
<p>The first release was simple: launch Chromium, crawl, print a CSP header. Playwright's browser
binaries were auto-installed if missing. A <code>--ignore-non-html</code> option followed quickly, to skip
pages that weren't HTML and could pollute the output.</p>
<h2 id="what-makes-it-useful">What makes it useful</h2>
<p>Two features have since turned cspresso from a crawler into an actually useful CSP rollout tool:</p>
<ul>
<li><strong><code>--bypass-csp</code></strong> strips any existing CSP or CSP-Report-Only headers from the crawled HTML, so
an already-enforcing policy doesn't skew the results. This lets you re-discover the full set of
origins your app <em>could</em> load, not just those your current policy permits.</li>
<li><strong><code>--evaluate CSP</code></strong> injects a proposed CSP as <code>Content-Security-Policy-Report-Only</code> on
crawled documents, reports any violations, and exits with code 1 if violations are detected.
This turns cspresso into a CI-checkable tool: propose a policy, evaluate it against your live
site, fail the pipeline if it would break anything.</li>
</ul>
<p>The recommendation is to use them together: <code>--bypass-csp --evaluate</code> so the evaluation isn't
influenced by the existing enforcement.</p>
<p>Keep in mind the security implications of <code>--bypass-csp</code>: if your site is compromised, bypassing
the CSP and executing the page in a browser - even headless - could evaluate malicious JavaScript
or CSS. This is a tool for analysing your own sites, not for pointing at untrusted URLs.</p>
<h2 id="correctness-and-polish">Correctness and polish</h2>
<p>A few bugs have needed fixing over time to date. The <code>--evaluate</code> mode was initially injecting the
proposed CSP into third-party domains as well, causing them to throw violations and trip the
result - the fix ensured the proposed policy was only applied to same-origin documents. The wait
condition was also relaxed: instead of waiting for <code>networkidle</code> (which some pages never reach),
cspresso now proceeds if the <code>load</code> event fires, even if <code>networkidle</code> doesn't arrive. This
fixed hangs on pages with persistent connections or long-polling.</p>
<h2 id="where-it-stands-today">Where it stands today</h2>
<p>cspresso is a small, focused tool. It doesn't try to be a full CSP linter or a security scanner.
It does one thing well: lets a real browser show you what your app loads, then turns that into a
policy you can review. The <code>--evaluate</code> mode makes it CI-friendly for staged CSP rollouts.</p>
<p>I have to admit I don't spend a lot of time developing cspresso compared to some of my other tools.
It probably deserves some more love!</p>
<p>If nerding out over CSPs is your thing, I also recommend having a look at Cory Myers'
<a href="https://github.com/cfm/webcat-csp-soundness" target="_blank" rel="noopener noreferrer">webcat-csp-soundness</a>
 project, and more generally
at <a href="https://webcat.tech" target="_blank" rel="noopener noreferrer">WEBCAT</a>
 itself.</p>
<p>The source and full changelog are at
<a href="https://git.mig5.net/mig5/cspresso" target="_blank" rel="noopener noreferrer">git.mig5.net/mig5/cspresso</a>
, with documentation at
<a href="https://cspresso.cafe" target="_blank" rel="noopener noreferrer">cspresso.cafe</a>
.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Language: Python</li>
<li>Browser: headless Chromium (Playwright)</li>
<li>Distribution: pip/pipx, AppImage</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://cspresso.cafe" target="_blank" rel="noopener noreferrer">Website / docs</a>
</li>
<li><a href="https://git.mig5.net/mig5/cspresso" target="_blank" rel="noopener noreferrer">Source repo</a>
</li>
<li><a href="https://pypi.org/project/cspresso/" target="_blank" rel="noopener noreferrer">PyPI</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Rolling out CSP?</div>
  <div class="smallprint mb-3">I can help you roll out CSP safely with monitoring, reporting, and staged enforcement.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>Wiring Enroll drift detection to Signal</title>
      <link>https://mig5.net/news/enroll-diff-webhook/</link>
      <pubDate>Sat, 25 Jul 2026 00:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/enroll-diff-webhook/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-bell"></i><span>Node-RED</span></div>
    <h1 class="display-6 fw-semibold mb-2">Wiring Enroll drift detection to Signal</h1>
    <p class="text-muted mb-3"><time datetime="2026-07-25">July 25, 2026</time></p>
    <p class="lead mb-0">A small Node-RED webhook that catches Enroll diff payloads, formats the changes into a
readable summary, and pushes it to your phone via Signal.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-07-25T00:00:00&#43;10:00">25 July 2026</time>
        </div>

<h2 id="when-a-server-drifts">When a server drifts</h2>
<p><a href="/news/enroll-history/">Enroll</a>
 can diff two harvests of the same host and tell you
exactly what changed: packages added or removed, users modified, services stopped or started,
config files altered. That's useful as a command you run manually. It's more useful as
something that happens automatically and tells you about it.</p>
<p><a href="https://nodered.org/" target="_blank" rel="noopener noreferrer">Node-RED</a>
 is a really fun way to develop automation workflows with
an open-source project that isn't 'open core' (unlike n8n). It's a little crusty at times
compared to those newer, shiner solutions, but it's totally free.</p>
<p>Node-RED, like n8n and others, lets you define HTTP 'ingress' listeners and then bolt on
the automation flows that occur behind that entry point. It's a perfect use case for webhooks,
including the sort that Enroll's <code>diff</code> command supports.</p>
<p>The idea is simple. Run Enroll's <code>diff</code> on a schedule, point the output at a webhook, and get a
notification on your phone the moment a server drifts. No inbox to check, no dashboard to
visit - just a Signal message that says &quot;these things changed on this host.&quot;</p>
<div class="mermaid">

sequenceDiagram
    participant E as Enroll
    participant N as Node-RED
    participant S as signal-cli-rest-api
    participant P as Phone (Signal)
    E->>N: POST webhook (diff JSON, X-Enroll-Secret)
    N->>N: Verify X-Enroll-Secret header
    N->>N: Parse and format (max 2000 chars)
    par 201 response
        N-->>E: 201 Created
    and Signal send (fire-and-forget)
        N->>S: POST /v2/send
        S-->>P: Signal message
    end

</div>

<h2 id="the-webhook-endpoint">The webhook endpoint</h2>
<p>The flow starts with an HTTP input node listening for <code>POST</code> on a long, unguessable
UUID-based URL. Enroll sends its diff payload as JSON to this endpoint, along with an
<code>X-Enroll-Secret</code> header. The URL itself is the first layer of authentication - it's a
capability URL, not a password. If you don't know the URL, you can't send to it.</p>
<p>When a payload arrives, the first stop is a function node called &quot;Verify X-Enroll-Secret&quot;
that checks the header against the flow's environment variable of the same name. If the
header is missing or doesn't match, the flow returns <code>401 Unauthorized</code> and stops - the
payload never reaches the formatter. Only valid requests continue to the &quot;Parse&quot; function
that does the real work: turning the structured diff into a human-readable summary.</p>
<h2 id="formatting-the-diff">Formatting the diff</h2>
<p>An Enroll diff can be large. A server might have dozens of changed packages, several modified
users, and a handful of service changes - all in one payload. Signal messages are not the place
for a firehose.</p>
<p>The Parse function enforces two limits: <code>MAX_CHARS</code> at 2000 and
<code>MAX_ITEMS</code> at 25. Lists are truncated and annotated with &quot;… (+<em>N</em>
more)&quot; so you know something was cut. The formatting is terse and scannable:</p>
<p>Changed users are listed by name with the changed keys in parentheses - <code>alice (shell, groups); bob (password)</code> - so you can see at a glance who changed and how. Changed
services follow the same pattern: <code>sshd (enabled); cron (status)</code>. Package lists are
joined with commas and truncated. The result is a summary you can read in a few seconds, not a
JSON document you have to parse.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gdscript3" data-lang="gdscript3"><span class="line"><span class="cl"><span class="k">const</span> <span class="n">r</span> <span class="o">=</span> <span class="n">msg</span><span class="o">.</span><span class="n">payload</span> <span class="o">||</span> <span class="p">{};</span>
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">MAX_CHARS</span> <span class="o">=</span> <span class="mi">2000</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">MAX_ITEMS</span> <span class="o">=</span> <span class="mi">25</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">function</span> <span class="n">formatList</span><span class="p">(</span><span class="n">title</span><span class="p">,</span> <span class="n">items</span><span class="p">,</span> <span class="n">prefix</span> <span class="o">=</span> <span class="s2">&#34;• &#34;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">items</span> <span class="o">=</span> <span class="p">(</span><span class="ne">Array</span><span class="o">.</span><span class="n">isArray</span><span class="p">(</span><span class="n">items</span><span class="p">)</span> <span class="err">?</span> <span class="n">items</span> <span class="p">:</span> <span class="p">[])</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="n">s</span> <span class="o">=&gt;</span> <span class="n">s</span> <span class="err">??</span> <span class="s2">&#34;&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span><span class="n">Boolean</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">items</span><span class="o">.</span><span class="n">length</span> <span class="o">===</span> <span class="mi">0</span><span class="p">)</span> <span class="k">return</span> <span class="s2">&#34;&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="k">const</span> <span class="n">shown</span> <span class="o">=</span> <span class="n">items</span><span class="o">.</span><span class="n">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="n">MAX_ITEMS</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">const</span> <span class="n">more</span> <span class="o">=</span> <span class="n">items</span><span class="o">.</span><span class="n">length</span> <span class="o">-</span> <span class="n">shown</span><span class="o">.</span><span class="n">length</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="n">let</span> <span class="n">line</span> <span class="o">=</span> <span class="err">`</span><span class="o">$</span><span class="p">{</span><span class="n">title</span><span class="p">}:</span> <span class="o">$</span><span class="p">{</span><span class="n">shown</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="s2">&#34;, &#34;</span><span class="p">)}</span><span class="err">`</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">more</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">)</span> <span class="n">line</span> <span class="o">+=</span> <span class="err">`</span> <span class="err">…</span> <span class="p">(</span><span class="o">+$</span><span class="p">{</span><span class="n">more</span><span class="p">}</span> <span class="n">more</span><span class="p">)</span><span class="err">`</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">line</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">function</span> <span class="n">formatChangedUsers</span><span class="p">(</span><span class="n">changed</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">changed</span> <span class="o">=</span> <span class="ne">Array</span><span class="o">.</span><span class="n">isArray</span><span class="p">(</span><span class="n">changed</span><span class="p">)</span> <span class="err">?</span> <span class="n">changed</span> <span class="p">:</span> <span class="p">[];</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">changed</span><span class="o">.</span><span class="n">length</span> <span class="o">===</span> <span class="mi">0</span><span class="p">)</span> <span class="k">return</span> <span class="s2">&#34;&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="k">const</span> <span class="n">shown</span> <span class="o">=</span> <span class="n">changed</span><span class="o">.</span><span class="n">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="n">MAX_ITEMS</span><span class="p">)</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="n">u</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">const</span> <span class="n">name</span> <span class="o">=</span> <span class="n">u</span><span class="o">.</span><span class="n">name</span> <span class="err">??</span> <span class="s2">&#34;&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="k">const</span> <span class="n">keys</span> <span class="o">=</span> <span class="ne">Object</span><span class="o">.</span><span class="n">keys</span><span class="p">(</span><span class="n">u</span><span class="o">.</span><span class="n">changes</span> <span class="o">||</span> <span class="p">{});</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">keys</span><span class="o">.</span><span class="n">length</span> <span class="err">?</span> <span class="err">`</span><span class="o">$</span><span class="p">{</span><span class="n">name</span><span class="p">}</span> <span class="p">(</span><span class="o">$</span><span class="p">{</span><span class="n">keys</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="s2">&#34;, &#34;</span><span class="p">)})</span><span class="err">`</span> <span class="p">:</span> <span class="n">name</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="p">});</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="err">`</span><span class="n">Users</span> <span class="n">changed</span><span class="p">:</span> <span class="o">$</span><span class="p">{</span><span class="n">shown</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="s2">&#34;; &#34;</span><span class="p">)}</span><span class="err">`</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">function</span> <span class="n">trimToMax</span><span class="p">(</span><span class="n">text</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">text</span><span class="o">.</span><span class="n">length</span> <span class="o">&lt;=</span> <span class="n">MAX_CHARS</span><span class="p">)</span> <span class="k">return</span> <span class="n">text</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">text</span><span class="o">.</span><span class="n">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="n">MAX_CHARS</span> <span class="o">-</span> <span class="mi">20</span><span class="p">)</span><span class="o">.</span><span class="n">trimEnd</span><span class="p">()</span> <span class="o">+</span> <span class="s2">&#34;</span><span class="se">\n</span><span class="s2">…(truncated)&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="o">//</span> <span class="n">Build</span> <span class="n">the</span> <span class="n">summary</span> <span class="n">lines</span>
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">lines</span> <span class="o">=</span> <span class="p">[</span><span class="err">`</span><span class="n">enroll</span> <span class="n">diff</span> <span class="err">@</span> <span class="o">$</span><span class="p">{</span><span class="n">r</span><span class="o">.</span><span class="n">new</span><span class="err">?</span><span class="o">.</span><span class="n">host</span> <span class="o">||</span> <span class="n">r</span><span class="o">.</span><span class="n">old</span><span class="err">?</span><span class="o">.</span><span class="n">host</span> <span class="o">||</span> <span class="s2">&#34;&#34;</span><span class="p">}</span><span class="err">`</span><span class="p">];</span>
</span></span><span class="line"><span class="cl"><span class="n">lines</span><span class="o">.</span><span class="n">push</span><span class="p">(</span><span class="n">formatList</span><span class="p">(</span><span class="s2">&#34;Packages added&#34;</span><span class="p">,</span> <span class="n">r</span><span class="o">.</span><span class="n">packages</span><span class="err">?</span><span class="o">.</span><span class="n">added</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="n">lines</span><span class="o">.</span><span class="n">push</span><span class="p">(</span><span class="n">formatList</span><span class="p">(</span><span class="s2">&#34;Packages removed&#34;</span><span class="p">,</span> <span class="n">r</span><span class="o">.</span><span class="n">packages</span><span class="err">?</span><span class="o">.</span><span class="n">removed</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="n">lines</span><span class="o">.</span><span class="n">push</span><span class="p">(</span><span class="n">formatList</span><span class="p">(</span><span class="s2">&#34;Services enabled +&#34;</span><span class="p">,</span> <span class="n">r</span><span class="o">.</span><span class="n">services</span><span class="err">?</span><span class="o">.</span><span class="n">enabled_added</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="n">lines</span><span class="o">.</span><span class="n">push</span><span class="p">(</span><span class="n">formatChangedUsers</span><span class="p">(</span><span class="n">r</span><span class="o">.</span><span class="n">users</span><span class="err">?</span><span class="o">.</span><span class="n">changed</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="n">lines</span><span class="o">.</span><span class="n">push</span><span class="p">(</span><span class="n">formatList</span><span class="p">(</span><span class="s2">&#34;Files added&#34;</span><span class="p">,</span> <span class="n">r</span><span class="o">.</span><span class="n">files</span><span class="err">?</span><span class="o">.</span><span class="n">added</span><span class="p">));</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="o">//</span> <span class="n">Join</span> <span class="n">non</span><span class="o">-</span><span class="n">empty</span> <span class="n">lines</span><span class="p">,</span> <span class="n">cap</span> <span class="n">at</span> <span class="mi">2000</span> <span class="n">chars</span>
</span></span><span class="line"><span class="cl"><span class="n">msg</span><span class="o">.</span><span class="n">payload</span> <span class="o">=</span> <span class="n">trimToMax</span><span class="p">(</span><span class="n">lines</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span><span class="n">s</span> <span class="o">=&gt;</span> <span class="n">s</span><span class="err">?</span><span class="o">.</span><span class="n">trim</span><span class="p">())</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="s2">&#34;</span><span class="se">\n</span><span class="s2">&#34;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="k">return</span> <span class="n">msg</span><span class="p">;</span>
</span></span></code></pre></div><h2 id="notification-via-signal">Notification via Signal</h2>
<p>The formatted summary is wrapped into a Signal message and posted to
<code>http://signal_cli:8080/v2/send</code> - the same REST endpoint used by the
<a href="/news/signal-ai-assistant/">Signal AI assistant</a>
. The message goes out from the
bot's number to a hardcoded recipient. No email, no Slack webhook, no dashboard - just Signal,
which means it shows up on your phone the same way any message does.</p>
<h2 id="the-201-response">The 201 response</h2>
<p>The HTTP response is sent immediately - <code>201 Created</code> - in parallel with
the Signal send. Enroll doesn't wait for the Signal message to be delivered; it gets an
acknowledgement as soon as the webhook receives the payload. The Signal send is fire-and-forget,
monitored only by debug nodes. This is the right design: the webhook's job is to receive and
acknowledge, not to block on a third-party notification service.</p>
<h2 id="shared-secret-authentication">Shared-secret authentication</h2>
<p>The webhook uses two layers of authentication: the unguessable capability URL and a shared
secret sent in the <code>X-Enroll-Secret</code> header. The verify function is a two-output function
node - valid requests go out port 1 to the Parse function, invalid requests go out port 2 to
an HTTP response node that returns <code>401</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gdscript3" data-lang="gdscript3"><span class="line"><span class="cl"><span class="k">const</span> <span class="n">expected</span> <span class="o">=</span> <span class="n">env</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;X-Enroll-Secret&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">received</span> <span class="o">=</span> <span class="n">msg</span><span class="o">.</span><span class="n">req</span> <span class="o">&amp;&amp;</span> <span class="n">msg</span><span class="o">.</span><span class="n">req</span><span class="o">.</span><span class="n">headers</span> <span class="err">?</span> <span class="n">msg</span><span class="o">.</span><span class="n">req</span><span class="o">.</span><span class="n">headers</span><span class="p">[</span><span class="s2">&#34;x-enroll-secret&#34;</span><span class="p">]</span> <span class="p">:</span> <span class="n">null</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">expected</span> <span class="o">||</span> <span class="n">expected</span> <span class="o">!==</span> <span class="n">received</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">msg</span><span class="o">.</span><span class="n">statusCode</span> <span class="o">=</span> <span class="mi">401</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="n">msg</span><span class="o">.</span><span class="n">payload</span> <span class="o">=</span> <span class="p">{</span> <span class="n">error</span><span class="p">:</span> <span class="s2">&#34;unauthorized&#34;</span> <span class="p">};</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="p">[</span><span class="n">null</span><span class="p">,</span> <span class="n">msg</span><span class="p">];</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">return</span> <span class="p">[</span><span class="n">msg</span><span class="p">,</span> <span class="n">null</span><span class="p">];</span>
</span></span></code></pre></div><p>The secret is stored as a flow-tab environment variable, not hardcoded in the function. This
keeps it out of the exported JSON and lets you rotate it without editing the flow.</p>
<h2 id="import-the-flow">Import the flow</h2>
<p>The complete flow - webhook HTTP in, secret verification, Parse function, Signal send, and
201 response - is available as a sanitised JSON file with the webhook UUID and phone numbers
replaced by placeholders:</p>
<p><a href="/flows/enroll-diff-webhook.json">Download enroll-diff-webhook.json</a>
</p>
<p>In Node-RED: hamburger menu → Import → select the file. After importing, replace
<code>&lt;your-webhook-uuid&gt;</code> in the HTTP in node's URL, set <code>&lt;bot-number&gt;</code> and
<code>&lt;recipient-number&gt;</code> in the Signal send function, and configure the <code>X-Enroll-Secret</code>
environment variable on the flow tab.</p>
<h2 id="where-to-from-here">Where to from here</h2>
<p>Signal notifications are cool for simple one-person infra setups (hey, I'm one of those!).</p>
<p>At a later date, I'll show a more sophisticated system that ingests Enroll data into an
'evidence engine' I've been developing, called KEEN - designed to automatically map data like
the sort Enroll produces, against security framework Controls such as those of ISO27001:2022
and others. Stay tuned...</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Trigger: Enroll diff mode</li>
<li>Transport: HTTP POST webhook</li>
<li>Auth: Capability URL + X-Enroll-Secret header</li>
<li>Notification: Signal (via signal-cli-rest-api)</li>
<li>Response: 201 Created (immediate)</li>
<li>Nodes: 10 (including debug)</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="/news/enroll-history/">Enroll: from snowflake to Ansible</a>
</li>
<li><a href="https://enroll.sh" target="_blank" rel="noopener noreferrer">Enroll docs</a>
</li>
<li><a href="https://git.mig5.net/mig5/enroll" target="_blank" rel="noopener noreferrer">Enroll source</a>
</li>
<li><a href="https://github.com/bbernhard/signal-cli-rest-api" target="_blank" rel="noopener noreferrer">signal-cli-rest-api</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Drift in your infrastructure?</div>
  <div class="smallprint mb-3">I do contract sysadmin and DevSecOps work and can help set up drift detection, config management, and alerting pipelines.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-bell"></i><span>Node-RED</span></div>
    <h1 class="display-6 fw-semibold mb-2">Wiring Enroll drift detection to Signal</h1>
    <p class="text-muted mb-3"><time datetime="2026-07-25">July 25, 2026</time></p>
    <p class="lead mb-0">A small Node-RED webhook that catches Enroll diff payloads, formats the changes into a
readable summary, and pushes it to your phone via Signal.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-07-25T00:00:00&#43;10:00">25 July 2026</time>
        </div>

<h2 id="when-a-server-drifts">When a server drifts</h2>
<p><a href="/news/enroll-history/">Enroll</a>
 can diff two harvests of the same host and tell you
exactly what changed: packages added or removed, users modified, services stopped or started,
config files altered. That's useful as a command you run manually. It's more useful as
something that happens automatically and tells you about it.</p>
<p><a href="https://nodered.org/" target="_blank" rel="noopener noreferrer">Node-RED</a>
 is a really fun way to develop automation workflows with
an open-source project that isn't 'open core' (unlike n8n). It's a little crusty at times
compared to those newer, shiner solutions, but it's totally free.</p>
<p>Node-RED, like n8n and others, lets you define HTTP 'ingress' listeners and then bolt on
the automation flows that occur behind that entry point. It's a perfect use case for webhooks,
including the sort that Enroll's <code>diff</code> command supports.</p>
<p>The idea is simple. Run Enroll's <code>diff</code> on a schedule, point the output at a webhook, and get a
notification on your phone the moment a server drifts. No inbox to check, no dashboard to
visit - just a Signal message that says &quot;these things changed on this host.&quot;</p>
<div class="mermaid">

sequenceDiagram
    participant E as Enroll
    participant N as Node-RED
    participant S as signal-cli-rest-api
    participant P as Phone (Signal)
    E->>N: POST webhook (diff JSON, X-Enroll-Secret)
    N->>N: Verify X-Enroll-Secret header
    N->>N: Parse and format (max 2000 chars)
    par 201 response
        N-->>E: 201 Created
    and Signal send (fire-and-forget)
        N->>S: POST /v2/send
        S-->>P: Signal message
    end

</div>

<h2 id="the-webhook-endpoint">The webhook endpoint</h2>
<p>The flow starts with an HTTP input node listening for <code>POST</code> on a long, unguessable
UUID-based URL. Enroll sends its diff payload as JSON to this endpoint, along with an
<code>X-Enroll-Secret</code> header. The URL itself is the first layer of authentication - it's a
capability URL, not a password. If you don't know the URL, you can't send to it.</p>
<p>When a payload arrives, the first stop is a function node called &quot;Verify X-Enroll-Secret&quot;
that checks the header against the flow's environment variable of the same name. If the
header is missing or doesn't match, the flow returns <code>401 Unauthorized</code> and stops - the
payload never reaches the formatter. Only valid requests continue to the &quot;Parse&quot; function
that does the real work: turning the structured diff into a human-readable summary.</p>
<h2 id="formatting-the-diff">Formatting the diff</h2>
<p>An Enroll diff can be large. A server might have dozens of changed packages, several modified
users, and a handful of service changes - all in one payload. Signal messages are not the place
for a firehose.</p>
<p>The Parse function enforces two limits: <code>MAX_CHARS</code> at 2000 and
<code>MAX_ITEMS</code> at 25. Lists are truncated and annotated with &quot;… (+<em>N</em>
more)&quot; so you know something was cut. The formatting is terse and scannable:</p>
<p>Changed users are listed by name with the changed keys in parentheses - <code>alice (shell, groups); bob (password)</code> - so you can see at a glance who changed and how. Changed
services follow the same pattern: <code>sshd (enabled); cron (status)</code>. Package lists are
joined with commas and truncated. The result is a summary you can read in a few seconds, not a
JSON document you have to parse.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gdscript3" data-lang="gdscript3"><span class="line"><span class="cl"><span class="k">const</span> <span class="n">r</span> <span class="o">=</span> <span class="n">msg</span><span class="o">.</span><span class="n">payload</span> <span class="o">||</span> <span class="p">{};</span>
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">MAX_CHARS</span> <span class="o">=</span> <span class="mi">2000</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">MAX_ITEMS</span> <span class="o">=</span> <span class="mi">25</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">function</span> <span class="n">formatList</span><span class="p">(</span><span class="n">title</span><span class="p">,</span> <span class="n">items</span><span class="p">,</span> <span class="n">prefix</span> <span class="o">=</span> <span class="s2">&#34;• &#34;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">items</span> <span class="o">=</span> <span class="p">(</span><span class="ne">Array</span><span class="o">.</span><span class="n">isArray</span><span class="p">(</span><span class="n">items</span><span class="p">)</span> <span class="err">?</span> <span class="n">items</span> <span class="p">:</span> <span class="p">[])</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="n">s</span> <span class="o">=&gt;</span> <span class="n">s</span> <span class="err">??</span> <span class="s2">&#34;&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span><span class="n">Boolean</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">items</span><span class="o">.</span><span class="n">length</span> <span class="o">===</span> <span class="mi">0</span><span class="p">)</span> <span class="k">return</span> <span class="s2">&#34;&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="k">const</span> <span class="n">shown</span> <span class="o">=</span> <span class="n">items</span><span class="o">.</span><span class="n">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="n">MAX_ITEMS</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">const</span> <span class="n">more</span> <span class="o">=</span> <span class="n">items</span><span class="o">.</span><span class="n">length</span> <span class="o">-</span> <span class="n">shown</span><span class="o">.</span><span class="n">length</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="n">let</span> <span class="n">line</span> <span class="o">=</span> <span class="err">`</span><span class="o">$</span><span class="p">{</span><span class="n">title</span><span class="p">}:</span> <span class="o">$</span><span class="p">{</span><span class="n">shown</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="s2">&#34;, &#34;</span><span class="p">)}</span><span class="err">`</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">more</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">)</span> <span class="n">line</span> <span class="o">+=</span> <span class="err">`</span> <span class="err">…</span> <span class="p">(</span><span class="o">+$</span><span class="p">{</span><span class="n">more</span><span class="p">}</span> <span class="n">more</span><span class="p">)</span><span class="err">`</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">line</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">function</span> <span class="n">formatChangedUsers</span><span class="p">(</span><span class="n">changed</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">changed</span> <span class="o">=</span> <span class="ne">Array</span><span class="o">.</span><span class="n">isArray</span><span class="p">(</span><span class="n">changed</span><span class="p">)</span> <span class="err">?</span> <span class="n">changed</span> <span class="p">:</span> <span class="p">[];</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">changed</span><span class="o">.</span><span class="n">length</span> <span class="o">===</span> <span class="mi">0</span><span class="p">)</span> <span class="k">return</span> <span class="s2">&#34;&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="k">const</span> <span class="n">shown</span> <span class="o">=</span> <span class="n">changed</span><span class="o">.</span><span class="n">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="n">MAX_ITEMS</span><span class="p">)</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="n">u</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">const</span> <span class="n">name</span> <span class="o">=</span> <span class="n">u</span><span class="o">.</span><span class="n">name</span> <span class="err">??</span> <span class="s2">&#34;&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="k">const</span> <span class="n">keys</span> <span class="o">=</span> <span class="ne">Object</span><span class="o">.</span><span class="n">keys</span><span class="p">(</span><span class="n">u</span><span class="o">.</span><span class="n">changes</span> <span class="o">||</span> <span class="p">{});</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">keys</span><span class="o">.</span><span class="n">length</span> <span class="err">?</span> <span class="err">`</span><span class="o">$</span><span class="p">{</span><span class="n">name</span><span class="p">}</span> <span class="p">(</span><span class="o">$</span><span class="p">{</span><span class="n">keys</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="s2">&#34;, &#34;</span><span class="p">)})</span><span class="err">`</span> <span class="p">:</span> <span class="n">name</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="p">});</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="err">`</span><span class="n">Users</span> <span class="n">changed</span><span class="p">:</span> <span class="o">$</span><span class="p">{</span><span class="n">shown</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="s2">&#34;; &#34;</span><span class="p">)}</span><span class="err">`</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">function</span> <span class="n">trimToMax</span><span class="p">(</span><span class="n">text</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">text</span><span class="o">.</span><span class="n">length</span> <span class="o">&lt;=</span> <span class="n">MAX_CHARS</span><span class="p">)</span> <span class="k">return</span> <span class="n">text</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">text</span><span class="o">.</span><span class="n">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="n">MAX_CHARS</span> <span class="o">-</span> <span class="mi">20</span><span class="p">)</span><span class="o">.</span><span class="n">trimEnd</span><span class="p">()</span> <span class="o">+</span> <span class="s2">&#34;</span><span class="se">\n</span><span class="s2">…(truncated)&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="o">//</span> <span class="n">Build</span> <span class="n">the</span> <span class="n">summary</span> <span class="n">lines</span>
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">lines</span> <span class="o">=</span> <span class="p">[</span><span class="err">`</span><span class="n">enroll</span> <span class="n">diff</span> <span class="err">@</span> <span class="o">$</span><span class="p">{</span><span class="n">r</span><span class="o">.</span><span class="n">new</span><span class="err">?</span><span class="o">.</span><span class="n">host</span> <span class="o">||</span> <span class="n">r</span><span class="o">.</span><span class="n">old</span><span class="err">?</span><span class="o">.</span><span class="n">host</span> <span class="o">||</span> <span class="s2">&#34;&#34;</span><span class="p">}</span><span class="err">`</span><span class="p">];</span>
</span></span><span class="line"><span class="cl"><span class="n">lines</span><span class="o">.</span><span class="n">push</span><span class="p">(</span><span class="n">formatList</span><span class="p">(</span><span class="s2">&#34;Packages added&#34;</span><span class="p">,</span> <span class="n">r</span><span class="o">.</span><span class="n">packages</span><span class="err">?</span><span class="o">.</span><span class="n">added</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="n">lines</span><span class="o">.</span><span class="n">push</span><span class="p">(</span><span class="n">formatList</span><span class="p">(</span><span class="s2">&#34;Packages removed&#34;</span><span class="p">,</span> <span class="n">r</span><span class="o">.</span><span class="n">packages</span><span class="err">?</span><span class="o">.</span><span class="n">removed</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="n">lines</span><span class="o">.</span><span class="n">push</span><span class="p">(</span><span class="n">formatList</span><span class="p">(</span><span class="s2">&#34;Services enabled +&#34;</span><span class="p">,</span> <span class="n">r</span><span class="o">.</span><span class="n">services</span><span class="err">?</span><span class="o">.</span><span class="n">enabled_added</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="n">lines</span><span class="o">.</span><span class="n">push</span><span class="p">(</span><span class="n">formatChangedUsers</span><span class="p">(</span><span class="n">r</span><span class="o">.</span><span class="n">users</span><span class="err">?</span><span class="o">.</span><span class="n">changed</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="n">lines</span><span class="o">.</span><span class="n">push</span><span class="p">(</span><span class="n">formatList</span><span class="p">(</span><span class="s2">&#34;Files added&#34;</span><span class="p">,</span> <span class="n">r</span><span class="o">.</span><span class="n">files</span><span class="err">?</span><span class="o">.</span><span class="n">added</span><span class="p">));</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="o">//</span> <span class="n">Join</span> <span class="n">non</span><span class="o">-</span><span class="n">empty</span> <span class="n">lines</span><span class="p">,</span> <span class="n">cap</span> <span class="n">at</span> <span class="mi">2000</span> <span class="n">chars</span>
</span></span><span class="line"><span class="cl"><span class="n">msg</span><span class="o">.</span><span class="n">payload</span> <span class="o">=</span> <span class="n">trimToMax</span><span class="p">(</span><span class="n">lines</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span><span class="n">s</span> <span class="o">=&gt;</span> <span class="n">s</span><span class="err">?</span><span class="o">.</span><span class="n">trim</span><span class="p">())</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="s2">&#34;</span><span class="se">\n</span><span class="s2">&#34;</span><span class="p">));</span>
</span></span><span class="line"><span class="cl"><span class="k">return</span> <span class="n">msg</span><span class="p">;</span>
</span></span></code></pre></div><h2 id="notification-via-signal">Notification via Signal</h2>
<p>The formatted summary is wrapped into a Signal message and posted to
<code>http://signal_cli:8080/v2/send</code> - the same REST endpoint used by the
<a href="/news/signal-ai-assistant/">Signal AI assistant</a>
. The message goes out from the
bot's number to a hardcoded recipient. No email, no Slack webhook, no dashboard - just Signal,
which means it shows up on your phone the same way any message does.</p>
<h2 id="the-201-response">The 201 response</h2>
<p>The HTTP response is sent immediately - <code>201 Created</code> - in parallel with
the Signal send. Enroll doesn't wait for the Signal message to be delivered; it gets an
acknowledgement as soon as the webhook receives the payload. The Signal send is fire-and-forget,
monitored only by debug nodes. This is the right design: the webhook's job is to receive and
acknowledge, not to block on a third-party notification service.</p>
<h2 id="shared-secret-authentication">Shared-secret authentication</h2>
<p>The webhook uses two layers of authentication: the unguessable capability URL and a shared
secret sent in the <code>X-Enroll-Secret</code> header. The verify function is a two-output function
node - valid requests go out port 1 to the Parse function, invalid requests go out port 2 to
an HTTP response node that returns <code>401</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gdscript3" data-lang="gdscript3"><span class="line"><span class="cl"><span class="k">const</span> <span class="n">expected</span> <span class="o">=</span> <span class="n">env</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;X-Enroll-Secret&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="k">const</span> <span class="n">received</span> <span class="o">=</span> <span class="n">msg</span><span class="o">.</span><span class="n">req</span> <span class="o">&amp;&amp;</span> <span class="n">msg</span><span class="o">.</span><span class="n">req</span><span class="o">.</span><span class="n">headers</span> <span class="err">?</span> <span class="n">msg</span><span class="o">.</span><span class="n">req</span><span class="o">.</span><span class="n">headers</span><span class="p">[</span><span class="s2">&#34;x-enroll-secret&#34;</span><span class="p">]</span> <span class="p">:</span> <span class="n">null</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">expected</span> <span class="o">||</span> <span class="n">expected</span> <span class="o">!==</span> <span class="n">received</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">msg</span><span class="o">.</span><span class="n">statusCode</span> <span class="o">=</span> <span class="mi">401</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="n">msg</span><span class="o">.</span><span class="n">payload</span> <span class="o">=</span> <span class="p">{</span> <span class="n">error</span><span class="p">:</span> <span class="s2">&#34;unauthorized&#34;</span> <span class="p">};</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="p">[</span><span class="n">null</span><span class="p">,</span> <span class="n">msg</span><span class="p">];</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">return</span> <span class="p">[</span><span class="n">msg</span><span class="p">,</span> <span class="n">null</span><span class="p">];</span>
</span></span></code></pre></div><p>The secret is stored as a flow-tab environment variable, not hardcoded in the function. This
keeps it out of the exported JSON and lets you rotate it without editing the flow.</p>
<h2 id="import-the-flow">Import the flow</h2>
<p>The complete flow - webhook HTTP in, secret verification, Parse function, Signal send, and
201 response - is available as a sanitised JSON file with the webhook UUID and phone numbers
replaced by placeholders:</p>
<p><a href="/flows/enroll-diff-webhook.json">Download enroll-diff-webhook.json</a>
</p>
<p>In Node-RED: hamburger menu → Import → select the file. After importing, replace
<code>&lt;your-webhook-uuid&gt;</code> in the HTTP in node's URL, set <code>&lt;bot-number&gt;</code> and
<code>&lt;recipient-number&gt;</code> in the Signal send function, and configure the <code>X-Enroll-Secret</code>
environment variable on the flow tab.</p>
<h2 id="where-to-from-here">Where to from here</h2>
<p>Signal notifications are cool for simple one-person infra setups (hey, I'm one of those!).</p>
<p>At a later date, I'll show a more sophisticated system that ingests Enroll data into an
'evidence engine' I've been developing, called KEEN - designed to automatically map data like
the sort Enroll produces, against security framework Controls such as those of ISO27001:2022
and others. Stay tuned...</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Trigger: Enroll diff mode</li>
<li>Transport: HTTP POST webhook</li>
<li>Auth: Capability URL + X-Enroll-Secret header</li>
<li>Notification: Signal (via signal-cli-rest-api)</li>
<li>Response: 201 Created (immediate)</li>
<li>Nodes: 10 (including debug)</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="/news/enroll-history/">Enroll: from snowflake to Ansible</a>
</li>
<li><a href="https://enroll.sh" target="_blank" rel="noopener noreferrer">Enroll docs</a>
</li>
<li><a href="https://git.mig5.net/mig5/enroll" target="_blank" rel="noopener noreferrer">Enroll source</a>
</li>
<li><a href="https://github.com/bbernhard/signal-cli-rest-api" target="_blank" rel="noopener noreferrer">signal-cli-rest-api</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Drift in your infrastructure?</div>
  <div class="smallprint mb-3">I do contract sysadmin and DevSecOps work and can help set up drift detection, config management, and alerting pipelines.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>SQLite batch inserts: wrapping transactions for a 94% performance win</title>
      <link>https://mig5.net/news/sql-transactions-sqlite-performance/</link>
      <pubDate>Thu, 23 Jul 2026 17:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/sql-transactions-sqlite-performance/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-speedometer2"></i><span>SQLite</span></div>
    <h1 class="display-6 fw-semibold mb-2">SQLite batch inserts: wrapping transactions for a 94% performance win</h1>
    <p class="text-muted mb-3"><time datetime="2026-07-23">July 23, 2026</time></p>
    <p class="lead mb-0">How wrapping batch SQL inserts in a single transaction transformed a 7-second
operation into a 0.4-second one, and why measuring the change mattered as much
as the fix itself.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-07-23T17:00:00&#43;10:00">23 July 2026</time>
        </div>

<h2 id="the-problem-each-insert-is-its-own-transaction">The problem: each insert is its own transaction</h2>
<p>SQLite is different from database systems like MySQL or PostgreSQL. The database
is a file on a filesystem, not a long-running daemon managing connection pools.
Locking relies on the underlying filesystem, and every operation that writes to
the database acquires a lock on the file.</p>
<p>This matters because in SQLite, every individual SQL statement that modifies data
implicitly starts its own transaction. That transaction is committed (or rolled
back) immediately after the statement completes. For a single insert this is
fine - it ensures ACID compliance and lets SQLite roll back if something goes
wrong. But when you are inserting many rows in one request, each insert becomes
a full round trip: open the file, obtain a lock, write the data, flush filesystem
caches, release the lock. Repeat for every row.</p>
<p>If your application also writes metadata - an audit log entry, a timestamp, a
related record - alongside each data insert, you are doing two or more
transactions per row. It adds up fast! Especially if your SQLite database is on
an NFS filesystem like Amazon's EFS (and <em>yes, SQLite does run on EFS. Just don't
use WAL mode</em>).</p>
<h2 id="measuring-the-baseline">Measuring the baseline</h2>
<p>Before changing anything, it was important to measure the problem. HTTP response
time was the right metric here, because it directly reflected what the caller
experienced. The graph below shows the response time for batch insert requests
before any optimisation was applied:</p>
<p><img src="performance_pre_optimising.png" alt="HTTP response times before optimisation, showing requests taking roughly 7 seconds"></p>
<p>Requests were taking around 7 seconds to complete, sometimes up to 9. On a
platform serving many concurrent requests, this was simply unacceptable - not
just for the caller waiting on the response, but because each slow request held
a write lock that blocked other operations from proceeding.</p>
<h2 id="the-fix-one-transaction-for-the-whole-batch">The fix: one transaction for the whole batch</h2>
<p>The solution is to explicitly wrap all the inserts in a single transaction.
Without an explicit <code>BEGIN</code>, SQLite starts an implicit transaction at the first
INSERT and commits it immediately after - meaning one lock acquisition and one
flush per row. By opening the transaction manually before the loop and committing
it once at the end, you collapse N lock acquisitions and N flushes down to one
of each.</p>
<p>The pseudocode, with uninteresting bits omitted:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="cl"><span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$this</span><span class="o">-&gt;</span><span class="na">dbo</span><span class="o">-&gt;</span><span class="na">exec</span><span class="p">(</span><span class="s2">&#34;BEGIN IMMEDIATE TRANSACTION&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">foreach</span> <span class="p">(</span><span class="nv">$payload</span> <span class="k">as</span> <span class="nv">$row</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nv">$this</span><span class="o">-&gt;</span><span class="na">dbo</span><span class="o">-&gt;</span><span class="na">exec</span><span class="p">(</span><span class="s2">&#34;INSERT INTO [....]&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$this</span><span class="o">-&gt;</span><span class="na">dbo</span><span class="o">-&gt;</span><span class="na">exec</span><span class="p">(</span><span class="s2">&#34;COMMIT&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span> <span class="k">catch</span> <span class="nx">Exception</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$this</span><span class="o">-&gt;</span><span class="na">dbo</span><span class="o">-&gt;</span><span class="na">exec</span><span class="p">(</span><span class="s2">&#34;ROLLBACK&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>Why <code>BEGIN IMMEDIATE</code> and not just <code>BEGIN</code>? A plain <code>BEGIN</code> starts a read
transaction that upgrades to a write lock when the first INSERT is encountered.
If another connection has already acquired a write lock by that point, your
transaction fails with <code>SQLITE_BUSY</code> at the INSERT - after you have already done
some work. <code>BEGIN IMMEDIATE</code> acquires the write lock up front, so you either get
the lock immediately or learn right away that the database is busy and can code
around it (retry, back off, or return a meaningful response to the client).</p>
<h2 id="measuring-the-result">Measuring the result</h2>
<p>The graph below shows the same HTTP response time metric, with the deployment of
the fix visible as a dramatic drop:</p>
<p><img src="performance_post_optimising.png" alt="HTTP response times after wrapping inserts in a single transaction, showing a drop to under 0.5 seconds"></p>
<p>Crunching the numbers:</p>
<ul>
<li>Previous average response time: ~8 seconds (range 7-9s)</li>
<li>Post-fix average response time: ~0.44 seconds (range 0.38-0.5s)</li>
<li>Speedup: (8 - 0.44) / 8 = 0.945, or roughly <strong>94.5%</strong></li>
</ul>
<p>Three lines of code - <code>BEGIN IMMEDIATE TRANSACTION</code> before the loop, <code>COMMIT</code>
after it, and <code>ROLLBACK</code> in the exception handler - produced a 94.5% improvement.</p>
<h2 id="why-monitoring-mattered">Why monitoring mattered</h2>
<p>The fix itself was simple. The harder part was knowing it was needed.</p>
<p>Without the baseline measurement, there was no way to know whether the slowness
was in the database layer, the network, the application logic, or somewhere else
entirely. Without the post-deployment measurement, there was no way to confirm the
fix actually worked in production, or to quantify how much it helped.</p>
<p>This is the pattern that matters: <strong>measure, change, measure again.</strong> A 94.5%
improvement is dramatic enough that it would have been obvious from the user
experience alone, but most performance changes are not. Most are 15% or 20%, and
without before-and-after data you are guessing at whether you helped, hurt, or did
nothing at all.</p>
<p>Graph the metric that matters to the caller - response time, throughput, error
rate - and keep it visible. The graph is what tells you the problem exists in the
first place, and it is what tells you the fix is real.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>SQLite implicitly starts &amp; commits a transaction per statement</li>
<li>Each insert is a full lock-acquire, flush, &amp; lock-release round trip</li>
<li><code>BEGIN IMMEDIATE TRANSACTION</code> before a batch loop collapses N round trips to one</li>
<li><code>ROLLBACK</code> on failure gives atomicity: the whole batch succeeds or none do</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://www.sqlite.org/lockingv3.html" target="_blank" rel="noopener noreferrer">SQLite: File Locking in SQLite 3</a>
</li>
<li><a href="https://www.sqlite.org/lang_transaction.html" target="_blank" rel="noopener noreferrer">SQLite: Transactions</a>
</li>
<li><a href="https://activesphere.com/blog/2018/12/24/understanding-sqlite-busy" target="_blank" rel="noopener noreferrer">Understanding SQLITE_BUSY</a>
</li>
<li><a href="https://medium.com/@JasonWyatt/squeezing-performance-from-sqlite-insertions-971aff98eef2" target="_blank" rel="noopener noreferrer">Squeezing performance from SQLite insertions</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Database performance not where it should be?</div>
  <div class="smallprint mb-3">I do contract sysadmin, automation, and performance work, including SQLite.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-speedometer2"></i><span>SQLite</span></div>
    <h1 class="display-6 fw-semibold mb-2">SQLite batch inserts: wrapping transactions for a 94% performance win</h1>
    <p class="text-muted mb-3"><time datetime="2026-07-23">July 23, 2026</time></p>
    <p class="lead mb-0">How wrapping batch SQL inserts in a single transaction transformed a 7-second
operation into a 0.4-second one, and why measuring the change mattered as much
as the fix itself.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-07-23T17:00:00&#43;10:00">23 July 2026</time>
        </div>

<h2 id="the-problem-each-insert-is-its-own-transaction">The problem: each insert is its own transaction</h2>
<p>SQLite is different from database systems like MySQL or PostgreSQL. The database
is a file on a filesystem, not a long-running daemon managing connection pools.
Locking relies on the underlying filesystem, and every operation that writes to
the database acquires a lock on the file.</p>
<p>This matters because in SQLite, every individual SQL statement that modifies data
implicitly starts its own transaction. That transaction is committed (or rolled
back) immediately after the statement completes. For a single insert this is
fine - it ensures ACID compliance and lets SQLite roll back if something goes
wrong. But when you are inserting many rows in one request, each insert becomes
a full round trip: open the file, obtain a lock, write the data, flush filesystem
caches, release the lock. Repeat for every row.</p>
<p>If your application also writes metadata - an audit log entry, a timestamp, a
related record - alongside each data insert, you are doing two or more
transactions per row. It adds up fast! Especially if your SQLite database is on
an NFS filesystem like Amazon's EFS (and <em>yes, SQLite does run on EFS. Just don't
use WAL mode</em>).</p>
<h2 id="measuring-the-baseline">Measuring the baseline</h2>
<p>Before changing anything, it was important to measure the problem. HTTP response
time was the right metric here, because it directly reflected what the caller
experienced. The graph below shows the response time for batch insert requests
before any optimisation was applied:</p>
<p><img src="performance_pre_optimising.png" alt="HTTP response times before optimisation, showing requests taking roughly 7 seconds"></p>
<p>Requests were taking around 7 seconds to complete, sometimes up to 9. On a
platform serving many concurrent requests, this was simply unacceptable - not
just for the caller waiting on the response, but because each slow request held
a write lock that blocked other operations from proceeding.</p>
<h2 id="the-fix-one-transaction-for-the-whole-batch">The fix: one transaction for the whole batch</h2>
<p>The solution is to explicitly wrap all the inserts in a single transaction.
Without an explicit <code>BEGIN</code>, SQLite starts an implicit transaction at the first
INSERT and commits it immediately after - meaning one lock acquisition and one
flush per row. By opening the transaction manually before the loop and committing
it once at the end, you collapse N lock acquisitions and N flushes down to one
of each.</p>
<p>The pseudocode, with uninteresting bits omitted:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="cl"><span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$this</span><span class="o">-&gt;</span><span class="na">dbo</span><span class="o">-&gt;</span><span class="na">exec</span><span class="p">(</span><span class="s2">&#34;BEGIN IMMEDIATE TRANSACTION&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">foreach</span> <span class="p">(</span><span class="nv">$payload</span> <span class="k">as</span> <span class="nv">$row</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nv">$this</span><span class="o">-&gt;</span><span class="na">dbo</span><span class="o">-&gt;</span><span class="na">exec</span><span class="p">(</span><span class="s2">&#34;INSERT INTO [....]&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$this</span><span class="o">-&gt;</span><span class="na">dbo</span><span class="o">-&gt;</span><span class="na">exec</span><span class="p">(</span><span class="s2">&#34;COMMIT&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span> <span class="k">catch</span> <span class="nx">Exception</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$this</span><span class="o">-&gt;</span><span class="na">dbo</span><span class="o">-&gt;</span><span class="na">exec</span><span class="p">(</span><span class="s2">&#34;ROLLBACK&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>Why <code>BEGIN IMMEDIATE</code> and not just <code>BEGIN</code>? A plain <code>BEGIN</code> starts a read
transaction that upgrades to a write lock when the first INSERT is encountered.
If another connection has already acquired a write lock by that point, your
transaction fails with <code>SQLITE_BUSY</code> at the INSERT - after you have already done
some work. <code>BEGIN IMMEDIATE</code> acquires the write lock up front, so you either get
the lock immediately or learn right away that the database is busy and can code
around it (retry, back off, or return a meaningful response to the client).</p>
<h2 id="measuring-the-result">Measuring the result</h2>
<p>The graph below shows the same HTTP response time metric, with the deployment of
the fix visible as a dramatic drop:</p>
<p><img src="performance_post_optimising.png" alt="HTTP response times after wrapping inserts in a single transaction, showing a drop to under 0.5 seconds"></p>
<p>Crunching the numbers:</p>
<ul>
<li>Previous average response time: ~8 seconds (range 7-9s)</li>
<li>Post-fix average response time: ~0.44 seconds (range 0.38-0.5s)</li>
<li>Speedup: (8 - 0.44) / 8 = 0.945, or roughly <strong>94.5%</strong></li>
</ul>
<p>Three lines of code - <code>BEGIN IMMEDIATE TRANSACTION</code> before the loop, <code>COMMIT</code>
after it, and <code>ROLLBACK</code> in the exception handler - produced a 94.5% improvement.</p>
<h2 id="why-monitoring-mattered">Why monitoring mattered</h2>
<p>The fix itself was simple. The harder part was knowing it was needed.</p>
<p>Without the baseline measurement, there was no way to know whether the slowness
was in the database layer, the network, the application logic, or somewhere else
entirely. Without the post-deployment measurement, there was no way to confirm the
fix actually worked in production, or to quantify how much it helped.</p>
<p>This is the pattern that matters: <strong>measure, change, measure again.</strong> A 94.5%
improvement is dramatic enough that it would have been obvious from the user
experience alone, but most performance changes are not. Most are 15% or 20%, and
without before-and-after data you are guessing at whether you helped, hurt, or did
nothing at all.</p>
<p>Graph the metric that matters to the caller - response time, throughput, error
rate - and keep it visible. The graph is what tells you the problem exists in the
first place, and it is what tells you the fix is real.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>SQLite implicitly starts &amp; commits a transaction per statement</li>
<li>Each insert is a full lock-acquire, flush, &amp; lock-release round trip</li>
<li><code>BEGIN IMMEDIATE TRANSACTION</code> before a batch loop collapses N round trips to one</li>
<li><code>ROLLBACK</code> on failure gives atomicity: the whole batch succeeds or none do</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://www.sqlite.org/lockingv3.html" target="_blank" rel="noopener noreferrer">SQLite: File Locking in SQLite 3</a>
</li>
<li><a href="https://www.sqlite.org/lang_transaction.html" target="_blank" rel="noopener noreferrer">SQLite: Transactions</a>
</li>
<li><a href="https://activesphere.com/blog/2018/12/24/understanding-sqlite-busy" target="_blank" rel="noopener noreferrer">Understanding SQLITE_BUSY</a>
</li>
<li><a href="https://medium.com/@JasonWyatt/squeezing-performance-from-sqlite-insertions-971aff98eef2" target="_blank" rel="noopener noreferrer">Squeezing performance from SQLite insertions</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Database performance not where it should be?</div>
  <div class="smallprint mb-3">I do contract sysadmin, automation, and performance work, including SQLite.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>JinjaTurtle: turning config files into templates, safely</title>
      <link>https://mig5.net/news/jinjaturtle-history/</link>
      <pubDate>Sat, 18 Jul 2026 00:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/jinjaturtle-history/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-braces"></i><span>JinjaTurtle</span></div>
    <h1 class="display-6 fw-semibold mb-2">Turning config files into templates, safely</h1>
    <p class="text-muted mb-3"><time datetime="2026-07-18">July 18, 2026</time></p>
    <p class="lead mb-0">JinjaTurtle started as a quick way to turn TOML and INI files into Jinja2 templates and Ansible
defaults. It now handles seven config formats, generates loops from repeated structures, and
neutralises template metacharacters to prevent injection from harvested config.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-07-18T00:00:00&#43;10:00">18 July 2026</time>
        </div>

<h2 id="why-it-exists">Why it exists</h2>
<p>Templatising a config file by hand is tedious: parse the file, invent variable names, replace
each value with a <code>{{ variable }}</code> placeholder, and write a defaults file mapping those
variables back to the original values. It's pure mechanical work, but it's easy to make
mistakes - miss a value, mismatch a name, break the file structure.</p>
<p>It also takes a really long time. Specifically, I created JinjaTurtle after converting the
<a href="https://docs.cosmos.network/cometbft/latest/docs/core/configuration" target="_blank" rel="noopener noreferrer">CometBFT config.toml</a>
 to Jinja2 and Ansible inventory,
as part of making an <a href="https://github.com/freedomofpress/ansible-role-cometbft" target="_blank" rel="noopener noreferrer">Ansible role for it</a>
 for one of my customers.
I don't want to have to do that again by hand!</p>
<p>JinjaTurtle automates the first pass. Point it at a config file, give it a role name, and it
produces a Jinja2 template alongside an Ansible defaults YAML file. The original structure is
preserved as much as possible, so the template looks like the source file with values swapped
for variables.</p>
<h2 id="evolving-the-project">Evolving the project</h2>
<p>JinjaTurtle started with TOML and INI, with YAML and JSON added almost immediately. XML came
next, using <a href="https://pypi.org/project/defusedxml/" target="_blank" rel="noopener noreferrer">defusedxml</a>
 for safe parsing.</p>
<p>I later refactored the format handlers into their own class, to make the codebase easier to
extend by adding new handlers.</p>
<p>A significant step was the ability to generate Jinja2 <code>loop</code> constructs from repeated structures
in YAML, JSON, and XML. A list of homogeneous entries becomes a single template iterated with
<code>{{ item.key }}</code>, rather than a flat set of scalar variables. When the structure isn't confident
enough to loop, it falls back to flattened scalars.</p>
<p>Bespoke format handlers have since been added for <strong>Postfix <code>main.cf</code></strong> and <strong>systemd unit files</strong>
(<code>*.service</code>, <code>*.socket</code>, <code>*.timer</code>), and <strong>OpenSSH config</strong> support (<code>ssh_config</code>,
<code>sshd_config</code>, and detected snippet files).</p>
<h2 id="growing-pains---type-preservation-and-faithful-output">Growing pains - type preservation and faithful output</h2>
<p>A subtle early problem was type coercion. Naive string substitution would turn a JSON boolean
<code>true</code> into a Python <code>True</code> in the rendered output, producing invalid JSON. The renderers were
rewritten to use JSON-aware expressions and to preserve indentation, newlines, and operators like
<code>&lt;</code> and <code>&gt;</code> faithfully, rather than round-tripping through a serialiser that would reformat the
file.</p>
<h2 id="the-security-model">The security model</h2>
<p>JinjaTurtle is frequently pointed at config files harvested from real systems - exactly the use
case that <a href="/projects/enroll/">Enroll</a>
 enables. As I developed Enroll and started having to think
about the security risks with that project, there was a lot of boundary cross-over between Enroll
and JinjaTurtle in terms of dealing with data. Some of that content may be influenced by an
untrusted party: a hostname, a login banner, a GECOS comment. JinjaTurtle needs to guarantee that
source content could not become executable template code.</p>
<p>Two guarantees have been established:</p>
<ul>
<li><strong>Values are data, never code.</strong> Every config value is replaced with a <code>{{ variable }}</code>
placeholder, and the original value is stored in the defaults file. When the template renders,
the placeholder prints the value as a literal string - Jinja2 doesn't recursively render the
contents of a variable, so a payload inside a value is inert.</li>
<li><strong>Verbatim text is neutralised.</strong> Comments, blank lines, headers, and unrecognised lines are
copied into the template, but any Jinja2 metacharacters (<code>{{ }}</code>, <code>{% %}</code>, <code>{# #}</code>) in that
text are escaped so they render as literal characters.</li>
</ul>
<p>Later hardening - done in lockstep with Enroll's own security work - has added <code>!unsafe</code> tagging for
Ansible variables, safe XML parsing in API use, symlink avoidance, and defences against embedded
Jinja in original files during conversion.</p>
<h2 id="where-it-stands-today">Where it stands today</h2>
<p>JinjaTurtle is a small, focused tool. It does one thing - turn config files into templates and
variable data - and it does it for seven formats with loop generation, type preservation, and a
security model that assumes the input might be hostile.</p>
<p>It pairs naturally with Enroll, which can detect structured configs and hand off to JinjaTurtle
automatically - but it can equally be used as a standalone tool just fine.</p>
<p>The source is at <a href="https://git.mig5.net/mig5/jinjaturtle" target="_blank" rel="noopener noreferrer">git.mig5.net/mig5/jinjaturtle</a>
.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Language: Python</li>
<li>Formats: TOML, YAML, JSON, INI, XML, Postfix, systemd, SSH</li>
<li>Output: Jinja2 templates + Ansible defaults</li>
<li>Pairs with: Enroll</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://git.mig5.net/mig5/jinjaturtle" target="_blank" rel="noopener noreferrer">Source repo</a>
</li>
<li><a href="https://pypi.org/project/jinjaturtle/" target="_blank" rel="noopener noreferrer">PyPI</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Want to manage infra safely?</div>
  <div class="smallprint mb-3">I can help you adopt config management without breaking production.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-braces"></i><span>JinjaTurtle</span></div>
    <h1 class="display-6 fw-semibold mb-2">Turning config files into templates, safely</h1>
    <p class="text-muted mb-3"><time datetime="2026-07-18">July 18, 2026</time></p>
    <p class="lead mb-0">JinjaTurtle started as a quick way to turn TOML and INI files into Jinja2 templates and Ansible
defaults. It now handles seven config formats, generates loops from repeated structures, and
neutralises template metacharacters to prevent injection from harvested config.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-07-18T00:00:00&#43;10:00">18 July 2026</time>
        </div>

<h2 id="why-it-exists">Why it exists</h2>
<p>Templatising a config file by hand is tedious: parse the file, invent variable names, replace
each value with a <code>{{ variable }}</code> placeholder, and write a defaults file mapping those
variables back to the original values. It's pure mechanical work, but it's easy to make
mistakes - miss a value, mismatch a name, break the file structure.</p>
<p>It also takes a really long time. Specifically, I created JinjaTurtle after converting the
<a href="https://docs.cosmos.network/cometbft/latest/docs/core/configuration" target="_blank" rel="noopener noreferrer">CometBFT config.toml</a>
 to Jinja2 and Ansible inventory,
as part of making an <a href="https://github.com/freedomofpress/ansible-role-cometbft" target="_blank" rel="noopener noreferrer">Ansible role for it</a>
 for one of my customers.
I don't want to have to do that again by hand!</p>
<p>JinjaTurtle automates the first pass. Point it at a config file, give it a role name, and it
produces a Jinja2 template alongside an Ansible defaults YAML file. The original structure is
preserved as much as possible, so the template looks like the source file with values swapped
for variables.</p>
<h2 id="evolving-the-project">Evolving the project</h2>
<p>JinjaTurtle started with TOML and INI, with YAML and JSON added almost immediately. XML came
next, using <a href="https://pypi.org/project/defusedxml/" target="_blank" rel="noopener noreferrer">defusedxml</a>
 for safe parsing.</p>
<p>I later refactored the format handlers into their own class, to make the codebase easier to
extend by adding new handlers.</p>
<p>A significant step was the ability to generate Jinja2 <code>loop</code> constructs from repeated structures
in YAML, JSON, and XML. A list of homogeneous entries becomes a single template iterated with
<code>{{ item.key }}</code>, rather than a flat set of scalar variables. When the structure isn't confident
enough to loop, it falls back to flattened scalars.</p>
<p>Bespoke format handlers have since been added for <strong>Postfix <code>main.cf</code></strong> and <strong>systemd unit files</strong>
(<code>*.service</code>, <code>*.socket</code>, <code>*.timer</code>), and <strong>OpenSSH config</strong> support (<code>ssh_config</code>,
<code>sshd_config</code>, and detected snippet files).</p>
<h2 id="growing-pains---type-preservation-and-faithful-output">Growing pains - type preservation and faithful output</h2>
<p>A subtle early problem was type coercion. Naive string substitution would turn a JSON boolean
<code>true</code> into a Python <code>True</code> in the rendered output, producing invalid JSON. The renderers were
rewritten to use JSON-aware expressions and to preserve indentation, newlines, and operators like
<code>&lt;</code> and <code>&gt;</code> faithfully, rather than round-tripping through a serialiser that would reformat the
file.</p>
<h2 id="the-security-model">The security model</h2>
<p>JinjaTurtle is frequently pointed at config files harvested from real systems - exactly the use
case that <a href="/projects/enroll/">Enroll</a>
 enables. As I developed Enroll and started having to think
about the security risks with that project, there was a lot of boundary cross-over between Enroll
and JinjaTurtle in terms of dealing with data. Some of that content may be influenced by an
untrusted party: a hostname, a login banner, a GECOS comment. JinjaTurtle needs to guarantee that
source content could not become executable template code.</p>
<p>Two guarantees have been established:</p>
<ul>
<li><strong>Values are data, never code.</strong> Every config value is replaced with a <code>{{ variable }}</code>
placeholder, and the original value is stored in the defaults file. When the template renders,
the placeholder prints the value as a literal string - Jinja2 doesn't recursively render the
contents of a variable, so a payload inside a value is inert.</li>
<li><strong>Verbatim text is neutralised.</strong> Comments, blank lines, headers, and unrecognised lines are
copied into the template, but any Jinja2 metacharacters (<code>{{ }}</code>, <code>{% %}</code>, <code>{# #}</code>) in that
text are escaped so they render as literal characters.</li>
</ul>
<p>Later hardening - done in lockstep with Enroll's own security work - has added <code>!unsafe</code> tagging for
Ansible variables, safe XML parsing in API use, symlink avoidance, and defences against embedded
Jinja in original files during conversion.</p>
<h2 id="where-it-stands-today">Where it stands today</h2>
<p>JinjaTurtle is a small, focused tool. It does one thing - turn config files into templates and
variable data - and it does it for seven formats with loop generation, type preservation, and a
security model that assumes the input might be hostile.</p>
<p>It pairs naturally with Enroll, which can detect structured configs and hand off to JinjaTurtle
automatically - but it can equally be used as a standalone tool just fine.</p>
<p>The source is at <a href="https://git.mig5.net/mig5/jinjaturtle" target="_blank" rel="noopener noreferrer">git.mig5.net/mig5/jinjaturtle</a>
.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Language: Python</li>
<li>Formats: TOML, YAML, JSON, INI, XML, Postfix, systemd, SSH</li>
<li>Output: Jinja2 templates + Ansible defaults</li>
<li>Pairs with: Enroll</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://git.mig5.net/mig5/jinjaturtle" target="_blank" rel="noopener noreferrer">Source repo</a>
</li>
<li><a href="https://pypi.org/project/jinjaturtle/" target="_blank" rel="noopener noreferrer">PyPI</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Want to manage infra safely?</div>
  <div class="smallprint mb-3">I can help you adopt config management without breaking production.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
    <item>
      <title>Enroll: from snowflake harvester to hardened Ansible generator</title>
      <link>https://mig5.net/news/enroll-history/</link>
      <pubDate>Sat, 11 Jul 2026 00:00:00 +1000</pubDate>
      <guid>https://mig5.net/news/enroll-history/</guid>
      <description><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-terminal"></i><span>Enroll</span></div>
    <h1 class="display-6 fw-semibold mb-2">From snowflake harvester to hardened Ansible generator</h1>
    <p class="text-muted mb-3"><time datetime="2026-07-11">July 11, 2026</time></p>
    <p class="lead mb-0">Enroll started as a way to snapshot a snowflake server's <code>/etc</code> into Ansible roles. After
months of development and several security audits, it's now a hardened harvesting tool with remote
mode, SOPS encryption, drift detection, and a focus on fail-closed safety.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-07-11T00:00:00&#43;10:00">11 July 2026</time>
        </div>

<h2 id="the-problem">The problem</h2>
<p>Every sysadmin has met the snowflake: a server that was hand-configured over years, never under
config management, and now nobody wants to touch it. Rebuilding it from scratch means weeks of
archaeology. Enroll was born to short-circuit that - inspect a running Debian or RedHat-like
host, harvest the state that matters, and generate Ansible roles and playbooks that could
reproduce it.</p>
<h2 id="the-beginning">The beginning</h2>
<p>I started Enroll in early December 2025 as a way to move quickly on exactly that problem. From
day one, I wanted to avoid the reverse problem: capturing <em>excessive</em> state that is a waste of
time - for example, stuff one gets from the OS no matter what.</p>
<p>The first iterations could inspect packages, services, and <code>/etc</code> files on a running host, that
were likely to be 'extra' stuff installed on the machine beyond the base OS image. The goal then
was to emit one Ansible role per detected package.</p>
<p>It became clear early on that Enroll could do more than copy files. A short time earlier in 2025
I had produced <a href="https://git.mig5.net/mig5/jinjaturtle" target="_blank" rel="noopener noreferrer">JinjaTurtle</a>
, a tool to generate Jinja2
templates from basic configuration files in various formats, and produce the key/value data
that can be plugged into Ansible inventory.</p>
<p>If JinjaTurtle was on the <code>PATH</code>, Enroll can generate Jinja2 templates and Ansible variables
from native config files rather than copying them verbatim.</p>
<p>After this, I realised that running Enroll across a fleet of unmanaged servers (the horror!)
effectively created a lot of monolothic repos <em>per host</em>, which is a bit of an anti-pattern for
Ansible, which is good at DRYing up configuration by moving host-specific data into inventory.</p>
<p>So introduced multi-site mode via the <code>--fqdn</code> option. And so the scope continued to creep: remote
mode introduced harvesting over SSH without installing anything on the target. SOPS encryption
was then added to secure harvests at rest, and diff mode to compare two harvests in order to
detect drift and notify about that by email or webhook.</p>
<h2 id="growing-up">Growing up</h2>
<p>RedHat support came next (partly because I became enthusiastic about running Enroll against
QubesOS AppVMs and templates) - DNF5, DNF, and RPM-based systems could be harvested alongside
Debian and APT. Enroll learned to explain its own harvests (<code>enroll explain</code>), validate them
against an official schema (which was partly just because I was interested in learning about
the JSON Schema standards), and even attempt to repair diff-detected drift automatically
(because I also miss the Puppet days where an agent would revert on-the-fly changes).</p>
<p>From there, I became interested in more esoteric stuff, such as: what exists on a server that
might not even be written to disk in a clear form such as a config file?</p>
<p>So 'firewall state' joined the harvest: <code>ipset</code> and <code>iptables</code> rules captured both from on-disk
config and from live runtime, emitted as an Ansible role. Flatpaks, Snaps, and Docker/Podman
images are also now detected. Active <code>sysctl</code> parameters are captured into
<code>/etc/sysctl.d/99-enroll.conf</code>.</p>
<p>In parallel, and in response to nice feedback from strangers, JinjaTurtle-powered templating
of <code>sshd_config</code> has since rounded out the config-file coverage.</p>
<p>Further feedback from an old friend pointed out that the number of roles in the manifested
Ansible was quite overwhelming - effectively a role per package. I have since modified Enroll
to detect the common 'category' of programs (the same sort of categories that tend to dictate
how your applications are grouped in an app menu in a desktop Linux OS, for example), and to
build Ansible roles based on those category names instead. (The old behaviour is still enforceable).</p>
<h2 id="the-hardening-turn">The hardening turn</h2>
<p>By mid 2026, I started to get a bit nervous. And, I'll be honest, throwing some LLMs at the
codebase started to uncover risks I hadn't really thought about before.</p>
<p>The more Enroll could do, the more obvious the attack surface became. The tool reads untrusted
filesystem state and generates code that runs on production hosts. Let's consider a 'shared hosting'
scenario: data pre-prepared by an unprivileged user could then be swept up into what is now
a malicious harvest. Malicious, how? I'm talking about the ability to inject malicious Jinja2
into generated playbooks, or use symlinks to exfiltrate files outside the harvest tree, just
to name a few examples.</p>
<p>In the end, I opted to remove some unsafe features and added a <em>lot</em> - and I mean <em>a lot</em> of
defence in depth. Examples:</p>
<ul>
<li>Auto-repair of drift was removed - automatically applying a harvest to repair drift meant a tampered harvest
could write arbitrary state. The safer path was to regenerate a manifest from a trusted harvest
and apply it yourself.</li>
<li>Harvests were validated before manifesting: symlinks pointing outside the
artifact tree were rejected, schema validation was enforced, and hardlinked source files were
refused.</li>
<li><code>PATH</code> checking was tightened when running as root.</li>
<li>Output directories were validated.</li>
<li>TOCTOU mitigations froze directory harvest bundles into a private <code>0700</code> copy before consumers
read them.</li>
<li>Jinja2 injection defences escaped verbatim JSON, forbade Ansible built-in globals in
generated variable names</li>
<li>salted role prefixes against malicious var name injection.</li>
</ul>
<p>There was even a period where I had added Puppet and Salt renderers, but I then removed them in
the same cycle - the complexity wasn't worth it, and Ansible-only kept the attack surface smaller.</p>
<h2 id="closing-the-remote-tampering-window">Closing the remote tampering window</h2>
<p>Remote harvesting also had its own subtle TOCTOU risk. Sudo-created remote bundles were chowned to the SSH
user before packaging - a tiny window where the user could tamper with the bundle. The fix keeps
bundles root-owned while root packages and hashed them, exposes only the archive to the
authenticated SSH uid, and verifies the root-computed digest after download. Tar member limits
are now enforced lazily during parsing, and aggregate byte and entry limits are applied to
directory harvest bundles with fail-closed behaviour under mutation, to avoid deliberate resource
exhaustion issues.</p>
<h2 id="lessons-learned">Lessons learned</h2>
<p>You can sometimes really get carried away without a strict scope.</p>
<p>I care a lot about security, and there was even a point where I felt too scared to keep Enroll
published. I worried a lot about the trust people might place in the tool given all the things I
(well, AI to a large extent) kept uncovering. For a while I pulled the app down altogether from
my servers/package repositories.</p>
<p>People close to me have since been at pains to point out that this sort of worry is what <em>makes</em>
a good maintainer. The worse problem is indifference.</p>
<p>There may still be security issues in Enroll. Nothing is perfect. As my friend Marco says, &quot;water
that is too pure, carries no fish&quot;.</p>
<p>When I put the app back up, some people even thanked me.</p>
<h2 id="where-it-stands">Where it stands</h2>
<p>Enroll today is a tool I'd run against a client's snowflake quite happily. It harvests
packages, services, config files, firewall state, sysctl, containers, and Flatpaks/Snaps. It
generates clean Ansible roles grouped by package or section. It diffs harvests for drift
detection. And it does all of this with a security model that assumes the harvested host might
be hostile. The <a href="https://git.mig5.net/mig5/enroll/src/branch/main/SECURITY.md" target="_blank" rel="noopener noreferrer">threat model</a>
 is
probably the most well defined I've ever written for a piece of software.</p>
<p>The code and full changelog are at
<a href="https://git.mig5.net/mig5/enroll" target="_blank" rel="noopener noreferrer">git.mig5.net/mig5/enroll</a>
, with documentation at
<a href="https://enroll.sh" target="_blank" rel="noopener noreferrer">enroll.sh</a>
.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Started: December 2025</li>
<li>Language: Python</li>
<li>Targets: Debian, Ubuntu, Fedora, RHEL-like</li>
<li>Output: Ansible roles &amp; playbooks</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://enroll.sh" target="_blank" rel="noopener noreferrer">Website / docs</a>
</li>
<li><a href="https://git.mig5.net/mig5/enroll" target="_blank" rel="noopener noreferrer">Source repo</a>
</li>
<li><a href="https://pypi.org/project/enroll/" target="_blank" rel="noopener noreferrer">PyPI</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Got snowflakes to enrol?</div>
  <div class="smallprint mb-3">I do contract sysadmin work and can help bring unmanaged hosts under config management safely.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></description>
      <content:encoded><![CDATA[<section class="hero hero--compact">
  <div class="container">
    <div class="kicker mb-3"><i class="bi bi-terminal"></i><span>Enroll</span></div>
    <h1 class="display-6 fw-semibold mb-2">From snowflake harvester to hardened Ansible generator</h1>
    <p class="text-muted mb-3"><time datetime="2026-07-11">July 11, 2026</time></p>
    <p class="lead mb-0">Enroll started as a way to snapshot a snowflake server's <code>/etc</code> into Ansible roles. After
months of development and several security audits, it's now a hardened harvesting tool with remote
mode, SOPS encryption, drift detection, and a focus on fail-closed safety.</p>
  </div>
</section>

<section class="section">
  <div class="container">
    <div class="row g-4">
      <div class="col-12 col-lg-8">
        <div class="small text-secondary mb-4 article-byline">
          By <a href="/about/" rel="author">Miguel Jacq</a>
          <span class="mx-1" aria-hidden="true">·</span>
          <time datetime="2026-07-11T00:00:00&#43;10:00">11 July 2026</time>
        </div>

<h2 id="the-problem">The problem</h2>
<p>Every sysadmin has met the snowflake: a server that was hand-configured over years, never under
config management, and now nobody wants to touch it. Rebuilding it from scratch means weeks of
archaeology. Enroll was born to short-circuit that - inspect a running Debian or RedHat-like
host, harvest the state that matters, and generate Ansible roles and playbooks that could
reproduce it.</p>
<h2 id="the-beginning">The beginning</h2>
<p>I started Enroll in early December 2025 as a way to move quickly on exactly that problem. From
day one, I wanted to avoid the reverse problem: capturing <em>excessive</em> state that is a waste of
time - for example, stuff one gets from the OS no matter what.</p>
<p>The first iterations could inspect packages, services, and <code>/etc</code> files on a running host, that
were likely to be 'extra' stuff installed on the machine beyond the base OS image. The goal then
was to emit one Ansible role per detected package.</p>
<p>It became clear early on that Enroll could do more than copy files. A short time earlier in 2025
I had produced <a href="https://git.mig5.net/mig5/jinjaturtle" target="_blank" rel="noopener noreferrer">JinjaTurtle</a>
, a tool to generate Jinja2
templates from basic configuration files in various formats, and produce the key/value data
that can be plugged into Ansible inventory.</p>
<p>If JinjaTurtle was on the <code>PATH</code>, Enroll can generate Jinja2 templates and Ansible variables
from native config files rather than copying them verbatim.</p>
<p>After this, I realised that running Enroll across a fleet of unmanaged servers (the horror!)
effectively created a lot of monolothic repos <em>per host</em>, which is a bit of an anti-pattern for
Ansible, which is good at DRYing up configuration by moving host-specific data into inventory.</p>
<p>So introduced multi-site mode via the <code>--fqdn</code> option. And so the scope continued to creep: remote
mode introduced harvesting over SSH without installing anything on the target. SOPS encryption
was then added to secure harvests at rest, and diff mode to compare two harvests in order to
detect drift and notify about that by email or webhook.</p>
<h2 id="growing-up">Growing up</h2>
<p>RedHat support came next (partly because I became enthusiastic about running Enroll against
QubesOS AppVMs and templates) - DNF5, DNF, and RPM-based systems could be harvested alongside
Debian and APT. Enroll learned to explain its own harvests (<code>enroll explain</code>), validate them
against an official schema (which was partly just because I was interested in learning about
the JSON Schema standards), and even attempt to repair diff-detected drift automatically
(because I also miss the Puppet days where an agent would revert on-the-fly changes).</p>
<p>From there, I became interested in more esoteric stuff, such as: what exists on a server that
might not even be written to disk in a clear form such as a config file?</p>
<p>So 'firewall state' joined the harvest: <code>ipset</code> and <code>iptables</code> rules captured both from on-disk
config and from live runtime, emitted as an Ansible role. Flatpaks, Snaps, and Docker/Podman
images are also now detected. Active <code>sysctl</code> parameters are captured into
<code>/etc/sysctl.d/99-enroll.conf</code>.</p>
<p>In parallel, and in response to nice feedback from strangers, JinjaTurtle-powered templating
of <code>sshd_config</code> has since rounded out the config-file coverage.</p>
<p>Further feedback from an old friend pointed out that the number of roles in the manifested
Ansible was quite overwhelming - effectively a role per package. I have since modified Enroll
to detect the common 'category' of programs (the same sort of categories that tend to dictate
how your applications are grouped in an app menu in a desktop Linux OS, for example), and to
build Ansible roles based on those category names instead. (The old behaviour is still enforceable).</p>
<h2 id="the-hardening-turn">The hardening turn</h2>
<p>By mid 2026, I started to get a bit nervous. And, I'll be honest, throwing some LLMs at the
codebase started to uncover risks I hadn't really thought about before.</p>
<p>The more Enroll could do, the more obvious the attack surface became. The tool reads untrusted
filesystem state and generates code that runs on production hosts. Let's consider a 'shared hosting'
scenario: data pre-prepared by an unprivileged user could then be swept up into what is now
a malicious harvest. Malicious, how? I'm talking about the ability to inject malicious Jinja2
into generated playbooks, or use symlinks to exfiltrate files outside the harvest tree, just
to name a few examples.</p>
<p>In the end, I opted to remove some unsafe features and added a <em>lot</em> - and I mean <em>a lot</em> of
defence in depth. Examples:</p>
<ul>
<li>Auto-repair of drift was removed - automatically applying a harvest to repair drift meant a tampered harvest
could write arbitrary state. The safer path was to regenerate a manifest from a trusted harvest
and apply it yourself.</li>
<li>Harvests were validated before manifesting: symlinks pointing outside the
artifact tree were rejected, schema validation was enforced, and hardlinked source files were
refused.</li>
<li><code>PATH</code> checking was tightened when running as root.</li>
<li>Output directories were validated.</li>
<li>TOCTOU mitigations froze directory harvest bundles into a private <code>0700</code> copy before consumers
read them.</li>
<li>Jinja2 injection defences escaped verbatim JSON, forbade Ansible built-in globals in
generated variable names</li>
<li>salted role prefixes against malicious var name injection.</li>
</ul>
<p>There was even a period where I had added Puppet and Salt renderers, but I then removed them in
the same cycle - the complexity wasn't worth it, and Ansible-only kept the attack surface smaller.</p>
<h2 id="closing-the-remote-tampering-window">Closing the remote tampering window</h2>
<p>Remote harvesting also had its own subtle TOCTOU risk. Sudo-created remote bundles were chowned to the SSH
user before packaging - a tiny window where the user could tamper with the bundle. The fix keeps
bundles root-owned while root packages and hashed them, exposes only the archive to the
authenticated SSH uid, and verifies the root-computed digest after download. Tar member limits
are now enforced lazily during parsing, and aggregate byte and entry limits are applied to
directory harvest bundles with fail-closed behaviour under mutation, to avoid deliberate resource
exhaustion issues.</p>
<h2 id="lessons-learned">Lessons learned</h2>
<p>You can sometimes really get carried away without a strict scope.</p>
<p>I care a lot about security, and there was even a point where I felt too scared to keep Enroll
published. I worried a lot about the trust people might place in the tool given all the things I
(well, AI to a large extent) kept uncovering. For a while I pulled the app down altogether from
my servers/package repositories.</p>
<p>People close to me have since been at pains to point out that this sort of worry is what <em>makes</em>
a good maintainer. The worse problem is indifference.</p>
<p>There may still be security issues in Enroll. Nothing is perfect. As my friend Marco says, &quot;water
that is too pure, carries no fish&quot;.</p>
<p>When I put the app back up, some people even thanked me.</p>
<h2 id="where-it-stands">Where it stands</h2>
<p>Enroll today is a tool I'd run against a client's snowflake quite happily. It harvests
packages, services, config files, firewall state, sysctl, containers, and Flatpaks/Snaps. It
generates clean Ansible roles grouped by package or section. It diffs harvests for drift
detection. And it does all of this with a security model that assumes the harvested host might
be hostile. The <a href="https://git.mig5.net/mig5/enroll/src/branch/main/SECURITY.md" target="_blank" rel="noopener noreferrer">threat model</a>
 is
probably the most well defined I've ever written for a piece of software.</p>
<p>The code and full changelog are at
<a href="https://git.mig5.net/mig5/enroll" target="_blank" rel="noopener noreferrer">git.mig5.net/mig5/enroll</a>
, with documentation at
<a href="https://enroll.sh" target="_blank" rel="noopener noreferrer">enroll.sh</a>
.</p>
</div>
<div class="col-12 col-lg-4">

<div class="callout p-4">
  <div class="fw-semibold mb-2">At a glance</div>
  <ul>
<li>Started: December 2025</li>
<li>Language: Python</li>
<li>Targets: Debian, Ubuntu, Fedora, RHEL-like</li>
<li>Output: Ansible roles &amp; playbooks</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-2">Links</div>
  <ul>
<li><a href="https://enroll.sh" target="_blank" rel="noopener noreferrer">Website / docs</a>
</li>
<li><a href="https://git.mig5.net/mig5/enroll" target="_blank" rel="noopener noreferrer">Source repo</a>
</li>
<li><a href="https://pypi.org/project/enroll/" target="_blank" rel="noopener noreferrer">PyPI</a>
</li>
</ul>

</div>

<div class="callout p-4">
  <div class="fw-semibold mb-1">Got snowflakes to enrol?</div>
  <div class="smallprint mb-3">I do contract sysadmin work and can help bring unmanaged hosts under config management safely.</div>
  <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
    <a class="btn btn-primary" href="/contact/">Contact me</a>
    <a class="btn btn-coffee" href="https://buymeacoffee.com/mig5" target="_blank" rel="noopener noreferrer"><i class="bi bi-cup-hot"></i> Buy me a coffee</a>
  </div>
  <div class="smallprint">Did you appreciate this article? Any support is appreciated!</div>
</div>

</div>
    </div>
  </div>
</section>

]]></content:encoded>
    </item>
    
  </channel>
</rss>
