fix(config): default tsnet state to a fixed per-user directory (#207)#219
Conversation
connect stored tsnet state in a CWD-relative ./ts-state, leaking the node's private identity (tailscaled.state) into the working directory and any git tree that auto-commits it. Add StateDirForPlatform(), mirroring LogDirForPlatform but using the semantic XDG state base and always returning an absolute path (temp-dir fallback when the platform base is empty). Use it as the default in both the Merge (connect) and LoadConfig paths, and route the auto-instance ephemeral branch through a temp dir instead of the CWD. Defense in depth: warn (non-blocking) when a resolved state dir is relative, reachable only via an explicit relative override. Default locations: Windows %LOCALAPPDATA%\ts-bridge\state macOS ~/Library/Application Support/ts-bridge/state Linux $XDG_STATE_HOME/ts-bridge/state (-> ~/.local/state/...) Behavior change: an existing binary-adjacent ./ts-state is no longer the default; pass --state-dir ./ts-state to keep it. No auto-migration. Spec: specs/STATE-001-fixed-per-user-state-dir/
|
Caution Review failedPull request was closed or merged during review 📝 WalkthroughWalkthroughReplaces the CWD-relative ChangesSTATE-001: Per-user absolute state directory
Sequence Diagram(s)sequenceDiagram
participant CLI as ts-bridge connect
participant LoadConfig as LoadConfig / Merge
participant StateDirHelper as StateDirForPlatform / EphemeralStateDir
participant OS as OS env vars
CLI->>LoadConfig: start without explicit state-dir
LoadConfig->>StateDirHelper: resolve default state directory
StateDirHelper->>OS: read platform base path
OS-->>StateDirHelper: absolute base or empty
StateDirHelper-->>LoadConfig: fixed per-user absolute path
LoadConfig->>LoadConfig: warnRelativeStateDir(path)
CLI->>LoadConfig: start in auto-instance mode
LoadConfig->>StateDirHelper: EphemeralStateDir(hostname)
StateDirHelper-->>LoadConfig: temp-based absolute path
LoadConfig->>LoadConfig: cfg.EphemeralState = true
CLI->>LoadConfig: pass relative --state-dir
LoadConfig->>LoadConfig: preserve override and warn
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (3)
internal/config/config.go (1)
56-60: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winKeep the fallback warning on
slog.The stderr fallback is unstructured logging. Use a
slog.TextHandlerfallback so the warning path stays consistent even beforeSetLoggeris called.Proposed fix
if logger != nil { logger.Warn(msg, "path", dir) } else { - fmt.Fprintf(os.Stderr, "WARNING: %s (path %q)\n", msg, dir) + slog.New(slog.NewTextHandler(os.Stderr, nil)).Warn(msg, "path", dir) }As per coding guidelines,
**/*.go: “Use structured logging withlog/slog(text or JSON format) for all logging”.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/config/config.go` around lines 56 - 60, The fallback warning path in the config logging helper should use structured slog output instead of writing directly to stderr. Update the warning branch in the logger helper that currently checks logger nil to route through a slog.TextHandler-based logger when no logger has been set, so both the normal and fallback paths stay consistent with SetLogger and the project’s structured logging standard. Keep the existing warning message and path context, but emit it via slog rather than fmt.Fprintf/os.Stderr.Source: Coding guidelines
internal/config/merge_test.go (1)
839-955: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winFold the new STATE-001 cases into a table with
t.Run.The new tests cover one behavior matrix and should be table-driven with subtests.
As per coding guidelines,
**/*_test.go: “Use table-driven tests witht.Runsubtests in Go test files”.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/config/merge_test.go` around lines 839 - 955, The new STATE-001 coverage in TestStateDirDefaultIsAbsolutePerUser, TestStateDirDefaultAutoNoInstanceIsPerUser, TestAutoInstanceEphemeralStateNotRelative, and TestStateDirExplicitOverridePreserved should be consolidated into one table-driven test. Refactor the cases into a single test function that iterates over a slice of scenarios and uses t.Run subtests for each, while preserving the assertions for Merge, StateDir, EphemeralState, and the explicit override behaviors.Source: Coding guidelines
internal/config/statedir_test.go (1)
45-71: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd the Linux
HOME -> ~/.local/statebranch as a table-driven subtest.The contract here also includes the Linux fallback when
XDG_STATE_HOMEis unset, but the current tests only cover the XDG override and the empty-env temp fallback. Folding these path variants intot.Runsubtests would both cover that missing branch and align this file with the repo’s required Go test style. As per coding guidelines,**/*_test.go: Always use table-driven tests with t.Run subtests in Go test files.Also applies to: 99-118
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/config/statedir_test.go` around lines 45 - 71, The state-dir platform coverage is incomplete and the test style should use table-driven t.Run subtests. Update TestStateDirForPlatform_HonorsPlatformBaseEnv to a table-driven structure with subtests in stateDirFor, and add the missing Linux case that verifies HOME falls back to ~/.local/state when XDG_STATE_HOME is unset, alongside the existing platform/env variants.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/config/config.go`:
- Around line 353-355: The ephemeral state path generation in
cfg.Hostname/EphemeralStateDir is taking untrusted hostname input directly into
the temp-directory path, so sanitize or reject invalid hostnames before calling
EphemeralStateDir, or enforce the sanitization inside EphemeralStateDir itself.
Update the cfg.StateDir initialization path to ensure TS_HOSTNAME, flag, or
YAML-provided hostnames cannot contain path separators or traversal segments,
and keep the existing cfg.EphemeralState behavior unchanged.
In `@internal/config/merge_test.go`:
- Around line 826-834: The test helpers in unsetStateEnv and the related
os.Setenv/defer os.Unsetenv path are overwriting any pre-existing environment
values without restoring them, which can affect later tests. Update the
setup/teardown around unsetStateEnv and the affected test case to save the prior
value for each TS_* variable, set or unset as needed during the test, and
register t.Cleanup to restore the original state after the test finishes.
- Around line 865-902: The new tests in
TestStateDirDefaultAutoNoInstanceIsPerUser and
TestAutoInstanceEphemeralStateNotRelative only assert that StateDir is absolute,
so they still allow regressions to the wrong absolute path. Tighten both cases
by asserting the exact expected state-dir contract returned by Merge for the
auto-mode default and the auto-mode with Instance "office", using cfg.StateDir
and the existing setup in unsetStateEnv to verify the precise location rather
than any absolute path.
In `@internal/config/statedir.go`:
- Around line 54-55: EphemeralStateDir currently joins hostname directly, so
path separators or “..” can escape the intended temp sandbox. Update
EphemeralStateDir to sanitize hostname into a single path segment before passing
it to filepath.Join with os.TempDir and appDirName, ensuring the returned
directory always stays under the temp-based state root.
In `@specs/STATE-001-fixed-per-user-state-dir/features.json`:
- Around line 45-49: The verification for STATE-001-f7 only checks a subset of
the documented locations, so update the verification entry in features.json to
cover every doc the acceptance criteria mentions. Expand the existing
verification command used by the STATE-001-f7 feature so it also inspects
site/src/content/docs/getting-started.md and
site/src/content/docs/cli-reference.md in addition to error-state-permissions.md
and configuration.md, ensuring the docs claim is actually enforced.
In `@specs/STATE-001-fixed-per-user-state-dir/verification.md`:
- Around line 24-31: The verification summary is overstating test completeness
because it omits required Go checks and skips race coverage. Update the
documented test status to reflect the repo’s required validation by running and
recording golangci-lint 1.62.2 locally, gosec on all Go source files, and go
test -race -v ./... for the test suite. Make sure the evidence in
verification.md clearly distinguishes completed checks from pending/manual smoke
testing and does not call the work fully green until those required symbols are
covered.
---
Nitpick comments:
In `@internal/config/config.go`:
- Around line 56-60: The fallback warning path in the config logging helper
should use structured slog output instead of writing directly to stderr. Update
the warning branch in the logger helper that currently checks logger nil to
route through a slog.TextHandler-based logger when no logger has been set, so
both the normal and fallback paths stay consistent with SetLogger and the
project’s structured logging standard. Keep the existing warning message and
path context, but emit it via slog rather than fmt.Fprintf/os.Stderr.
In `@internal/config/merge_test.go`:
- Around line 839-955: The new STATE-001 coverage in
TestStateDirDefaultIsAbsolutePerUser,
TestStateDirDefaultAutoNoInstanceIsPerUser,
TestAutoInstanceEphemeralStateNotRelative, and
TestStateDirExplicitOverridePreserved should be consolidated into one
table-driven test. Refactor the cases into a single test function that iterates
over a slice of scenarios and uses t.Run subtests for each, while preserving the
assertions for Merge, StateDir, EphemeralState, and the explicit override
behaviors.
In `@internal/config/statedir_test.go`:
- Around line 45-71: The state-dir platform coverage is incomplete and the test
style should use table-driven t.Run subtests. Update
TestStateDirForPlatform_HonorsPlatformBaseEnv to a table-driven structure with
subtests in stateDirFor, and add the missing Linux case that verifies HOME falls
back to ~/.local/state when XDG_STATE_HOME is unset, alongside the existing
platform/env variants.
🪄 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: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 14c1ebfb-26e8-4c6b-96df-46524ac49c79
📒 Files selected for processing (14)
docs/troubleshooting/error-state-permissions.mdinternal/config/config.gointernal/config/config_test.gointernal/config/merge.gointernal/config/merge_test.gointernal/config/statedir.gointernal/config/statedir_test.gosite/src/content/docs/cli-reference.mdsite/src/content/docs/configuration.mdsite/src/content/docs/getting-started.mdspecs/STATE-001-fixed-per-user-state-dir/features.jsonspecs/STATE-001-fixed-per-user-state-dir/proposal.mdspecs/STATE-001-fixed-per-user-state-dir/tasks.mdspecs/STATE-001-fixed-per-user-state-dir/verification.md
- lint: extract osWindows/osDarwin consts (goconst counted the package-wide 3rd "windows" occurrence); add //nolint:unparam to stateDirFor (goos is a cross-OS test seam; the sole production caller passes runtime.GOOS, and test callers are excluded from unparam). - security: sanitize the hostname in EphemeralStateDir to a single safe path segment so separators or ".." cannot escape the temp state root. - tests: snapshot+restore TS_* env via t.Cleanup; consolidate the state-dir cases into table-driven tests with exact-path assertions; add the Linux ~/.local/state fallback and the no-traversal cases. - docs: features.json f7 now checks all four docs; verification.md records which checks run in CI only (lint/-race) vs locally.
|
Thanks for the review. Addressed in CodeRabbit points:
|
🤖 I have created a release *beep* *boop* --- ## [1.15.4](v1.15.3...v1.15.4) (2026-06-24) ### Bug Fixes * **cli:** stop dumping the usage block on runtime errors ([#210](#210)) ([#222](#222)) ([d3985be](d3985be)) * **config:** default tsnet state to a fixed per-user directory ([#207](#207)) ([#219](#219)) ([7aa99f6](7aa99f6)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved CLI error handling so usage information is no longer dumped during runtime errors. * Updated the default `tsnet` storage behavior to use a stable per-user directory. * Added the v1.15.4 release entry to the changelog. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
…specs (#225) ## Summary - Adds two lessons to `docs/lessons.md` (under the existing `## 2026-06-24` block): - **STATE-001**: CWD-relative default state path leaks node identity into git — rule: persistent state defaults must be absolute and per-user (`StateDirForPlatform()` pattern) - **#209**: Structurally impossible config combinations (e.g. `hskey-` without a control URL) must be rejected at config-load time, not at runtime - Archives two specs whose PRs are fully merged and issues closed: - `specs/STATE-001-fixed-per-user-state-dir/` → `specs/archive/` (PR #219 merged) - `specs/HOST-002-config-precedence-and-windows-ci/` → `specs/archive/` (issue #193 closed) ## Checklist - [x] No code changes — docs and spec housekeeping only - [x] Lesson style matches existing entries (bold rule, context, generalized takeaway) - [x] Both archived `proposal.md` files updated to `status: archived` Closes #207 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Documentation** * Added new lesson notes covering safer default state storage and clearer config validation behavior. * **Chores** * Marked two archived spec proposals as completed in the project documentation. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
Summary
ts-bridge connectstored tsnet state in a CWD-relative./ts-state, leaking the node'sprivate identity (
tailscaled.state) into the working directory — and into any git tree thatauto-commits it (e.g. an Obsidian vault with
obsidian-git). Logs already use a fixed per-userlocation (
LogDirForPlatform); state did not. This makes the default state directory a fixed,per-user, always-absolute path, mirroring the logs location.
Closes #207.
Root cause
connectresolves config viaconfig.Merge(precedence flags > env > yaml > defaults). With no--state-dir/TS_STATE_DIR,StateDirfell through to thedefaultStateDir = "./ts-state"fallback — CWD-relative and non-ephemeral. There was no
StateDirForPlatform()analogue toLogDirForPlatform().Changes
internal/config/statedir.go:StateDirForPlatform()— per-user, always absolute (temp-dir fallback when the platformbase is empty, so a missing
HOME/LOCALAPPDATAcan never produce a relative path — arobustness gap
LogDirForPlatformstill has).EphemeralStateDir()— unifies the auto-instance throwaway-identity path under a temp dir.Merge(connect) andLoadConfigpaths; the auto-instance ephemeralbranch no longer touches the CWD.
relative override).
docs/troubleshooting+ site getting-started / configuration / cli-reference).specs/STATE-001-fixed-per-user-state-dir/.Default locations
%LOCALAPPDATA%\ts-bridge\state~/Library/Application Support/ts-bridge/state$XDG_STATE_HOME/ts-bridge/state(→~/.local/state/ts-bridge/state)Behavior change
An existing binary-adjacent
./ts-stateis no longer the default → a fresh node identity at the newpath (possible orphan node in the control plane). Pass
--state-dir ./ts-stateto keep the oldlocation. No auto-migration — moving a live
tailscaled.stateis unsafe. Documented indocs/troubleshooting/error-state-permissions.md.Verification
go build ./... && go vet ./... && go test ./...→ all packagesok(local Windows, no-race; CIruns
-raceon Linux). 9 new tests cover per-OS paths,$XDG_STATE_HOME, theempty-env → absolute-temp fallback, the absolute per-user default, the ephemeral-not-relative
branch, override preservation, and the warn-if-relative guard.
Manual smoke test (real
connect, confirming no./ts-stateis created in the CWD) pending — needsa live auth key.
Summary by CodeRabbit
New Features
Bug Fixes
0700permissions.--state-dir/TS_STATE_DIR/YAML state directory values verbatim (including relative opt-ins).