Skip to content

feat(fleet): per-worker keys in the desktop fleet server#91

Merged
GeiserX merged 4 commits into
mainfrom
feat/fleet-per-worker-keys
Jul 11, 2026
Merged

feat(fleet): per-worker keys in the desktop fleet server#91
GeiserX merged 4 commits into
mainfrom
feat/fleet-per-worker-keys

Conversation

@GeiserX

@GeiserX GeiserX commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Makes CashPilot-Desktop's fleet_server compatible with the per-worker-key protocol shipped in the CashPilot web UI (v1.0.0) and the CashPilot-android client — so any client (mobile, docker worker) interoperates with the desktop fleet server the same way it does with the web UI.

Before: /api/workers/heartbeat validated a single shared token for every device.
Now:

  • Enroll — a device's first heartbeat (shared token) is issued its own key, returned once as worker_key.
  • Confirm — on the device's first own-key heartbeat, the key is confirmed.
  • Reject — a confirmed device presenting the shared token gets 401 (impersonation protection; a leaked device key is scoped to one device).
  • Reissue — a fresh key is re-delivered on each heartbeat until the device confirms, so a dropped response can't lock a device out.

Store (internal/store): per-device api_key_hash + key_confirmed with a forward-only, guarded SQLite migration; SetFleetDeviceKey / ConfirmFleetDeviceKey / FleetDeviceKeyState / HashFleetKey. A one-way hash is sufficient — the desktop fleet server only verifies inbound heartbeats and issues keys (it never calls out to devices), and it rotates the key on each unconfirmed reissue rather than re-delivering.
Server (fleet_server.go): the pure, table-tested classifyFleetAuth drives handleWorkerHeartbeat.

Verified: go build ./... + go vet ./... clean; go test -race ./... green — including the store key lifecycle, the classifyFleetAuth matrix, bearerToken, and a full enroll→confirm→reject HTTP handler flow. gofmt clean.

Companion: CashPilot-android PR #37 (client side). Beads: CashPilot-Desktop-jet/-xkf/-10u/-rxx (closed).

Summary by CodeRabbit

  • New Features
    • Fleet workers now receive unique device keys during enrollment and use them for subsequent heartbeats.
    • Shared API keys are limited to initial enrollment after a device confirms its own key.
    • Heartbeat requests now include per-IP rate limiting and improved protection against concurrent enrollment changes.
    • Existing installations automatically migrate to support device key tracking.
  • Documentation
    • Added guidance on fleet key behavior, enrollment, and recommended network configuration.

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@GeiserX, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 51 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3c9f637a-aa39-4caa-820a-859c2553bae4

📥 Commits

Reviewing files that changed from the base of the PR and between 3edf159 and 842f7b2.

📒 Files selected for processing (2)
  • fleet_server_test.go
  • internal/store/store_test.go
📝 Walkthrough

Walkthrough

Fleet heartbeats now use per-device keys issued through bootstrap enrollment. Key state is stored and migrated in SQLite, requests are rate-limited, and fleet state mutations are serialized across heartbeat, deletion, and stale-device cleanup paths.

Changes

Fleet key security

Layer / File(s) Summary
Device key storage and migration
internal/store/store.go, internal/store/store_test.go
Fleet devices persist hashed keys and confirmation state, with migration checks for existing databases and lifecycle coverage.
Heartbeat authentication and rate limiting
fleet_server.go, fleet_server_test.go
Heartbeats use bootstrap or per-device credentials, issue keys until confirmation, confirm valid device keys, and enforce per-IP throttling.
Fleet state synchronization
app.go
A fleet mutex serializes heartbeat key changes with device deletion and stale-device sweeping; the limiter is initialized during Fleet startup.
Fleet behavior documentation
CHANGELOG.md, CLAUDE.md
Documentation describes enrollment, confirmation, migration, rate limiting, serialization, and network guidance.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • GeiserX/CashPilot-Desktop#29: Adds the original Fleet heartbeat endpoint and device storage that this change extends with per-device key enrollment.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding per-worker keys to the fleet server.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/fleet-per-worker-keys

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.

@codecov

codecov Bot commented Jul 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.28655% with 32 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.92%. Comparing base (178fbf7) to head (842f7b2).

Files with missing lines Patch % Lines
internal/store/store.go 75.30% 10 Missing and 10 partials ⚠️
fleet_server.go 86.66% 8 Missing and 4 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main      #91      +/-   ##
==========================================
+ Coverage   74.70%   74.92%   +0.22%     
==========================================
  Files          14       14              
  Lines        3261     3418     +157     
==========================================
+ Hits         2436     2561     +125     
- Misses        651      669      +18     
- Partials      174      188      +14     
Files with missing lines Coverage Δ
fleet_server.go 80.78% <86.66%> (+2.21%) ⬆️
internal/store/store.go 80.36% <75.30%> (-0.93%) ⬇️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Makes CashPilot-Desktop's fleet_server compatible with the per-worker-key protocol
(web UI v1.0.0 + the CashPilot-android client). The heartbeat API validated a
single shared token for every device; it now:
- issues each device its own key on first contact (returned once as worker_key),
- requires that key thereafter and CONFIRMS it on first own-key use,
- rejects the shared token for a confirmed device (impersonation protection),
- re-issues a fresh key each heartbeat until the device confirms, so a dropped
  response can't lock a device out.

Store: per-device api_key_hash + key_confirmed (forward-only guarded migration);
SetFleetDeviceKey / ConfirmFleetDeviceKey / FleetDeviceKeyState + HashFleetKey.
Server: classifyFleetAuth (pure, table-tested) drives handleWorkerHeartbeat.

Verified: go build/vet clean; go test -race ./... green.
@GeiserX GeiserX force-pushed the feat/fleet-per-worker-keys branch from 32990d5 to b2faa63 Compare July 11, 2026 00:51
GeiserX added 2 commits July 11, 2026 02:57
From the architect review (all 7 security checks passed):
- SetFleetDeviceKey/ConfirmFleetDeviceKey now error on a 0-row UPDATE, so a
  device is never handed a worker_key whose hash wasn't actually stored (closes a
  narrow issued-but-not-stored -> lockout race). Test added.
- Document that shared-token enrollment is trust-on-first-use (keep the token
  secret + the fleet API on the loopback default), and note the (kind,name)
  identity is derived from mutable client fields.

go build/vet clean; go test -race ./... green.
…findings

From the 7-lens PR review:
- Serialize the read->classify->upsert->set/confirm sequence (new App.fleetMu, also
  held by the reap + manual-delete paths): closes the concurrent/retried-heartbeat
  and reap-race TOCTOU that could hand a client a key the DB discarded or blank a
  confirmed device's key.
- Per-IP rate limiter on the heartbeat/enrollment endpoint (anti-flood/anti-abuse).
- Constant-time shared-token check now compares fixed-length hashes (no token-length
  timing leak).
- columnExists guards the interpolated table identifier (future-proofing).
- Fix stale app.go comment referencing the removed validFleetBearer.
- Tests: ALTER-backfill migration path (the real upgrade path), reissue-until-
  confirmed through the HTTP handler, concurrent-enroll under -race, rate limiter,
  identifier guard. Document TOFU/transport residuals (loopback default; TLS proxy
  for LAN; full TLS a follow-up).

go build/vet clean; go test -race ./... green.

@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.

🧹 Nitpick comments (2)
app.go (1)

807-810: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Lock scope is correct; consider panic safety.

The explicit Lock()/Unlock() (vs defer) is the right call here — it releases the lock before GetFleetState() and error handling, minimizing contention with heartbeats. The heartbeat handler correctly uses defer instead since it has multiple return points.

One minor trade-off: if DeleteFleetDevice or SweepStaleFleetDevices panicked, the lock would remain held. In practice this is negligible (both are simple db.Exec calls that return errors, and the store is nil-guarded upstream). If you ever want belt-and-suspenders, a small closure keeps both properties:

🔒 Optional: panic-safe scoped lock
 func (a *App) RemoveFleetDevice(id int64) (FleetState, error) {
 	if err := a.ready(); err != nil {
 		return FleetState{}, err
 	}
 	if id <= 0 {
 		return FleetState{}, fmt.Errorf("the local desktop device cannot be removed")
 	}
-	a.fleetMu.Lock()
-	err := a.store.DeleteFleetDevice(id)
-	a.fleetMu.Unlock()
+	var err error
+	func() {
+		a.fleetMu.Lock()
+		defer a.fleetMu.Unlock()
+		err = a.store.DeleteFleetDevice(id)
+	}()
 	if err != nil {
 		return FleetState{}, err
 	}
 	return a.GetFleetState()
 }

Also applies to: 1115-1118

🤖 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 `@app.go` around lines 807 - 810, Make the lock scope panic-safe in the fleet
deletion paths by wrapping each Lock/operation/Unlock sequence around
DeleteFleetDevice and SweepStaleFleetDevices in a small closure, while ensuring
the lock is released before GetFleetState and subsequent error handling.
fleet_server.go (1)

188-194: 🚀 Performance & Scalability | 🔵 Trivial

fleetMu serializes every heartbeat globally, not per device. Fine for a small loopback fleet, but as device count grows this single lock (held across FleetDeviceKeyStateUpsertFleetHeartbeat → set/confirm) becomes a throughput chokepoint. If you later scale up, the cleaner fix is to run the read→classify→write as one BEGIN IMMEDIATE SQLite transaction (atomicity without a process-wide mutex) or shard the lock by (kind, name).

🤖 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 `@fleet_server.go` around lines 188 - 194, Replace the process-wide fleetMu
serialization around the heartbeat read→classify→upsert→set/confirm sequence
with per-device locking keyed by device kind and name, or an equivalent atomic
BEGIN IMMEDIATE SQLite transaction. Preserve coordination with reap/delete paths
while allowing heartbeats for different devices to proceed concurrently.
🤖 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.

Nitpick comments:
In `@app.go`:
- Around line 807-810: Make the lock scope panic-safe in the fleet deletion
paths by wrapping each Lock/operation/Unlock sequence around DeleteFleetDevice
and SweepStaleFleetDevices in a small closure, while ensuring the lock is
released before GetFleetState and subsequent error handling.

In `@fleet_server.go`:
- Around line 188-194: Replace the process-wide fleetMu serialization around the
heartbeat read→classify→upsert→set/confirm sequence with per-device locking
keyed by device kind and name, or an equivalent atomic BEGIN IMMEDIATE SQLite
transaction. Preserve coordination with reap/delete paths while allowing
heartbeats for different devices to proceed concurrently.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f2ec12f0-ef52-47c9-bb09-3e00f3d1a4bd

📥 Commits

Reviewing files that changed from the base of the PR and between 178fbf7 and 3edf159.

📒 Files selected for processing (7)
  • CHANGELOG.md
  • CLAUDE.md
  • app.go
  • fleet_server.go
  • fleet_server_test.go
  • internal/store/store.go
  • internal/store/store_test.go

Close the codecov/patch gaps on the review-hardening branches: the handler 429
path, the rate-limiter's expired-IP sweep, and columnExists rejecting an unsafe
table identifier. go test -race green.
@GeiserX GeiserX merged commit dfd2699 into main Jul 11, 2026
6 checks passed
@GeiserX GeiserX deleted the feat/fleet-per-worker-keys branch July 11, 2026 10:24
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.

1 participant