<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nikita Vakula</title>
    <description>The latest articles on DEV Community by Nikita Vakula (@krjakbrjak).</description>
    <link>https://dev.to/krjakbrjak</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F976432%2F5c48dcb2-398e-4839-85f1-fb21c75a0eb3.png</url>
      <title>DEV Community: Nikita Vakula</title>
      <link>https://dev.to/krjakbrjak</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/krjakbrjak"/>
    <language>en</language>
    <item>
      <title>When systemd-resolved Picks the Wrong DNS Server</title>
      <dc:creator>Nikita Vakula</dc:creator>
      <pubDate>Tue, 17 Mar 2026 22:41:56 +0000</pubDate>
      <link>https://dev.to/krjakbrjak/when-systemd-resolved-picks-the-wrong-dns-server-3pk5</link>
      <guid>https://dev.to/krjakbrjak/when-systemd-resolved-picks-the-wrong-dns-server-3pk5</guid>
      <description>&lt;p&gt;In a &lt;a href="https://dev.to/krjakbrjak/building-a-simple-dns-forwarder-for-vms-in-go-1gm4"&gt;previous post&lt;/a&gt;, I described how I built a DNS forwarder for &lt;a href="https://github.com/q-controller/qcontroller" rel="noopener noreferrer"&gt;qcontroller&lt;/a&gt; — a tool that manages QEMU VM instances. The forwarder watches the host's &lt;code&gt;resolv.conf&lt;/code&gt; for changes and propagates upstream DNS servers to VMs transparently. It worked great — until I noticed that VMs occasionally failed to resolve private hostnames defined in the host's &lt;code&gt;/etc/hosts&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Symptom
&lt;/h2&gt;

&lt;p&gt;The setup was straightforward. Inside each VM, DHCP advertised three DNS servers: the gateway IP (pointing to the forwarder) plus &lt;code&gt;8.8.8.8&lt;/code&gt; and &lt;code&gt;1.1.1.1&lt;/code&gt; as fallbacks. From the host, querying the forwarder directly worked fine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;dig @192.168.71.1 myserver.internal.corp
&lt;span class="gp"&gt;;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; ANSWER SECTION:
&lt;span class="go"&gt;myserver.internal.corp. 0   IN  A   10.0.50.42
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But from inside a VM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;dig myserver.internal.corp
&lt;span class="gp"&gt;;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; -&amp;gt;&amp;gt;HEADER&lt;span class="o"&gt;&amp;lt;&amp;lt;-&lt;/span&gt; &lt;span class="no"&gt;opcode&lt;/span&gt;&lt;span class="sh"&gt;: QUERY, status: NXDOMAIN
&lt;/span&gt;&lt;span class="gp"&gt;;&lt;/span&gt;&lt;span class="sh"&gt;; SERVER: 127.0.0.53#53(127.0.0.53) (UDP)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;NXDOMAIN&lt;/code&gt;. The VM's systemd-resolved returned a negative answer — even though the forwarder had the correct one. What was going on?&lt;/p&gt;

&lt;h2&gt;
  
  
  systemd-resolved Treats All DNS Servers as Equivalent
&lt;/h2&gt;

&lt;p&gt;A quick &lt;code&gt;resolvectl status&lt;/code&gt; inside the VM revealed the problem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Current DNS Server: 1.1.1.1
       DNS Servers: 192.168.71.1 1.1.1.1 8.8.8.8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;systemd-resolved had picked &lt;code&gt;1.1.1.1&lt;/code&gt; as its active server — not the forwarder. And &lt;code&gt;1.1.1.1&lt;/code&gt; knows nothing about my private &lt;code&gt;/etc/hosts&lt;/code&gt; entries.&lt;/p&gt;

&lt;p&gt;This is by design. From the &lt;a href="https://www.freedesktop.org/software/systemd/man/latest/systemd-resolved.service.html" rel="noopener noreferrer"&gt;systemd-resolved documentation&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The nss-dns resolver maintains little state between subsequent DNS queries, and for each query always talks to the first listed DNS server from /etc/resolv.conf first, and on failure continues with the next until reaching the end of the list which is when the query fails. The resolver in systemd-resolved however maintains state, and will continuously talk to the same server for all queries in a particular lookup scope until some form of error is seen at which point it will switch to the next server, and then stay with it for all queries on the scope until the next failure, and so on, eventually returning to the first configured server. This is done to optimize lookup times, in particular given that the resolver typically must first probe server feature sets when talking to a server, which takes time. &lt;strong&gt;This different behaviour implies that listed DNS servers per lookup scope must be equivalent in the zones they serve, so that sending a query to one of them will yield the same results as sending it to another configured DNS server.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In other words: all configured DNS servers within a scope are treated as &lt;strong&gt;interchangeable&lt;/strong&gt;. systemd-resolved picks one, sticks with it, and only rotates on failure. If &lt;code&gt;1.1.1.1&lt;/code&gt; responds (even with NXDOMAIN), that counts as "working" — so it never bothers trying the forwarder.&lt;/p&gt;

&lt;p&gt;The relevant selection logic lives in &lt;a href="https://github.com/systemd/systemd/blob/main/src/resolve/resolved-dns-scope.c" rel="noopener noreferrer"&gt;&lt;code&gt;resolved-dns-scope.c&lt;/code&gt;&lt;/a&gt; (&lt;code&gt;dns_scope_get_dns_server()&lt;/code&gt;) and &lt;a href="https://github.com/systemd/systemd/blob/main/src/resolve/resolved-dns-server.c" rel="noopener noreferrer"&gt;&lt;code&gt;resolved-dns-server.c&lt;/code&gt;&lt;/a&gt; (&lt;code&gt;manager_next_dns_server()&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;The fix is straightforward: advertise &lt;strong&gt;only&lt;/strong&gt; the forwarder's IP via DHCP, so the VM's systemd-resolved has no choice but to use it. No public servers in the mix means no wrong server to stick to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Forwarding to systemd-resolved
&lt;/h2&gt;

&lt;p&gt;But the previous forwarder design had a gap. As described in the &lt;a href="https://dev.to/krjakbrjak/building-a-simple-dns-forwarder-for-vms-in-go-1gm4"&gt;earlier post&lt;/a&gt;, it read upstream servers from &lt;code&gt;/run/systemd/resolve/resolv.conf&lt;/code&gt; — which contains the real upstream DNS servers (like &lt;code&gt;8.8.8.8&lt;/code&gt;), bypassing systemd-resolved entirely. That means the forwarder also bypassed everything systemd-resolved provides: &lt;code&gt;/etc/hosts&lt;/code&gt; resolution, mDNS, split-DNS, VPN routing.&lt;/p&gt;

&lt;p&gt;What if the forwarder just forwarded to &lt;code&gt;127.0.0.53&lt;/code&gt; instead?&lt;/p&gt;

&lt;p&gt;It turns out this is easy to do. As explained in the &lt;a href="https://dev.to/krjakbrjak/network-namespaces-isolating-vm-networking-44o5"&gt;network namespaces post&lt;/a&gt;, the DNS forwarder runs in the &lt;strong&gt;root network namespace&lt;/strong&gt; — it listens on the gateway IP (the host-side end of the veth pair), which is reachable from the VM namespace. Since it's in the root namespace, it can talk to &lt;code&gt;127.0.0.53&lt;/code&gt; directly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;VM query ──► gateway IP:53 (forwarder, root ns) ──► 127.0.0.53 (systemd-resolved)
                                                         │
                                                         ├── /etc/hosts
                                                         ├── /etc/resolv.conf
                                                         ├── mDNS
                                                         ├── VPN split-DNS
                                                         └── ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The forwarder just needed one small extension — a &lt;code&gt;WithUpstreams&lt;/code&gt; option that accepts static upstream addresses instead of reading from a file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;forwarder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;dns&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewDNSFailoverForwarder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;dns&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithForwarderAddress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gatewayIP&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;dns&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithForwarderTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;dns&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithUpstreams&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"127.0.0.53:53"&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;WithUpstreams&lt;/code&gt; is provided, the forwarder stores the addresses directly — no file watching, no fsnotify, no resolv.conf parsing. When it's not provided, the existing behavior kicks in: watch &lt;code&gt;resolv.conf&lt;/code&gt; and update upstreams dynamically.&lt;/p&gt;

&lt;p&gt;The configuration is modeled as a protobuf &lt;code&gt;oneof&lt;/code&gt;, making the two modes mutually exclusive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight protobuf"&gt;&lt;code&gt;&lt;span class="kd"&gt;message&lt;/span&gt; &lt;span class="nc"&gt;Dns&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="na"&gt;zone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;oneof&lt;/span&gt; &lt;span class="n"&gt;upstream&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="na"&gt;resolv_conf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;StaticUpstreams&lt;/span&gt; &lt;span class="na"&gt;static&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When neither is set, the forwarder falls back to auto-detecting the resolv.conf path — preserving full backward compatibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  Covering All Cases
&lt;/h2&gt;

&lt;p&gt;This naturally leads to three deployment modes, each covering different environments:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;systemd-resolved (most Linux desktops/servers):&lt;/strong&gt; Use static upstreams pointing to &lt;code&gt;127.0.0.53&lt;/code&gt;. Gets &lt;code&gt;/etc/hosts&lt;/code&gt;, mDNS, split-DNS, VPN — everything systemd-resolved handles.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"dns"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"zone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"static"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"endpoints"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"127.0.0.53:53"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Non-systemd with resolv.conf:&lt;/strong&gt; Use the dynamic resolv.conf watcher. The forwarder picks up upstream changes automatically.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"dns"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"zone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"resolv_conf"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/etc/resolv.conf"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Non-systemd with CoreDNS:&lt;/strong&gt; For environments where CoreDNS plugins are needed (e.g., the &lt;a href="https://coredns.io/plugins/hosts/" rel="noopener noreferrer"&gt;&lt;code&gt;hosts&lt;/code&gt; plugin&lt;/a&gt; for &lt;code&gt;/etc/hosts&lt;/code&gt; support), qcontroller also supports an embedded CoreDNS backend:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;dns&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewCoreDNSServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;dns&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithForwarderAddress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gatewayIP&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;dns&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithResolvconfPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/etc/resolv.conf"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The root cause came down to a design assumption in systemd-resolved: all configured DNS servers must be equivalent. When some know about private resources and others don't, things break in subtle, hard-to-debug ways.&lt;/p&gt;

&lt;p&gt;The fix turned out to be small. The forwarder already ran in the root namespace, so &lt;code&gt;127.0.0.53&lt;/code&gt; was right there. Adding a &lt;code&gt;WithUpstreams&lt;/code&gt; option and a &lt;code&gt;oneof&lt;/code&gt; in the protobuf schema was enough to make it work. VMs get full host DNS resolution — &lt;code&gt;/etc/hosts&lt;/code&gt;, VPN, mDNS — without touching their configuration.&lt;/p&gt;

</description>
      <category>systemd</category>
      <category>dns</category>
      <category>networking</category>
      <category>go</category>
    </item>
    <item>
      <title>Giving Your AI the Right Context with Model Context Protocol (MCP)</title>
      <dc:creator>Nikita Vakula</dc:creator>
      <pubDate>Mon, 09 Mar 2026 19:02:15 +0000</pubDate>
      <link>https://dev.to/krjakbrjak/giving-your-ai-the-right-context-with-model-context-protocol-mcp-276o</link>
      <guid>https://dev.to/krjakbrjak/giving-your-ai-the-right-context-with-model-context-protocol-mcp-276o</guid>
      <description>&lt;p&gt;Nowadays, pretty much everyone works with AI one way or another. Whether it's writing code, debugging, designing infrastructure — LLMs have pushed our productivity to yet another level. But here's the thing: in order to utilize their power more efficiently, we can actually help them be more efficient. That's where the Model Context Protocol (MCP) comes in — and in this post, I'll show how to build a simple MCP server in Go.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Say you're working on a backend. You have your data — maybe a database, maybe an API — and you want to think about what kind of interface or tooling you could build around it. You open your favorite AI assistant and start describing your data structures. "I have a books table with id, title, author, and a loans table that references..." — you get the idea. It works, but it's tedious. You're essentially doing the model's homework.&lt;/p&gt;

&lt;p&gt;Why not just let it look at the data directly?&lt;/p&gt;

&lt;p&gt;For a small service without authentication, sure — you could point it at an endpoint and say "fetch some data, see its structure." But imagine you're working on something bigger. Something behind authentication layers, internal APIs, complex data relationships. You can't just hand the model a URL and hope for the best.&lt;/p&gt;

&lt;p&gt;Instead, you could build a small application that sits between the model and your backend — something that knows how to pull the data and explain its shape to the model. The model calls your app, your app talks to the backend, and the model gets exactly the context it needs.&lt;/p&gt;

&lt;p&gt;You get the idea. If only there was a standard protocol for this...&lt;/p&gt;

&lt;h2&gt;
  
  
  What is Model Context Protocol (MCP)?
&lt;/h2&gt;

&lt;p&gt;It's called the &lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;Model Context Protocol (MCP)&lt;/a&gt;, designed by Anthropic. And it's exactly what you'd want.&lt;/p&gt;

&lt;p&gt;MCP defines a standard way for AI models to discover and call external tools. Your app becomes an MCP server — it advertises what it can do (search, fetch, create, whatever), and the model calls those tools when it needs context. No more manual copy-pasting. No more describing your data schema in a chat window.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Simple Example: Library Catalog
&lt;/h2&gt;

&lt;p&gt;To see how this works in practice, I built a tiny MCP server in Go — a library catalog. Two tools: search books and get book details.&lt;/p&gt;

&lt;p&gt;Here's the MCP server configuration (&lt;code&gt;.mcp.json&lt;/code&gt;), placed in the root of your workspace. This file defines MCP servers that provide additional context and capabilities to the AI client (e.g. Claude Code):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"library"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./mcp-library"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the server itself — using the &lt;a href="https://github.com/modelcontextprotocol/go-sdk" rel="noopener noreferrer"&gt;official Go SDK&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;server&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Implementation&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="s"&gt;"library-mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Version&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"1.0.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tool&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;        &lt;span class="s"&gt;"search_books"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Description&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Search the library catalog. Returns matching books."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;InputSchema&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RawMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`{
            "type": "object",
            "properties": {
                "title":  {"type": "string", "description": "Filter by book title."},
                "author": {"type": "string", "description": "Filter by author name."}
            }
        }`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallToolRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallToolResult&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;parseArgs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;searchBooks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"author"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;textResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tool&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;        &lt;span class="s"&gt;"get_book"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Description&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Get full details for a book by its ID, including loan history."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;InputSchema&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RawMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`{
            "type": "object",
            "properties": {
                "id": {"type": "string", "description": "The book ID."}
            },
            "required": ["id"]
        }`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallToolRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallToolResult&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;parseArgs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;getBook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;textResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Background&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StdioTransport&lt;/span&gt;&lt;span class="p"&gt;{});&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each tool declares its name, description, and input schema — this is what the model sees. When the model decides it needs to search for books by Tolkien, it calls &lt;code&gt;search_books&lt;/code&gt; with &lt;code&gt;{"author": "Tolkien"}&lt;/code&gt;. The server does the lookup and returns the results. The model never had to be told what the data looks like — it discovered and queried it on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Looks Like in Practice
&lt;/h2&gt;

&lt;p&gt;With this MCP server running, I can open Claude Code in my project directory and just have a conversation:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Are there any books by Tolkien?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The model picks up the &lt;code&gt;search_books&lt;/code&gt; tool, calls it with the right filter, and comes back with the results. No prompting gymnastics. No pasting JSON blobs. It just works.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5dilznsmss2m23h3gpc6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5dilznsmss2m23h3gpc6.png" alt="VSCode/Claude using an MCP server to search a library catalog" width="800" height="481"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And the best part — this is a trivial example with hardcoded data. Replace the mock data with actual database queries or API calls to your production backend, and you've got yourself an AI assistant that truly understands your system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;MCP bridges the gap between what the model can do and what it knows about your specific context. Instead of explaining your world to the model, you give it the tools to explore it. The protocol is open, the SDKs are available in multiple languages, and the integration with tools like Claude Code is already there.&lt;/p&gt;

&lt;p&gt;If you're building anything where an LLM could benefit from knowing your data — and let's be honest, that's most things these days — MCP is worth looking into.&lt;/p&gt;

&lt;p&gt;The full source code for this example is available &lt;a href="https://gist.github.com/krjakbrjak/d8522e7c6f969305141e91e8523bf31c" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>llm</category>
      <category>go</category>
    </item>
    <item>
      <title>Writing a BPF packet filter on macOS in Go</title>
      <dc:creator>Nikita Vakula</dc:creator>
      <pubDate>Thu, 19 Feb 2026 20:46:57 +0000</pubDate>
      <link>https://dev.to/krjakbrjak/writing-a-bpf-packet-filter-on-macos-in-go-45al</link>
      <guid>https://dev.to/krjakbrjak/writing-a-bpf-packet-filter-on-macos-in-go-45al</guid>
      <description>&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Without filter                 With BPF filter

  Network     Userspace          Network     Userspace
 ┌───────┐   ┌─────────┐       ┌───────┐   ┌─────────┐
 │  ARP  │──→│  ARP    │       │  ARP  │──→│  ARP    │
 │  IPv4 │──→│  IPv4   │       │  IPv4 │   │  reply  │
 │  ARP  │──→│  ARP    │       │  ARP  │   │         │
 │  IPv6 │──→│  IPv6   │       │  IPv6 │   │         │
 │  IPv4 │──→│  IPv4   │       │  IPv4 │   │         │
 │  ARP  │──→│  ARP    │       │  ARP  │   │         │
 │  ...  │──→│  ...    │       │  ...  │   │         │
 └───────┘   └─────────┘       └───────┘   └─────────┘
  ~10,000     ~10,000            ~10,000       ~100
  packets     copied             packets      copied

  App filters in userspace       Kernel filters before copy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The problem: discovering VM IP addresses without a guest agent
&lt;/h2&gt;

&lt;p&gt;In a recent change to qcontroller, I removed the dependency on QEMU Guest Agent (QGA) for discovering a VM's IP address. Previously, users had to install QGA inside every VM—easy enough with cloud-init, but still a hard requirement just to answer the question "what IP did this VM get?"&lt;/p&gt;

&lt;p&gt;The alternative: ARP scanning. I already control the MAC addresses assigned to VMs, so I can periodically broadcast ARP requests on the virtual network interface and match the replies against known MACs. Pure Layer 2, no guest cooperation needed.&lt;/p&gt;

&lt;p&gt;This post isn't about the ARP scanner itself (that's in &lt;a href="https://github.com/q-controller/qcontroller/pull/26" rel="noopener noreferrer"&gt;PR #26&lt;/a&gt;). It's about a problem I hit on macOS, and how six lines of BPF bytecode solved it. The BPF filter is implemented in &lt;a href="https://github.com/q-controller/qcontroller/pull/27" rel="noopener noreferrer"&gt;PR #27&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Raw sockets on macOS: there aren't any
&lt;/h2&gt;

&lt;p&gt;On Linux, you open an &lt;code&gt;AF_PACKET&lt;/code&gt; socket, bind it to an interface, and you're reading raw Ethernet frames. macOS doesn't support &lt;code&gt;AF_PACKET&lt;/code&gt;. Instead, you go through BPF—Berkeley Packet Filter.&lt;/p&gt;

&lt;p&gt;The setup looks roughly like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open &lt;code&gt;/dev/bpf0&lt;/code&gt; (or &lt;code&gt;/dev/bpf1&lt;/code&gt;, &lt;code&gt;/dev/bpf2&lt;/code&gt;, ... — you try them until one is available)&lt;/li&gt;
&lt;li&gt;Bind it to a network interface with &lt;code&gt;BIOCSETIF&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Enable immediate mode with &lt;code&gt;BIOCIMMEDIATE&lt;/code&gt; so reads return as soon as a packet arrives, rather than waiting for the buffer to fill&lt;/li&gt;
&lt;li&gt;Optionally enable promiscuous mode with &lt;code&gt;BIOCPROMISC&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Read from the file descriptor—you get raw Ethernet frames, each prefixed by a &lt;code&gt;bpf_hdr&lt;/code&gt; struct&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This works. But there's a catch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The flood
&lt;/h2&gt;

&lt;p&gt;Promiscuous mode means the BPF device captures &lt;em&gt;everything&lt;/em&gt; on the wire—not just frames addressed to your MAC. On my home network, which has maybe a dozen devices, a few seconds of capture produced roughly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;~9,400 ARP frames (requests and replies from all devices)&lt;/li&gt;
&lt;li&gt;~190 IPv4 frames&lt;/li&gt;
&lt;li&gt;~50 IPv6 frames&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's about &lt;strong&gt;10,000 frames&lt;/strong&gt; copied from kernel to userspace, where my Go code then checks each one: is it ARP? Is it a reply? Does the sender MAC match a VM I care about? For 99% of those frames, the answer is no.&lt;/p&gt;

&lt;p&gt;On a busier network—an office, a data center—this gets much worse. We're doing an O(n) scan of the entire network's chatter to find the handful of ARP replies we actually need. The kernel already has all these frames in its buffers; we're just making it copy them all to us so we can throw most away.&lt;/p&gt;

&lt;h2&gt;
  
  
  BPF is more than a packet source
&lt;/h2&gt;

&lt;p&gt;Here's the coolest thing about BPF: it's not just a mechanism for reading packets. It includes a programmable filter that runs &lt;em&gt;inside the kernel&lt;/em&gt;, before packets are copied to userspace. The "F" in BPF stands for Filter, and that filter is the interesting part.&lt;/p&gt;

&lt;p&gt;BPF defines a small virtual machine with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two registers&lt;/strong&gt;: &lt;code&gt;A&lt;/code&gt; (accumulator) and &lt;code&gt;X&lt;/code&gt; (index), both 32-bit&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A small instruction set&lt;/strong&gt;: load, store, jump, arithmetic, return&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The VM operates on the raw packet data. Instructions can load bytes from specific offsets in the packet, compare them, and either accept or reject the packet. The kernel runs this program on every incoming frame. Only frames that pass the filter get copied to userspace.&lt;/p&gt;

&lt;p&gt;This is the same mechanism that powers &lt;code&gt;tcpdump&lt;/code&gt; expressions. When you write &lt;code&gt;tcpdump arp&lt;/code&gt;, tcpdump compiles that into BPF bytecode and installs it via &lt;code&gt;BIOCSETF&lt;/code&gt;. We can do the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Ethernet frame layout
&lt;/h2&gt;

&lt;p&gt;To write a BPF filter, you need to know exactly what bytes you're looking at. An Ethernet frame carrying an ARP message is 42 bytes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Ethernet header (14 bytes):
  [0:6]   Destination MAC (broadcast: ff:ff:ff:ff:ff:ff)
  [6:12]  Source MAC
  [12:14] EtherType         ← 0x0806 means ARP

ARP payload (28 bytes):
  [14:16] Hardware type      (1 = Ethernet)
  [16:18] Protocol type      (0x0800 = IPv4)
  [18]    Hardware addr len   (6)
  [19]    Protocol addr len   (4)
  [20:22] Operation          ← 1 = request, 2 = reply
  [22:28] Sender MAC
  [28:32] Sender IP
  [32:38] Target MAC
  [38:42] Target IP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two fields matter for filtering:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Byte offset 12&lt;/strong&gt; (2 bytes): the EtherType. If it's not &lt;code&gt;0x0806&lt;/code&gt;, this isn't ARP—drop it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Byte offset 20&lt;/strong&gt; (2 bytes): the ARP opcode. If it's not &lt;code&gt;0x0002&lt;/code&gt;, this isn't a reply—drop it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The filter: six instructions
&lt;/h2&gt;

&lt;p&gt;Here's the complete BPF program using the &lt;code&gt;BPF_STMT&lt;/code&gt;/&lt;code&gt;BPF_JUMP&lt;/code&gt; macros from the &lt;a href="https://man.openbsd.org/bpf.4" rel="noopener noreferrer"&gt;bpf(4) man page&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="n"&gt;BPF_STMT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BPF_LD&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;BPF_H&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;BPF_ABS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;            &lt;span class="c1"&gt;// A = halfword at offset 12 (EtherType)&lt;/span&gt;
&lt;span class="n"&gt;BPF_JUMP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BPF_JMP&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;BPF_JEQ&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;BPF_K&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x0806&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// if A == 0x0806 (ARP) continue, else skip 3 to drop&lt;/span&gt;
&lt;span class="n"&gt;BPF_STMT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BPF_LD&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;BPF_H&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;BPF_ABS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;            &lt;span class="c1"&gt;// A = halfword at offset 20 (ARP opcode)&lt;/span&gt;
&lt;span class="n"&gt;BPF_JUMP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BPF_JMP&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;BPF_JEQ&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;BPF_K&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x0002&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// if A == 0x0002 (reply) continue, else skip 1 to drop&lt;/span&gt;
&lt;span class="n"&gt;BPF_STMT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BPF_RET&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;BPF_K&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0xFFFFFFFF&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;            &lt;span class="c1"&gt;// ACCEPT: return entire packet&lt;/span&gt;
&lt;span class="n"&gt;BPF_STMT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BPF_RET&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;BPF_K&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;                     &lt;span class="c1"&gt;// DROP: return 0 bytes (discard)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;BPF_STMT(code, k)&lt;/code&gt; encodes a non-branching instruction. &lt;code&gt;BPF_JUMP(code, k, jt, jf)&lt;/code&gt; encodes a conditional branch where &lt;code&gt;jt&lt;/code&gt; and &lt;code&gt;jf&lt;/code&gt; are the number of instructions to skip forward on true/false. The &lt;code&gt;code&lt;/code&gt; field is built by combining a class (&lt;code&gt;BPF_LD&lt;/code&gt;, &lt;code&gt;BPF_JMP&lt;/code&gt;, &lt;code&gt;BPF_RET&lt;/code&gt;), a size (&lt;code&gt;BPF_H&lt;/code&gt; for halfword—2 bytes), and an addressing mode (&lt;code&gt;BPF_ABS&lt;/code&gt; for absolute packet offset, &lt;code&gt;BPF_K&lt;/code&gt; for constant).&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;BPF_RET&lt;/code&gt; instruction tells the kernel how many bytes of the packet to copy to userspace. Returning &lt;code&gt;0xFFFFFFFF&lt;/code&gt; (the maximum &lt;code&gt;uint32&lt;/code&gt;) means "copy the entire packet." Returning &lt;code&gt;0&lt;/code&gt; means "copy nothing"—i.e., drop the packet.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;BPF_JUMP&lt;/code&gt; takes two skip counts: &lt;code&gt;jt&lt;/code&gt; (jump true) and &lt;code&gt;jf&lt;/code&gt; (jump false). A skip of 0 means "don't skip, just execute the next instruction"—sometimes called falling through. A skip of 3 means "skip the next 3 instructions."&lt;/p&gt;

&lt;p&gt;Let's trace through what happens for different packets:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An ARP reply arrives.&lt;/strong&gt; &lt;code&gt;BPF_LD&lt;/code&gt; loads bytes [12:14] into &lt;code&gt;A&lt;/code&gt;: &lt;code&gt;0x0806&lt;/code&gt;. &lt;code&gt;BPF_JEQ&lt;/code&gt; compares against &lt;code&gt;0x0806&lt;/code&gt;: match, &lt;code&gt;jt=0&lt;/code&gt;, so we fall through. Next &lt;code&gt;BPF_LD&lt;/code&gt; loads bytes [20:22]: &lt;code&gt;0x0002&lt;/code&gt;. &lt;code&gt;BPF_JEQ&lt;/code&gt; compares against &lt;code&gt;0x0002&lt;/code&gt;: match, fall through. &lt;code&gt;BPF_RET&lt;/code&gt; returns &lt;code&gt;0xFFFFFFFF&lt;/code&gt;—the kernel copies the full packet to userspace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An ARP request arrives.&lt;/strong&gt; Same path through the first three instructions, but bytes [20:22] contain &lt;code&gt;0x0001&lt;/code&gt; (request, not reply). &lt;code&gt;BPF_JEQ&lt;/code&gt;: no match, &lt;code&gt;jf=1&lt;/code&gt;, skip 1 instruction forward—past the accept—landing on &lt;code&gt;BPF_RET&lt;/code&gt; returning &lt;code&gt;0&lt;/code&gt;. Packet dropped. Never reaches userspace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An IPv4 packet arrives.&lt;/strong&gt; &lt;code&gt;BPF_LD&lt;/code&gt; loads bytes [12:14]: &lt;code&gt;0x0800&lt;/code&gt;. &lt;code&gt;BPF_JEQ&lt;/code&gt; against &lt;code&gt;0x0806&lt;/code&gt;: no match, &lt;code&gt;jf=3&lt;/code&gt;, skip 3 instructions forward, landing directly on the drop. Two instructions and it's done. The kernel never even looks at the ARP opcode field.&lt;/p&gt;

&lt;p&gt;Most traffic on a network is IPv4/IPv6, and it gets rejected after just two instructions—a load and a conditional jump. The kernel doesn't copy a single byte to userspace for those packets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing it in Go
&lt;/h2&gt;

&lt;p&gt;Go's &lt;code&gt;syscall&lt;/code&gt; package has &lt;code&gt;BpfStmt&lt;/code&gt; and &lt;code&gt;BpfJump&lt;/code&gt; functions for constructing BPF instructions, but they're deprecated. The recommended replacement is &lt;code&gt;golang.org/x/net/bpf&lt;/code&gt;, which provides typed instruction structs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;arpReplyFilter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;bpf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Instruction&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;bpf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LoadAbsolute&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Off&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Size&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;                          &lt;span class="c"&gt;// BPF_LD+BPF_H+BPF_ABS  k=12&lt;/span&gt;
    &lt;span class="n"&gt;bpf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JumpIf&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Cond&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bpf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JumpEqual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Val&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0x0806&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SkipFalse&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c"&gt;// BPF_JMP+BPF_JEQ+BPF_K k=0x0806 jt=0 jf=3&lt;/span&gt;
    &lt;span class="n"&gt;bpf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LoadAbsolute&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Off&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Size&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;                          &lt;span class="c"&gt;// BPF_LD+BPF_H+BPF_ABS  k=20&lt;/span&gt;
    &lt;span class="n"&gt;bpf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JumpIf&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Cond&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bpf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JumpEqual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Val&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0x0002&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SkipFalse&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c"&gt;// BPF_JMP+BPF_JEQ+BPF_K k=0x0002 jt=0 jf=1&lt;/span&gt;
    &lt;span class="n"&gt;bpf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RetConstant&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Val&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0xFFFFFFFF&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;                            &lt;span class="c"&gt;// BPF_RET+BPF_K          k=0xFFFFFFFF&lt;/span&gt;
    &lt;span class="n"&gt;bpf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RetConstant&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Val&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;                                     &lt;span class="c"&gt;// BPF_RET+BPF_K          k=0&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each Go struct maps directly to a BPF instruction. &lt;code&gt;LoadAbsolute{Off: 12, Size: 2}&lt;/code&gt; is &lt;code&gt;BPF_STMT(BPF_LD+BPF_H+BPF_ABS, 12)&lt;/code&gt;—load a halfword (2 bytes) from absolute packet offset 12. &lt;code&gt;JumpIf{Cond: bpf.JumpEqual, Val: 0x0806, SkipFalse: 3}&lt;/code&gt; is &lt;code&gt;BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, 0x0806, 0, 3)&lt;/code&gt;—&lt;code&gt;SkipFalse: 3&lt;/code&gt; means "if not equal, skip 3 instructions forward" (landing on the final &lt;code&gt;BPF_RET&lt;/code&gt; that drops the packet).&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;bpf.Assemble&lt;/code&gt; function compiles these typed instructions into raw bytecode (&lt;code&gt;[]bpf.RawInstruction&lt;/code&gt;). But here's where it gets interesting: &lt;code&gt;golang.org/x/net/bpf&lt;/code&gt; doesn't provide a function to install the filter on a macOS BPF device. It does for Linux sockets (&lt;code&gt;SO_ATTACH_FILTER&lt;/code&gt;), but the macOS &lt;code&gt;BIOCSETF&lt;/code&gt; ioctl needs a &lt;code&gt;syscall.BpfProgram&lt;/code&gt; struct pointing to &lt;code&gt;syscall.BpfInsn&lt;/code&gt; values. Fortunately, &lt;code&gt;bpf.RawInstruction&lt;/code&gt; and &lt;code&gt;syscall.BpfInsn&lt;/code&gt; have identical memory layouts—both are &lt;code&gt;{Op uint16, Jt uint8, Jf uint8, K uint32}&lt;/code&gt;—so an &lt;code&gt;unsafe.Pointer&lt;/code&gt; cast works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;setBPFFilterARPReply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;bpf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Assemble&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;arpReplyFilter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"failed to assemble BPF filter: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;prog&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BpfProgram&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Len&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;   &lt;span class="kt"&gt;uint32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="n"&gt;Insns&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BpfInsn&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="n"&gt;unsafe&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pointer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errno&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Syscall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SYS_IOCTL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;uintptr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BIOCSETF&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;uintptr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unsafe&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pointer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;prog&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;errno&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"BIOCSETF failed: %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errno&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Testing without hardware
&lt;/h2&gt;

&lt;p&gt;One of the nice things about &lt;code&gt;golang.org/x/net/bpf&lt;/code&gt; is that it includes &lt;code&gt;bpf.NewVM&lt;/code&gt;, a userspace BPF interpreter. You can feed it your filter program and run arbitrary byte slices through it to verify the accept/drop logic without opening any devices or network interfaces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;TestARPReplyFilter_DropsARPRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;testing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;bpf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewVM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;arpReplyFilter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;require&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NoError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;frame&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;buildARPRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HardwareAddr&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="m"&gt;0x11&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0x22&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0x33&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0x44&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0x55&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0x66&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IP&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IP&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;vm&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;require&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NoError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;assert&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Zero&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"ARP request should be dropped"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;vm.Run&lt;/code&gt; returns the number of bytes the filter would accept. Zero means drop. This makes BPF filter logic fully unit-testable—no root privileges, no network interfaces, no platform dependencies.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;Before the filter, with debug logging enabled to count frames:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Received frame: 0x0806
Received frame: 0x0800
Received frame: 0x0806
Received frame: 0x86dd
Received frame: 0x0800
...
(~10,000 frames in a few seconds)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;0x0806&lt;/code&gt; is ARP, &lt;code&gt;0x0800&lt;/code&gt; is IPv4, &lt;code&gt;0x86dd&lt;/code&gt; is IPv6—all mixed together, all copied to userspace.&lt;/p&gt;

&lt;p&gt;After installing the filter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Received frame: 0x0806
Received frame: 0x0806
Received frame: 0x0806
...
(~100 frames in a few seconds)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only &lt;code&gt;0x0806&lt;/code&gt;. Only ARP replies. A &lt;strong&gt;~100x reduction&lt;/strong&gt; in packets reaching userspace, achieved by six instructions running in the kernel. The CPU and memory cost of processing those extra 9,900 frames per scan cycle is simply gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Beyond ARP: other things you can filter
&lt;/h2&gt;

&lt;p&gt;The same pattern applies any time you want to isolate a specific type of traffic. A BPF filter is just a sequence of field checks at fixed byte offsets — once you know the layout of the packet you're after, writing the filter is mechanical. A few examples:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HTTP/HTTPS traffic&lt;/strong&gt; (custom sniffer for a specific service). Three layers: EtherType &lt;code&gt;0x0800&lt;/code&gt; at offset 12, IP protocol &lt;code&gt;0x06&lt;/code&gt; (TCP) at offset 23, TCP destination port at offset 36. Matching two ports requires two &lt;code&gt;JumpIf&lt;/code&gt; instructions — the first jumps to accept on port 80, the second drops anything that isn't 443:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;httpFilter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;bpf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Instruction&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;bpf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LoadAbsolute&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Off&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Size&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;bpf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JumpIf&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Cond&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bpf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JumpEqual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Val&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0x0800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SkipFalse&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;6&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c"&gt;// IPv4?&lt;/span&gt;
    &lt;span class="n"&gt;bpf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LoadAbsolute&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Off&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;23&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Size&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;bpf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JumpIf&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Cond&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bpf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JumpEqual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Val&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0x06&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SkipFalse&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;   &lt;span class="c"&gt;// TCP?&lt;/span&gt;
    &lt;span class="n"&gt;bpf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LoadAbsolute&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Off&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;36&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Size&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;                          &lt;span class="c"&gt;// dst port&lt;/span&gt;
    &lt;span class="n"&gt;bpf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JumpIf&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Cond&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bpf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JumpEqual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Val&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SkipTrue&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;      &lt;span class="c"&gt;// port 80 → accept&lt;/span&gt;
    &lt;span class="n"&gt;bpf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JumpIf&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Cond&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bpf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JumpEqual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Val&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;443&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SkipFalse&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;    &lt;span class="c"&gt;// port 443 → accept&lt;/span&gt;
    &lt;span class="n"&gt;bpf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RetConstant&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Val&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0xFFFFFFFF&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;bpf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RetConstant&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Val&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This assumes a standard 20-byte IP header. It also only matches the destination port — outgoing requests. To catch responses too, add the same OR check against the source port at offset 34.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ICMP only&lt;/strong&gt; (ping traffic, latency tooling). Check EtherType &lt;code&gt;0x0800&lt;/code&gt; at offset 12, then load the IP protocol byte at offset 23 and compare to &lt;code&gt;0x01&lt;/code&gt;. Two checks — done.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DNS&lt;/strong&gt; (queries and replies). EtherType &lt;code&gt;0x0800&lt;/code&gt; at offset 12, IP protocol &lt;code&gt;0x11&lt;/code&gt; (UDP) at offset 23, then the 2-byte UDP destination port at offset 36 equal to &lt;code&gt;0x0035&lt;/code&gt; (53). Three checks; everything else is gone before it reaches your code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DHCP&lt;/strong&gt; (watching address assignments on a local network). Same shape as DNS — EtherType &lt;code&gt;0x0800&lt;/code&gt;, UDP — but match destination port &lt;code&gt;0x0043&lt;/code&gt; (67, server) or &lt;code&gt;0x0044&lt;/code&gt; (68, client).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Traffic from a specific MAC address&lt;/strong&gt;. The source MAC sits at offsets 6–11 in the Ethernet header. Load 4 bytes at offset 6, compare to the upper 32 bits of the target MAC; load 2 bytes at offset 10, compare to the lower 16 bits. Two checks, no IP layer involved.&lt;/p&gt;

&lt;p&gt;The principle is always the same: find the fixed-offset fields that uniquely identify the traffic you want, put the most common rejection first, and jump to the drop on mismatch. The kernel handles the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;If you're doing any kind of raw packet capture on macOS through &lt;code&gt;/dev/bpf*&lt;/code&gt;, installing a filter is straightforward and the performance difference is dramatic. Six instructions, two conditional checks, and the kernel does the work for you.&lt;/p&gt;

&lt;p&gt;One constraint worth knowing: classic BPF on macOS is read-only. You can observe and filter packets, but you cannot modify or inject them. If that's a requirement, you'll need a different approach.&lt;/p&gt;

</description>
      <category>bpf</category>
      <category>arp</category>
      <category>go</category>
      <category>macos</category>
    </item>
    <item>
      <title>Solving Keycloak Internal vs External Access in Kubernetes with hostname-backchannel-dynamic</title>
      <dc:creator>Nikita Vakula</dc:creator>
      <pubDate>Tue, 17 Feb 2026 13:23:13 +0000</pubDate>
      <link>https://dev.to/krjakbrjak/solving-keycloak-internal-vs-external-access-in-kubernetes-with-hostname-backchannel-dynamic-19ja</link>
      <guid>https://dev.to/krjakbrjak/solving-keycloak-internal-vs-external-access-in-kubernetes-with-hostname-backchannel-dynamic-19ja</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Using OpenID Connect (OIDC) as an authentication source is one of the best practices when working with infrastructure, as it significantly improves both security and maintainability. &lt;a href="https://www.keycloak.org/" rel="noopener noreferrer"&gt;Keycloak&lt;/a&gt; is an excellent open-source project widely adopted for this purpose. It supports many features and storage backends (such as PostgreSQL) and has straightforward deployment instructions on their official website.&lt;/p&gt;

&lt;p&gt;However, I recently encountered an interesting challenge when deploying Keycloak in Kubernetes that required a specific configuration to solve internal service communication issues.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: External Hostname vs Internal Access
&lt;/h2&gt;

&lt;p&gt;When deploying Keycloak in Kubernetes, you typically specify a public hostname using the &lt;code&gt;--hostname=https://auth.example.com&lt;/code&gt; parameter. This works perfectly for external clients accessing your authentication service.&lt;/p&gt;

&lt;p&gt;But here's where it gets tricky: imagine you have other services running in your Kubernetes cluster—perhaps a container registry or CI server—that need to authenticate with Keycloak. These services need to access the discovery URL at &lt;code&gt;https://auth.example.com/realms/{realm-name}/.well-known/openid-configuration&lt;/code&gt; to retrieve authentication configuration.&lt;/p&gt;

&lt;p&gt;The issue arises because Keycloak internally always redirects to (and generates tokens/URLs based on) the hostname that was specified during deployment. But what happens when this public URL is not resolvable by pods inside the Kubernetes cluster? This creates a problem where internal services can't properly reach Keycloak for backchannel requests (token introspection, userinfo, etc.), even if they can reach the pod via internal DNS.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution: Dynamic Backchannel Hostname
&lt;/h2&gt;

&lt;p&gt;Fortunately, Keycloak provides a CLI option to address this exact issue (available when the &lt;code&gt;hostname:v2&lt;/code&gt; feature is enabled):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nt"&gt;--features&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt;:v2
&lt;span class="nt"&gt;--hostname-backchannel-dynamic&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This configuration tells Keycloak to dynamically determine the backchannel (internal) URLs based on the incoming request, allowing access via:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Direct IP addresses&lt;/li&gt;
&lt;li&gt;Internal Kubernetes DNS (e.g., &lt;code&gt;keycloak.keycloak-namespace.svc.cluster.local:8080/realms/{realm-name}/.well-known/openid-configuration&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;--hostname-backchannel-dynamic=true&lt;/code&gt; enabled:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;External Access: Clients outside the cluster use the public hostname (&lt;code&gt;https://auth.example.com&lt;/code&gt;) for authentication flows.&lt;/li&gt;
&lt;li&gt;Internal Access: Services within the cluster can use the internal Kubernetes service DNS name to communicate directly with Keycloak pods.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This dual-access approach ensures that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;External clients get the proper public URL for authentication flows&lt;/li&gt;
&lt;li&gt;Internal services can reliably reach Keycloak using cluster-internal DNS resolution&lt;/li&gt;
&lt;li&gt;No complex network routing or additional ingress configuration is needed just for internal communication&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Note for production&lt;/strong&gt;: For this to work securely, make sure your ingress / reverse proxy correctly passes &lt;code&gt;Forwarded&lt;/code&gt; or &lt;code&gt;X-Forwarded-*&lt;/code&gt; headers, and consider enabling HTTPS on both external and internal access paths.&lt;/p&gt;

&lt;h2&gt;
  
  
  Example Configuration
&lt;/h2&gt;

&lt;p&gt;Here's how you might configure this in a Kubernetes deployment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;keycloak&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;keycloak&lt;/span&gt;
        &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;quay.io/keycloak/keycloak:latest&lt;/span&gt;
        &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;start&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--features=hostname:v2&lt;/span&gt;           &lt;span class="c1"&gt;# required for dynamic backchannel&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--hostname=https://auth.example.com&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--hostname-backchannel-dynamic=true&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--db=postgres&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--proxy-headers=forwarded&lt;/span&gt;        &lt;span class="c1"&gt;# important for correct header handling behind proxy/ingress&lt;/span&gt;
        &lt;span class="c1"&gt;# ... other configuration (ports, HTTPS, DB credentials via env vars, etc.)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The --hostname-backchannel-dynamic=true flag (combined with the hostname:v2 feature) is a simple yet powerful solution for mixed internal/external access scenarios in Kubernetes. While the public URL remains ideal for external client access, internal service-to-service communication often requires this flexibility.&lt;/p&gt;

&lt;p&gt;Keycloak's hostname configuration options make it a robust choice for authentication infrastructure in containerized environments.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.keycloak.org/documentation" rel="noopener noreferrer"&gt;Keycloak Official Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.keycloak.org/server/hostname" rel="noopener noreferrer"&gt;Keycloak Server Configuration&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>keycloak</category>
      <category>kubernetes</category>
      <category>networking</category>
    </item>
    <item>
      <title>Building a Simple DNS Forwarder for VMs in Go</title>
      <dc:creator>Nikita Vakula</dc:creator>
      <pubDate>Fri, 30 Jan 2026 14:43:29 +0000</pubDate>
      <link>https://dev.to/krjakbrjak/building-a-simple-dns-forwarder-for-vms-in-go-1gm4</link>
      <guid>https://dev.to/krjakbrjak/building-a-simple-dns-forwarder-for-vms-in-go-1gm4</guid>
      <description>&lt;h2&gt;
  
  
  Introduction: Why DNS "Just Works" … Until It Doesn't
&lt;/h2&gt;

&lt;p&gt;On modern Linux systems, systemd-resolved handles DNS resolution transparently — you rarely need to think about it. It simply works.&lt;br&gt;
But when managing QEMU-based virtual machines with &lt;a href="https://github.com/q-controller/qcontroller" rel="noopener noreferrer"&gt;qcontroller&lt;/a&gt;, things get more interesting. &lt;code&gt;qcontroller&lt;/code&gt; supports two main ways to configure networking and DNS for VM instances:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;DHCP (default fallback)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cloud-Init network configuration&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When Cloud-Init's network config is not used, it falls back to DHCP. As explained in &lt;a href="https://dev.to/krjakbrjak/network-namespaces-isolating-vm-networking-44o5"&gt;the previous article&lt;/a&gt;, qcontroller runs the QEMU process inside a dedicated network namespace connected to the host's root namespace via a veth pair.&lt;br&gt;
This namespace isolation is powerful: port 53 (DNS) is free inside the namespace, so we can run our own DHCP and DNS services without conflicts.&lt;br&gt;
For DHCP, I use the excellent, modular &lt;a href="https://github.com/coredhcp/coredhcp" rel="noopener noreferrer"&gt;CoreDHCP&lt;/a&gt; server — embedded and running in a separate goroutine. One of its key configuration fields is the DNS server IP (DHCP clients always query DNS on port 53). I simply pass the nameserver IPs from the QEMU subcommand configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nl"&gt;"linuxSettings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"network"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"br0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"gateway_ip"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"192.168.71.1/24"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"bridge_ip"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"192.168.71.3/24"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"dhcp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"192.168.71.4/24"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"end"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"192.168.71.254/24"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"lease_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;86400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"dns"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"8.8.8.8"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"8.8.4.4"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"lease_file"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./build/run/qcontroller-dhcp-leases"&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"start_dns"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This configuration will start the internal DNS server and use the IPs specified in the &lt;code&gt;dns&lt;/code&gt; field as fallback DNS resolvers.&lt;/p&gt;

&lt;p&gt;When static IPs are preferred, you can provide Cloud-Init network config with dedicated nameservers. This setup is reliable: start the VM, and everything configures itself automatically.&lt;br&gt;
I thought my work was done — until I connected the host to a VPN. Suddenly, DNS resolution for resources in the VPN subnet stopped working inside the VMs.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Two Core Problems
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Detecting host DNS changes (e.g., new VPN nameservers added to the host)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Propagating those changes to running VMs without disrupting or compromising guest services&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Touching running VMs directly is dangerous — a mistake could break critical services. We need a safer approach.&lt;/p&gt;
&lt;h3&gt;
  
  
  Solution Part 1: Detecting Host DNS Changes Reliably
&lt;/h3&gt;

&lt;p&gt;On Linux, nameservers are traditionally listed in &lt;code&gt;/etc/resolv.conf&lt;/code&gt;. But on &lt;code&gt;systemd&lt;/code&gt;-based systems, &lt;code&gt;/etc/resolv.conf&lt;/code&gt; is usually a symlink to a stub file pointing to &lt;code&gt;127.0.0.53&lt;/code&gt; (&lt;code&gt;systemd-resolved&lt;/code&gt;’s local resolver). The real upstream servers are managed elsewhere.&lt;/p&gt;

&lt;p&gt;The correct location is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/run/systemd/resolve/resolv.conf&lt;/code&gt; (on systemd systems)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/etc/resolv.conf&lt;/code&gt; (fallback for non-systemd setups)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because &lt;code&gt;qcontroller&lt;/code&gt; runs in a separate network namespace, we can still access these host files via the namespace setup.&lt;br&gt;
Polling the file works but wastes resources. Better: &lt;em&gt;watch for changes using filesystem notifications&lt;/em&gt;.&lt;br&gt;
In Go, the battle-tested &lt;a href="https://github.com/fsnotify/fsnotify" rel="noopener noreferrer"&gt;fsnotify&lt;/a&gt; library handles this perfectly. For maximum reliability (especially with systemd's atomic renames), watch the parent directory (&lt;code&gt;/run/systemd/resolve/&lt;/code&gt; or &lt;code&gt;/etc/&lt;/code&gt;) instead of the file itself. This captures creates, removes, and modifications cleanly.&lt;/p&gt;
&lt;h3&gt;
  
  
  Solution Part 2: Parsing resolv.conf Without Reinventing the Wheel
&lt;/h3&gt;

&lt;p&gt;Once a change is detected, parse the file to extract upstream servers.&lt;br&gt;
Parsing &lt;code&gt;resolv.conf&lt;/code&gt; manually is doable but error-prone and best avoided. Instead, use the mature &lt;a href="https://github.com/miekg/dns" rel="noopener noreferrer"&gt;miekg/dns&lt;/a&gt; library — the de-facto standard DNS toolkit in Go. It includes built-in parsers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="s"&gt;"github.com/miekg/dns"&lt;/span&gt;

&lt;span class="n"&gt;upstreams&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfgErr&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;dns&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ClientConfigFromFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/run/systemd/resolve/resolv.conf"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cfgErr&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// fallback to /etc/resolv.conf&lt;/span&gt;
    &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfgErr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dns&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ClientConfigFromFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/etc/resolv.conf"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cfgErr&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Servers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;upstreams&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;upstreams&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JoinHostPort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Port&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// upstreams now contains the upstream addresses&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;em&gt;fsnotify&lt;/em&gt; + &lt;em&gt;miekg/dns&lt;/em&gt;, we reliably detect and load updated upstreams from the host.&lt;/p&gt;

&lt;h3&gt;
  
  
  Solution Part 3: Static DNS in VMs + Smart Forwarding
&lt;/h3&gt;

&lt;p&gt;Instead of dynamically reconfiguring VMs (risky!), give every VM a single, static DNS resolver IP — the address of our embedded DNS server inside the namespace.&lt;br&gt;
But how can one static resolver handle host DNS changes (VPNs, etc.)?&lt;br&gt;
Enter a &lt;strong&gt;custom DNS forwarder&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Listens on port 53 in the VM namespace&lt;/li&gt;
&lt;li&gt;Forwards queries sequentially to the current upstream list (from host &lt;code&gt;resolv.conf&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Returns immediately on the first positive response (NOERROR + answers &amp;gt; 0)&lt;/li&gt;
&lt;li&gt;Otherwise continues to the next upstream&lt;/li&gt;
&lt;li&gt;Falls back to the last negative response (e.g. NXDOMAIN or NODATA)&lt;/li&gt;
&lt;li&gt;Returns SERVFAIL only if all upstreams fail completely (network errors)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This "optimistic fallback until positive" logic is simple yet powerful — it mirrors real-world needs like &lt;strong&gt;VPN + public DNS chaining&lt;/strong&gt;.&lt;br&gt;
The full implementation lives in &lt;code&gt;qcontroller&lt;/code&gt; — see the &lt;a href="https://github.com/q-controller/qcontroller/pull/24" rel="noopener noreferrer"&gt;latest changes&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fallback for Resilience
&lt;/h2&gt;

&lt;p&gt;What happens if &lt;code&gt;qcontroller&lt;/code&gt; crashes (hopefully not the case!) or stops? VMs keep running, but DNS updates from the host stop.&lt;br&gt;
To handle this gracefully, configure a fallback nameserver list in the QEMU config (e.g., &lt;code&gt;8.8.8.8&lt;/code&gt;, &lt;code&gt;1.1.1.1&lt;/code&gt;, &lt;code&gt;9.9.9.9&lt;/code&gt;). VMs then fall back to public DNS — not ideal for internal/VPN resources, but better than total failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;With this setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;VMs always use a single, static DNS IP&lt;/li&gt;
&lt;li&gt;The embedded forwarder dynamically follows host DNS changes (including VPN connections)&lt;/li&gt;
&lt;li&gt;No guest reconfiguration needed → zero risk to running services&lt;/li&gt;
&lt;li&gt;Reliable detection via &lt;strong&gt;fsnotify&lt;/strong&gt; + robust parsing via &lt;strong&gt;miekg/dns&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Graceful fallback via configurable public resolvers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your VMs now have the exact same network connectivity as the host root namespace — &lt;strong&gt;automatically&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Enjoy hassle-free DNS in your VM fleet!&lt;/p&gt;

</description>
      <category>go</category>
      <category>linux</category>
      <category>networking</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>From Swagger UI to React: Building qcontroller's Frontend</title>
      <dc:creator>Nikita Vakula</dc:creator>
      <pubDate>Thu, 08 Jan 2026 22:36:54 +0000</pubDate>
      <link>https://dev.to/krjakbrjak/from-swagger-ui-to-react-building-qcontrollers-frontend-2k62</link>
      <guid>https://dev.to/krjakbrjak/from-swagger-ui-to-react-building-qcontrollers-frontend-2k62</guid>
      <description>&lt;p&gt;In previous articles, I introduced &lt;a href="https://github.com/q-controller/qcontroller" rel="noopener noreferrer"&gt;qcontroller&lt;/a&gt;, a powerful tool for managing the complete lifecycle of QEMU VM instances—creating, starting, stopping, and removing VMs with database-like operations.&lt;/p&gt;

&lt;p&gt;While qcontroller's REST API worked well for automation, and Swagger UI provided basic interaction capabilities, the growing adoption revealed a critical pain point: managing VMs through Swagger UI was becoming increasingly tedious for daily operations. What started as a backend-focused project clearly needed a proper frontend.&lt;/p&gt;

&lt;p&gt;I built the &lt;a href="https://github.com/q-controller/qcontroller-ui" rel="noopener noreferrer"&gt;qcontroller UI&lt;/a&gt;—a React-based web interface that transforms VM management from a technical chore into an intuitive experience. After spending considerable time on infrastructure and backend development, returning to frontend work was a refreshing change that reminded me why I love building user-facing applications.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Built a React frontend for qcontroller to replace cumbersome Swagger UI. Key highlights:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tech stack&lt;/strong&gt;: React + TypeScript + Mantine + Vite for modern, maintainable development&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-time updates&lt;/strong&gt;: WebSocket integration for live VM status changes and IP allocation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code generation&lt;/strong&gt;: OpenAPI Generator for REST client + Protocol Buffers for WebSocket messages&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single binary distribution&lt;/strong&gt;: Go's &lt;code&gt;embed&lt;/code&gt; directive bundles the entire React app into the executable&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Result&lt;/strong&gt;: Users download one file and get both API and web interface with zero setup&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv17ew2lfbet0nzu8wyc4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv17ew2lfbet0nzu8wyc4.png" alt="Dashboard" width="800" height="422"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Challenge: Beyond Basic CRUD Operations
&lt;/h2&gt;

&lt;p&gt;The UI requirements seemed straightforward at first glance, but the devil was in the details. VM management operations naturally split into two domains:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VM Image Management:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Upload custom VM images (crucial for development workflows)&lt;/li&gt;
&lt;li&gt;List available images with metadata&lt;/li&gt;
&lt;li&gt;Remove unused images to save storage&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;VM Instance Lifecycle:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create instances with complex configuration options&lt;/li&gt;
&lt;li&gt;Start, stop, and delete VMs&lt;/li&gt;
&lt;li&gt;Monitor real-time status changes&lt;/li&gt;
&lt;li&gt;Track resource allocation (IP addresses, ports, etc.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The real complexity emerged from the parameters involved. Creating a VM isn't just clicking "start"—it involves networking configurations, resource allocation, storage options, and more. Each operation needed a thoughtful UI that could handle this complexity without overwhelming users.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Game Changer: Real-Time Updates
&lt;/h2&gt;

&lt;p&gt;The most critical missing piece was live feedback. In the Swagger UI world, you'd make a request and manually refresh to see status changes. But VM operations are inherently asynchronous—starting a VM takes time, IP allocation happens dynamically, and status changes occur continuously.&lt;/p&gt;

&lt;p&gt;This drove me to implement WebSocket-based event streaming in qcontroller itself. Now the UI could show real-time updates as VMs boot up, IP addresses get assigned, and operations complete. This single feature transformed the user experience from static and frustrating to dynamic and responsive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tech Stack Decisions: Modern Tools for Modern Problems
&lt;/h2&gt;

&lt;p&gt;Choosing the right frontend stack was crucial for both development speed and long-term maintainability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://react.dev/" rel="noopener noreferrer"&gt;React&lt;/a&gt; + TypeScript&lt;/strong&gt;: The obvious choice for component-based UI development. React's virtual DOM model and extensive ecosystem made it perfect for building dynamic interfaces.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://mantine.dev/" rel="noopener noreferrer"&gt;Mantine&lt;/a&gt;&lt;/strong&gt;: After evaluating several component libraries, Mantine stood out for its high-quality, responsive components and excellent developer experience. Every component looked professional out of the box—crucial for a developer tool that needed to feel polished.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://vitejs.dev/" rel="noopener noreferrer"&gt;Vite&lt;/a&gt;&lt;/strong&gt;: Modern build tooling that feels lightning-fast compared to Webpack. The development server starts instantly, and hot module replacement actually works reliably.&lt;/p&gt;

&lt;p&gt;The real elegance came from React's Context API for handling WebSocket connections. Instead of prop drilling or complex state management, the entire app could reactively update from a single WebSocket stream:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;UpdatesContext&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/common/updates-context&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;UpdatesProvider&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wsUrl&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setData&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ws&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WebSocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wsUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;binaryType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;arraybuffer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onopen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Implementation&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Implementation&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;readyState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;WebSocket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OPEN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;wsUrl&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;UpdatesContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Provider&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/UpdatesContext.Provider&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And then, in your app entry point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;ReactDOM&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-dom/client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;UpdatesProvider&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/common/updates-provider&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;ReactDOM&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createRoot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;root&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;StrictMode&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;UpdatesProvider&lt;/span&gt; &lt;span class="nx"&gt;wsUrl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/ws&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* App content */&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/UpdatesProvider&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;  &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/React.StrictMode&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Code Generation: The API-First Approach
&lt;/h2&gt;

&lt;p&gt;For the REST API communication, I leveraged &lt;a href="https://openapi-generator.tech/" rel="noopener noreferrer"&gt;OpenAPI Generator&lt;/a&gt; to automatically generate TypeScript client code. This API-first approach eliminates the common frontend-backend synchronization problems and ensures type safety across the entire stack.&lt;/p&gt;

&lt;p&gt;The async nature of VM operations presented an interesting challenge. While OpenAPI excels at describing synchronous REST operations, there's no standard way to describe WebSocket-based event streams. &lt;a href="https://www.asyncapi.com/" rel="noopener noreferrer"&gt;AsyncAPI&lt;/a&gt; exists but didn't fit my specific needs, and I wanted to avoid the complexity of gRPC-Web proxies.&lt;/p&gt;

&lt;p&gt;The solution was surprisingly elegant: using Protocol Buffers for WebSocket messages. With &lt;a href="https://github.com/stephenh/ts-proto" rel="noopener noreferrer"&gt;ts-proto&lt;/a&gt;, the WebSocket message handling became as type-safe as the REST API, with everything generated from &lt;code&gt;.proto&lt;/code&gt; definitions. Only a few lines of WebSocket connection code needed to be written manually—the rest was generated and type-safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Deployment Game-Changer: Single Binary with Embedded UI
&lt;/h2&gt;

&lt;p&gt;One of the most compelling aspects of this project turned out to be the deployment strategy. For qcontroller's specific use case—a tool that gets distributed as a standalone binary—this approach was a perfect match.&lt;/p&gt;

&lt;p&gt;qcontroller is written in Go, which already provides excellent deployment characteristics: compile once, run anywhere, no runtime dependencies. Since the tool is designed to be downloaded and run directly by users, maintaining that simplicity was crucial. But how do you include a modern React application without breaking this elegant distribution model?&lt;/p&gt;

&lt;p&gt;For most web applications, you'd have separate frontend and backend deployments, CDNs for static assets, or containerized solutions. But qcontroller needed to stay true to its "single binary" philosophy for easy adoption and maintenance.&lt;/p&gt;

&lt;p&gt;Go's &lt;a href="https://golang.org/pkg/embed/" rel="noopener noreferrer"&gt;&lt;code&gt;embed&lt;/code&gt;&lt;/a&gt; directive provided the perfect solution for this specific requirement—the entire React build becomes part of the binary itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;frontend&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s"&gt;"embed"&lt;/span&gt;
  &lt;span class="s"&gt;"net/http"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;//go:embed generated/*&lt;/span&gt;
&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;webFS&lt;/span&gt; &lt;span class="n"&gt;embed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FS&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;basepath&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HandlerFunc&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="s"&gt;"generated/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;basepath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;webFS&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c"&gt;// Serve index.html for client-side routing&lt;/span&gt;
      &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ServeFileFS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;webFS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"generated/index.html"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ServeFileFS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;webFS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For qcontroller's distribution model, this delivers exactly what's needed: users download one binary, run it, and immediately get both the API and UI. No configuration files, no separate setup steps, no version mismatches between frontend and backend components.&lt;/p&gt;

&lt;p&gt;The maintenance benefits are significant too. There's no need to coordinate releases between multiple services, no asset versioning concerns, and no deployment complexity. Users always get a perfectly matched frontend and backend in a single download.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results: From Functional to Delightful
&lt;/h2&gt;

&lt;p&gt;The transformation from Swagger UI to a custom React interface has been remarkable. What was once a series of API calls requiring manual status checks is now an intuitive dashboard with real-time updates. VM creation involves guided forms instead of raw JSON, and operations provide immediate visual feedback.&lt;/p&gt;

&lt;p&gt;The development experience reinforced something I've always believed: when you choose the right tools, frontend development can be just as systematic and maintainable as backend work. The combination of TypeScript, code generation, and well-designed component libraries created a development workflow that felt as robust as my usual Go projects.&lt;/p&gt;

&lt;p&gt;The qcontroller UI proves that developer tools don't have to sacrifice usability for power. With the right architecture and toolchain, you can build interfaces that are both technically sophisticated and genuinely pleasant to use.&lt;/p&gt;

</description>
      <category>react</category>
      <category>typescript</category>
      <category>websockets</category>
      <category>go</category>
    </item>
    <item>
      <title>Network Namespaces: Isolating VM Networking</title>
      <dc:creator>Nikita Vakula</dc:creator>
      <pubDate>Sat, 29 Nov 2025 22:31:01 +0000</pubDate>
      <link>https://dev.to/krjakbrjak/network-namespaces-isolating-vm-networking-44o5</link>
      <guid>https://dev.to/krjakbrjak/network-namespaces-isolating-vm-networking-44o5</guid>
      <description>&lt;p&gt;In my previous articles, I discussed various networking approaches for Linux virtualization. I developed &lt;a href="https://github.com/q-controller/qcontroller" rel="noopener noreferrer"&gt;qcontroller&lt;/a&gt;, a tool responsible for managing the complete lifecycle of QEMU VM instances—creating, starting, stopping, and removing VMs with database-like operations.&lt;/p&gt;

&lt;p&gt;Since modern VMs typically require internet access and inter-VM communication, qcontroller also manages firewall settings using nftables rules. The original networking scheme involved creating bridges, configuring nftables chains, and establishing rules to allow traffic flow between the internet, VMs, and host system. Each VM connects through a TAP device that uses the bridge as its master interface.&lt;/p&gt;

&lt;p&gt;While this approach works well, it has a significant drawback: all networking components—bridges, TAP devices, and nftables rules—exist within the host's network stack. This "pollution" of the host networking requires careful cleanup to avoid breaking the host system when removing VMs. Each interface and rule must be individually and properly removed.&lt;/p&gt;

&lt;p&gt;I prefer solutions where removing a single component automatically cleans up everything else. Fortunately, Linux provides exactly this capability through &lt;strong&gt;network namespaces&lt;/strong&gt;. Let's explore how network namespaces can help build a cleaner, more isolated solution for managing VM networking.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are Network Namespaces?
&lt;/h2&gt;

&lt;p&gt;Most developers familiar with Docker have encountered the concept of &lt;a href="https://en.wikipedia.org/wiki/Linux_namespaces" rel="noopener noreferrer"&gt;namespaces&lt;/a&gt;, particularly network namespaces. This Linux kernel feature allows you to create isolated network stacks on the same physical host, each appearing as a completely separate network environment. According to the &lt;a href="https://man7.org/linux/man-pages/man7/network_namespaces.7.html" rel="noopener noreferrer"&gt;Linux manual pages&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Network namespaces provide isolation of the system resources associated with networking: network devices, IPv4 and IPv6 protocol stacks, IP routing tables, firewall rules, the /proc/net directory (which is a symbolic link to /proc/pid/net), the /sys/class/net directory, various files under /proc/sys/net, port numbers (sockets), and so on. In addition, network namespaces isolate the UNIX domain abstract socket namespace (see unix(7)).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is exactly what we need—a completely separate network stack with its own devices, routing tables, and firewall rules. However, when you create a new network namespace, it starts empty with no network devices. So how do we connect it to the internet? &lt;a href="https://man7.org/linux/man-pages/man7/network_namespaces.7.html" rel="noopener noreferrer"&gt;The Linux manual&lt;/a&gt; explains the solution:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A virtual network (veth(4)) device pair provides a pipe-like abstraction that can be used to create tunnels between network namespaces, and can be used to create a bridge to a physical network device in another namespace. When a namespace is freed, the veth(4) devices that it contains are destroyed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The key insight here is the automatic cleanup: when a namespace is deleted, all its contained veth devices are automatically destroyed—exactly the behavior we want!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6fjzv10ks4yfjkf92zsp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6fjzv10ks4yfjkf92zsp.png" alt="Linux network namespace" width="753" height="183"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating and Configuring a Network Namespace
&lt;/h2&gt;

&lt;p&gt;Since our host network stack has internet connectivity, we need to connect our new namespace to the host network using a veth pair (which acts like a virtual ethernet cable). For the pair to communicate, both ends need IP addresses. Here are the commands to set this up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Create a new network namespace called 'example'&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip netns add example

&lt;span class="c"&gt;# Create a veth pair (virtual ethernet cable)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;add host-veth &lt;span class="nb"&gt;type &lt;/span&gt;veth peer name example-veth

&lt;span class="c"&gt;# Move one end of the veth pair into the new namespace&lt;/span&gt;
&lt;span class="c"&gt;# (initially both ends exist in the host namespace)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link set &lt;/span&gt;example-veth netns example

&lt;span class="c"&gt;# Assign IP addresses to both ends of the veth pair&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip addr add 192.168.26.1/24 dev host-veth              &lt;span class="c"&gt;# Host end&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip netns &lt;span class="nb"&gt;exec &lt;/span&gt;example ip addr add 192.168.26.2/24 dev example-veth  &lt;span class="c"&gt;# Namespace end&lt;/span&gt;

&lt;span class="c"&gt;# Bring both interfaces up&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link set &lt;/span&gt;dev host-veth up
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip netns &lt;span class="nb"&gt;exec &lt;/span&gt;example ip &lt;span class="nb"&gt;link set &lt;/span&gt;dev example-veth up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After executing these commands, we have successfully configured a new network namespace and connected it to the host namespace via a veth pair. Let's test the connectivity with &lt;code&gt;ip netns exec example ping 192.168.26.1&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;PING 192.168.26.1 &lt;span class="o"&gt;(&lt;/span&gt;192.168.26.1&lt;span class="o"&gt;)&lt;/span&gt; 56&lt;span class="o"&gt;(&lt;/span&gt;84&lt;span class="o"&gt;)&lt;/span&gt; bytes of data.
64 bytes from 192.168.26.1: &lt;span class="nv"&gt;icmp_seq&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nv"&gt;ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;64 &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.038 ms
64 bytes from 192.168.26.1: &lt;span class="nv"&gt;icmp_seq&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2 &lt;span class="nv"&gt;ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;64 &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.073 ms
64 bytes from 192.168.26.1: &lt;span class="nv"&gt;icmp_seq&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3 &lt;span class="nv"&gt;ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;64 &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.070 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Excellent! The connection works. Notice that network devices belonging to different namespaces are isolated from each other (try running &lt;code&gt;ip a&lt;/code&gt; in both namespaces to see this separation).&lt;/p&gt;

&lt;p&gt;Now we have two separate network stacks that can communicate with each other. However, only the host can access the internet. To provide internet access to our new namespace, we need to configure routing and NAT rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enabling Internet Access
&lt;/h2&gt;

&lt;p&gt;First, we need to configure the namespace to route all traffic through the host veth interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Set default route in the namespace to use the host veth interface&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip netns &lt;span class="nb"&gt;exec &lt;/span&gt;example ip route add default via 192.168.26.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, we need to configure the host to forward traffic and perform NAT:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Enable IP forwarding in the kernel&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; net.ipv4.ip_forward&lt;span class="o"&gt;=&lt;/span&gt;1

&lt;span class="c"&gt;# Allow established connections from internet back to namespace&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-A&lt;/span&gt; FORWARD &lt;span class="nt"&gt;-i&lt;/span&gt; enp0s1 &lt;span class="nt"&gt;-o&lt;/span&gt; host-veth &lt;span class="nt"&gt;-m&lt;/span&gt; state &lt;span class="nt"&gt;--state&lt;/span&gt; RELATED,ESTABLISHED &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT

&lt;span class="c"&gt;# Allow new outgoing connections from namespace to internet&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-A&lt;/span&gt; FORWARD &lt;span class="nt"&gt;-i&lt;/span&gt; host-veth &lt;span class="nt"&gt;-o&lt;/span&gt; enp0s1 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT

&lt;span class="c"&gt;# Masquerade (NAT) traffic from the namespace subnet&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-t&lt;/span&gt; nat &lt;span class="nt"&gt;-A&lt;/span&gt; POSTROUTING &lt;span class="nt"&gt;-s&lt;/span&gt; 192.168.26.0/24 &lt;span class="nt"&gt;-o&lt;/span&gt; enp0s1 &lt;span class="nt"&gt;-j&lt;/span&gt; MASQUERADE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; Replace &lt;code&gt;enp0s1&lt;/code&gt; with your actual physical network interface name (find it with &lt;code&gt;ip route show default&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Now the namespace can reach the internet! Test with &lt;code&gt;sudo ip netns exec example ping 8.8.8.8&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;PING 8.8.8.8 &lt;span class="o"&gt;(&lt;/span&gt;8.8.8.8&lt;span class="o"&gt;)&lt;/span&gt; 56&lt;span class="o"&gt;(&lt;/span&gt;84&lt;span class="o"&gt;)&lt;/span&gt; bytes of data.
64 bytes from 8.8.8.8: &lt;span class="nv"&gt;icmp_seq&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nv"&gt;ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;117 &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10.2 ms
64 bytes from 8.8.8.8: &lt;span class="nv"&gt;icmp_seq&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2 &lt;span class="nv"&gt;ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;117 &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;9.66 ms
64 bytes from 8.8.8.8: &lt;span class="nv"&gt;icmp_seq&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3 &lt;span class="nv"&gt;ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;117 &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;9.31 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Adding Bridge and TAP Devices for VMs
&lt;/h2&gt;

&lt;p&gt;Now we have established a separate network stack connected to both the host and internet. This is already powerful, but for my use case, I wanted to run all VMs inside this isolated network namespace to avoid polluting the host networking and enable easy cleanup—simply delete the namespace and all virtual interfaces disappear automatically.&lt;/p&gt;

&lt;p&gt;To achieve this, we need to make a few adjustments:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Create a bridge&lt;/strong&gt; within the namespace&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remove the IP address&lt;/strong&gt; from the namespace veth interface&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assign the IP address&lt;/strong&gt; to the bridge instead&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set the bridge as master&lt;/strong&gt; for the veth interface&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connect all VM TAP devices&lt;/strong&gt; to this bridge
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Create a bridge in the namespace&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip netns &lt;span class="nb"&gt;exec &lt;/span&gt;example ip &lt;span class="nb"&gt;link &lt;/span&gt;add name br0 &lt;span class="nb"&gt;type &lt;/span&gt;bridge

&lt;span class="c"&gt;# Remove IP from veth interface and add it to the bridge&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip netns &lt;span class="nb"&gt;exec &lt;/span&gt;example ip addr del 192.168.26.2/24 dev example-veth
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip netns &lt;span class="nb"&gt;exec &lt;/span&gt;example ip addr add 192.168.26.2/24 dev br0

&lt;span class="c"&gt;# Add veth interface to the bridge&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip netns &lt;span class="nb"&gt;exec &lt;/span&gt;example ip &lt;span class="nb"&gt;link set &lt;/span&gt;example-veth master br0

&lt;span class="c"&gt;# Bring the bridge up&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip netns &lt;span class="nb"&gt;exec &lt;/span&gt;example ip &lt;span class="nb"&gt;link set &lt;/span&gt;br0 up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now all VM TAP devices created within this namespace will use the bridge as their master, and all VM networking components live in the dedicated namespace. For implementation details, see &lt;a href="https://github.com/q-controller/qcontroller/pull/6" rel="noopener noreferrer"&gt;this pull request&lt;/a&gt; showing how this was integrated into qcontroller.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: Embedded DHCP Server
&lt;/h2&gt;

&lt;p&gt;This networking redesign was partly motivated by the inconvenience of relying on external DHCP servers. Managing a separate DHCP service—starting it independently and configuring interfaces—initially seemed like it would provide flexibility, but in practice proved cumbersome.&lt;/p&gt;

&lt;p&gt;I wanted to integrate a DHCP server directly into qcontroller, but faced a significant obstacle: DHCP servers must bind to port &lt;code&gt;67&lt;/code&gt;. If the host system already has a DHCP service running on this port, you cannot start another one in the same network namespace.&lt;/p&gt;

&lt;p&gt;Network namespaces solve this elegantly! Since each namespace has its own isolated network stack, including port space, you can run a DHCP server on port &lt;code&gt;67&lt;/code&gt; within the namespace without conflicts. This allows qcontroller to provide integrated DHCP services for VM networking while keeping everything cleanly separated from the host system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Network namespaces provide an elegant solution for isolating VM networking infrastructure. Key benefits include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Clean separation&lt;/strong&gt; of VM networking from host networking&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatic cleanup&lt;/strong&gt; when deleting the namespace&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Port isolation&lt;/strong&gt; enabling embedded services like DHCP&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Complete control&lt;/strong&gt; over routing, firewall rules, and network topology&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simplified management&lt;/strong&gt; through namespace-scoped operations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By leveraging network namespaces, we can build more robust and maintainable virtualization solutions that don't interfere with the host system's networking configuration.&lt;/p&gt;

</description>
      <category>networking</category>
      <category>linux</category>
      <category>opensource</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Running QEMU VMs on ARM64: UEFI Requirements</title>
      <dc:creator>Nikita Vakula</dc:creator>
      <pubDate>Sun, 05 Oct 2025 14:41:15 +0000</pubDate>
      <link>https://dev.to/krjakbrjak/running-qemu-vms-on-arm64-uefi-requirements-5c9e</link>
      <guid>https://dev.to/krjakbrjak/running-qemu-vms-on-arm64-uefi-requirements-5c9e</guid>
      <description>&lt;p&gt;In my previous notes, I've discussed how &lt;a href="https://www.qemu.org/" rel="noopener noreferrer"&gt;QEMU&lt;/a&gt; serves as a versatile and flexible tool for creating and managing virtual machines. One of QEMU's greatest strengths is its support for a wide range of platforms, making it an ideal choice for cross-platform development and testing. However, this versatility requires us to understand the subtle differences between architectures when configuring our VMs.&lt;/p&gt;

&lt;p&gt;In this article, I'll explain why the QEMU commands that work for x86_64 platforms require specific adjustments when running ARM64 VMs, with a particular focus on the UEFI firmware requirements that are essential for ARM64 virtualization.&lt;/p&gt;

&lt;h2&gt;
  
  
  Understanding the Difference: ARM64 vs x86_64 Booting
&lt;/h2&gt;

&lt;p&gt;When working with ARM64 architecture, there's a fundamental difference in how the system boots compared to traditional x86_64 systems. While ARM64 can utilize different boot methods including U-Boot for embedded systems, UEFI (Unified Extensible Firmware Interface) is the default and preferred method for server and cloud environments. As documented in the &lt;a href="https://documentation.ubuntu.com/server/how-to/virtualisation/qemu/index.html" rel="noopener noreferrer"&gt;Ubuntu server virtualization guide&lt;/a&gt;, Ubuntu ARM64 cloud images specifically rely on UEFI for hardware initialization and kernel loading.&lt;/p&gt;

&lt;p&gt;Unlike x86_64, which can boot using legacy BIOS or UEFI without additional configuration in QEMU, ARM64 cloud images typically require explicitly configured UEFI firmware. When using QEMU for ARM64 virtualization with cloud images like Ubuntu, we must explicitly provide:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;UEFI Firmware (.fd) file&lt;/strong&gt;: These files contain the actual UEFI firmware code, which includes drivers, bootloaders, and the pre-boot environment for the system. Think of this as the replacement for traditional BIOS.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;UEFI Variables (.vars) file&lt;/strong&gt;: These store data in the system's non-volatile RAM (NVRAM) that control the UEFI environment. This includes critical information such as the default boot entry, boot order, and secure boot settings.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Finding Available Firmware Files
&lt;/h2&gt;

&lt;p&gt;Fortunately, when you install QEMU, it automatically includes supported firmware files for various architectures. To locate the firmware files available in your QEMU installation, run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;qemu-system-aarch64 &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="nb"&gt;help&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This command will display output similar to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/opt/homebrew/Cellar/qemu/10.1.0/bin/../share/qemu-firmware
/opt/homebrew/Cellar/qemu/10.1.0/bin/../share/qemu
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These directories contain both firmware and UEFI variable files for different architectures. For ARM64 (aarch64) with the "virt" machine type, the suitable firmware is typically &lt;code&gt;edk2-aarch64-code.fd&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Properly Configuring ARM64 VMs
&lt;/h2&gt;

&lt;p&gt;To run an ARM64 VM, we need to adjust our QEMU command from what we might use for x86_64. Here's a proper example for running an Ubuntu ARM64 cloud image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;qemu-system-aarch64 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-machine&lt;/span&gt; virt &lt;span class="nt"&gt;-accel&lt;/span&gt; hvf &lt;span class="nt"&gt;-m&lt;/span&gt; 2048 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-nographic&lt;/span&gt; &lt;span class="nt"&gt;-hda&lt;/span&gt; ./ubuntu-25.04-server-cloudimg-amd64.img &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-smbios&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1,serial&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;ds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'nocloud;s=http://192.168.178.37:8000/'&lt;/span&gt;
  &lt;span class="nt"&gt;-bios&lt;/span&gt; edk2-aarch64-code.fd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let's break down the new elements that are specific to ARM64:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-machine virt&lt;/code&gt;: We use the "virt" machine type instead of "q35" (which is for x86_64)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-bios&lt;/code&gt;: option to specify firmware&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;bios&lt;/code&gt; parameter is critical here as it tells QEMU to use UEFI firmware.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Running ARM64 VMs with QEMU requires understanding the essential role that UEFI plays in the boot process. By correctly specifying the firmware, you can successfully run ARM64 virtual machines even on different host architectures.&lt;/p&gt;

&lt;h2&gt;
  
  
  Useful links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://wiki.freebsd.org/arm64/QEMU" rel="noopener noreferrer"&gt;FreeBSD on QEMU ARM64&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://documentation.ubuntu.com/server/how-to/virtualisation/qemu/index.html" rel="noopener noreferrer"&gt;Virtualisation with QEMU&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>qemu</category>
      <category>arm64</category>
    </item>
    <item>
      <title>Local DNS Resolution for Docker Containers in Development</title>
      <dc:creator>Nikita Vakula</dc:creator>
      <pubDate>Mon, 08 Sep 2025 06:27:42 +0000</pubDate>
      <link>https://dev.to/krjakbrjak/local-dns-resolution-for-docker-containers-in-development-3gdf</link>
      <guid>https://dev.to/krjakbrjak/local-dns-resolution-for-docker-containers-in-development-3gdf</guid>
      <description>&lt;h2&gt;
  
  
  The challenge: service discovery in containers
&lt;/h2&gt;

&lt;p&gt;In modern backend development, most systems run in isolated environments—most commonly, containers. A typical backend consists of several services that need to communicate with each other. Orchestrators like Kubernetes and Docker Compose provide internal DNS so services can reach each other by hostname. It’s convenient and often feels like magic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why internal DNS isn’t enough (the “public URL” problem)
&lt;/h2&gt;

&lt;p&gt;What if you need to access your service via a public URL? Imagine a reverse proxy fronting everything, with Keycloak behind it and an oauth2-proxy handling authentication via Keycloak. oauth2-proxy needs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;--redirect-url&lt;/code&gt; — the URL the browser hits&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--oidc-issuer-url&lt;/code&gt; — the URL the proxy uses to obtain tokens&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;To avoid CSRF issues you also set &lt;code&gt;--cookie-secure=true&lt;/code&gt;. Because clients reach Keycloak &lt;strong&gt;through&lt;/strong&gt; the reverse proxy, the redirect URL must point to the proxy; the issuer URL should also point to Keycloak. You &lt;strong&gt;could&lt;/strong&gt; use an internal DNS name for the issuer URL, but that breaks CSRF checks—&lt;strong&gt;both URLs must share the same hostname&lt;/strong&gt;, which is typically a public domain you don’t have in local dev. Dilemma.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why mismatched hostnames trigger “CSRF” errors
&lt;/h2&gt;

&lt;p&gt;During the OAuth/OIDC flow your proxy sets a short-lived value (state/nonce) in a cookie on the exact host the user is visiting (e.g. &lt;code&gt;auth.local.test&lt;/code&gt;). When Keycloak redirects back, the proxy must compare the state in the callback URL with the copy stored in that cookie. That comparison is the CSRF defence.&lt;br&gt;
If you mix hosts—say the browser hits &lt;code&gt;https://auth.local.test&lt;/code&gt; but your issuer is &lt;code&gt;http://keycloak:8080&lt;/code&gt;—the browser won’t send the cookie to the other host. Different host =&amp;gt; different cookie scope. On top of that, &lt;code&gt;--cookie-secure=true&lt;/code&gt; means the cookie is only sent over HTTPS, so any HTTP hop drops it. Modern SameSite rules also treat different hosts as “cross-site”, which further blocks the cookie from riding along. The proxy can’t find the cookie it set, the state check fails, and you get a CSRF error.&lt;/p&gt;

&lt;p&gt;This is why resolving the “public” name to your container locally is so effective: every step sees the same host, so the browser sends the right cookie and the CSRF check passes.&lt;/p&gt;
&lt;h2&gt;
  
  
  Existing Solutions
&lt;/h2&gt;

&lt;p&gt;At this point, you either fake domains in &lt;code&gt;/etc/hosts&lt;/code&gt; or look for a tool that maps container names to hostnames. I started with the smart &lt;a href="https://github.com/ruudud/devdns" rel="noopener noreferrer"&gt;devdns&lt;/a&gt; project and even &lt;a href="https://github.com/krjakbrjak/name_resolver/tree/f92d96ada706d4d760693dc8adfb0f4f9656f0ec" rel="noopener noreferrer"&gt;tried automating&lt;/a&gt; hosts-file updates on Docker start/stop. It worked, but hosts files are brittle and easily clobbered. I wanted something that behaves like real DNS without hand-editing files.&lt;/p&gt;
&lt;h2&gt;
  
  
  A Better Approach: Local DNS Server for Containers
&lt;/h2&gt;

&lt;p&gt;Run a local DNS server that watches running containers. If a query matches a container’s name (or alias), answer with the container’s IP. Otherwise, forward to your normal upstreams (Google, Cloudflare, etc.). Docker’s APIs are great in Go, and &lt;a href="https://github.com/miekg/dns" rel="noopener noreferrer"&gt;miekg/dns&lt;/a&gt; makes DNS straightforward, so I built a tiny server in Go. You can find the code &lt;a href="https://github.com/krjakbrjak/name_resolver" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  How it works (at a glance)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Browser asks DNS for &lt;strong&gt;example.com&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Local resolver checks if a container named/aliased &lt;strong&gt;example.com&lt;/strong&gt; is running.&lt;/li&gt;
&lt;li&gt;If yes → return the container’s IP. If no → forward to public DNS and return that IP.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;
  &lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxnfebhh5lsjhylury7d6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxnfebhh5lsjhylury7d6.png" alt="Local DNS Container Name Resolution Flow" width="546" height="501"&gt;&lt;/a&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Use the Local DNS Server
&lt;/h2&gt;

&lt;p&gt;When you run the DNS server locally (for example, on port &lt;code&gt;53&lt;/code&gt;), it will resolve container names to their IP addresses automatically. Here’s a simple example using Docker Compose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ubuntu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.com&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sleep"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;infinity"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After starting this Compose file, any DNS query for &lt;code&gt;github.com&lt;/code&gt; will resolve to the IP address of the &lt;code&gt;ubuntu&lt;/code&gt; container. For instance, running &lt;code&gt;dig github.com&lt;/code&gt; will return:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="p"&gt;;&lt;/span&gt; &amp;lt;&amp;lt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; DiG 9.20.4-3ubuntu1.2-Ubuntu &amp;lt;&amp;lt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; github.com
&lt;span class="p"&gt;;;&lt;/span&gt; global options: +cmd
&lt;span class="p"&gt;;;&lt;/span&gt; Got answer:
&lt;span class="p"&gt;;;&lt;/span&gt; -&amp;gt;&amp;gt;HEADER&lt;span class="o"&gt;&amp;lt;&amp;lt;-&lt;/span&gt; &lt;span class="no"&gt;opcode&lt;/span&gt;&lt;span class="sh"&gt;: QUERY, status: NOERROR, id: 44158
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 65494
;; QUESTION SECTION:
;github.com.                    IN      A

;; ANSWER SECTION:
github.com.             0       IN      A       172.21.0.2

;; Query time: 2 msec
;; SERVER: 127.0.0.53#53(127.0.0.53) (UDP)
;; WHEN: Sun Sep 07 19:24:24 CEST 2025
;; MSG SIZE  rcvd: 55
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice that the IP address in the answer section matches the container’s IP. You can verify this with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose ps &lt;span class="nt"&gt;-q&lt;/span&gt; ubuntu | xargs docker inspect &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s1"&gt;'{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'&lt;/span&gt;
172.21.0.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Configuring Your System to Use the DNS Server
&lt;/h2&gt;

&lt;p&gt;To use this DNS server, configure your system to point to it. For example, if using &lt;code&gt;systemd-resolved&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;resolvectl dns &amp;lt;INTERFACE&amp;gt; 127.0.0.1:5300
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This change is temporary and will reset on reboot. To revert manually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart systemd-resolved
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Local dev often breaks when parts of your stack see different hostnames. A tiny local DNS server fixes that: resolve container names to their IPs, forward everything else upstream, and your dev environment starts behaving like production without hacks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this helps you
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;One hostname end-to-end → fewer auth/cookie surprises.&lt;/li&gt;
&lt;li&gt;No manual hosts edits&lt;/li&gt;
&lt;li&gt;Works with Compose out of the box; trivial to verify with dig.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>go</category>
      <category>devops</category>
      <category>dns</category>
    </item>
    <item>
      <title>Introducing qcontroller: Declarative VM Management with QEMU and Go</title>
      <dc:creator>Nikita Vakula</dc:creator>
      <pubDate>Tue, 05 Aug 2025 09:19:18 +0000</pubDate>
      <link>https://dev.to/krjakbrjak/introducing-qcontroller-declarative-vm-management-with-qemu-and-go-1d8m</link>
      <guid>https://dev.to/krjakbrjak/introducing-qcontroller-declarative-vm-management-with-qemu-and-go-1d8m</guid>
      <description>&lt;p&gt;Managing local virtual machines shouldn't require heavy tooling, brittle shell scripts, or overly complex setups.&lt;br&gt;&lt;br&gt;
If you've ever kludged together shell scripts just to boot a test VM, you'll relate.&lt;br&gt;&lt;br&gt;
That’s why I built qcontroller — a lightweight yet powerful controller for managing QEMU-based VMs using Go, gRPC, and REST APIs.&lt;/p&gt;
&lt;h2&gt;
  
  
  💡 Why qcontroller?
&lt;/h2&gt;

&lt;p&gt;VirtualBox and VMware felt too bloated or restrictive for my needs. I used Multipass a lot, but it would occasionally break or misbehave in frustrating ways — inconsistent states, full network ranges, unrecoverable crashes, etc.&lt;/p&gt;

&lt;p&gt;I wanted:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A reliable, minimal, and flexible VM controller&lt;/li&gt;
&lt;li&gt;Based on QEMU, which works everywhere (including Apple Silicon)&lt;/li&gt;
&lt;li&gt;Backed by APIs, not scripts&lt;/li&gt;
&lt;li&gt;Easy to extend and understand&lt;/li&gt;
&lt;li&gt;Built in Go, not C++ or Bash&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Thus, qcontroller was born — a self-contained binary that orchestrates VMs declaratively and exposes gRPC/HTTP APIs for full control.&lt;/p&gt;
&lt;h2&gt;
  
  
  🧠 What is qcontroller?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;qcontroller&lt;/code&gt; is a tool for managing QEMU-based VMs through a clean API. It has three main components:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;controller: Core lifecycle logic exposed via gRPC&lt;/li&gt;
&lt;li&gt;qemu: A low-level wrapper around the QEMU system binary&lt;/li&gt;
&lt;li&gt;gateway: Exposes a RESTful HTTP interface using gRPC-Gateway&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can launch, start, stop, and query VMs declaratively using JSON config files or REST/gRPC calls.&lt;/p&gt;


&lt;h2&gt;
  
  
  🔧 Architecture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo9rrdo1t434v5x1pn57c.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo9rrdo1t434v5x1pn57c.jpeg" alt="Architecture diagram" width="732" height="293"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each component plays a role:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;controller speaks Protobuf/gRPC and orchestrates VM lifecycle.&lt;/li&gt;
&lt;li&gt;qemu is responsible for actually running and managing VM processes.&lt;/li&gt;
&lt;li&gt;gateway is a REST API layer for HTTP clients and tools.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All communication is done via gRPC, and an OpenAPI schema is auto-generated for the REST interface.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Separation of Controller and QEMU&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It's important to note that the application was split into two  components: the controller and QEMU. This separation was necessary because the qemu command requires elevated privileges due to its use of networking features such as TAP on Linux and vmnet on macOS.&lt;/p&gt;

&lt;p&gt;To avoid granting elevated rights to the entire application, a minimal QEMU service was created. This service runs as root and is responsible solely for executing the qemu command. The controller, on the other hand, manages the virtual machine lifecycle and runs as a non-root user, ensuring a more secure and controlled execution environment.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  ⚙️ Key Features
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;✅ Cross-platform: Works on Linux and macOS&lt;/li&gt;
&lt;li&gt;📡 API-driven: Full control via gRPC and REST (thanks to gRPC-Gateway)&lt;/li&gt;
&lt;li&gt;🧱 Single static binary: Easy to deploy, distribute, and containerize&lt;/li&gt;
&lt;li&gt;🗃️ Custom image upload: Use your own QEMU images to launch VMs&lt;/li&gt;
&lt;li&gt;📜 OpenAPI docs: Rendered Swagger UI interface built-in&lt;/li&gt;
&lt;li&gt;🔄 Extensible: Add features like snapshots or volumes easily&lt;/li&gt;
&lt;li&gt;🧠 Declarative config: Describe your VM setup as a JSON file&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;
  
  
  🚀 Quick Start
&lt;/h2&gt;

&lt;p&gt;Use the included &lt;code&gt;start.sh&lt;/code&gt; script to spin everything up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bash start.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This command will start all three components:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;➜ gateway: &lt;code&gt;http://localhost:8080&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;➜ qemu: &lt;code&gt;0.0.0.0:8008&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;➜ controller: &lt;code&gt;0.0.0.0:8009&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then hit the REST API (e.g. using swagger ui, that is hosted at &lt;code&gt;http://localhost:8080/v1/swagger/index.html&lt;/code&gt;):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frmyaucjrtpif1rf1tw9b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frmyaucjrtpif1rf1tw9b.png" alt="swagger UI snapshot" width="800" height="353"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  📦 Bring Your Own Image
&lt;/h2&gt;

&lt;p&gt;One key motivation behind qcontroller is the ability to run &lt;em&gt;your own custom-built images&lt;/em&gt;.&lt;br&gt;
Build a base Ubuntu image with QEMU Guest Agent (QGA) using Packer, and then upload it (together with the ID) to qcontroller via the API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST localhost:8080/v1/images &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"file=@ubuntu/base"&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"id=base-image"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can reference this image ID when launching VMs.&lt;br&gt;
This enables workflows like:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;CI/CD testing on specific environments&lt;/li&gt;
&lt;li&gt;Ephemeral VM provisioning with consistent configs&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  🧪 Built with Go and Protobuf
&lt;/h2&gt;

&lt;p&gt;Internally, qcontroller uses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;protoc-gen-go, protoc-gen-go-grpc, grpc-gateway&lt;/li&gt;
&lt;li&gt;Auto-generated OpenAPI via gnostic&lt;/li&gt;
&lt;li&gt;Linting and formatting with golangci-lint&lt;/li&gt;
&lt;li&gt;Structured, typed configs backed by Protobuf&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're a Go developer, extending it is trivial. Want to add snapshot support? Just define it in the .proto files and hook into the logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  📚 Related Posts
&lt;/h2&gt;

&lt;p&gt;This project builds on two prior posts I wrote:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://dev.to/krjakbrjak/automating-bridge-tap-networking-with-a-go-binary-no-bash-no-fuss-kgm"&gt;Automating Bridge-TAP Networking with a Go Binary (no bash, no fuss)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/krjakbrjak/qemu-qapi-client-for-go-native-code-gen-straight-from-qemu-4m13"&gt;QEMU QAPI Client for Go: Native Code Gen Straight From QEMU&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  🔗 GitHub
&lt;/h2&gt;

&lt;p&gt;Source code: &lt;a href="https://github.com/q-controller/qcontroller" rel="noopener noreferrer"&gt;qcontroller&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  💬 Feedback?
&lt;/h2&gt;

&lt;p&gt;I’d love to hear your thoughts, suggestions, or bug reports! Open a GitHub issue or drop a comment below 👇&lt;br&gt;
Thanks for reading, and happy VM hacking 🧠💻&lt;/p&gt;

</description>
      <category>go</category>
      <category>qemu</category>
      <category>grpc</category>
      <category>devops</category>
    </item>
    <item>
      <title>QEMU QAPI Client for Go — Native Code-Gen Straight from QEMU</title>
      <dc:creator>Nikita Vakula</dc:creator>
      <pubDate>Sun, 03 Aug 2025 22:51:33 +0000</pubDate>
      <link>https://dev.to/krjakbrjak/qemu-qapi-client-for-go-native-code-gen-straight-from-qemu-4m13</link>
      <guid>https://dev.to/krjakbrjak/qemu-qapi-client-for-go-native-code-gen-straight-from-qemu-4m13</guid>
      <description>&lt;h2&gt;
  
  
  Ever glued together socat, raw JSON, and a prayer just to talk to QEMU from Go?
&lt;/h2&gt;

&lt;p&gt;I did—until one missing comma killed an overnight CI run. So I bolted a Go backend onto QEMU’s own QAPI generator, and now every VM call is type-safe, fully async, and always in sync with upstream. ➡️ &lt;a href="https://github.com/q-controller/qapi-client" rel="noopener noreferrer"&gt;Check out the project on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I Built This
&lt;/h2&gt;

&lt;p&gt;QEMU already ships a rich JSON interface (QAPI), yet Go developers still end up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Writing raw JSON by hand – easy to mistype, hard to test.&lt;/li&gt;
&lt;li&gt;Chasing partial wrappers – most cover only “common” commands.&lt;/li&gt;
&lt;li&gt;Fixing silent schema drift.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What You Get
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Fully typed bindings for every command, struct, and enum in qapi-schema.json.&lt;/li&gt;
&lt;li&gt;Async events &amp;amp; requests via goroutines and channels—no polling loops.&lt;/li&gt;
&lt;li&gt;Transport helpers for QMP, QGA, serial pipes, TCP, stdio… the lot.&lt;/li&gt;
&lt;li&gt;Schema-sync guarantee – regenerate on each QEMU bump; drift becomes impossible.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The generated Go code is structured per schema module and works out of the box with a reusable core client.&lt;/p&gt;

&lt;h2&gt;
  
  
  🚀 Example
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Generate Go bindigs:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./generate.sh &lt;span class="nt"&gt;--schema&lt;/span&gt; qemu/qapi/qapi-schema.json &lt;span class="nt"&gt;--out-dir&lt;/span&gt; generated &lt;span class="nt"&gt;--package&lt;/span&gt; qapi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Use the generated client in Go:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;monitor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;monitorErr&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewMonitor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;monitorErr&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;monitorErr&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;monitor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;msgCh&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;monitor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;monitor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"example instance"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;socketPath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reqErr&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;qapi&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PrepareQmpCapabilitiesRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;qapi&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QObjQmpCapabilitiesArg&lt;/span&gt;&lt;span class="p"&gt;{});&lt;/span&gt; &lt;span class="n"&gt;reqErr&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chErr&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;monitor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"example instance"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="n"&gt;chErr&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;ch&lt;/span&gt; &lt;span class="c"&gt;// capabilities negotiated&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Listen to events:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;msgCh&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"event: %+v&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because every struct and enum comes straight from the schema, your IDE autocompletes everything, and compile-time checks catch mismatched fields long before QEMU ever sees the request.&lt;/p&gt;

&lt;p&gt;A runnable sample lives in &lt;a href="https://github.com/q-controller/qapi-client/tree/main/example" rel="noopener noreferrer"&gt;/example&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: Custom QEMU Builds Welcome
&lt;/h2&gt;

&lt;p&gt;Running a patched QEMU with extra QAPI entries? No problem—point the generator at your fork’s qapi-schema.json and commit the result. Add one GitHub Actions step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Regenerate QAPI bindings&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;./generate.sh \&lt;/span&gt;
      &lt;span class="s"&gt;--schema ./qemu-fork/qapi/qapi-schema.json \&lt;/span&gt;
      &lt;span class="s"&gt;--out-dir ./generated \&lt;/span&gt;
      &lt;span class="s"&gt;--package qapi&lt;/span&gt;
  &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the schema changes, the action yields a clean diff and your pull request instantly shows whether anything downstream breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;Stop juggling JSON and second-guessing upgrades—let QEMU’s own generator keep your Go client perfectly aligned. Star the repo, open an issue, or submit a PR if you spot something missing. Your VMs (and future, better-rested self) will thank you.&lt;/p&gt;

</description>
      <category>go</category>
      <category>virtualization</category>
      <category>qemu</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Automating Bridge + TAP Networking with a Go Binary (No Bash, No Fuss)</title>
      <dc:creator>Nikita Vakula</dc:creator>
      <pubDate>Sat, 02 Aug 2025 11:17:14 +0000</pubDate>
      <link>https://dev.to/krjakbrjak/automating-bridge-tap-networking-with-a-go-binary-no-bash-no-fuss-kgm</link>
      <guid>https://dev.to/krjakbrjak/automating-bridge-tap-networking-with-a-go-binary-no-bash-no-fuss-kgm</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR:
&lt;/h2&gt;

&lt;p&gt;In &lt;a href="https://dev.to/krjakbrjak/setting-up-vm-networking-on-linux-bridges-taps-and-more-2bbc"&gt;my previous article&lt;/a&gt;, I manually set up Linux bridge + TAP networking for VMs. This time, I built a Go tool that automates the whole process: creating a bridge, setting up a TAP device, configuring NAT and firewall rules via &lt;code&gt;nftables&lt;/code&gt;, and ensuring traffic isn’t blocked by UFW. It’s a single binary that gives your VMs internet access and full host communication — no shell scripts or manual firewall hacking required. Check out the &lt;a href="https://github.com/q-controller/network-utils" rel="noopener noreferrer"&gt;source&lt;/a&gt; and try it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bridge networking shouldn’t feel like defusing a bomb
&lt;/h2&gt;

&lt;p&gt;You just want the guest online — but fifteen minutes later, you’re still flipping &lt;code&gt;ip&lt;/code&gt; flags, poking at &lt;code&gt;nftables&lt;/code&gt;, and praying &lt;code&gt;UFW&lt;/code&gt; doesn’t nuke your DHCP.&lt;/p&gt;

&lt;p&gt;I’ve wrapped that whole ritual into a small Go binary: &lt;code&gt;network-utils&lt;/code&gt;.&lt;br&gt;
Run &lt;code&gt;create-bridge&lt;/code&gt;, &lt;code&gt;configure-bridge&lt;/code&gt;, &lt;code&gt;create-tap&lt;/code&gt;, launch QEMU — done.&lt;/p&gt;

&lt;p&gt;No bash. No orphaned firewall chains. Reproducible every time.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why another tool?
&lt;/h2&gt;

&lt;p&gt;There are a few motivations behind building a dedicated tool:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Reproducibility&lt;/strong&gt;: I wanted a CLI that always configured things the same way, without depending on system state or shell scripts.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Firewall integration&lt;/strong&gt;: Tools like &lt;code&gt;UFW&lt;/code&gt; often silently block bridged traffic unless you understand how it hooks into &lt;code&gt;nftables&lt;/code&gt;. This tool handles that correctly.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Declarative, not imperative&lt;/strong&gt;: Just say "&lt;em&gt;create a bridge on 192.168.26.0/24&lt;/em&gt;" — the tool will do the right thing.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Design decisions
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;nftables&lt;/code&gt; instead of &lt;code&gt;iptables&lt;/code&gt;: Modern Linux distros default to nftables, and this tool configures NAT, forwarding, and firewall rules using it directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom chains for NAT/forwarding&lt;/strong&gt;: Instead of modifying default chains like forward, we inject our own and jump to them explicitly. This avoids interfering with UFW or systemd-managed rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minimal dependencies&lt;/strong&gt;: It’s a Go binary with no external dependencies beyond &lt;code&gt;ethtool&lt;/code&gt; for Tx checksum offload.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wi-Fi note&lt;/strong&gt;: many WLAN chips can’t pass Ethernet frames, so the tool transparently falls back to masquerading - perfectly fine for most VM use-cases.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;Let’s say you want to give your QEMU guest a TAP interface bridged to your host Wi-Fi. The steps normally would look like:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Create a bridge (ip link add br0 type bridge)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Assign it an IP range (ip addr add 192.168.26.1/24 dev br0)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Enable forwarding, NAT, and allow DHCP/DNS&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Create a TAP device&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Attach it to the bridge&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Route VM traffic through host interface (e.g. &lt;code&gt;wlan0&lt;/code&gt;)&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This tool wraps all of that into a few commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./network-utils create-bridge &lt;span class="nt"&gt;--name&lt;/span&gt; br0 &lt;span class="nt"&gt;--cidr&lt;/span&gt; 192.168.26.0/24 &lt;span class="nt"&gt;--disable-tx-offload&lt;/span&gt;
./network-utils configure-bridge &lt;span class="nt"&gt;--name&lt;/span&gt; br0 &lt;span class="nt"&gt;--hostIf&lt;/span&gt; wlan0
./network-utils create-tap &lt;span class="nt"&gt;--name&lt;/span&gt; tap0 &lt;span class="nt"&gt;--bridge&lt;/span&gt; br0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Firewall rules: done right
&lt;/h2&gt;

&lt;p&gt;One of the trickiest parts of bridge networking is getting firewalling and NAT right without accidentally breaking DNS or DHCP. Here’s how this tool addresses that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Masquerading&lt;/strong&gt;: Adds a NAT rule that masquerades outbound packets from the bridge subnet via the selected host interface.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forwarding&lt;/strong&gt;: Allows forwarding between the bridge and the host interface in both directions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DNS/DHCP&lt;/strong&gt;: Explicit rules allow UDP ports 53 and 67 to ensure clients can boot and resolve domains.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chain isolation&lt;/strong&gt;: All rules are installed in dedicated nftables chains. These are jumped to from main chains before UFW or other tools get to filter traffic.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After configuration, your ruleset will look something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;chain INPUT {
    type filter hook input priority filter; policy drop;
    jump EXAMPLE-INPUT
    counter packets 192887 bytes 102499866 jump ufw-before-input
    counter packets 1927 bytes 858681 jump ufw-after-input
    counter packets 456 bytes 15792 jump ufw-after-logging-input
    counter packets 456 bytes 15792 jump ufw-reject-input
    counter packets 456 bytes 15792 jump ufw-track-input
}

chain FORWARD {
    type filter hook forward priority filter; policy drop;
    jump EXAMPLE-FORWARD
    counter packets 12152 bytes 74495358 jump DOCKER-FORWARD
    counter packets 0 bytes 0 jump ufw-before-logging-forward
    counter packets 0 bytes 0 jump ufw-before-forward
    counter packets 0 bytes 0 jump ufw-after-forward
    counter packets 0 bytes 0 jump ufw-after-logging-forward
    counter packets 0 bytes 0 jump ufw-reject-forward
    counter packets 0 bytes 0 jump ufw-track-forward
}

chain EXAMPLE-FORWARD {
    iifname "br0" oifname "wlp0s20f3" accept
    iifname "wlp0s20f3" oifname "br0" ct state established,related accept
}

chain EXAMPLE-INPUT {
    udp dport 53 accept
    udp dport 67 accept
    udp dport 68 accept
    tcp dport 53 accept
    tcp dport 67 accept
    tcp dport 68 accept
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This makes the bridge + tap networking both &lt;strong&gt;transparent&lt;/strong&gt; and &lt;strong&gt;robust&lt;/strong&gt; — it survives UFW being enabled, and won’t conflict with unrelated system firewall rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  Full example: QEMU with internet access
&lt;/h2&gt;

&lt;p&gt;Once the setup is done, launch your VM like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;qemu-system-x86_64 &lt;span class="nt"&gt;-machine&lt;/span&gt; q35 &lt;span class="nt"&gt;-accel&lt;/span&gt; kvm &lt;span class="nt"&gt;-m&lt;/span&gt; 960 &lt;span class="nt"&gt;-nographic&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-device&lt;/span&gt; virtio-net,netdev&lt;span class="o"&gt;=&lt;/span&gt;net0,mac&lt;span class="o"&gt;=&lt;/span&gt;2e:c8:40:59:7d:16 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-netdev&lt;/span&gt; tap,id&lt;span class="o"&gt;=&lt;/span&gt;net0,ifname&lt;span class="o"&gt;=&lt;/span&gt;tap0,script&lt;span class="o"&gt;=&lt;/span&gt;no,downscript&lt;span class="o"&gt;=&lt;/span&gt;no &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-qmp&lt;/span&gt; unix:/tmp/test.sock,server,wait&lt;span class="o"&gt;=&lt;/span&gt;off &lt;span class="nt"&gt;-cpu&lt;/span&gt; host &lt;span class="nt"&gt;-smp&lt;/span&gt; 1 &lt;span class="nt"&gt;-hda&lt;/span&gt; &amp;lt;IMAGE&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The guest will:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Have an IP assigned via DHCP (if you’re running a DHCP server on the bridge)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Be able to reach the host&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Have outbound internet access via NAT&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Installation and permissions
&lt;/h2&gt;

&lt;p&gt;Clone the repo, then: &lt;code&gt;go build&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;By default, network configuration requires elevated privileges. Instead of running the tool with &lt;code&gt;sudo&lt;/code&gt;, you can grant it the necessary capability directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;setcap cap_net_admin,cap_sys_admin+ep ./network-utils                          
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;CAP_NET_ADMIN&lt;/code&gt; - creating or configuring TAP devs/bridges&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CAP_SYS_ADMIN&lt;/code&gt; - Disabling TX offload via ethtool&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;If you're doing VM automation or container networking and need a clean, modern, and firewall-safe bridge+tap setup — this tool is for you.&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
