Skip to content

fix(serve): survive dual-stack port collisions at startup - #1406

Merged
wesm merged 5 commits into
mainfrom
fix/daemon-dual-stack-port-collision
Aug 14, 2026
Merged

fix(serve): survive dual-stack port collisions at startup#1406
wesm merged 5 commits into
mainfrom
fix/daemon-dual-stack-port-collision

Conversation

@wesm

@wesm wesm commented Aug 14, 2026

Copy link
Copy Markdown
Member

Wildcard startup could select a port already occupied on IPv4 by binding only
its IPv6 side. The daemon then advertised the IPv4 endpoint, where readiness
could contact the unrelated process and leave an attached restart waiting after
the real server had started.

Wildcard port selection now checks supported IPv4 and IPv6 families separately
and uses the existing next-port fallback when either side is occupied.
Ephemeral wildcard assignments receive the same cross-family validation, and
startup returns an error instead of reusing an occupied port when the candidate
range is exhausted.

Backend readiness now uses a temporary server-held challenge proof. It never
sends the configured bearer token to an unverified listener, rejects redirects,
and follows the server's configured base path. The temporary proof is disabled
as soon as startup completes. Managed Caddy retains its generic TCP readiness
check because it does not implement the AgentsView startup proof.

Unsupported address families remain excluded from port selection, preserving
IPv4-only hosts. A process can still race to claim a selected port before bind,
but an unrelated listener cannot forge readiness, receive the reusable token,
or publish as a healthy AgentsView runtime.

wesm added 3 commits August 14, 2026 11:45
An unrelated IPv4 listener can make daemon readiness target the wrong process
while AgentsView serves on IPv6. Record the chosen collision and identity-probe
behavior before changing the shared serve lifecycle.
On macOS a wildcard listen can bind IPv6 while an unrelated process
still owns the same port on IPv4. Port selection accepted that port,
readiness passed on a bare TCP dial that reached the IPv4 process, and
an attached daemon restart then waited forever for a runtime record the
identity probes kept rejecting.

Port selection now probes the IPv4 and IPv6 wildcard addresses
separately and moves to the next port when either family is occupied. A
family that cannot bind at all stays out of the check, so IPv4-only
hosts keep their behavior. Backend readiness now requires the
authenticated daemon ping to answer from this process instead of
accepting any TCP connection, so no foreign listener can make startup
publish a healthy runtime. Managed Caddy keeps its generic TCP check
because Caddy does not speak the daemon protocol.

The design spec gains the two decisions this uncovered: bind-family
classification for IPv6-disabled hosts and the process ID match in the
readiness probe.
Identity-aware readiness originally assumed that every daemon ping lived at
the root URL. Read-only PG and DuckDB servers can mount the full application
below a base path, so that assumption made valid deployments time out and stop
after opening their listener.

Use the server's mounted ping path for readiness. This keeps the stronger
process identity check without regressing base-path deployments.
@roborev-ci

roborev-ci Bot commented Aug 14, 2026

Copy link
Copy Markdown

roborev: Combined Review (8974713)

High — Readiness probe can disclose the bearer token to a colliding listener

  • Location: cmd/agentsview/serve_runtime.go:189
  • Problem: During wildcard-host startup, an attacker-controlled IPv4 listener may receive the readiness request—including cfg.AuthToken—while AgentsView binds IPv6. The PID identity check occurs only after the reusable token has already been disclosed. Exhausting all 100 candidate ports also causes FindAvailablePort to return the original occupied port.
  • Fix: Retain and serve from the listeners acquired during port selection, or use a server-held ephemeral startup nonce that does not expose the persistent bearer token. Return an error when the candidate range is exhausted.

Medium — Ephemeral wildcard ports bypass dual-stack collision checks

  • Location: internal/server/server.go:1132
  • Problem: The start == 0 path skips the new IPv4/IPv6 probes, so an ephemeral wildcard bind can select a port already occupied on the other address family and cause identity-aware readiness to fail.
  • Fix: Validate dynamically assigned wildcard ports with listenProbesFree, retry on collision, and add coverage for wildcard port-zero selection.

Reviewers: 2 done | Synthesis: codex, 12s | Total: 6m58s

A split IPv4/IPv6 collision means startup does not yet know which process owns
the advertised loopback endpoint. Sending the configured bearer token during
readiness can therefore disclose a reusable credential to an unrelated
listener.

Use a temporary server-held challenge proof and reject redirects so readiness
authenticates the first listener without exposing the bearer token. Fail an
exhausted port search and validate ephemeral wildcard assignments so collision
fallbacks never reuse a known occupied port.
@roborev-ci

roborev-ci Bot commented Aug 14, 2026

Copy link
Copy Markdown

roborev: Combined Review (9959d99)

No issues found.


Reviewers: 2 done | Synthesis: codex | Total: 6m46s

The implementation and its regression coverage now carry the startup behavior.
Keep the branch focused on the shipped change instead of retaining its planning
artifact.
@roborev-ci

roborev-ci Bot commented Aug 14, 2026

Copy link
Copy Markdown

roborev: Combined Review (955b652)

No issues found.


Reviewers: 2 done | Synthesis: codex | Total: 5m47s

@wesm
wesm merged commit d7e82fa into main Aug 14, 2026
23 checks passed
@wesm
wesm deleted the fix/daemon-dual-stack-port-collision branch August 14, 2026 20:13
wesm added a commit to salmonumbrella/agentsview that referenced this pull request Aug 16, 2026
…dy-core

* origin/main: (25 commits)
  ci: retry failed Docker image builds (kenn-io#1437)
  Speed up HTTP sync by processing only changed sessions (kenn-io#1414)
  chore: remove dead code and unused frontend exports (kenn-io#1434)
  fix(parser): populate Kimi session cwd (kenn-io#1427)
  feat(frontend): add raw and formatted tool output display (kenn-io#1424)
  feat(insights): support OpenAI-compatible endpoints (kenn-io#1430)
  fix(parser): support legacy Zed thread schemas (kenn-io#1429)
  perf(signals): index duplicate prompt comparisons (kenn-io#1425)
  fix(config): honor explicit empty agent directory arrays (kenn-io#1423)
  feat(parser): add DeepSeek Harness session support (kenn-io#1402)
  test(sync): wait for archive audit stall state (kenn-io#1422)
  chore(deps): update github actions dependencies (kenn-io#1421)
  Tombstone stored Claude sessions a complete full parse no longer emits (kenn-io#1392)
  fix(sync): report stalled syncs as unhealthy (kenn-io#1419)
  Reduce idle CPU and let users turn off unused providers (kenn-io#1374)
  fix(serve): survive dual-stack port collisions at startup (kenn-io#1406)
  fix(sync): bound startup reconciliation and expose daemon identity (kenn-io#1413)
  fix(deps): update module golang.org/x/mod to v0.40.0 [security] (kenn-io#1400)
  Persist Claude and Codex freshness digests across engine restarts (kenn-io#1393)
  fix(sync): add lifecycle logging (kenn-io#1399)
  ...
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant