Skip to content

feat(web): embed tsnet for codewhale web --tailscale - #5635

Open
Hmbown wants to merge 2 commits into
mainfrom
grok/mission1-tsnet
Open

feat(web): embed tsnet for codewhale web --tailscale#5635
Hmbown wants to merge 2 commits into
mainfrom
grok/mission1-tsnet

Conversation

@Hmbown

@Hmbown Hmbown commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds opt-in codewhale web --tailscale on top of origin/main. Default codewhale web stays loopback-only (127.0.0.1). --tailscale without --web is rejected (clap requires = "web" plus runtime validation).
  • Embed vs serve. PR Enterprise launch readiness: operator packet, Codewhale launch, #5585, #5617 #5628 (origin/cursor/enterprise-launch-readiness-8ed3) is the precursor: it wraps the Tailscale CLI (tailscale serve --bg --https=443 localhost:<port>). This PR takes the next step the mission asked for: prefer an embedded tsnet node from the official crates.io tailscale 0.5.0 crate (Device::new, Device::tcp_listen, tailscale::axum::Listener, Config.requested_hostname = "codewhale"codewhale.<tailnet>.ts.net via NodeInfo::fqdn). Cookie/CORS *.ts.net origin allowlisting is copied from 5628's runtime_api/auth.rs pattern.
  • Crate choice. Official 0.5.0 can listen and serve HTTP through axum (verified with cargo check -p codewhale-tui --features tailscale). It cannot mint HTTPS certificates — the crate README lists HTTPS Certificates, MagicDNS, and Tailscale Serve as unsupported. Embedded traffic is HTTP :80 over the WireGuard overlay. When embed is not compiled, is disabled (CODEWHALE_TSNET_DISABLE), or cannot auth (CODEWHALE_TSNET_AUTHKEY / TS_AUTHKEY), the same --tailscale flag falls back to 5628's CLI serve (browser-trusted TLS on the machine MagicDNS name). geiserx_tailscale was not needed because official already has tcp_listen.
  • Heavy dep is feature-gated (tailscale optional on codewhale-tui / codewhale-cli) so default builds stay lean. Tests stub the node; CI does not need a live tailnet.

Test plan

  • cargo test -p codewhale-tui --lib -- tailscale cookie_origin public_origin bootstrap_is_loopback launcher_url tailscale_requires_web … (flag wiring, origin allowlist, --tailscale without --web, MagicDNS helpers)
  • cargo test -p codewhale-cli --lib -- web_command web_tailscale serve_help_documents
  • cargo check -p codewhale-tui --features tailscale (official crate API compiles)
  • Manual: codewhale web still binds loopback only
  • Manual: codewhale web --tailscale with Tailscale CLI connected uses CLI serve fallback in a default (no-feature) build
  • Manual: build --features tailscale and set TS_AUTHKEY to get http://codewhale.<tailnet>.ts.net

Made with Cursor

No-Issue: Opt-in embedded tsnet for codewhale web; precursor design from #5628; no tracking issue to close.

Keep default codewhale web loopback-only. --tailscale prefers the official
tailscale 0.5.0 crate (Device::tcp_listen + axum) when compiled in, and
falls back to PR #5628's tailscale serve HTTPS when embed cannot auth.

Co-authored-by: Cursor <cursoragent@cursor.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@claude

claude Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Hmbown's task in 6m 56s —— View job


Claude's review of PR #5635

Todo list

  • Gather context (PR description, changed files)
  • Read diff for all changed files (git diff origin/main...HEAD)
  • Review crates/tui/src/runtime_api/tailscale.rs (new, 569 lines)
  • Review crates/tui/src/runtime_api/auth.rs (cookie/CORS origin allowlist changes)
  • Review crates/tui/src/runtime_api/web.rs and runtime_api.rs (flag wiring)
  • Review crates/cli/src/lib.rs (clap flag wiring / validation)
  • Check for provider/model/route safety concerns
  • Check tests for new behavior
  • Post final review

Summary

Solid, well-documented feature with good test coverage of the flag wiring, cookie-origin allowlist, and bootstrap-trust logic. --tailscale correctly requires --web (both via clap requires and a runtime validate_runtime_api_options check), and the pre-existing options.web && options.host != "127.0.0.1" guard (runtime_api.rs:846) keeps the loopback bind honest even with --tailscale layered on — that's the load-bearing invariant that makes the new Host-header trust check in bootstrap_peer_is_trusted safe (it only ever fires on the embedded tsnet listener, which is only reachable by genuine tailnet peers, or on the loopback/CLI-serve path where peer_ip is already loopback). Good use of constant-time comparison for the bootstrap nonce/session token (pre-existing, preserved).

Left 4 inline comments, most important first:

  1. runtime_api.rs:991-994 — the embedded tsnet EmbeddedTsnet/Arc<Device> is a local temporary that's dropped right after spawn_http80 starts the listener task; only the JoinHandle is retained in TailscaleGuard. Unless the tailscale crate's axum::Listener independently keeps the Device alive, this could tear down the embedded tailnet session immediately after startup. I couldn't check the crate source in this sandbox to confirm either way — flagged as a correctness risk worth a manual "stays reachable after 60s" smoke test before merge.
  2. tailscale.rs:300-313spawn_http80 uses app.into_make_service() (no connect-info), so bootstrap_peer_is_trusted's peer-IP branch is always None on the embedded listener; trust there silently collapses to the Host-header check alone. Works today because of the network-level isolation, but looks unintentional and forecloses a future CGNAT-range check.
  3. tailscale.rs:234-243 — the platform cfg predicate is duplicated verbatim 6× in this file plus once in Cargo.toml; a single alias would avoid drift.
  4. tailscale.rs:406-408 — minor: fqdn_opt then fqdn fallback calls into the same node data twice; harmless but worth a comment or simplifying.

Also note: web.rs:19-21's comment ("nonce remains … loopback-only") is now stale given this PR intentionally allows tailnet-origin bootstrap; not filed as a separate inline comment since it's a pre-existing line this PR didn't touch, but worth a follow-up wording fix.

No provider/model/route-selection code is touched by this PR, so EPIC #2608 invariants don't apply here.

Not run: cargo test/cargo check --features tailscale (no network/build execution performed in this review pass) — please rely on the PR's own reported test runs and the manual test-plan items still unchecked in the description.

Branch: grok/mission1-tsnet

Comment on lines +991 to +994
tailscale::TailscalePublish::Embedded(node) => {
let handle = node.spawn_http80(app.clone()).await?;
tailscale_guard.arm_embedded(handle);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Correctness risk: the embedded tsnet Device may be dropped right after the listener starts.

tailscale::TailscalePublish::Embedded(node) => {
    let handle = node.spawn_http80(app.clone()).await?;
    tailscale_guard.arm_embedded(handle);
}

node: EmbeddedTsnet owns the only Arc<Device> we ever create (tailscale.rs:265-278). spawn_http80 only borrows &self to call ipv4_addr()/tcp_listen(), then returns a JoinHandle. Nothing stores node/the Arc<Device> anywhere else — tailscale_guard only keeps the JoinHandle (TailscaleGuardInner::Embedded { serve } in tailscale.rs:196-201). At the end of this match arm, node goes out of scope and drops.

Unless tailscale::axum::Listener (or the netstack TcpListener it wraps) internally clones/holds its own strong reference to the Device to keep the tailnet session alive, this drops the device immediately after startup, which would tear down the WireGuard session/route registration while the spawned axum::serve task keeps trying to serve on a now-dead listener — i.e. the embedded front could go dark right after "Codewhale web advertised on Tailscale…" is printed.

I wasn't able to check the tailscale 0.5.0 crate source in this sandbox to confirm one way or the other. Worth verifying explicitly (e.g. a manual smoke test that keeps the connection open for a few minutes and issues a second request), and if the crate doesn't keep it alive, storing the EmbeddedTsnet/Arc<Device> alongside the JoinHandle in TailscaleGuardInner::Embedded would fix it.

Comment on lines +300 to +313
{
let ipv4 = self
.device
.ipv4_addr()
.await
.context("embedded tsnet has no tailnet IPv4 address yet")?;
let listener = self
.device
.tcp_listen((ipv4, 80).into())
.await
.context("embedded tsnet tcp_listen(:80) failed")?;
let listener: ::tailscale::axum::Listener = listener.into();
Ok(tokio::spawn(async move {
let _ = axum::serve(listener, app.into_make_service()).await;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

spawn_http80 serves with app.into_make_service() (no connect-info), while the primary loopback listener in runtime_api.rs uses into_make_service_with_connect_info::<SocketAddr>(). That means for every request that arrives through this embedded tsnet listener, request.extensions().get::<ConnectInfo<SocketAddr>>() in bootstrap_peer_is_trusted (web.rs:118-124) is always None, so trust for this listener silently collapses to the Host-header check alone — the peer-IP branch of that function is dead code on this path.

Functionally this still holds together today because the tsnet virtual interface itself is only reachable by genuine tailnet peers (isolation comes from the network layer, not the Host check), but it looks unintentional rather than deliberate — there's no comment explaining it, and it forecloses ever tightening this later with a "peer IP is in the tailnet CGNAT range" check without another change. Consider into_make_service_with_connect_info::<SocketAddr>() here too (tsnet peer addresses should still be available from the netstack listener) so the two trust checks in bootstrap_peer_is_trusted are both live.

Comment on lines +234 to +243
#[cfg(all(
feature = "tailscale",
any(
all(
target_os = "linux",
any(target_arch = "x86_64", target_arch = "aarch64")
),
all(target_os = "macos", target_arch = "aarch64")
)
))]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor simplification: the platform predicate all(feature = "tailscale", any(all(target_os = "linux", any(target_arch = "x86_64", target_arch = "aarch64")), all(target_os = "macos", target_arch = "aarch64"))) is copy-pasted verbatim 6 times in this file (here, embedded_tsnet_compiled, twice on EmbeddedTsnet/spawn_http80, and on the embed module), plus mirrored again in crates/tui/Cargo.toml's target table. A single cfg_alias/build-script --cfg tsnet_supported (or at least a local macro) would remove the risk of the copies drifting when the supported-platform matrix changes.

Comment thread crates/tui/src/runtime_api/tailscale.rs Outdated
Comment on lines +406 to +408
let magic_dns = node.fqdn_opt(false).filter(|dns| dns.contains('.')).unwrap_or_else(|| {
node.fqdn(false)
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: node.fqdn_opt(false) is queried and, only if it fails the contains('.') filter, node.fqdn(false) is called as a fallback — two calls into the same underlying node data for what's presumably the same FQDN. If fqdn_opt already returned an incomplete/empty value, it's unclear why fqdn(false) would return something better. Not a functional bug (the subsequent !magic_dns.contains('.') bail still guards it), but worth a one-line comment on why the fallback exists, or simplifying to a single call.

Co-authored-by: Cursor <cursoragent@cursor.com>
@claude

claude Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Hmbown's task in 59s —— View job


Claude's review of PR #5635 (update after synchronize)

Todo list

  • Gather context (PR description, prior review comments, changed files)
  • Diff against origin/main to see what changed since the last review pass
  • Check whether prior inline findings (Device lifetime, connect-info, cfg duplication, fqdn double-call) were addressed
  • Re-review any newly changed lines
  • Post final update

Summary

New commit since the last review pass is 4675b0b style(web): rustfmt tailscale embed and session tests — confirmed via git show 4675b0b that it is formatting-only (line wrapping, use reordering, collapsing multi-line assert_eq!/.expect() calls onto single lines). No logic changed in tailscale.rs, web.rs, or runtime_api.rs.

All 4 inline findings from the previous review pass (job run) still apply verbatim against current HEAD:

  1. runtime_api.rs:991-994tailscale::TailscalePublish::Embedded(node) is a local temporary; only the JoinHandle from node.spawn_http80(...) is retained in tailscale_guard (arm_embedded). Confirmed unchanged at these exact lines. node (and its Arc<Device>) still drops at the end of the match arm. Unless tailscale::axum::Listener/netstack::TcpListener independently keeps the Device alive internally, the embedded tailnet session may tear down right after startup while the spawned axum::serve task keeps running against a dead listener. This is the highest-priority item — worth confirming with a manual "stays reachable after 60s+" smoke test before merge, as the PR's own test plan still has that box unchecked.
  2. tailscale.rs:315spawn_http80 still uses app.into_make_service() (no connect-info), unlike the loopback listener in runtime_api.rs:1047 which uses into_make_service_with_connect_info::<SocketAddr>(). The peer-IP branch of bootstrap_peer_is_trusted is dead code on the embedded path. Still functionally safe today (isolation comes from the tsnet network layer, not the Host check) but looks unintentional.
  3. tailscale.rs — the platform cfg(all(feature = "tailscale", any(all(target_os = "linux", ...), all(target_os = "macos", ...)))) predicate is still duplicated verbatim (9 occurrences across tailscale.rs/Cargo.toml). A single cfg alias would prevent drift if the supported-platform matrix changes.
  4. tailscale.rs:405-411node.fqdn_opt(false) then node.fqdn(false) fallback still calls into the same node data twice; harmless (guarded by the later contains('.') bail) but still worth a one-line comment or simplifying to one call.

No new correctness, provider/model/route-safety, or security issues introduced by this commit — it's a pure rustfmt pass. No provider/model/route-selection code is touched by this PR, so EPIC #2608 invariants don't apply.

Not run: cargo test/cargo check --features tailscale in this pass (no network/build execution performed) — rely on the PR's own reported test runs; the manual smoke-test items in the test plan (especially the "stays reachable" check implied by finding #1) remain unchecked.

Branch: grok/mission1-tsnet

@Hmbown

Hmbown commented Aug 27, 2026

Copy link
Copy Markdown
Owner Author

Superseded by #5659: same tsnet/Tailscale Serve path rebased onto main after #5648, plus the 288px Runtime rail, C-whale marks, and tailnet QR. #5635 stays unmerged (credit/lint red and now conflicting). Do not merge this PR.

Hmbown pushed a commit that referenced this pull request Aug 27, 2026
Version drift requires issue numbers from feat commits in CHANGELOG.md,
and crates/tui/CHANGELOG.md must match the root slice.

Signed-off-by: CodeWhale Bot <bot@codewhale.net>
Hmbown pushed a commit that referenced this pull request Aug 27, 2026
Rebase `codewhale web --tailscale` onto main after #5648. Default web stays
loopback. Tailnet Serve/tsnet is not Funnel and not account `/rc`. The
embedded client uses the C-whale marks, a 288px rail that no longer collides
with session facts, and a QR for the MagicDNS origin when a tailnet front is
up.

Harvested from PR #5635 by @wuisabel-gif

Co-authored-by: Isabel Wu <231155141+wuisabel-gif@users.noreply.github.com>
Signed-off-by: CodeWhale Bot <bot@codewhale.net>
Hmbown pushed a commit that referenced this pull request Aug 27, 2026
Version drift requires issue numbers from feat commits in CHANGELOG.md,
and crates/tui/CHANGELOG.md must match the root slice.

Signed-off-by: CodeWhale Bot <bot@codewhale.net>
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