fix(config): make a settings save durable, and keep it private - #792
Conversation
config.yaml is the file the gateway boots from, and every save from the settings UI rewrote it with os.WriteFile + Rename: no fsync of the temp file, no fsync of the directory. A rename is only atomic for bytes that already reached the disk, so a power cut mid-save could publish a truncated or zero-length config and leave an unattended gateway unbootable. Every other durable write in the repo already syncs the file, renames, then syncs the directory (state/parquet.go, driverrepo, nova/identity, state/homelink_credentials); config was the one hole. Both sync failures are now reported. The caller's contract is "the config is now saved", and a save that cannot be made durable has not met it. The temp file was also created 0644 and rename carried that mode onto a file holding MQTT passwords, API keys and OAuth refresh tokens. It is now created 0600 with O_EXCL, after clearing any temp an interrupted save left behind — OpenFile only applies the mode when it creates the file, so a stale 0644 temp would have leaked the mode through the next save. Saves are serialized: the settings handlers do not hold a write lock across a save, and two overlapping requests share one temp path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ba47477a8f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // configFileMode is owner-only because config.yaml carries MQTT passwords, | ||
| // API keys and OAuth refresh tokens. Rename replaces the destination inode, so | ||
| // whatever mode the temp file has is the mode the saved config ends up with. | ||
| const configFileMode os.FileMode = 0o600 |
There was a problem hiding this comment.
Apply an owner-only ACL on Windows
On the supported Windows build shipped by make release (Makefile:179-190), passing 0o600 to os.OpenFile does not establish Unix-style owner permissions: Go only maps the write bit to the Windows read-only attribute, and the new file inherits its directory ACL. Thus, when config.yaml is stored in a directory readable by another local account, the MQTT passwords, API keys, and refresh tokens remain readable despite this privacy fix (and the new mode assertion would report 0666 on Windows). Use a Windows-specific ACL implementation, or otherwise create the file with an owner-only security descriptor.
Useful? React with 👍 / 👎.
The bug
config.yamlis the file the gateway boots from. Every settings save from theUI rewrites it, and the writer did this:
No fsync of the temp file, no fsync of the directory. A rename is only atomic
for bytes that have already reached the disk, so a power cut between the write
and the flush can publish a truncated or zero-length
config.yaml— on adevice whose whole point is coming back up unattended. The directory fsync
matters for the same reason one step up: without it the rename itself can be
lost, leaving the old config and a stray
.tmp.Every other durable write in the repo already does this correctly —
state/parquet.go(file sync, rename,syncDir),driverrepo's atomicactivation,
nova/identity,state/homelink_credentials. Config was the onehole.
Second bug: the temp file was created
0644and the rename carried that modeonto a file holding MQTT passwords, API keys and OAuth refresh tokens.
The fix
SaveAtomicnow uses the ordering the rest of the repo uses: create the temp0600, write, fsync the file, rename, fsync the directory.Three details worth review:
"the config is now saved". A save that could not be made durable has not met
it, and the settings UI should not tell the operator otherwise.
O_EXCLafter clearing a stale temp.OpenFileonly applies the modewhen it creates the file. If a previous save was interrupted and left a
0644temp behind, reusing it would have carried0644through the renameand leaked the secrets anyway. Clearing first and creating exclusively also
means the write refuses to follow a symlink planted at that path.
save (
CfgMuis onlyRLocked to snapshot the old config for the diff), andall savers share one temp path. Two overlapping
POST /api/configrequestscould rename half of each other's bytes over
config.yaml. A package-levelmutex is the cheap fix and keeps the single predictable temp name — the
alternative,
os.CreateTemp, scatters a fresh secret-bearing file perinterrupted save.
config.SaveAtomicis the only writer ofconfig.yaml:bootstrap.go,nova_claim.goand the API's injectedSaveConfigseam all route through it.The updater's rollback path stages snapshot files itself but already uses
0600(go/cmd/ftw-updater/main.go:1055,:1084), so nothing bypasses this.On the test seam
The two syncs live in a small
durableWriterstruct rather than being calleddirectly, and
SaveAtomicdelegates to an unexportedsaveAtomic(w, path, c).That is the least ceremony I could find that lets a test assert what actually
matters here: that the file sync happens before the rename and the
directory sync after it, and that either failure fails the save. Asserting
"a save works" cannot distinguish the fixed code from the broken code — the
broken code also produced a correct file on a machine that does not lose power.
Production always uses
defaultDurableWriter; nothing else assigns thosefields.
Tests
Three new tests beside the existing
SaveAtomicones, each verified to failagainst a mutation of the fix:
TestSaveAtomicWritesOwnerOnlyMode(3 cases: new file, replacing a0644config, stale0644temp)0644→ all three failTestSaveAtomicSyncsFileBeforeRenameAndDirAftersync order = [dir]; move the dir fsync before the rename → "the directory was fsynced before the rename"TestSaveAtomicReportsSyncFailure(file sync fails, dir sync fails)_ = w.syncFile(f)→ "error = , want it to report no space left on device"The file-sync-fails case also asserts the previous config is still on disk
untouched, and both cases assert no temp file survives.
Operator-visible change
After the first save through the UI,
config.yamlbecomes0600owned by theprocess user (UID 100 in the container). An operator who created the file by
hand as a different user and reads it without
sudowill notice. That is theintended trade — the file holds credentials.
Contention
Branched from
master.#732and#736touchconfig.goin the schemasection only; neither diff mentions
SaveAtomic, and neither touchesconfig_test.go. No open PR touchesgo/internal/config/config.go's writer.make verifyclean (run in an isolated worktree — the shared checkout has anunrelated agent's in-flight
pvmodelbuild break in it).