<?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: Osmium</title>
    <description>The latest articles on DEV Community by Osmium (@osag).</description>
    <link>https://dev.to/osag</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3876023%2F3b75b958-9068-407d-84bb-8e881c71f025.png</url>
      <title>DEV Community: Osmium</title>
      <link>https://dev.to/osag</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/osag"/>
    <language>en</language>
    <item>
      <title>How Tailscale's Route Table Gets Wiped by mwan3 on OpenWrt</title>
      <dc:creator>Osmium</dc:creator>
      <pubDate>Sat, 10 Oct 2026 06:43:15 +0000</pubDate>
      <link>https://dev.to/osag/how-tailscales-route-table-gets-wiped-by-mwan3-on-openwrt-4jln</link>
      <guid>https://dev.to/osag/how-tailscales-route-table-gets-wiped-by-mwan3-on-openwrt-4jln</guid>
      <description>&lt;p&gt;My trusty Netcore N60 Pro runs on OpenWrt 25.12 with both mwan3 (2.12.0) and Tailscale (v1.98.3) installed. Tailscale would work fine, then randomly stop being reachable. Every time I touched the mwan3 config, tailscale broke immediately after. &lt;/p&gt;

&lt;h2&gt;
  
  
  Background: routing tables, ip rule, and firewall marks
&lt;/h2&gt;

&lt;p&gt;Before the investigation, it's worth untangling three concepts that are easy to conflate:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Routing table&lt;/strong&gt; — Linux allows multiple routing tables at once, each with a numeric id. The default main table is 254. Tailscale uses its own table, id 52, to hold peer routes. mwan3 allocates one table per WAN interface.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;IP rule&lt;/strong&gt; — decides which table a packet's lookup goes to. It sits above the routing tables and matches top to bottom by priority, e.g. from all lookup 52 means "send all packets to table 52 for the lookup."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Firewall mark (fwmark)&lt;/strong&gt; — mwan3's mechanism is to have iptables/nftables mark a packet first (e.g. tag which WAN it came in on), then have ip rule route based on that mark to the matching table. This is why mwan3 depends so heavily on the firewall layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The investigation: a few wrong turns first
&lt;/h2&gt;

&lt;h3&gt;
  
  
  First guess: mwan3 must be deleting something
&lt;/h3&gt;

&lt;p&gt;mwan3 restart was rebuilding its own policy routing rules and clobbering tailscale's rule pointing at table 52. Testing didn't confirm it:&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;root@N60Pro:~#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ip rule | &lt;span class="nb"&gt;grep &lt;/span&gt;52
&lt;span class="go"&gt;1370: from all lookup 52
&lt;/span&gt;&lt;span class="gp"&gt;root@N60Pro:~#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ip route show table 52
&lt;span class="gp"&gt;root@N60Pro:~#&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;The rule was still there, but table 52 itself was empty.&lt;/strong&gt; This narrowed things down: the problem wasn't at the ip rule layer, it was the table itself getting wiped.&lt;/p&gt;

&lt;h3&gt;
  
  
  Second guess: This nft seems to be doing weird things
&lt;/h3&gt;

&lt;p&gt;mwan3 restart was throwing a screen full of errors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;iptables v1.8.10 (nf_tables): table `mangle' is incompatible, use 'nft' tool.
Error: cmp sreg undef
Error: meta sreg is not an immediate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;OpenWrt has used nftables/firewall4 since 22.03, but the official mwan3 package is still iptables-based and relies on the iptables-nft compatibility shim, which frequently fails to translate rules. The suspicion was that this broken compat layer was causing mwan3 to malfunction outright, which led to considering mwan3-nft (a community nftables port) or even downgrading OpenWrt entirely.&lt;/p&gt;

&lt;p&gt;But doing a &lt;code&gt;nft list ruleset&lt;/code&gt; showed mwan3's own chains and packet counters actively incrementing (&lt;code&gt;packets 3956602 bytes 456220071, etc.&lt;/code&gt;), proving mwan3's core policy routing was actually working fine, those errors were log noise, unrelated to table 52 being wiped. So that leads to one precise question: who is clearing table 52, and why?&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the code: mwan3's flush loop
&lt;/h2&gt;

&lt;p&gt;With a little help from Claude, grepping mwan3's init script for anything that flushes routing tables:&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;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"ip route flush&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;route flush"&lt;/span&gt; /lib/mwan3/ /etc/init.d/mwan3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That landed on line 77 of &lt;code&gt;/etc/init.d/mwan3&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="k"&gt;for &lt;/span&gt;tid &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nv"&gt;$IP&lt;/span&gt; route list table all | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-ne&lt;/span&gt; &lt;span class="s1"&gt;'s/.*table \([0-9]\+\).*/\1/p'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$tid&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; &lt;span class="nv"&gt;$MWAN3_INTERFACE_MAX&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="nv"&gt;$IP&lt;/span&gt; route flush table &lt;span class="nv"&gt;$tid&lt;/span&gt; &amp;amp;&amp;gt; /dev/null
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So, line by line:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;$IP route list table all&lt;/code&gt;, lists every routing table currently present on the system, not just mwan3's own.&lt;/li&gt;
&lt;li&gt;sed extracts the table X number from each route, deduplicates and sorts, giving a list of every table id currently in use.&lt;/li&gt;
&lt;li&gt;For each table id: if it's greater than &lt;code&gt;$MWAN3_INTERFACE_MAX&lt;/code&gt;, skip it. Otherwise, flush it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Plain and simple for mwan3, but bad news for Tailscale. The code assumes "any table id below the threshold must be mine". Tailscale's table 52 just happens to sit below that threshold, so every mwan3 restart sweeps it up along with mwan3's own tables.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why mmdefault - 3: how the threshold is computed
&lt;/h2&gt;

&lt;p&gt;But what exactly is $MWAN3_INTERFACE_MAX? How it's being used and where to configure it?&lt;/p&gt;

&lt;p&gt;Digging into mwan3's &lt;code&gt;common.sh&lt;/code&gt;, here's where &lt;code&gt;$MWAN3_INTERFACE_MAX&lt;/code&gt; comes from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;config_get MMX_MASK globals mmx_mask &lt;span class="s1"&gt;'0x3F00'&lt;/span&gt;

&lt;span class="nv"&gt;bitcnt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;mwan3_count_one_bits MMX_MASK&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;mmdefault&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;bitcnt&lt;/span&gt;&lt;span class="sh"&gt;)-1))
MWAN3_INTERFACE_MAX=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;mmdefault-3&lt;span class="k"&gt;))&lt;/span&gt;&lt;span class="sh"&gt;
uci_toggle_state mwan3 globals iface_max "&lt;/span&gt;&lt;span class="nv"&gt;$MWAN3_INTERFACE_MAX&lt;/span&gt;&lt;span class="sh"&gt;"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Breaking it down step by step:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: bitcnt.&lt;/strong&gt; &lt;code&gt;mmx_mask&lt;/code&gt; is the mask mwan3 uses for its fwmark bits, defaulting to &lt;code&gt;0x3F00&lt;/code&gt;. In binary, &lt;code&gt;0011 1111 0000 0000&lt;/code&gt;, which has 6 set bits. This can be verified directly from the device's own &lt;code&gt;nft list ruleset&lt;/code&gt; output, where mwan3's rules consistently show &lt;code&gt;meta mark &amp;amp; 0x00003f00&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: mmdefault.&lt;/strong&gt; &lt;code&gt;mmdefault = (1 &amp;lt;&amp;lt; bitcnt) - 1&lt;/code&gt; is simply the largest value representable with those 6 bits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;mmdefault = (1 &amp;lt;&amp;lt; 6) - 1 = 64 - 1 = 63
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 3: subtract 3 to get the final threshold.&lt;/strong&gt; The "-3" in &lt;code&gt;mmdefault - 3&lt;/code&gt; isn't arbitrary: mwan3 reserves the top few values in the mark range for special states (blackhole, unreachable, etc., visible in the &lt;code&gt;ip rule&lt;/code&gt; output earlier as entries like &lt;code&gt;5250: from all fwmark 0x80000/0xff0000 unreachable)&lt;/code&gt;, which can't be assigned to ordinary WAN interfaces and so get carved out of the total. Final result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MWAN3_INTERFACE_MAX = 63 - 3 = 60
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Found it, this is the core of the problem: &lt;strong&gt;tailscale uses table 52, which is less than 60&lt;/strong&gt;, so it falls squarely inside the range mwan3 manages and gets flushed on every mwan3 restart.&lt;/p&gt;

&lt;p&gt;Here's a piquant thing, tailscale using table 52, only because the digits 5 and 2 sit above the letters T and S on a QWERTY keyboard, and there is no environment variable or startup flag to change this table number. Which means this conflict can only be resolved from mwan3's side.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution
&lt;/h2&gt;

&lt;p&gt;Since table 52 can't move, the fix is to push the threshold below 52 instead. Tracing back the formula: the threshold is set by the bit count of &lt;strong&gt;mmx_mask&lt;/strong&gt;, and &lt;strong&gt;mmx_mask&lt;/strong&gt; is an ordinary UCI config option under config globals in &lt;code&gt;/etc/config/mwan3&lt;/code&gt;, not hardcoded. Lower the bit count, and the threshold drops with it. The default is &lt;code&gt;0x3f00&lt;/code&gt;, so how about a &lt;code&gt;0x1f00&lt;/code&gt;? 28 &amp;lt; 52. Should get the job done.&lt;/p&gt;

&lt;h2&gt;
  
  
  One small wrinkle
&lt;/h2&gt;

&lt;p&gt;Changing the mask should solve the problem, but it didn't. table 52 was still being wiped, after some more scratching heads and some more Claude-please-help-me-how-it-happened, &lt;code&gt;common.sh&lt;/code&gt;'s logic for reading &lt;code&gt;MWAN3_INTERFACE_MAX&lt;/code&gt; turned up a caching gotcha:&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="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;MWAN3_STATUS_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/mmx_mask"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;readfile MMX_MASK &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;MWAN3_STATUS_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/mmx_mask"&lt;/span&gt;
    &lt;span class="nv"&gt;MWAN3_INTERFACE_MAX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;uci_get_state mwan3 globals iface_max&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;else
    &lt;/span&gt;config_get MMX_MASK globals mmx_mask &lt;span class="s1"&gt;'0x3F00'&lt;/span&gt;
    &lt;span class="c"&gt;# recompute bitcnt / mmdefault / MWAN3_INTERFACE_MAX ...&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This recompute only happens if the cache file doesn't exist. The cache file lives at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/var/run/mwan3/mmx_mask
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deleting it forces the else branch to run and recompute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uci &lt;span class="nb"&gt;set &lt;/span&gt;mwan3.globals.mmx_mask&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'0x1F00'&lt;/span&gt;
uci commit mwan3
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /var/run/mwan3/mmx_mask
/etc/init.d/mwan3 restart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Verification
&lt;/h2&gt;

&lt;p&gt;After the restart, both the cache and the threshold had updated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;root@N60Pro:~# &lt;span class="nb"&gt;cat&lt;/span&gt; /var/run/mwan3/mmx_mask
0x1f00
root@N60Pro:~# &lt;span class="nb"&gt;cat&lt;/span&gt; /var/state/mwan3 | &lt;span class="nb"&gt;grep &lt;/span&gt;iface_max
mwan3.globals.iface_max&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'28'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the full test: restart tailscale so table 52 has content, then restart mwan3 and check whether it survives.&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;root@N60Pro:~#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;/etc/init.d/tailscale restart
&lt;span class="gp"&gt;root@N60Pro:~#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;8
&lt;span class="gp"&gt;root@N60Pro:~#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ip route show table 52
&lt;span class="go"&gt;100.87.123.123 dev tailscale0
100.100.100.100 dev tailscale0
100.107.123.123 dev tailscale0

&lt;/span&gt;&lt;span class="gp"&gt;root@N60Pro:~#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;/etc/init.d/mwan3 restart
&lt;span class="gp"&gt;root@N60Pro:~#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;5
&lt;span class="gp"&gt;root@N60Pro:~#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ip route show table 52
&lt;span class="go"&gt;100.87.123.123 dev tailscale0
100.100.100.100 dev tailscale0
100.107.123.123 dev tailscale0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finally it fixed! And turns out no hack required at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/tailscale/tailscale/blob/v1.66.1/wgengine/router/router_linux.go" rel="noopener noreferrer"&gt;tailscale/tailscale — router_linux.go, v1.66.1&lt;/a&gt; — the mwan3-detection / policy base priority 1300 logic, and the &lt;code&gt;tailscaleRouteTable = "52"&lt;/code&gt; constant with its "TS on a QWERTY keyboard" comment.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/tailscale/tailscale/pull/12035" rel="noopener noreferrer"&gt;tailscale/tailscale PR #12035 — cmd/tailscaled: add ip-rule-pref-base argument&lt;/a&gt; — the 2024-07-31 patch that made the ip rule priority base configurable (not a fix for the table-52 constant itself).&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://git.openwrt.org/?p=feed/packages.git;a=blob;f=net/mwan3/files/lib/mwan3/common.sh;hb=HEAD" rel="noopener noreferrer"&gt;openwrt/packages — net/mwan3/files/lib/mwan3/common.sh&lt;/a&gt; — official mwan3, where &lt;code&gt;mmx_mask&lt;/code&gt; → &lt;code&gt;bitcnt&lt;/code&gt; → &lt;code&gt;mmdefault&lt;/code&gt; → &lt;code&gt;MWAN3_INTERFACE_MAX&lt;/code&gt; is computed.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://git.openwrt.org/?p=feed/packages.git;a=blob;f=net/mwan3/files/etc/init.d/mwan3;hb=HEAD" rel="noopener noreferrer"&gt;openwrt/packages — net/mwan3/files/etc/init.d/mwan3&lt;/a&gt; — official mwan3 init script containing the unguarded &lt;code&gt;ip route flush table $tid&lt;/code&gt; loop (line ~77 at the time of writing).&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://openwrt.org/docs/guide-user/network/wan/multiwan/mwan3" rel="noopener noreferrer"&gt;OpenWrt wiki — mwan3 (iptables)&lt;/a&gt; — official docs, including the "no longer recommended" notice for the iptables-based package on current releases.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://openwrt.org/docs/guide-user/network/wan/multiwan/mwan3-nft" rel="noopener noreferrer"&gt;OpenWrt wiki — mwan3 (nftables, unofficial)&lt;/a&gt; — docs for the community nftables port used in the fix.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/dl12345/mwan3" rel="noopener noreferrer"&gt;dl12345/mwan3&lt;/a&gt; — source of the nftables port; see &lt;code&gt;/etc/init.d/mwan3&lt;/code&gt; in this repo for the ip-rule deletion fix (dual priority+content gate) and the still-unguarded route-table flush loop discussed above.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/dl12345/luci-app-mwan3" rel="noopener noreferrer"&gt;dl12345/luci-app-mwan3&lt;/a&gt; — companion LuCI frontend built for the nftables port.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://gist.github.com/jamesmacwhite/7f6463ae7b307692c26a81be5cddbdcf" rel="noopener noreferrer"&gt;jamesmacwhite's mwan3-nft-update.sh (gist)&lt;/a&gt; — the install/update script used to fetch and install dl12345's packages via apk.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://forum.openwrt.org/t/tailscale-exit-node-not-working-with-mwan3-on-openwrt-25-12-4-gl-inet-flint-2-mt-6000/250382" rel="noopener noreferrer"&gt;forum.openwrt.org — Tailscale exit node not working with mwan3 on OpenWrt 25.12.4&lt;/a&gt; — a separate, independently-reported case of the same mwan3/tailscale conflict family.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tailscale.com/docs/reference/troubleshooting/network-configuration/tailscale-ip-routes" rel="noopener noreferrer"&gt;Tailscale docs — IP routes installed by Tailscale&lt;/a&gt; — official confirmation that tailscale routes live in table 52.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>openwrt</category>
      <category>tailscale</category>
      <category>networking</category>
      <category>mwan3</category>
    </item>
    <item>
      <title>Turn your OpenWrt router into a quorum device for Proxmox VE cluster</title>
      <dc:creator>Osmium</dc:creator>
      <pubDate>Fri, 01 May 2026 14:26:19 +0000</pubDate>
      <link>https://dev.to/osag/turn-your-openwrt-router-into-a-quorum-device-for-proxmox-ve-cluster-561m</link>
      <guid>https://dev.to/osag/turn-your-openwrt-router-into-a-quorum-device-for-proxmox-ve-cluster-561m</guid>
      <description>&lt;h2&gt;
  
  
  Why Bother
&lt;/h2&gt;

&lt;p&gt;I have a two-node Proxmox VE cluster, and two-node clusters have an inherent distributed systems problem: if either machine goes down, the surviving node can't tell whether it should keep running or not. Running a quorum usually requires a majority vote, which normally means at least three voters. With only two machines, one vote each, lose one and you're down to one vote. No quorum and your cluster stops.&lt;/p&gt;

&lt;p&gt;The solution is a quorum device. Proxmox supports &lt;code&gt;corosync-qnetd&lt;/code&gt;, a lightweight daemon from the corosync project that acts as a third-party tiebreaker. A qnetd doesn't participate in storage or compute, it just casts the deciding vote during a split-brain scenario. By bringing the total vote count to three, the cluster maintains a majority requirement and consequently, if any node fails, the surviving node and the router’s vote can collectively sustain quorum, making the cluster keeps running.&lt;/p&gt;

&lt;p&gt;The ideal host for something like this is a device in a home network that basically never gets turned off, like a router. &lt;/p&gt;

&lt;p&gt;I had a Xiaomi Mini router running OpenWrt, MT7620 chip, 128MB DDR2 memory, 16MB SPI Flash, mipsel_24kc architecture. It runs 24/7, draws a few watts, perfect for the job. The problem: there's no corosync-qnetd package for mipsel in the official OpenWrt feeds. :(&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%2Ffblrpdc0er8lbnbkkr41.jpg" 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%2Ffblrpdc0er8lbnbkkr41.jpg" alt="An online image of Xiaomi Mini Router" width="474" height="474"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding a Starting Point
&lt;/h2&gt;

&lt;p&gt;After some searching, I found someone on GitHub who had compiled corosync-qnetd for aarch64 (ARM 64-bit) OpenWrt, targeting GL.iNet routers and using the old opkg/ipk package format. A good starting point.&lt;/p&gt;

&lt;p&gt;I had essentially zero experience with C, Makefiles, or cross-compilation packaging. I knew C programs need a Makefile to build, but that was about it. So my approach was simply throw it at Claude, hit an error, ask Claude about the error, hit the next error, ask about that one like a no-brainer. &lt;strong&gt;Six hours of tweaking compile parameters over and over, eventually it compiled successfully (phew). I installed it on the router, and it worked!&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Dependency Investigation
&lt;/h2&gt;

&lt;p&gt;But I needed to understand &lt;em&gt;why&lt;/em&gt; it works. Time to do a proper investigation. &lt;/p&gt;

&lt;p&gt;I started digging into the Makefile syntax, the build parameters, and the dependency structure. But mastering every parameter would be impossible because many of them are deeply encapsulated, documented in their own specific docs with their own quirks. &lt;/p&gt;

&lt;p&gt;My approach is simply &lt;strong&gt;upstream-first&lt;/strong&gt;, and I wanted to use official packages whenever possible to maintain generality. The original project compiled custom versions of &lt;code&gt;NSS&lt;/code&gt;, &lt;code&gt;NSPR&lt;/code&gt;, &lt;code&gt;libknet&lt;/code&gt;, and &lt;code&gt;libqb&lt;/code&gt;, plus a homebrewed &lt;code&gt;corosync-nss-tools&lt;/code&gt; and that's a lot of custom packages to maintain.&lt;/p&gt;

&lt;p&gt;To verify what was actually necessary, I used a trick from Claude during our earlier sessions: running &lt;code&gt;ldd&lt;/code&gt; against a binary shows you exactly which shared libraries it actually links to at runtime. So I ran it against the &lt;code&gt;corosync-qnetd&lt;/code&gt; binary on the router:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ldd /usr/sbin/corosync-qnetd
  /lib/ld-musl-mipsel-sf.so.1
  libnss3.so &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; /usr/lib/libnss3.so
  libssl3.so &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; /usr/lib/libssl3.so
  libnspr4.so &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; /usr/lib/libnspr4.so
  libgcc_s.so.1 &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; /lib/libgcc_s.so.1
  libc.so &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; /lib/ld-musl-mipsel-sf.so.1
  libnssutil3.so &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; /usr/lib/libnssutil3.so
  libplc4.so &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; /usr/lib/libplc4.so
  libplds4.so &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; /usr/lib/libplds4.so
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And that's pretty much it, NSS and NSPR. Nothing else.&lt;/p&gt;

&lt;p&gt;Which means that of the four dependencies bundled in the original project, two of them, &lt;code&gt;libknet&lt;/code&gt; and &lt;code&gt;libqb&lt;/code&gt;, were completely unnecessary for qnetd. &lt;/p&gt;

&lt;p&gt;&lt;code&gt;libknet&lt;/code&gt; handles inter-node network communication for the corosync cluster daemon itself; &lt;code&gt;libqb&lt;/code&gt; provides logging and IPC for the corosync main process. &lt;/p&gt;

&lt;p&gt;But the &lt;code&gt;qnetd&lt;/code&gt; server is an independent program, it doesn't use either of them. Not to mention the author's project README said it was for qnetd, yet it compiled and bundled libraries that only the cluster daemon needs. My best guess is that they looked at corosync's upstream README, which lists all dependencies for the full corosync project (including the qdevice client), and just compiled everything at once.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Portability Principle
&lt;/h3&gt;

&lt;p&gt;My goal became: &lt;strong&gt;compile only corosync-qnetd itself, and let the package manager handle everything else through official repositories.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The final dependency chain is clean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;corosync-qnetd (custom-compiled, the only custom package)
├── libnss    ← official OpenWrt package
├── nspr   ← official OpenWrt package (pulled in by libnss)
├── nss-utils ← official OpenWrt package (includes `certutil`, `pk12util` used for certificate management)
└── openssl-util ← official OpenWrt package (needed by the `certutil` signing script)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Technical Challenges
&lt;/h2&gt;

&lt;p&gt;With the "what" and "why" covered, here are the significant technical problems I encountered and how they were solved, in roughly the order I hit them.&lt;/p&gt;

&lt;h3&gt;
  
  
  NSS Cross-Compilation: The Deepest Pit
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;corosync-qnetd&lt;/code&gt; depends on Mozilla NSS (Network Security Services) for TLS. NSS doesn't use autoconf, it has its own build system called &lt;code&gt;coreconf&lt;/code&gt;, driven by environment variables to determine the target platform. The key variables are defined in &lt;code&gt;coreconf/arch.mk&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight make"&gt;&lt;code&gt;&lt;span class="nv"&gt;OS_ARCH&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;$(&lt;/span&gt;subst /,_,&lt;span class="p"&gt;$(&lt;/span&gt;shell &lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c"&gt;# e.g. "Linux"&lt;/span&gt;
&lt;span class="nv"&gt;OS_TEST&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;$(&lt;/span&gt;shell &lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c"&gt;# e.g. "x86_64", "mipsel"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;OS_ARCH&lt;/code&gt; equals &lt;code&gt;Linux&lt;/code&gt;, the build system loads &lt;code&gt;coreconf/Linux.mk&lt;/code&gt; for platform-specific flags. The problem is: in a cross-compilation environment, &lt;code&gt;uname -s&lt;/code&gt; and &lt;code&gt;uname -m&lt;/code&gt; return the host machine's values, not the target's. If you don't override these variables, NSS would happily detects my &lt;code&gt;x86_64&lt;/code&gt; build server, uses the host's &lt;code&gt;cc&lt;/code&gt;, and produces x86 object files. The link stage then explodes because you're trying to link x86 objects against mipsel libraries.&lt;/p&gt;

&lt;p&gt;The original aarch64 project had set &lt;code&gt;OS_ARCH=aarch64&lt;/code&gt; with &lt;code&gt;USE_64=1&lt;/code&gt;, which was non-standard, the &lt;a href="https://github.com/openwrt/packages/blob/master/libs/nss/Makefile" rel="noopener noreferrer"&gt;OpenWrt official NSS Makefile&lt;/a&gt; uses &lt;code&gt;OS_ARCH=Linux&lt;/code&gt; and &lt;code&gt;OS_TEST=$(ARCH)&lt;/code&gt; instead. The aarch64 build likely worked despite the non-standard &lt;code&gt;OS_ARCH&lt;/code&gt; value due to other variables compensating, but that approach couldn't be carried over to mipsel.&lt;/p&gt;

&lt;p&gt;Diagnosing this took a while. The build log showed a wall of link errors that initially looked like missing libraries. The real clue was checking the object files with &lt;code&gt;file&lt;/code&gt; command, they were all x86 binaries, not mipsel.&lt;/p&gt;

&lt;p&gt;The fix was to follow the same pattern as OpenWrt's official NSS package, explicitly tell NSS everything it needs to know about the target:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight make"&gt;&lt;code&gt;&lt;span class="nv"&gt;OS_ARCH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Linux        &lt;span class="c"&gt;# load coreconf/Linux.mk&lt;/span&gt;
&lt;span class="nv"&gt;OS_TARGET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Linux      &lt;span class="c"&gt;# what NSS actually checks internally&lt;/span&gt;
&lt;span class="nv"&gt;OS_TEST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;mipsel       &lt;span class="c"&gt;# equivalent to uname -m on the target&lt;/span&gt;
&lt;span class="nv"&gt;CPU_ARCH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;mipsel      &lt;span class="c"&gt;# target CPU architecture&lt;/span&gt;
&lt;span class="c"&gt;# USE_64 not set    # mipsel_24kc is 32-bit
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Through all of this I also learned some C compilation basics, every dependency's headers and libraries need to be present in the build environment before anything will compile, and how OpenWrt's feed system works to pull in those dependencies automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  NSPR Header File Mess
&lt;/h3&gt;

&lt;p&gt;Corosync depends on nss, and nss depends on NSPR (Netscape Portable Runtime), NSS's sub-makefiles hardcode the search paths for NSPR headers and libraries, paths like &lt;code&gt;dist/&amp;lt;OBJDIR&amp;gt;/include&lt;/code&gt; and &lt;code&gt;dist/public/nspr&lt;/code&gt;. Setting the &lt;code&gt;NSPR_INCLUDE_DIR&lt;/code&gt; environment variable isn't enough because different NSS build modules look for NSPR in different places using different methods.&lt;/p&gt;

&lt;p&gt;The fix was to manually stage NSPR's headers and libraries into the directory structure NSS expects before compilation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight make"&gt;&lt;code&gt;&lt;span class="err"&gt;mkdir&lt;/span&gt; &lt;span class="err"&gt;-p&lt;/span&gt; &lt;span class="err"&gt;$(PKG_BUILD_DIR)/dist/target.OBJ/include&lt;/span&gt;
&lt;span class="err"&gt;cp&lt;/span&gt; &lt;span class="err"&gt;-fpRL&lt;/span&gt; &lt;span class="err"&gt;$(STAGING_DIR)/usr/include/nspr/.&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
    &lt;span class="err"&gt;$(PKG_BUILD_DIR)/dist/target.OBJ/include/&lt;/span&gt;
&lt;span class="err"&gt;mkdir&lt;/span&gt; &lt;span class="err"&gt;-p&lt;/span&gt; &lt;span class="err"&gt;$(PKG_BUILD_DIR)/dist/public/nspr&lt;/span&gt;
&lt;span class="err"&gt;cp&lt;/span&gt; &lt;span class="err"&gt;-fpRL&lt;/span&gt; &lt;span class="err"&gt;$(STAGING_DIR)/usr/include/nspr/.&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
    &lt;span class="err"&gt;$(PKG_BUILD_DIR)/dist/public/nspr/&lt;/span&gt;
&lt;span class="err"&gt;mkdir&lt;/span&gt; &lt;span class="err"&gt;-p&lt;/span&gt; &lt;span class="err"&gt;$(PKG_BUILD_DIR)/dist/target.OBJ/lib&lt;/span&gt;
&lt;span class="err"&gt;for&lt;/span&gt; &lt;span class="err"&gt;lib&lt;/span&gt; &lt;span class="err"&gt;in&lt;/span&gt; &lt;span class="err"&gt;libnspr4.so&lt;/span&gt; &lt;span class="err"&gt;libplc4.so&lt;/span&gt; &lt;span class="err"&gt;libplds4.so;&lt;/span&gt; &lt;span class="err"&gt;do&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
    &lt;span class="err"&gt;cp&lt;/span&gt; &lt;span class="err"&gt;-fpL&lt;/span&gt; &lt;span class="err"&gt;$(STAGING_DIR)/usr/lib/$$lib&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
        &lt;span class="err"&gt;$(PKG_BUILD_DIR)/dist/target.OBJ/lib/&lt;/span&gt; &lt;span class="err"&gt;2&amp;gt;/dev/null&lt;/span&gt; &lt;span class="err"&gt;||&lt;/span&gt; &lt;span class="err"&gt;true;&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
&lt;span class="err"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A host-native version of &lt;code&gt;nsinstall&lt;/code&gt; (a build tool NSS needs to run on the host during compilation) also had to be compiled first, otherwise it would try to run a mipsel binary on the host machine.&lt;/p&gt;

&lt;h3&gt;
  
  
  The ar Parameter
&lt;/h3&gt;

&lt;p&gt;All &lt;code&gt;.c&lt;/code&gt; files compiled correctly with the cross-compiler, but linking produced an error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mipsel-openwrt-linux-musl-ar cr target.OBJ/arena.o target.OBJ/error.o ...
mipsel-openwrt-linux-musl-ar: target.OBJ/arena.o: file format not recognized
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ar&lt;/code&gt; was treating the first &lt;code&gt;.o&lt;/code&gt; file as an existing archive. The cause: NSS's makefile adds &lt;code&gt;cr&lt;/code&gt; flags when invoking &lt;code&gt;ar&lt;/code&gt;, but I had also set &lt;code&gt;AR="$(TARGET_CROSS)ar cr"&lt;/code&gt; in the OpenWrt Makefile. Two &lt;code&gt;cr&lt;/code&gt;s stacked together, mangling the command-line arguments entirely. The fix was changing &lt;code&gt;AR&lt;/code&gt; from &lt;code&gt;$(TARGET_CROSS)ar cr&lt;/code&gt; to just &lt;code&gt;$(TARGET_CROSS)ar&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trimming NSS: Cut to the Bone
&lt;/h3&gt;

&lt;p&gt;A full NSS build is painfully slow and drags in &lt;code&gt;sqlite3&lt;/code&gt; and a bunch of other dependencies. corosync-qnetd only uses a small subset of NSS functionality. Only the six &lt;code&gt;.so&lt;/code&gt; files qnetd actually needs were kept: &lt;code&gt;libnss3&lt;/code&gt;, &lt;code&gt;libssl3&lt;/code&gt;, &lt;code&gt;libsmime3&lt;/code&gt;, &lt;code&gt;libnssutil3&lt;/code&gt;, &lt;code&gt;libsoftokn3&lt;/code&gt;, and &lt;code&gt;libfreebl3&lt;/code&gt;. The original project already had the idea here, it used &lt;code&gt;NSS_DISABLE_DBM=1&lt;/code&gt; to eliminate the sqlite3 dependency and &lt;code&gt;NSS_DISABLE_LIBPKIX=1&lt;/code&gt; to skip PKIX certificate path validation. I kept both of these flags. But the package relationship required careful handling, so I created a separate package called &lt;code&gt;nss-qnetd&lt;/code&gt; instead of reusing the original's NSS build, for two reasons.&lt;/p&gt;

&lt;p&gt;First, naming. The package needed to coexist with the official &lt;code&gt;libnss&lt;/code&gt; in OpenWrt's package system without conflicting. nss-qnetd declares &lt;code&gt;PROVIDES:=libnss&lt;/code&gt; so OpenWrt's package manager can resolve the &lt;code&gt;libnss3.so&lt;/code&gt; dependency at compile time. But at runtime on the router, corosync-qnetd's runtime dependency points to the official &lt;code&gt;libnss&lt;/code&gt; from the feeds — compile with the stripped version for headers and libraries, run with the package from upstream.&lt;/p&gt;

&lt;p&gt;Second, improved robustness. The original project used patch files to modify NSS source files like &lt;code&gt;nss/cmd/manifest.mn&lt;/code&gt;. Patches depend on exact line numbers, when I upgraded to NSS 3.112, the line counts didn't match and the patch failed with &lt;code&gt;malformed patch at line 3&lt;/code&gt;, so by replacing all patches with direct shell commands in &lt;code&gt;Build/Compile&lt;/code&gt; gives it a more robust way of injecting params.&lt;/p&gt;

&lt;h3&gt;
  
  
  The custom &lt;code&gt;corosync-nss-tools&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Then there was &lt;code&gt;corosync-nss-tools&lt;/code&gt;, a package the original author created that doesn't exist upstream. It bundled &lt;code&gt;certutil&lt;/code&gt; and &lt;code&gt;pk12util&lt;/code&gt; (NSS command-line tools for managing certificate databases) together with custom shell wrapper scripts that reimplemented the certificate initialisation logic. But OpenWrt's official feeds already have an &lt;code&gt;nss-utils&lt;/code&gt; package that provides certutil and pk12util. And the upstream &lt;code&gt;corosync-qdevice&lt;/code&gt; project already ships its own &lt;code&gt;corosync-qnetd-certutil&lt;/code&gt; script. There was no need to repackage binaries that are already available or to rewrite scripts that upstream already maintains. Removing &lt;code&gt;corosync-nss-tools&lt;/code&gt; entirely and depending on the official &lt;code&gt;nss-utils&lt;/code&gt; was the straightforward improvement.&lt;/p&gt;

&lt;h3&gt;
  
  
  The musl dladdr Trap
&lt;/h3&gt;

&lt;p&gt;This fix was inherited from the original project and is worth documenting. NSS's &lt;code&gt;libsoftokn3.so&lt;/code&gt; calls &lt;code&gt;dladdr()&lt;/code&gt; at runtime to find its own file path, then uses that path to locate and load &lt;code&gt;libfreebl3.so&lt;/code&gt;. This works fine on glibc, but musl's &lt;code&gt;dladdr()&lt;/code&gt; implementation behaves differently — in certain cases it can't correctly resolve the path of an already-loaded library, causing softokn to fail to find freebl. Mozilla bug tracker &lt;a href="https://bugzilla.mozilla.org/show_bug.cgi?id=511312" rel="noopener noreferrer"&gt;Bug 511312&lt;/a&gt; confirmed this.&lt;/p&gt;

&lt;p&gt;The original author's fix was to use &lt;code&gt;patchelf&lt;/code&gt; after compilation to add an explicit dependency on &lt;code&gt;libfreebl3.so&lt;/code&gt; to &lt;code&gt;libsoftokn3.so&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight make"&gt;&lt;code&gt;&lt;span class="err"&gt;find&lt;/span&gt; &lt;span class="err"&gt;$(PKG_BUILD_DIR)/dist&lt;/span&gt; &lt;span class="err"&gt;-name&lt;/span&gt; &lt;span class="err"&gt;libsoftokn3.so&lt;/span&gt; &lt;span class="err"&gt;-exec&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
    &lt;span class="err"&gt;$(STAGING_DIR_HOST)/bin/patchelf&lt;/span&gt; &lt;span class="err"&gt;--add-needed&lt;/span&gt; &lt;span class="err"&gt;libfreebl3.so&lt;/span&gt; &lt;span class="err"&gt;{}&lt;/span&gt; &lt;span class="err"&gt;\;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This makes the dynamic linker automatically load freebl when loading softokn, bypassing the &lt;code&gt;dladdr()&lt;/code&gt; issue entirely. Notably, the &lt;a href="https://github.com/openwrt/packages/blob/master/libs/nss/Makefile" rel="noopener noreferrer"&gt;OpenWrt official NSS Makefile&lt;/a&gt; uses a related approach — it sets &lt;code&gt;FREEBL_NO_DEPEND=1&lt;/code&gt; to handle the same &lt;code&gt;softokn-freebl&lt;/code&gt; loading relationship.&lt;/p&gt;

&lt;h3&gt;
  
  
  OpenWrt 25.12: Big Changes to Package Management
&lt;/h3&gt;

&lt;p&gt;With cross-compilation sorted, I ran into a series of changes in the latest OpenWrt 25.12.&lt;/p&gt;

&lt;p&gt;The biggest change was the package manager switch from opkg to apk (Alpine Package Keeper). Output format changed from &lt;code&gt;.ipk&lt;/code&gt; to &lt;code&gt;.apk&lt;/code&gt;. Fortunately, the OpenWrt SDK 25.12 natively outputs apk format, I just needed to update all &lt;code&gt;.ipk&lt;/code&gt; references to &lt;code&gt;.apk&lt;/code&gt; in the build scripts.&lt;/p&gt;

&lt;p&gt;But after installing the corosync-qnetd apk, more insidious problems were at runtime. Running the certificate initialization script &lt;code&gt;corosync-qnetd-certutil -i&lt;/code&gt; produced a barrage of errors:&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="go"&gt;stat: applet not found
chown: unrecognized option '--reference'
sha1sum: command not found
ps: unrecognized option: e
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;OpenWrt's userspace tools are mostly BusyBox, stripped-down versions with limited options. BusyBox's &lt;code&gt;chown&lt;/code&gt; doesn't support &lt;code&gt;--reference&lt;/code&gt;, the &lt;code&gt;stat&lt;/code&gt; applet wasn't compiled into this firmware's BusyBox, &lt;code&gt;ps&lt;/code&gt; doesn't support the &lt;code&gt;-e&lt;/code&gt; flag. The certutil script was written for a full GNU/Linux environment and fell apart in BusyBox land.&lt;/p&gt;

&lt;p&gt;Full GNU versions of these tools were needed. But here was another trap: OpenWrt 25.12 splits coreutils into dozens of individual sub-packages (&lt;code&gt;coreutils-chown&lt;/code&gt;, &lt;code&gt;coreutils-stat&lt;/code&gt;, &lt;code&gt;coreutils-sha1sum&lt;/code&gt;, ...). The main &lt;code&gt;coreutils&lt;/code&gt; package is an empty shell — installing it installs nothing. &lt;code&gt;apk info -L coreutils&lt;/code&gt; outputs exactly one line: &lt;code&gt;lib/apk/packages/coreutils.list&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The final list of runtime dependencies that needed to be installed individually: &lt;code&gt;bash&lt;/code&gt;, &lt;code&gt;coreutils-chown&lt;/code&gt;, &lt;code&gt;coreutils-stat&lt;/code&gt;, &lt;code&gt;coreutils-sha1sum&lt;/code&gt;, &lt;code&gt;procps-ng&lt;/code&gt; (for a full &lt;code&gt;ps&lt;/code&gt;), &lt;code&gt;openssh-sftp-server&lt;/code&gt; (PVE's scp defaults to SFTP mode — without sftp-server on the router, certificate copying fails), and &lt;code&gt;openssl-util&lt;/code&gt; (the certutil signing script needs the openssl command-line tool).&lt;/p&gt;

&lt;p&gt;All of these were added to the Makefile's &lt;code&gt;DEPENDS&lt;/code&gt;, so a &lt;code&gt;apk add corosync-qnetd&lt;/code&gt; pulls in everything automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Final Result
&lt;/h2&gt;

&lt;p&gt;After all the pitfalls were navigated, the end product is one clean OpenWrt package. Installation only takes a single command and all dependencies are pulled automatically. Initialise the certificate database (key generation takes about five to ten minutes on mipsel hardware), start the service, then run &lt;code&gt;pvecm qdevice setup &amp;lt;router IP&amp;gt;&lt;/code&gt; on any PVE node. Certificates exchange automatically, and the cluster recognises the Qdevice.&lt;/p&gt;

&lt;p&gt;The core changes in the final Makefile: corrected NSS architecture parameters from aarch64 to the proper mipsel 32-bit combination; trimmed NSS to compile only the libraries qnetd needs; added all the runtime dependencies and removed unnecessary libknet and libqb; migrated from ipk to apk.&lt;/p&gt;

&lt;p&gt;Porting corosync-qnetd to mipsel means thousands of MIPS-based routers running OpenWrt can now serve as quorum arbitrators for Proxmox clusters. &lt;/p&gt;

&lt;p&gt;A full day's work, morning to night. Worth it.&lt;/p&gt;

&lt;p&gt;GitHub Releases: &lt;a href="https://github.com/osmiumsilver/corosync-qnetd-openwrt-mipsel/releases" rel="noopener noreferrer"&gt;https://github.com/osmiumsilver/corosync-qnetd-openwrt-mipsel/releases&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  More Links
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;NSS Build System&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://hg.mozilla.org/mozilla-central/file/tip/security/nss/coreconf/arch.mk" rel="noopener noreferrer"&gt;NSS &lt;code&gt;coreconf/arch.mk&lt;/code&gt;&lt;/a&gt; — Where &lt;code&gt;OS_ARCH&lt;/code&gt;, &lt;code&gt;OS_TEST&lt;/code&gt;, and &lt;code&gt;OS_TARGET&lt;/code&gt; are resolved. Shows that &lt;code&gt;Linux.mk&lt;/code&gt; is included whenever &lt;code&gt;OS_ARCH=Linux&lt;/code&gt;, regardless of CPU architecture.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/servo/nss/blob/master/coreconf/Linux.mk" rel="noopener noreferrer"&gt;NSS &lt;code&gt;coreconf/Linux.mk&lt;/code&gt;&lt;/a&gt; — Platform-specific flags for Linux targets, including the &lt;code&gt;AR&lt;/code&gt; definition that causes the double-&lt;code&gt;cr&lt;/code&gt; issue.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://fossies.org/linux/nss/nss/coreconf/detect_host_arch.py" rel="noopener noreferrer"&gt;NSS &lt;code&gt;coreconf/detect_host_arch.py&lt;/code&gt;&lt;/a&gt; — Host architecture detection script; recognizes mips/mips64 but only for host detection, not cross-compilation.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/openwrt/packages/blob/master/libs/nss/Makefile" rel="noopener noreferrer"&gt;OpenWrt official NSS Makefile&lt;/a&gt; — Reference implementation for cross-compiling NSS on OpenWrt. Uses &lt;code&gt;OS_ARCH=Linux&lt;/code&gt;, &lt;code&gt;OS_TEST=$(ARCH)&lt;/code&gt;, and &lt;code&gt;FREEBL_NO_DEPEND=1&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;corosync-qdevice / corosync-qnetd&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/osmiumsilver/corosync-qnetd-openwrt-mipsel" rel="noopener noreferrer"&gt;osmiumsilver/corosync-qnetd-openwrt-mipsel&lt;/a&gt; —  My mipsel port to OpenWrt 25.12+&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/corosync/corosync-qdevice" rel="noopener noreferrer"&gt;corosync/corosync-qdevice&lt;/a&gt; — Upstream source.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/jrparks/corosync-qnetd-openwrt" rel="noopener noreferrer"&gt;jrparks/corosync-qnetd-openwrt&lt;/a&gt; — The original aarch64 OpenWrt project this work was forked from.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Community Discussions&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://forum.openwrt.org/t/porting-corosync-qdevice-to-openwrt/193940" rel="noopener noreferrer"&gt;Porting corosync-qdevice to OpenWrt&lt;/a&gt; — OpenWrt forum thread on the challenges of building corosync-qdevice for OpenWrt.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://forum.proxmox.com/threads/corosync-qnetd-on-openwrt.181963/" rel="noopener noreferrer"&gt;Corosync QNetd on OpenWrt — Proxmox Forum&lt;/a&gt; — jrparks' original announcement of the aarch64 build.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/Entware/Entware/issues/994" rel="noopener noreferrer"&gt;Package Request: corosync-qdevice — Entware&lt;/a&gt; — Earlier request for corosync-qdevice on mipsel.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>proxmox</category>
      <category>corosync</category>
      <category>homelab</category>
      <category>openwrt</category>
    </item>
    <item>
      <title>How a missing @ in a filename broke my Netlify build</title>
      <dc:creator>Osmium</dc:creator>
      <pubDate>Mon, 27 Apr 2026 12:18:09 +0000</pubDate>
      <link>https://dev.to/osag/how-a-missing-in-a-filename-broke-my-netlify-build-10k</link>
      <guid>https://dev.to/osag/how-a-missing-in-a-filename-broke-my-netlify-build-10k</guid>
      <description>&lt;h2&gt;
  
  
  It was fine three months ago :(
&lt;/h2&gt;

&lt;p&gt;Three months ago I deployed my Qwik site to Netlify. And after some while, I pushed an new, unrelated content change and the build died without touching a thing on it’s codebase:&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="go"&gt;Edge Functions bundling
────────────────────────────────────────────────────────────────

Packaging Edge Functions from .netlify/edge-functions directory:
 - entry.netlify-edge

[Error: ENOENT: no such file or directory, stat '/tmp/tmp-2269-6qAdCcbGEFml/qwik-city-not-found-paths.js'] {
  errno: -2,
  code: 'ENOENT',
  syscall: 'stat',
  path: '/tmp/tmp-2269-6qAdCcbGEFml/qwik-city-not-found-paths.js'
}
Node.js v22.22.1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No version bumps in &lt;code&gt;package.json&lt;/code&gt;. No config changes. The build itself (client, server) compiled cleanly. If it worked three months ago with the exact same versions, why was it broken now?&lt;/p&gt;

&lt;p&gt;I did the usual steps: Cleared cache and ran the build, failed. But build process worked fine on local machine. Upgraded all dependencies with &lt;code&gt;ncu&lt;/code&gt; to their latest versions, pushed again, still failed. Until I created a brand new Qwik hello-world project, pushed it to Netlify. &lt;em&gt;Still failed&lt;/em&gt;, that’s when I knew there must be something wrong on Netlify’s side.&lt;/p&gt;

&lt;p&gt;Searching online for &lt;code&gt;"qwik netlify build error ENOENT file not found"&lt;/code&gt; wasn't really helpful, found only one forum post describing the same issue. No replies. Auto-closed after seven days. So I posted on Netlify's answers forum and hoping someone could give a solution. A response did come eventually, but hours later. The site was down and needed to come back up ASAP, so I wasn't going to sit there refreshing a forum tab. I started digging deep.&lt;/p&gt;




&lt;h2&gt;
  
  
  The First Clue: A Missing @
&lt;/h2&gt;

&lt;p&gt;I noticed the failure was in the Edge Functions bundling step, an additional step after everything else was done. I opened the output directory on my local machine expecting to see the missing file:&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="go"&gt;.netlify/edge-functions/entry.netlify-edge/
├── @qwik-city-not-found-paths.js  ← Here it is!
├── @qwik-city-plan.js
├── @qwik-city-static-paths.js
├── entry.netlify-edge.js
└── ...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's when it gets interesting, the file existed. It just had an &lt;code&gt;@&lt;/code&gt; in front of it. Yet the error occurs when attempting to call &lt;code&gt;stat&lt;/code&gt; on a file with a filename that is almost identical, except for the missing &lt;code&gt;@&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If the bundler knew this file existed, why was it looking for a file with the wrong name?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Something was stripping the &lt;code&gt;@&lt;/code&gt; somewhere in the process.&lt;/p&gt;




&lt;h2&gt;
  
  
  Following the Trail
&lt;/h2&gt;

&lt;p&gt;I pulled down the source of &lt;code&gt;@netlify/build&lt;/code&gt; from Netlify’s GitHub repo including the package &lt;code&gt;edge-bundler&lt;/code&gt;, which responsible for that Edge Functions bundling step. I followed the call chain:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;edge_functions/index.ts → bundler.ts → formats/tarball.ts&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;tarball.ts&lt;/code&gt;, I found the function file list being built:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;listRecursively&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bundleDir&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;relative&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bundleDir&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getUnixPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;bundleDir&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tarballPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;gzip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the code packs bundled edge functions into a tarball for deployment. It calls &lt;code&gt;tar.create()&lt;/code&gt; with a list of filenames. And at that point, the filenames still had their &lt;code&gt;@&lt;/code&gt; prefix intact. The correct name was being passed in. So the issue wasn't in the path calculation. The &lt;code&gt;@&lt;/code&gt; was being stripped inside &lt;code&gt;tar.create()&lt;/code&gt; itself.&lt;/p&gt;




&lt;h2&gt;
  
  
  Reproducing the Bug
&lt;/h2&gt;

&lt;p&gt;To confirm, I put together a minimal reproduction with some help from Claude:&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="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;tar&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;tar&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;fs&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;fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;os&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;os&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;path&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;path&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tmpDir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdtempSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tmpdir&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;test-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tmpDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@test-file.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;export default 1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Without fix: ENOENT — @ is stripped&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tmpDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/tmp/out.tar.gz&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;gzip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@test-file.js&lt;/span&gt;&lt;span class="dl"&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 i got:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Error: ENOENT: no such file or directory, stat '.../test-file.js']
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bug was confirmed: &lt;code&gt;node-tar&lt;/code&gt; WAS doing something on filenames that result in the removal of the &lt;code&gt;@&lt;/code&gt; symbol.&lt;/p&gt;




&lt;h2&gt;
  
  
  Root Cause: bsdtar (libarchive)’s &lt;code&gt;@archive&lt;/code&gt; syntax
&lt;/h2&gt;

&lt;p&gt;After some digging online, bsdtar/libarchive has a feature where entries starting with &lt;code&gt;@&lt;/code&gt; have special meaning: &lt;code&gt;@archive.tar&lt;/code&gt; means "open that archive and include its contents". &lt;code&gt;node-tar&lt;/code&gt;, as a tar implementation, inherited this behavior. When it sees a filename starting with &lt;code&gt;@&lt;/code&gt; in the files list, it strips the &lt;code&gt;@&lt;/code&gt; symbol and tries to open the remainder as a tar archive to read entries from.&lt;/p&gt;

&lt;p&gt;So when &lt;code&gt;node-tar&lt;/code&gt; anything starts with a @ symbol:&lt;/p&gt;

&lt;p&gt;→ Interpret this as an &lt;code&gt;archive-include&lt;/code&gt; directive&lt;br&gt;
→ It tried to open &lt;code&gt;qwik-city-not-found-paths.js&lt;/code&gt; as a tar archive&lt;br&gt;
→ Run &lt;code&gt;stat&lt;/code&gt; on &lt;code&gt;qwik-city-not-found-paths.js&lt;/code&gt;&lt;br&gt;
→ ENOENT&lt;/p&gt;


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

&lt;p&gt;In &lt;code&gt;tarball.ts&lt;/code&gt;, where the files array is passed to &lt;code&gt;tar.create()&lt;/code&gt;, prefix each entry with &lt;code&gt;./&lt;/code&gt;:&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="c1"&gt;// Before&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;listRecursively&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bundleDir&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;relative&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bundleDir&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getUnixPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;// After&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;listRecursively&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bundleDir&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;relative&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bundleDir&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;getUnixPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;// ← this line&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The Chain Reaction
&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%2Fjo4337tt672orh71yw0q.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%2Fjo4337tt672orh71yw0q.png" alt="PR got merged" width="800" height="281"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After the PR was merged into &lt;code&gt;@netlify/build&lt;/code&gt;, the maintainer traced the issue further upstream and submitted a PR to &lt;code&gt;node-tar&lt;/code&gt; itself — fixing the error handling so that errors from &lt;code&gt;@&lt;/code&gt;-prefixed entries are properly catchable instead of becoming unhandled promise rejections that the caller couldn't catch.&lt;/p&gt;

&lt;p&gt;Moreover, somehow it also uncovered a CI coverage gap: the test suite for tarball handling had a describe block that was being silently skipped because of a wrong Deno version number in the test configuration. An entire set of tarball tests had been passing CI without actually running.&lt;/p&gt;

&lt;p&gt;One missing &lt;code&gt;@&lt;/code&gt;, and suddenly there were three fixes flowing in different directions.&lt;/p&gt;




&lt;h2&gt;
  
  
  Looking Back
&lt;/h2&gt;

&lt;p&gt;Reading someone else's TypeScript to find out why my site wouldn't deploy is not something I'd done before this week. The typical workaround for this kind of issue you would find online, is to duplicate the files without the &lt;code&gt;@&lt;/code&gt;. That works. Your build passes. You move on. But somewhere the same bug is still waiting for the next person. And the next. Until someone reads the function that's eating the &lt;code&gt;@&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There's something I've been thinking about since. The experienced developers who maintain these projects, they do this kind of invisible work all the time, and they do it faster than I ever could. They trace a bug, fix it, and the result is that thousands of people simply never encounter the problem. Nobody knows it was ever there. &lt;/p&gt;

&lt;p&gt;I'm not a developer by trade. If these projects had been closed-source, my story would have ended at the forum post with no replies, waiting for someone on the other side to notice. But because the code was right there, I could pull it down and find the answer myself. A workaround solves your problem. A root cause fix solves everyone's.&lt;/p&gt;




&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Post on netlify forum: &lt;a href="https://answers.netlify.com/t/edge-functions-bundling-failed-for-qwik-enoent-no-such-file-or-directory-stat-qwik-city-not-found-paths-js-vite-7/160697" rel="noopener noreferrer"&gt;Edge Functions bundling failed for Qwik: ENOENT: no such file or directory, stat ‘@qwik-city-not-found-paths.js’ (Vite 7) - #6 by statusbot - Support - Netlify Support Forums&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;PR: netlify/build: &lt;a href="https://github.com/netlify/build/pull/6990" rel="noopener noreferrer"&gt;fix: add ./ prefix to tar entries to handle @ prefixed filenames&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Upstream: node-tar: fix from pieh:  &lt;a href="https://github.com/isaacs/node-tar/commit/01082a42c3256ca6054f9627911cce4dbfe00d92" rel="noopener noreferrer"&gt;error from @ file entry should be catchable&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Upstream: node-tar: where stripping @ symbol as directives: &lt;a href="https://github.com/isaacs/node-tar/blob/main/src/create.ts#L43" rel="noopener noreferrer"&gt;node-tar/src/create.ts at main · isaacs/node-tar&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Bug reproducing repo: &lt;a href="https://github.com/osmiumsilver/demo-tar-at-file" rel="noopener noreferrer"&gt;osmiumsilver/demo-tar-at-file&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>netlify</category>
      <category>javascript</category>
      <category>troubleshooting</category>
    </item>
  </channel>
</rss>
