feat: add NetBird VPN support - #759
AndrewMoryakov wants to merge 11 commits into
Conversation
Add NetBird as a second VPN option alongside Tailscale, using the official NetBird cloud server. Only one VPN runs at a time due to NanoKVM Cube's 161MB RAM constraint. Backend: - NetBird service with install/uninstall/start/stop/restart/login/down - CLI wrapper with socket-based daemon communication and timeout handling - Bundled binary installation with version tracking - VPN preference service (/etc/kvm/vpn) for mutual exclusion Frontend: - NetBird settings tab mirroring Tailscale's state machine UI - Autostart toggle on both Tailscale and NetBird headers - Device flow login: Login button -> URL -> "I have logged in" - Device panel with enable switch, info rows, disconnect button Init scripts: - S99netbird init script with TUN device setup and GOMEMLIMIT=30MiB - S95nanokvm reads /etc/kvm/vpn preference, starts only selected VPN - Swap setup and OOM protection for NanoKVM-Server process
|
Wow, thank you! I’ll be really looking forward to when @wj-xiao merges it! |
|
Any update on this? It'd be a fantastic addition... |
…wing The Go collector's only default input is how much the heap has grown since the last collection. It cannot see what the board has left. This board shows 166MB to Linux and about 96MB spare once the video carveout, the kernel and the running services are counted. Below roughly 30MB free the board stops being able to start a process at all. fork fails, the kernel selects no OOM victim, and nothing recovers it except a power cycle. So the failure this guards against is not a killed server. It is a board that answers nothing and cannot be reached to repair it, which on a KVM is the whole product. GOMEMLIMIT supplies the input the collector lacks. The default is 64MiB, a little over twice the 27.8MB peak measured on the reference device across 26 hours, read from /proc/<pid>/status VmHWM. That leaves the collector room to work before it becomes the thing consuming the board. /etc/kvm/GOMEMLIMIT.server overrides it, in MiB. tailscaled keeps its own /etc/kvm/GOMEMLIMIT and the two stay separate on purpose: one number cannot suit both a VPN daemon and the process that answers the browser, and a value chosen for tailscaled would silently disable this one. The value is checked before use, because Go refuses to start when GOMEMLIMIT does not parse, so a typo in that file would take the server down rather than mis-size it. No oom_score_adj is set. The kernel here does not pick a victim under the pressure this fork has measured, because allocation stops working first. Lowering the score would read as protection while changing nothing. tools/service/test-server-memlimit.sh runs the shipped function against a fake config directory and reads the result from a child process, since the server is a child and a value that is assigned but never exported reaches nothing. Five deliberate breakages were tried against it and all five fail the suite, including that one. Upstream carries the init-script half of this as part of pull request sipeed#759. The NetBird VPN in that pull request is not taken.
I'll do it within a week. |
|
For anyone need netbird / tailscale / zerotier on nanokvm Can try my custom image for NanoKVM, https://github.com/onekvm/onekvm-release-nanokvm/releases/tag/0.1.0 Netbird Extension: https://github.com/onekvm/onekvm-extension-netbird |
Resolve conflicts with the multi-user (sipeed#876) and init-script refactors: - settings/index.tsx: move the NetBird tab into the admin-only tab list next to Tailscale. - S95nanokvm: rebase the NetBird changes onto upstream's start_services/stop_services helpers — GOMEMLIMIT and OOM protection now live in start_services, VPN selection moved to start_vpn(). restart keeps upstream's migration guard and no longer bounces the VPN. - Add NetBird strings to zh, zh_tw and ru; other locales fall back to en. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…wing The Go collector's only default input is how much the heap has grown since the last collection. It cannot see what the board has left. This board shows 166MB to Linux and about 96MB spare once the video carveout, the kernel and the running services are counted. Below roughly 30MB free the board stops being able to start a process at all. fork fails, the kernel selects no OOM victim, and nothing recovers it except a power cycle. So the failure this guards against is not a killed server. It is a board that answers nothing and cannot be reached to repair it, which on a KVM is the whole product. GOMEMLIMIT supplies the input the collector lacks. The default is 64MiB, a little over twice the 27.8MB peak measured on the reference device across 26 hours, read from /proc/<pid>/status VmHWM. That leaves the collector room to work before it becomes the thing consuming the board. /etc/kvm/GOMEMLIMIT.server overrides it, in MiB. tailscaled keeps its own /etc/kvm/GOMEMLIMIT and the two stay separate on purpose: one number cannot suit both a VPN daemon and the process that answers the browser, and a value chosen for tailscaled would silently disable this one. The value is checked before use, because Go refuses to start when GOMEMLIMIT does not parse, so a typo in that file would take the server down rather than mis-size it. No oom_score_adj is set. The kernel here does not pick a victim under the pressure this fork has measured, because allocation stops working first. Lowering the score would read as protection while changing nothing. tools/service/test-server-memlimit.sh runs the shipped function against a fake config directory and reads the result from a child process, since the server is a child and a value that is assigned but never exported reaches nothing. Five deliberate breakages were tried against it and all five fail the suite, including that one. Upstream carries the init-script half of this as part of pull request sipeed#759. The NetBird VPN in that pull request is not taken.
a7e2924 to
b0ca30a
Compare
|
Updated after a long silence — sorry for that. Rebased onto current Two things changed substantially since the original version, and one of them is a design decision The client is no longer bundled. NetBird publishes no riscv64 build, so this builds one in CI Is that acceptable? If you would rather not have NanoKVM releases carry a third-party client as Second: CI has never run on this branch. Workflow runs for fork pull requests sit at The test plan in the description is deliberately unchecked: I do not have the device in front of me |
a2be3cb to
96bc0bb
Compare
These came in with the NetBird branch but have nothing to do with NetBird, and each needs its own justification and its own measurement on hardware: - setup_swap() writes a 128 MB swapfile to /data/swapfile. /data is mounted only when /dev/mmcblk0p3 exists (S01fs:35-39), that partition is created only on devices with /boot/usb.disk0 (S01fs:21), and it is formatted exFAT (S01fs:30). Both failure paths are silent while the script still prints "Swap enabled", and the 128 MB is spent on the user's partition on first boot regardless. - SERVER_GOMEMLIMIT=20MiB caps NanoKVM-Server's soft heap. Go does not fail at that limit, it collects continuously — on a device that also runs the video pipeline. The value appears to assume the swap above actually works. - oom_score_adj = -800 adds a sleep 1 to every boot. Also reverts the file header from Rev3.2 back to Rev3.1, since the changes that bumped it are gone. start_services is now byte-identical to what upstream ships. If the memory work is worth doing it belongs in its own PR with measurements, not carried along by a VPN feature where reviewers read it as merge noise. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
NetBird publishes no riscv64 build. Rather than bundle a ~38 MB client into the OTA package for every device — most of which will never enable it — NanoKVM builds one and attaches it to its own release; the device downloads it on demand in the next commit, as it already does for Tailscale. Measured: the package is currently 14.7 MiB (content-length of nanokvm_2.4.3.tar.gz). The client is ~13 MiB gzipped, so bundling would mean roughly +90 % for everyone. - scripts/build-netbird.sh cross-compiles the version pinned by kvmapp/system/netbird/VERSION, and checks the result for ELF e_machine=243 with the same od-based test package.sh applies to every shipped artifact. -trimpath, a normalized tar and gzip -n make a rebuild byte-identical for a given Go toolchain; the toolchain itself is pinned in the workflow, not here. - package.yml builds it, cached on the pinned version, the script hash and the toolchain. The step must stay after "Assemble package": package.sh:88 does rm -rf build/release. - release.yml publishes it and expects it back on promotion. - verify-release-assets.sh checks the archive layout, the version inside the archive against the firmware pin, and the architecture of the binary. This is a standing commitment for the project: every release would cross-compile a third-party client and publish it under the NanoKVM name. Reviewing it apart from the feature is deliberate — it can be rejected on its own terms. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
96bc0bb to
e2a87e9
Compare
…wing The Go collector's only default input is how much the heap has grown since the last collection. It cannot see what the board has left. This board shows 166MB to Linux and about 96MB spare once the video carveout, the kernel and the running services are counted. Below roughly 30MB free the board stops being able to start a process at all. fork fails, the kernel selects no OOM victim, and nothing recovers it except a power cycle. So the failure this guards against is not a killed server. It is a board that answers nothing and cannot be reached to repair it, which on a KVM is the whole product. GOMEMLIMIT supplies the input the collector lacks. The default is 64MiB, a little over twice the 27.8MB peak measured on the reference device across 26 hours, read from /proc/<pid>/status VmHWM. That leaves the collector room to work before it becomes the thing consuming the board. /etc/kvm/GOMEMLIMIT.server overrides it, in MiB. tailscaled keeps its own /etc/kvm/GOMEMLIMIT and the two stay separate on purpose: one number cannot suit both a VPN daemon and the process that answers the browser, and a value chosen for tailscaled would silently disable this one. The value is checked before use, because Go refuses to start when GOMEMLIMIT does not parse, so a typo in that file would take the server down rather than mis-size it. No oom_score_adj is set. The kernel here does not pick a victim under the pressure this fork has measured, because allocation stops working first. Lowering the score would read as protection while changing nothing. tools/service/test-server-memlimit.sh runs the shipped function against a fake config directory and reads the result from a child process, since the server is a child and a value that is assigned but never exported reaches nothing. Five deliberate breakages were tried against it and all five fail the suite, including that one. Upstream carries the init-script half of this as part of pull request sipeed#759. The NetBird VPN in that pull request is not taken.
…wing The Go collector's only default input is how much the heap has grown since the last collection. It cannot see what the board has left. This board shows 166MB to Linux and about 96MB spare once the video carveout, the kernel and the running services are counted. Below roughly 30MB free the board stops being able to start a process at all. fork fails, the kernel selects no OOM victim, and nothing recovers it except a power cycle. So the failure this guards against is not a killed server. It is a board that answers nothing and cannot be reached to repair it, which on a KVM is the whole product. GOMEMLIMIT supplies the input the collector lacks. The default is 64MiB, a little over twice the 27.8MB peak measured on the reference device across 26 hours, read from /proc/<pid>/status VmHWM. That leaves the collector room to work before it becomes the thing consuming the board. /etc/kvm/GOMEMLIMIT.server overrides it, in MiB. tailscaled keeps its own /etc/kvm/GOMEMLIMIT and the two stay separate on purpose: one number cannot suit both a VPN daemon and the process that answers the browser, and a value chosen for tailscaled would silently disable this one. The value is checked before use, because Go refuses to start when GOMEMLIMIT does not parse, so a typo in that file would take the server down rather than mis-size it. No oom_score_adj is set. The kernel here does not pick a victim under the pressure this fork has measured, because allocation stops working first. Lowering the score would read as protection while changing nothing. tools/service/test-server-memlimit.sh runs the shipped function against a fake config directory and reads the result from a child process, since the server is a child and a value that is assigned but never exported reaches nothing. Five deliberate breakages were tried against it and all five fail the suite, including that one. Upstream carries the init-script half of this as part of pull request sipeed#759. The NetBird VPN in that pull request is not taken.
Builds on the original NetBird commit in this branch. Two changes, kept together because both rewrite netbird.Service.Install: the client is no longer expected to be bundled, and the one-VPN-at-a-time rule is enforced rather than only honoured at boot. Delivery. install.go expected /kvmapp/system/netbird/netbird, which nothing in the tree produced — the feature could not work on any built image. It now downloads the release asset from the preceding commit, installing it with a rename so it can replace a running binary. /etc/kvm/netbird_url overrides the URL for forks and for test devices, since no upstream release carries the asset yet. Enforcement. The rule lived only in SetPreference, so one click on Install or Start raised a second tunnel on a device with RAM for one. - service/extensions/vpnpref owns /etc/kvm/vpn and the lock, importing neither client. That breaks the cycle which otherwise keeps netbird from reading its own preference: package vpn imports both clients. - start, restart and up refuse when the other client is selected, as does netbird's login — `netbird up` raises the tunnel. Tailscale's login does not: it only authenticates, and its internal recovery start is guarded. Install is exempt on both sides; it raises a tunnel only when its client is already selected, and demanding the preference up front would be a trap, because the autostart toggle only appears once the client is installed. - The check is in the HTTP layer on purpose: SetPreference calls Cli.Start() while the file still holds the previous value, so the same check inside Cli.Start() would make switching, and its rollback, impossible. - Operations that start or stop a client are serialized; a concurrent request is refused rather than queued behind the client's 60 s timeout. netbird's Down is included because a timed-out `netbird down` restarts the service, so it can start the daemon rather than only stop it. - A failed switch restarts the previous client and leaves the preference alone. - `tailscale up` is bounded at 45 s. It was unbounded, and it now runs under the lock: an unauthenticated node would have held that lock until the server restarted, failing every VPN request. Boot. S95nanokvm runs before rc reaches S98tailscaled and S99netbird, so it only has to remove the client that was not selected. The two directions differ for a reason outside this script: kvm_system's new_app_init() runs `rm -f /etc/init.d/S99*` after an application update, and the glob does not reach S98tailscaled, which the same function refreshes instead. So S99 is restored when NetBird is the selected VPN and S98 never is. The `stop` verb no longer stops either VPN — update-nanokvm.py calls it before an update and `restart` afterwards, so doing that tore down the tunnel the administrator was connected through and never brought it back. Stopping. Both Cli.Stop() implementations are idempotent: "not installed", "no script" and "not running" all answer the caller's question with yes. S99netbird confirms the daemon exited by polling — start-stop-daemon -K and killall return 0 once the signal is sent, which says nothing about whether the process died. S98tailscaled reports success from its stop branch whatever happens, so that direction stays weaker; fixing it is out of scope here. Also in this commit, and worth naming rather than leaving to the diff: - Cli.Login() had a race between cmd.Wait() and the goroutines reading its pipes. os/exec closes those pipes inside Wait, so a fast-exiting command could drop the login URL. Wait now runs after both readers finish. - ServiceRunning() treated a missing or non-executable init script as an error rather than as "not running", and matched on "running", which is a substring of "not running". Both are fixed, and Stop() reads the same state the same way. - refreshScript() copies the firmware's init script over the installed one before stopping, because the polling logic lives in that script and an OTA can leave an older copy behind. - uninstall() now also removes the init script and the pidfile, and the Uninstall handler stops the daemon and checks the result before removing anything, then starts Tailscale and writes the preference last. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Builds on the original NetBird UI in this branch; this commit fixes it. - The login URL returned by the Enable switch was discarded. Flipping the switch instead reported a tunnel that was up while the device waited for an authorization nobody opened. The URL panel is now shared with the Login screen, and the switch opens it the way the Login screen does. - An unbound device was shown a device panel with empty Name and IP and no way forward; it is routed to Login instead. - The autostart switches ignored rsp.code. Failure codes arrive with HTTP 200 (proto/response.go), so the toggle reported success while no VPN was running. - Slow endpoints get per-request timeouts above the shared 60 s default: the NetBird CLI on riscv64 can take minutes, and install downloads the client. Status goes through http.request because http.get takes no per-request config, which leaves the shared wrapper untouched. - Error handling for VPN calls is completed on both tabs: the NetBird components had lost the .catch their Tailscale counterparts have, and the Tailscale header never had one — its autostart calls get theirs here. - Uninstall is now reachable from the UI, as it is for Tailscale. Strings go to all 24 locales instead of four. Tailscale is translated everywhere, and leaving NetBird in English for twenty of them would be a regression against that. Where a Tailscale string already said the same thing, the locale's own wording is reused, so only genuinely new keys were translated. stopDesc is not reused: the Tailscale wording promises that stopping disables autostart, which is true there — Cli.Stop() removes the init script — but not for NetBird, whose service starts again at the next boot while its autostart is on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
e2a87e9 to
c78ea95
Compare
Switching VPNs could strand a device permanently. SetPreference stopped the current client before starting the new one, and every handler that could raise a tunnel refused unless its client already owned the preference. On a device reached through the VPN being switched away from, that meant: the session died when the old client stopped, NetBird came up unbound — its first login is interactive and had not happened — and a reboot reproduced the same state. There was no way back without physical or LAN access. The gate was also circular: signing in to NetBird needed the preference, and setting the preference needed a working NetBird. The preference now governs one thing: which client autostarts at boot. It is not a permission to run. - lockVPN replaces claim(). Handlers still serialize against each other, but no longer check the preference, so a client can be installed, started and signed in to while the other one carries the session. - SetPreference no longer starts anything. If the other client is not running there is nothing to lose and the preference is simply recorded. If it is running, the incoming client must be *connected* first — Management and Signal for NetBird, BackendState=Running for Tailscale — and only then is the other one stopped. A working tunnel is never cut for one that is not up yet. - Install starts the daemon regardless of preference. S99netbird runs `netbird service run`; the tunnel comes from `netbird up`, the login step, so on a never-bound device the daemon carries no tunnel. A previously bound client may reconnect on start — not verified here — so two tunnels can overlap during a switch. That is accepted: refusing is what locked devices out. The cost of this design is the overlap. The device has RAM for one client comfortably, and both daemons run only while a user is part-way through a switch. The boot path is unchanged: select_vpn still leaves exactly one init script in place, so a reboot always converges on the preference. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Switching autostart stops the client that is carrying the session. The server now refuses to do that until the incoming client is connected, so the device stays reachable — but "connected" only proves the device joined the new network. It cannot prove the operator's own machine is on it. If the administrator has not joined their own machine to the incoming VPN, or access rules do not permit it, the switch succeeds and they lose access anyway. That is recoverable — the device is on the network for anyone who is on it — but it is worth knowing before clicking, and the device cannot check it. The confirmation dialogs on both tabs gain a description saying so. Added as a separate key rather than lengthening the title, so the question and the warning read apart, in all 24 locales. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review of the previous commit found that the connectivity gate protected one path and left the rest of the model behind. - connected() ran netbird status with restart-on-timeout, so the predicate could restart the service it was asked about — bouncing the tunnel whose fate was being decided, for up to three minutes while holding the VPN lock. It now uses StatusOnly, which never recovers by restarting. - tailscale status had no deadline and is now called under that lock, so a wedged daemon would have held it until the server restarted. Bounded like the rest. - running() returned false whenever it could not tell, which skipped the gate and stopped nothing — leaving a live client carrying a tunnel the preference said was gone. Uncertainty now counts as running, so the gate applies. - A failed preference write left the device with the old client stopped and the file still naming it: the next boot would have started neither. The stopped client is restarted, and the reply says which of the two happened. - Switching autostart to a client that is not installed is refused. It would have removed the other client's init script at the next boot with nothing to put in its place. Still open, and not addressed here: stop and uninstall can cut the tunnel the caller is using, on both tabs. SetPreference is guarded, those are not, and for Tailscale the outcome is permanent because select_vpn never restores its init script. That needs a decision about whether to guard, warn, or accept. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SetPreference refuses to stop a client until the incoming one is connected, but three other buttons reach the same outcome with no check at all: stop and uninstall, on both tabs. A review walking the user paths found that stopping Tailscale over its own tunnel is the worst of them — Cli.Stop() removes /etc/init.d/S98tailscaled and select_vpn deliberately never puts it back, so the device does not come back on the next boot either. Guarding those would be wrong: stopping a client is a legitimate thing to want, and the server cannot know which route the operator is using. So they warn. The wording is not uniform, because the outcomes are not: - NetBird stop says the connection returns at the next boot while its autostart is on, which is what the code does — select_vpn restores its init script. - Tailscale stop and both uninstalls say it will not come back on its own and to make sure another way in exists. - NetBird uninstall adds that autostart returns to Tailscale, which has to be installed and working already — if it is not, the device ends up with neither. Four keys in each of the 24 locales, composed from the same fragments per locale so the four strings and both tabs stay consistent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…st gaps A path walk of the previous round found that the rollback added there does not work in its only use case, plus three smaller holes. - The rollback called Start(), which begins by copying the init script from /kvmapp. On a read-only or full filesystem — the reason the preference write failed in the first place — that copy fails too, so the client that was just stopped stayed stopped. Both clients gained Resume(), which starts the script already on disk without copying. For NetBird that always works, because its Stop() leaves the script in place; for Tailscale it can genuinely fail, since its Stop() removes the script, and the comment says so rather than pretending otherwise. - The gate checked that the incoming client was installed. Installed is not the same as startable at boot: select_vpn restores S99netbird from /kvmapp when the binary is executable, but never restores S98tailscaled. A Tailscale that was stopped through the UI passed the check and would then not start at all. The gate now mirrors what the boot script actually requires. - netbird's Uninstall handed autostart to Tailscale without checking it could start. On a device where Tailscale was never installed that left neither. - isTimeout matched the substring "killed" in an error message — the same trick this PR removed from status parsing earlier, and wrong for the same reason: an external SIGKILL is indistinguishable from a deadline. Status() now wraps the deadline case and the check is errors.Is. - Status probes get their own 10s budget instead of borrowing UpTimeout's 45s. Two probes run in sequence under the VPN lock and their total has to stay under the browser's 60s. Locale fixes from the same review: Korean particles were left in their hedged "(으)로" form although the noun is known at write time; Hungarian, Catalan and Vietnamese sentences were mis-composed from the fragments; Japanese and both Chinese locales had an ASCII space after 。 that the template seam introduced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
Adds NetBird as a second VPN option alongside Tailscale, using the official NetBird cloud.
The device has too little RAM for two VPN clients (161 MB on the Cube), so exactly one may run
at a time.
/etc/kvm/vpnrecords which; at bootS95nanokvmremoves the init script of the clientthat was not selected, and the HTTP handlers that can raise a tunnel — install, start, restart, up,
login — refuse when the other VPN is selected.
How the client is delivered
NetBird publishes no riscv64 build, so NanoKVM builds one in CI and attaches it to its own
release. The device downloads it on demand — the same shape as the existing Tailscale install,
which fetches from the vendor.
It is deliberately not bundled into the OTA package. Measured: the current package is 14.7 MiB
(
content-lengthofnanokvm_2.4.3.tar.gz), and the NetBird client is 13.4 MiB gzipped — bundlingwould grow the package to ~28 MiB, +91 % for every device, including the majority that will
never enable NetBird.
scripts/build-netbird.sh— cross-compiles the pinned version (-trimpath, normalized tar,gzip -n, so a rebuild of the same tag is byte-identical) and refuses anythingthat is not a riscv64 ELF, using the same
od-based checkpackage.shapplies to every shippedbinary.
kvmapp/system/netbird/VERSION— the single source of truth for the pinned version.verify-release-assets.shchecks the asset before publication: archive layout, the versioninside it, and the architecture of the binary.
/etc/kvm/netbird_urloverrides the download URL, so forks and test devices do not depend on anupstream release existing yet.
Backend
service/extensions/vpnpref— one owner for/etc/kvm/vpn. It exists as its own package becausevpnimportsnetbirdandtailscale, so those two cannot importvpnback.service/extensions/netbird— CLI wrapper, download-and-install, handlers.service/extensions/vpn— preference get/set. On a failed switch the previous VPN is restartedand the preference is left untouched; it is written last, so the file never claims a state the
device is not in.
than queued behind the client's 60 s timeout. Read-only calls (
status,down) are not gated.CheckToken+RequireRole(admin), like the rest of/api/extensions.Frontend
can take minutes.
Init scripts
S99netbird— TUN setup,GOMEMLIMIT=30MiB, PID management.S95nanokvm— reads/etc/kvm/vpnand starts only the selected VPN.Known limitations
text; localizing them would mean changing the error contract, which is out of scope here.
S98tailscaleddoes not propagate failures — itsstartandstopbranches printFAILwithout a non-zero exit, so a failed rollback cannot be distinguished from a successful one. The
rollback message therefore says "rollback attempted". Fixing that script (and the missing command
timeouts in
tailscale/cli.go) touches code unrelated to this feature, so it is left alone here.A first attempt at it (fix(tailscale): propagate init-script failures and bound command runtime #883) was closed: reviewing it showed that
start-stop-daemon -Kreturns0 as soon as the signal is sent, so the check was inverted. The same finding was applied to
S99netbirdin this PR, which now confirms the daemon is actually gone before reporting success.GOMEMLIMIT=30MiBfor the NetBird daemon has not been measured on hardware. For comparison,S98tailscaledreads its limit from/etc/kvm/GOMEMLIMITand defaults to 512 MiB.Test plan
/usr/bin/netbird, switch autostart to NetBird — expect an error,a working Tailscale, and
/etc/kvm/vpnunchangedNot yet verified
CI has never run on this branch: workflow runs for fork pull requests wait for maintainer approval,
so the build job, the cache and the asset verification are unexercised. The feature has also not
been exercised on hardware yet — the test plan above is unchecked, deliberately, rather than
optimistically ticked.