feat(web): embed tsnet for codewhale web --tailscale - #5635
Conversation
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>
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Claude finished @Hmbown's task in 6m 56s —— View job Claude's review of PR #5635Todo list
SummarySolid, well-documented feature with good test coverage of the flag wiring, cookie-origin allowlist, and bootstrap-trust logic. Left 4 inline comments, most important first:
Also note: No provider/model/route-selection code is touched by this PR, so EPIC #2608 invariants don't apply here. Not run: Branch: |
| tailscale::TailscalePublish::Embedded(node) => { | ||
| let handle = node.spawn_http80(app.clone()).await?; | ||
| tailscale_guard.arm_embedded(handle); | ||
| } |
There was a problem hiding this comment.
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.
| { | ||
| 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; |
There was a problem hiding this comment.
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.
| #[cfg(all( | ||
| feature = "tailscale", | ||
| any( | ||
| all( | ||
| target_os = "linux", | ||
| any(target_arch = "x86_64", target_arch = "aarch64") | ||
| ), | ||
| all(target_os = "macos", target_arch = "aarch64") | ||
| ) | ||
| ))] |
There was a problem hiding this comment.
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.
| let magic_dns = node.fqdn_opt(false).filter(|dns| dns.contains('.')).unwrap_or_else(|| { | ||
| node.fqdn(false) | ||
| }); |
There was a problem hiding this comment.
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 finished @Hmbown's task in 59s —— View job Claude's review of PR #5635 (update after synchronize)Todo list
SummaryNew commit since the last review pass is All 4 inline findings from the previous review pass (job run) still apply verbatim against current HEAD:
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: Branch: |
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>
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>
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>
Summary
codewhale web --tailscaleon top of origin/main. Defaultcodewhale webstays loopback-only (127.0.0.1).--tailscalewithout--webis rejected (claprequires = "web"plus runtime validation).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.iotailscale0.5.0 crate (Device::new,Device::tcp_listen,tailscale::axum::Listener,Config.requested_hostname = "codewhale"→codewhale.<tailnet>.ts.netviaNodeInfo::fqdn). Cookie/CORS*.ts.netorigin allowlisting is copied from 5628'sruntime_api/auth.rspattern.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--tailscaleflag falls back to 5628's CLI serve (browser-trusted TLS on the machine MagicDNS name).geiserx_tailscalewas not needed because official already hastcp_listen.tailscaleoptional oncodewhale-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,--tailscalewithout--web, MagicDNS helpers)cargo test -p codewhale-cli --lib -- web_command web_tailscale serve_help_documentscargo check -p codewhale-tui --features tailscale(official crate API compiles)codewhale webstill binds loopback onlycodewhale web --tailscalewith Tailscale CLI connected uses CLI serve fallback in a default (no-feature) build--features tailscaleand setTS_AUTHKEYto gethttp://codewhale.<tailnet>.ts.netMade with Cursor
No-Issue: Opt-in embedded tsnet for codewhale web; precursor design from #5628; no tracking issue to close.