Skip to content

feat: transparent tailnet http routing via httputil#3

Merged
smithclay merged 2 commits into
mainfrom
httputil-support
Jun 1, 2026
Merged

feat: transparent tailnet http routing via httputil#3
smithclay merged 2 commits into
mainfrom
httputil-support

Conversation

@smithclay
Copy link
Copy Markdown
Collaborator

@smithclay smithclay commented May 31, 2026

What

After tailscale_up, QuackScale wraps DuckDB's global HTTPUtil so any HTTP request to a tailnet host (100.64.0.0/10, *.ts.net) is dialed over the embedded tsnet node. Clients can ATTACH 'quack:100.x.x.x:9494' (or hit any read_csv/httpfs URL on the tailnet) directly — no tailscale_quack_forward.

Auto-installed on tailscale_up/tailscale_login; opt out with http_route => false. It coexists with the existing forwarder (disjoint by host: IsTailnetHost matches only 100.64/10 + *.ts.net, never the forwarder's 127.0.0.1).

Based on duckdb-tailscale

The HTTPUtil-wrapping approach is ported from smithclay/duckdb-tailscale (src/tailscale_http.cpp), rebased onto QuackScale's TailscaleBridge. This PR extends it with:

  • Keep-alive — persistent connection per peer, Content-Length and chunked transfer-encoding framing, redial-once on a stale pooled connection, bounded idle-client pool (vs duckdb-tailscale's one-request-per-connection Connection: close).
  • Forwarder coexistence — the transparent router and tailscale_quack_forward run side by side; the forwarder stays for MagicDNS short names, pinned loopback ports, and non-HTTP clients.

Changes

  • src/tailscale_http.{hpp,cpp}TailscaleHTTPUtil (wraps the previous util, delegates non-tailnet traffic to httpfs) + keep-alive TailscaleHTTPClient.
  • TailscaleBridge::DialTCP — tsnet dial that releases the handle lock before dialing, so httpfs's parallel range reads aren't serialized.
  • quackscale_extension.cpphttp_route parameter (default true); router installed on up/login.
  • quack_uri() / quack_discover() — prefer the routable 100.x IPv4 (the MagicDNS short name stays as a labeled extra; it isn't reachable by the transparent path, only the forwarder).
  • e2e — capability-gated ROUTER_PASSED probe doing a direct ATTACH over the router, gated so the release-binary e2e (which predates this) still passes.
  • docs — README + GUIDE document the router as the default path and the forwarder as the fallback.

Validation

  • ✅ Builds clean (static + loadable); offline make test 24/24.
  • Verified on real Tailscale — two tsnet nodes; the client ATTACH 'quack:100.x:9494' with no forwarder returned the server row, and quack_uri() returned the 100.x IP.
  • ⚠️ Keep-alive reuse under heavy / ranged load not yet stress-tested.

Follow-ups

  • Windows: the HTTP client is POSIX-only (throws), matching the forwarder.
  • content_handler buffers the whole body (no streaming yet).
  • The idle client pool's fds aren't flushed on tailscale_down.

Opened as draft pending the compose e2e run + a keep-alive stress check.

Wrap DuckDB's global HTTPUtil so HTTP to tailnet hosts (100.64.0.0/10,
*.ts.net) is dialed over the embedded tsnet node, letting clients
ATTACH 'quack:100.x:9494' directly with no tailscale_quack_forward.
Auto-installed on tailscale_up/login (opt out: http_route => false);
coexists with the forwarder (disjoint by host).

The keep-alive client holds one connection per peer, frames responses by
Content-Length or chunked transfer-encoding, redials once on a stale
pooled connection, and pools idle clients. quack_uri()/quack_discover()
now prefer the routable 100.x IPv4.

Ported from smithclay/duckdb-tailscale, adding keep-alive and forwarder
coexistence. Verified on real Tailscale (direct ATTACH, no forwarder).
@CLAassistant
Copy link
Copy Markdown

CLAassistant commented May 31, 2026

CLA assistant check
All committers have signed the CLA.

@lmangani lmangani marked this pull request as ready for review June 1, 2026 11:29
@lmangani
Copy link
Copy Markdown
Contributor

lmangani commented Jun 1, 2026

⚠️ The Headscale Docker compose loop (the ROUTER_PASSED script wiring) was not run end-to-end locally — only bash -n / shellcheck + the capability gate against the built binary. headscale-e2e.yml runs a release binary that predates the feature, so the probe is gated off there.

Pre-release v1.0.4 builds with this PR for testing in CI https://github.com/Query-farm/quackscale/releases/tag/v1.0.4

Address PR review findings on the transparent router:
- ToHTTPResponse sets success/request_error from the status, so 4xx/5xx (and
  unparseable status lines) are no longer reported as successful fetches.
- RoundTrip only retries idempotent requests (no body); a POST/PUT body is never
  resent on a reused-connection failure.
- IsTailnetHost no longer intercepts https:// URLs — those go to httpfs (real
  TLS) instead of being mis-sent as cleartext to port 80.
- Strict Content-Length / chunk-size parsing (reject non-digits), and distinguish
  a mid-body socket error from a clean EOF so truncated bodies aren't accepted.
- Close a kept-alive connection if the peer over-sends past Content-Length.
- e2e capability gate fails loudly if quackscale can't load (vs silently skipping
  the router probe); probe enable/skip is now logged in bootstrap and entrypoint.
- Comment/doc fixes (RegisterTailscaleHTTPUtil call sites, response framing,
  DialTCP concurrency rationale, RoutableTailnetIP, bootstrap probe rationale).

Verified on real Tailscale (direct ATTACH still passes) + offline tests 24/24.
@smithclay smithclay merged commit 88821d7 into main Jun 1, 2026
3 checks passed
@lmangani
Copy link
Copy Markdown
Contributor

lmangani commented Jun 1, 2026

Fantastic @smithclay 🎉 does the README need to be updated too perhaps?

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.

3 participants