Skip to content

fix(config): default tsnet state to a fixed per-user directory (#207)#219

Merged
mlorentedev merged 3 commits into
masterfrom
fix/state-dir-per-user-default
Jun 24, 2026
Merged

fix(config): default tsnet state to a fixed per-user directory (#207)#219
mlorentedev merged 3 commits into
masterfrom
fix/state-dir-per-user-default

Conversation

@mlorentedev

@mlorentedev mlorentedev commented Jun 24, 2026

Copy link
Copy Markdown
Owner

Summary

ts-bridge connect stored tsnet state in a CWD-relative ./ts-state, leaking the node's
private identity (tailscaled.state) into the working directory — and into any git tree that
auto-commits it (e.g. an Obsidian vault with obsidian-git). Logs already use a fixed per-user
location (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

connect resolves config via config.Merge (precedence flags > env > yaml > defaults). With no
--state-dir / TS_STATE_DIR, StateDir fell through to the defaultStateDir = "./ts-state"
fallback — CWD-relative and non-ephemeral. There was no StateDirForPlatform() analogue to
LogDirForPlatform().

Changes

  • New internal/config/statedir.go:
    • StateDirForPlatform() — per-user, always absolute (temp-dir fallback when the platform
      base is empty, so a missing HOME / LOCALAPPDATA can never produce a relative path — a
      robustness gap LogDirForPlatform still has).
    • EphemeralStateDir() — unifies the auto-instance throwaway-identity path under a temp dir.
  • Default wired into both the Merge (connect) and LoadConfig paths; the auto-instance ephemeral
    branch no longer touches the CWD.
  • Non-blocking warning when a resolved state dir is relative (only reachable via an explicit
    relative override).
  • Docs updated (docs/troubleshooting + site getting-started / configuration / cli-reference).
  • Spec: specs/STATE-001-fixed-per-user-state-dir/.

Default locations

OS Path
Windows %LOCALAPPDATA%\ts-bridge\state
macOS ~/Library/Application Support/ts-bridge/state
Linux $XDG_STATE_HOME/ts-bridge/state (→ ~/.local/state/ts-bridge/state)

Behavior change

An existing binary-adjacent ./ts-state is no longer the default → a fresh node identity at the new
path (possible orphan node in the control plane). Pass --state-dir ./ts-state to keep the old
location. No auto-migration — moving a live tailscaled.state is unsafe. Documented in
docs/troubleshooting/error-state-permissions.md.

Verification

go build ./... && go vet ./... && go test ./... → all packages ok (local Windows, no -race; CI
runs -race on Linux). 9 new tests cover per-OS paths, $XDG_STATE_HOME, the
empty-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-state is created in the CWD) pending — needs
a live auth key.

Summary by CodeRabbit

  • New Features

    • State directory defaults now resolve to a fixed, always-absolute per-user location based on your OS (no longer working-directory-relative).
    • Auto-instance mode now uses an always-absolute temporary state location.
    • Explicit relative state-dir values now trigger a warning to help prevent accidental state leakage.
  • Bug Fixes

    • Updated CLI, configuration, and troubleshooting docs to reflect the new default path and required 0700 permissions.
    • Preserved explicitly provided --state-dir/TS_STATE_DIR/YAML state directory values verbatim (including relative opt-ins).

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/
@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

Replaces the CWD-relative ./ts-state default with a fixed per-user absolute state directory, adds relative-path warnings, moves auto-instance state to a temp-based path, and updates tests, docs, and STATE-001 spec artifacts.

Changes

STATE-001: Per-user absolute state directory

Layer / File(s) Summary
Spec and acceptance artifacts
specs/STATE-001-fixed-per-user-state-dir/*
Adds the proposal, tasks, feature cases, and verification record for the new state-dir behavior and validation checklist.
State directory resolution helpers
internal/config/statedir.go, internal/config/statedir_test.go
Adds the platform resolver, per-OS base selection, CWD-relative detection, ephemeral temp-based path generation, and tests for absolute-path and traversal behavior.
Config loading and merge wiring
internal/config/config.go, internal/config/merge.go, internal/config/config_test.go, internal/config/merge_test.go
Switches default resolution to StateDirForPlatform(), adds relative-path warnings, updates auto-instance state to EphemeralStateDir(), and adjusts config tests for the new defaults and override behavior.
Documentation updates
docs/troubleshooting/error-state-permissions.md, site/src/content/docs/cli-reference.md, site/src/content/docs/configuration.md, site/src/content/docs/getting-started.md
Revises state-dir troubleshooting and reference docs to describe the per-user default paths, permissions, and warning behavior.

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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • mlorentedev/ts-bridge#151: Touches the same internal/config/merge.go resolution path that now changes default and auto-instance state-dir handling.
  • mlorentedev/ts-bridge#165: Also modifies internal/config/config.go’s auto-instance flow, which interacts with the state-dir setup updated here.

Poem

🐰 No more ./ts-state in the dirt,
I hop to a tidy path that won’t squirt.
Per-user and absolute, snug as can be,
With a warning for paths that wander free.
Hop hop! 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: switching tsnet state to a fixed per-user directory.
Description check ✅ Passed The description covers the summary, root cause, changes, behavior change, and verification, though the template sections are not fully matched.
Linked Issues check ✅ Passed The changes satisfy #207 by removing the CWD-relative default, adding per-user absolute resolution, and warning on relative overrides.
Out of Scope Changes check ✅ Passed The added docs, tests, and spec files are directly tied to the state-dir fix and do not appear unrelated.
Docstring Coverage ✅ Passed Docstring coverage is 87.50% which is sufficient. The required threshold is 80.00%.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/state-dir-per-user-default

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🧹 Nitpick comments (3)
internal/config/config.go (1)

56-60: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Keep the fallback warning on slog.

The stderr fallback is unstructured logging. Use a slog.TextHandler fallback so the warning path stays consistent even before SetLogger is 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 with log/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 win

Fold 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 with t.Run subtests 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 win

Add the Linux HOME -> ~/.local/state branch as a table-driven subtest.

The contract here also includes the Linux fallback when XDG_STATE_HOME is unset, but the current tests only cover the XDG override and the empty-env temp fallback. Folding these path variants into t.Run subtests 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

📥 Commits

Reviewing files that changed from the base of the PR and between af00c78 and 6bd79e6.

📒 Files selected for processing (14)
  • docs/troubleshooting/error-state-permissions.md
  • internal/config/config.go
  • internal/config/config_test.go
  • internal/config/merge.go
  • internal/config/merge_test.go
  • internal/config/statedir.go
  • internal/config/statedir_test.go
  • site/src/content/docs/cli-reference.md
  • site/src/content/docs/configuration.md
  • site/src/content/docs/getting-started.md
  • specs/STATE-001-fixed-per-user-state-dir/features.json
  • specs/STATE-001-fixed-per-user-state-dir/proposal.md
  • specs/STATE-001-fixed-per-user-state-dir/tasks.md
  • specs/STATE-001-fixed-per-user-state-dir/verification.md

Comment thread internal/config/config.go
Comment thread internal/config/merge_test.go Outdated
Comment thread internal/config/merge_test.go Outdated
Comment thread internal/config/statedir.go Outdated
Comment thread specs/STATE-001-fixed-per-user-state-dir/features.json
Comment thread specs/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.
@mlorentedev

Copy link
Copy Markdown
Owner Author

Thanks for the review. Addressed in e18b2d9 (rebased on the master-merge). Also fixed the actual CI lint failure (goconst "windows"osWindows/osDarwin consts; unparam stateDirFor//nolint:unparam, since goos is a cross-OS test seam and the sole production caller passes runtime.GOOS).

CodeRabbit points:

  • EphemeralStateDir hostname path traversal — fixed. EphemeralStateDir now reduces the hostname to a single safe segment via the existing SanitizeHostnameLabel (fallback "instance"), so separators / .. cannot escape the temp state root. New TestEphemeralStateDirNoTraversal covers ../../evil, ..\..\evil, /etc/passwd, a/b/c, and empty.
  • Test env not restored — fixed. unsetStateEnvrestoreStateEnv, which snapshots each TS_* var and restores it via t.Cleanup.
  • Assertions too loose (absolute-only) — fixed. Consolidated into table-driven TestStateDirResolution with exact contracts: == StateDirForPlatform() for the default and == EphemeralStateDir(cfg.Hostname) for the ephemeral case.
  • statedir_test table-driven + missing Linux ~/.local/state fallback — fixed. TestStateDirFor is now table-driven and includes the XDG_STATE_HOME-unset → ~/.local/state branch (each OS case runs only on its own OS, since path/filepath semantics are OS-specific; the Linux + Windows CI test jobs cover both).
  • features.json f7 only checked a subset of docs — fixed. f7 now greps all four docs; added f8 for the traversal hardening.
  • verification.md overstated completeness — fixed. It now distinguishes local checks (build/vet/test) from CI-only ones (lint, -race) and no longer claims fully-green; the manual smoke test is marked pending.
  • slog fallback instead of fmt.Fprintf in the warn helper (nitpick) — intentionally skipped. The stderr fallback matches the sibling warnEnvVar / warnPermission / warnUnknownField helpers; switching only this one would be inconsistent, and converting all of them is out of scope here.

@mlorentedev
mlorentedev merged commit 7aa99f6 into master Jun 24, 2026
11 of 12 checks passed
@mlorentedev
mlorentedev deleted the fix/state-dir-per-user-default branch June 24, 2026 18:10
mlorentedev added a commit that referenced this pull request Jun 24, 2026
🤖 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 -->
mlorentedev added a commit that referenced this pull request Jun 26, 2026
…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 -->
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.

BUG: connect stores tsnet state in a CWD-relative ./ts-state — leaks node identity into the working dir (and any auto-committed repo)

1 participant