Skip to content

[client] Use atomic write/rename pattern for ssh config - #5867

Merged
lixmal merged 1 commit into
netbirdio:mainfrom
lotheac:push-yxxuyyrxxlls
May 4, 2026
Merged

[client] Use atomic write/rename pattern for ssh config#5867
lixmal merged 1 commit into
netbirdio:mainfrom
lotheac:push-yxxuyyrxxlls

Conversation

@lotheac

@lotheac lotheac commented Apr 13, 2026

Copy link
Copy Markdown
Contributor

otherwise, ssh can fail due while the config file is being written and incomplete.

Describe your changes

We've seen some errors running ssh when netbird is managing /etc/ssh_config.d/99-netbird.conf -- ssh might read an incomplete file if the netbird client happens to be in the middle of writing to it.

Issue ticket number and link

none

Stack

Checklist

  • Is it a bug fix
  • Is a typo/documentation fix
  • Is a feature enhancement
  • It is a refactor
  • Created tests that fail without the change (if possible)

By submitting this pull request, you confirm that you have read and agree to the terms of the Contributor License Agreement.

Documentation

Select exactly one:

  • I added/updated documentation for this change
  • Documentation is not needed for this change (explain why)

This is a bug fix and does not need to be documented separately.

Docs PR URL (required if "docs added" is checked)

Paste the PR link from https://github.com/netbirdio/docs here:

https://github.com/netbirdio/docs/pull/__

Summary by CodeRabbit

  • Bug Fixes
    • Enhanced SSH configuration file writing process for improved reliability and consistency.

otherwise, ssh can fail due while the config file is being written and
incomplete.
@CLAassistant

CLAassistant commented Apr 13, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@coderabbitai

coderabbitai Bot commented Apr 13, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

The Manager.writeSSHConfig method now writes SSH config to a temporary file first, then atomically renames it to the final destination using os.Rename. This replaces the previous direct-write approach, improving write safety. Directory creation logic and success logging remain unchanged.

Changes

Cohort / File(s) Summary
SSH Config Write Atomicity
client/ssh/config/manager.go
Modified writeSSHConfig to write to a temporary file (<original>.tmp) first, then atomically swap it into place via os.Rename, addressing non-atomic file write issues.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~8 minutes

Possibly related issues

Poem

🐰 A temp file, then a swift rename,
No partial writes to cause us pain,
Atomic swaps, so spry and neat,
Make SSH configs safe and sweet!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The description covers the problem statement, change description, and required checklist items; all essential sections from the template are completed.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed The title clearly and accurately describes the main change: implementing an atomic write/rename pattern for SSH config file writes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@sonarqubecloud

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/ssh/config/manager.go`:
- Around line 228-235: The current code uses a fixed sshConfigPathTmp
(sshConfigPath + ".tmp") which can cause concurrent write/rename races; change
the logic in the function that writes the SSH config (references:
sshConfigPathTmp, writeFileWithTimeout, m.sshConfigDir, sshConfigPath,
sshConfig) to create a unique temp file in the same directory (e.g., via
os.CreateTemp or ioutil.TempFile), write to that temp file (using
writeFileWithTimeout or equivalent), ensure you close and remove the temp file
on any error paths, and then atomically rename the temp file to sshConfigPath;
also fix the error message to report the temp file name when write fails and
ensure cleanup happens on both write and rename failures.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 48e22866-c098-40d4-bc9c-0705dd0d0377

📥 Commits

Reviewing files that changed from the base of the PR and between 5259e5d and 7f6735d.

📒 Files selected for processing (1)
  • client/ssh/config/manager.go

Comment on lines +228 to 235
sshConfigPathTmp := sshConfigPath + ".tmp"

if err := os.MkdirAll(m.sshConfigDir, 0755); err != nil {
return fmt.Errorf("create SSH config directory %s: %w", m.sshConfigDir, err)
}

if err := writeFileWithTimeout(sshConfigPath, []byte(sshConfig), 0644); err != nil {
if err := writeFileWithTimeout(sshConfigPathTmp, []byte(sshConfig), 0644); err != nil {
return fmt.Errorf("write SSH config file %s: %w", sshConfigPath, err)

@coderabbitai coderabbitai Bot Apr 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use a unique temp file path to avoid writer collisions.

Using a fixed *.tmp name allows concurrent calls to clobber each other and can produce rename/write races. Create a unique temp file in the same directory and clean it up on all failure paths.

Proposed fix
 func (m *Manager) writeSSHConfig(sshConfig string) error {
 	sshConfigPath := filepath.Join(m.sshConfigDir, m.sshConfigFile)
-	sshConfigPathTmp := sshConfigPath + ".tmp"
 
 	if err := os.MkdirAll(m.sshConfigDir, 0755); err != nil {
 		return fmt.Errorf("create SSH config directory %s: %w", m.sshConfigDir, err)
 	}
+
+	tmpFile, err := os.CreateTemp(m.sshConfigDir, m.sshConfigFile+".*.tmp")
+	if err != nil {
+		return fmt.Errorf("create temp SSH config file in %s: %w", m.sshConfigDir, err)
+	}
+	sshConfigPathTmp := tmpFile.Name()
+	_ = tmpFile.Close()
+	defer os.Remove(sshConfigPathTmp)
 
 	if err := writeFileWithTimeout(sshConfigPathTmp, []byte(sshConfig), 0644); err != nil {
 		return fmt.Errorf("write SSH config file %s: %w", sshConfigPath, err)
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/ssh/config/manager.go` around lines 228 - 235, The current code uses a
fixed sshConfigPathTmp (sshConfigPath + ".tmp") which can cause concurrent
write/rename races; change the logic in the function that writes the SSH config
(references: sshConfigPathTmp, writeFileWithTimeout, m.sshConfigDir,
sshConfigPath, sshConfig) to create a unique temp file in the same directory
(e.g., via os.CreateTemp or ioutil.TempFile), write to that temp file (using
writeFileWithTimeout or equivalent), ensure you close and remove the temp file
on any error paths, and then atomically rename the temp file to sshConfigPath;
also fix the error message to report the temp file name when write fails and
ensure cleanup happens on both write and rename failures.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

concurrent calls should not happen unless multiple netbird clients are running. safer is better though, I agree - but I will leave it to the maintainers to improve this specific part incrementally (since my PR does not make things any less safe)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lotheac, that's a fair point — a single running client is the normal case, so the fixed .tmp name collision risk is indeed theoretical. Your PR is strictly an improvement: writing directly to the final path was the unsafe baseline, and the atomic rename eliminates the partial-read race you set out to fix. The os.CreateTemp hardening would be a nice-to-have on top, but it's not a blocker for this change.

Would you like me to open a follow-up GitHub issue to track the os.CreateTemp improvement so it doesn't get lost?

ʕ •ᴥ•ʔ


🧠 Learnings used
Learnt from: lixmal
Repo: netbirdio/netbird PR: 5688
File: client/firewall/uspfilter/localip.go:20-21
Timestamp: 2026-03-27T14:40:41.583Z
Learning: In `client/firewall/uspfilter/localip.go` (netbirdio/netbird), `localIPManager.UpdateLocalIPs` is intentionally lock-free on writes. It is only ever called through `(uspfilter.Manager).UpdateLocalIPs` → `engine.handleSync`, which holds `e.syncMsgMux` for its entire execution, serializing all snapshot rebuilds. Do not flag the absence of an internal writer mutex on `localIPManager` as a race condition.

Learnt from: lixmal
Repo: netbirdio/netbird PR: 5826
File: client/internal/portforward/manager.go:35-38
Timestamp: 2026-04-08T14:55:48.251Z
Learning: In `client/internal/portforward/manager.go` (netbirdio/netbird), persisting permanent-lease (ttl=0/UPnP error 725 fallback) port mapping state for crash-recovery cleanup is intentionally deferred to a follow-up PR. The blocker is that `State.Cleanup` requires NAT gateway re-discovery via `nat.DiscoverGateway`, which can block startup for ~10 seconds when no gateway is present, affecting all clients. The TODO comment at line ~35 documents this constraint. Do not flag the missing state persistence for permanent leases as a blocking issue in this PR.

Learnt from: lixmal
Repo: netbirdio/netbird PR: 5687
File: client/internal/engine_ssh.go:44-50
Timestamp: 2026-03-25T06:24:52.840Z
Learning: In `client/internal/engine_ssh.go` (netbirdio/netbird), the IPv6 SSH setup steps — `AddListener` and `AddInboundDNAT` for the IPv6 overlay address — are intentionally best-effort and independent of each other, mirroring the v4 behavior. IPv6 soft-failure is by design so that IPv4 SSH continues to work when IPv6 cannot be set up. Do not flag the lack of atomicity between the IPv6 listener and IPv6 DNAT as a bug.

Learnt from: lixmal
Repo: netbirdio/netbird PR: 5530
File: management/internals/modules/reverseproxy/service/manager/manager.go:302-317
Timestamp: 2026-03-07T14:48:20.330Z
Learning: In `management/internals/modules/reverseproxy/service/manager/manager.go`, `checkPortConflict` uses `LockingStrengthUpdate` (SELECT FOR UPDATE) to serialize on existing port rows. For an unused port (no existing rows to lock), two concurrent transactions could both pass the DB check, but the proxy's OS-level port binding acts as the hard uniqueness guard — only one process can bind a given port. This dual-layer enforcement (DB best-effort + proxy binding) is the accepted design; no additional DB uniqueness constraint is needed.

Learnt from: lixmal
Repo: netbirdio/netbird PR: 5031
File: client/ssh/proxy/proxy.go:537-559
Timestamp: 2026-01-05T06:33:50.474Z
Learning: The NetBird SSH proxy (client/ssh/proxy/proxy.go) is ephemeral and tied to a single SSH client session. It operates through stdio and exists only for the lifetime of one client connection, so session-scoped contexts are appropriate for the proxy's lifecycle.

Learnt from: lixmal
Repo: netbirdio/netbird PR: 5587
File: shared/management/http/api/openapi.yml:3151-3171
Timestamp: 2026-03-13T06:22:59.436Z
Learning: Repo netbirdio/netbird PR `#5587`: For reverse proxy header authentication, HeaderAuthConfig.value is required in requests and cleared in responses. On update, Manager.preserveExistingAuthSecrets() calls preserveHeaderAuthHashes() before validateHeaderAuthValues() (management/internals/modules/reverseproxy/service/manager/manager.go Lines ~555–597), so sending value: "" preserves the existing hashed secret; providing a non-empty value rotates it.

Learnt from: bcmmbaga
Repo: netbirdio/netbird PR: 5441
File: management/server/user.go:745-749
Timestamp: 2026-02-24T19:32:13.189Z
Learning: In Go codebases like netbirdio/netbird, methods returning (T, error) should follow the convention: if an error is returned, propagate it and return early. When the caller handles the error immediately, explicit nil checks on the returned value are unnecessary; rely on the error to guard control flow and only inspect the value when err is nil. This guidance applies broadly to Store methods such as GetUserByUserID in Go code; prefer early return on error and avoid redundant nil checks after a successful error path.

Learnt from: lixmal
Repo: netbirdio/netbird PR: 5530
File: proxy/internal/udp/relay.go:276-278
Timestamp: 2026-03-07T13:59:16.506Z
Learning: For Go 1.25+ projects, use sync.WaitGroup.Go() to run goroutines because it handles Add(1) and defer Done() automatically. Do not flag wg.Go(...) as a compilation error in codebases that require Go 1.25 or newer. If supporting older Go versions, gate such usage with build tags or avoid wg.Go() to maintain compatibility; verify the module targets Go 1.25+ before adopting this pattern.

Learnt from: lixmal
Repo: netbirdio/netbird PR: 5688
File: client/firewall/uspfilter/forwarder/icmp.go:268-287
Timestamp: 2026-03-25T09:21:53.814Z
Learning: When using gVisor’s `header.ICMPv6Checksum(ICMPv6ChecksumParams)` (`gvisor.dev/gvisor/pkg/tcpip/header`), the pseudo-header checksum is computed internally from `Src`, `Dst`, and `Header`. Use `PayloadCsum`/`PayloadLen` only for scattered-buffer cases where the ICMPv6 payload bytes are not included in `Header`. If `Header` already contains the complete ICMPv6 message (ICMPv6 header + all data), call `ICMPv6Checksum` with `PayloadCsum: 0` and `PayloadLen: 0` (or omit those fields). Do not pass a separately computed pseudo-header checksum (e.g., `header.PseudoHeaderChecksum(...)`) as `PayloadCsum` in that case, because it will be double-counted and produce an incorrect checksum.

@lixmal lixmal left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Atomic write/rename is the right fix. LGTM. Will follow up with a separate PR using a unique temp filename and cleanup-on-failure.

@lixmal lixmal changed the title ssh config manager: use atomic write/rename pattern [client] Use atomic write/rename pattern for ssh config May 4, 2026
@lixmal
lixmal merged commit 4268a5c into netbirdio:main May 4, 2026
36 of 41 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants