Skip to content

security/netbird: make the daemon configuration write survivable, and fix a setting that never applied - #5673

Open
BxnnyG wants to merge 2 commits into
opnsense:masterfrom
BxnnyG:netbird/pr-daemon-config
Open

security/netbird: make the daemon configuration write survivable, and fix a setting that never applied#5673
BxnnyG wants to merge 2 commits into
opnsense:masterfrom
BxnnyG:netbird/pr-daemon-config

Conversation

@BxnnyG

@BxnnyG BxnnyG commented Aug 26, 2026

Copy link
Copy Markdown

Important notices

If AI was used, please disclose:

  • Model used: Claude Opus 5, via Claude Code
  • Extent of AI involvement: the code, the tests, the commit messages and this description were written by the model working under my direction. Every change was reviewed by me and verified on my own production OPNsense 26.7 router before submission, and the commits carry Co-Authored-By trailers.

I did not open an issue first and should have — I am happy to do that now, or to split anything here that is too large to review as a single change. Apologies for submitting without this template filled in; that was my mistake, not a deliberate omission.


Two defects in the same function, both about /var/db/netbird/config.json — the file that holds the peer's WireGuard private key.

1. The write was not atomic

syncConfig() read the file, changed sixteen keys and wrote it back with file_put_contents(), which truncates and then writes. Any interrupted write — a full /var, a power cut — leaves the file short, and there is no other copy of that key on the box. The cost is not a wrong setting; it is re-enrolling the firewall by hand from the console.

The new content now goes to a sibling temporary file, is flushed with fsync() and renamed over the target. Both the temporary file and the config.json.bak it leaves behind are created under umask 0077 and then given the target's own mode, so the key never widens on the way through — a chmod() afterwards would be closing a door that was already open.

The read path also says which failure it hit. A missing configuration and a corrupt one shared one log line; they are different problems and only one of them is recoverable by hand.

2. IpMapping is not a NetBird configuration key

Settings → General → Force IP Mapping validates, saves, survives Apply and lands in config.json as "IpMapping". NetBird has no such field — not in v0.74.4, not in v0.60.0, v0.45.0 or v0.30.0 — and a code search over netbirdio/netbird matches the substring only inside the function name parseNATExternalIPMappings. Go's encoding/json drops unknown keys, so the daemon has been reading the file and ignoring that line ever since the setting was added.

Read as names and types only, the key set of a live config.json and the persisted fields of NetBird's Config struct at v0.74.4 agree on 36 of 37 names. IpMapping is the one the daemon does not know.

The field the help text describes is NATExternalIPs, set on the CLI with --external-ip-map. The help text gives its three forms correctly, down to the examples, so the feature was understood — only its destination was not. Two things were wrong: the name and the type, since the daemon wants a list of strings rather than a comma-separated one.

Worth flagging for the changelog: anyone who has filled that field in has had a setting that did nothing, and after this it does something.

Testing

Both changes were verified on a live 26.7 router (os-netbird built from this branch, daemon 0.74.4):

  • Apply leaves a config.json.bak byte-for-byte the size of the previous configuration, both 0600, and netbird status afterwards shows management and signal connected — the daemon keeps its identity.
  • jq '{IpMapping, NATExternalIPs}' /var/db/netbird/config.json{"IpMapping": null, "NATExternalIPs": []}.

ConfigFile is deliberately free of OPNsense classes so it can be exercised without the framework, and I have unit tests for it — the atomic replacement, the backup, the mode, the failure paths and the list splitting. They are not in this diff, because no plugin in this repository ships a tests/ directory and there is no CI to run them; I did not want to introduce a convention as a side effect of a bugfix. Happy to add them in whatever shape you would want them, or to leave them out.

Checked against phpcs --standard=<core>/ruleset.xml: no new errors or warnings.

Benny and others added 2 commits August 26, 2026 10:35
/var/db/netbird/config.json holds the peer's WireGuard private key. Applying
settings rewrote it with file_put_contents, which truncates and then writes:
an interrupted write - a full /var, a power cut - leaves it short, and there
is no other copy of that key on the box. The cost is not a wrong setting, it
is re-enrolling the firewall by hand from the console.

The new content now goes to a sibling temporary file, is flushed to disk and
renamed over the target, which POSIX requires to be atomic, and the previous
content is kept as config.json.bak. Both are created under umask 0077 and
then given the target's own mode, so the key never widens on the way through
- a chmod afterwards would be closing a door that was already open.

The read path now says which failure it hit. A missing configuration and a
corrupt one shared one log line; they are different problems and only one of
them is recoverable by hand.

Verified on a 26.7 router: Apply leaves a config.json.bak byte-for-byte the
size of the previous configuration, both 0600, and the daemon keeps its
identity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The setting validates, saves, survives Apply and lands in config.json as
"IpMapping". NetBird has no such field - not in v0.74.4, not in v0.60.0,
v0.45.0 or v0.30.0, and a code search over netbirdio/netbird matches the
substring only inside the function name parseNATExternalIPMappings. Go's
encoding/json drops unknown keys, so the daemon has been reading the file
and ignoring that line ever since the setting was added.

Read as names and types only, the key set of a live config.json and the
persisted fields of NetBird's Config struct at v0.74.4 agree on 36 of 37
names. IpMapping is the one the daemon does not know.

The field the help text describes is NATExternalIPs, set on the CLI with
--external-ip-map. The help text gives its three forms correctly, down to
the examples, so the feature was understood - only its destination was not.

Two things were wrong, not one: the name and the type. The daemon wants a
list of strings, so a comma-separated field becomes a list, and an empty
field becomes an empty list rather than null or a list holding one empty
string - to a Go []string those are three different values.

IpMapping is removed rather than left beside the new key, so the next reader
does not have to work out which of the two the daemon reads.

Anyone who has filled this field in has had a setting that did nothing.
After this it does something, which is worth knowing before upgrading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant