Skip to content

feat(control): advertise and decode zstd-compressed map responses#257

Merged
GeiserX merged 2 commits into
mainfrom
feat/mapresponse-zstd-compress
Jun 15, 2026
Merged

feat(control): advertise and decode zstd-compressed map responses#257
GeiserX merged 2 commits into
mainfrom
feat/mapresponse-zstd-compress

Conversation

@GeiserX

@GeiserX GeiserX commented Jun 15, 2026

Copy link
Copy Markdown
Owner

What

Make the streaming map poll negotiate and decode zstd-compressed MapResponse frames, matching the Go control client.

  • Request side: MapRequestBuilder::new now sets Compress = "zstd" on every map request. Go's control/controlclient/direct.go sets request.Compress = "zstd" unconditionally for all client builds; an empty Compress is an observable not-Go fingerprint.
  • Response side: the map-poll frame reader (tokio::map_stream) decompresses each frame before deserializing, mirroring Go's per-frame zstdframe.AppendDecode.

How

  • A new decompress_frame helper recognizes the zstd magic (0x28 0xb5 0x2f 0xfd) and stream-decompresses via ruzstd::decoding::StreamingDecoder.
  • Two independent bounds: the existing 16 MiB cap now bounds the compressed on-wire frame; a new MAX_DECODED_NETMAP (64 MiB) bounds the decompressed output as a zip-bomb / decompression-amplification guard (reads one byte past the limit and rejects). ruzstd additionally rejects an over-large declared window at frame init, so neither the window header nor the output can drive an unbounded allocation.
  • Graceful degradation: a frame that does not begin with the zstd magic (a control plane that ignored the Compress request and replied uncompressed) is parsed verbatim — no silent stall, and zero wire-fingerprint cost since the request is byte-identical either way.
  • ruzstd is pure-Rust (no C zstd-sys, no aws-lc/openssl/ring), so the ring-only / musl-static egress posture is unchanged. Decode-only.
  • Also prunes the now-dead stun-rs/dashmap lock entries left orphaned after the earlier StunProber removal, so a --locked build matches the manifests.

Tests

  • Cross-implementation interop KAT — a zstd frame produced by a foreign encoder (reference zstd CLI v1.5.7) decodes to the original JSON and drives a StateUpdate. This is the property that matters: we decode control's (Go's) output, not just frames our own encoder round-trips.
  • Self-compressed decode, uncompressed graceful-degradation decode, malformed-frame rejection, zip-bomb rejection (decompressed > 64 MiB ends the stream, never allocates), and the builder Compress = "zstd" assertion (value + wire key).
  • Verified live against Tailscale SaaS: a fresh ephemeral node joined controlplane.tailscale.com, was assigned a CGNAT IPv4, and decoded the full real Go-encoded zstd netmap — 17 peers, self-node, status, and MagicDNS resolution all correct.

Gates (local, all green)

cargo test -p geiserx_ts_control (240) · -p geiserx_ts_runtime (328) · clippy --all-targets -- -D warnings · fmt --check · cargo doc (broken_intra_doc_links=deny) · cargo run -p checks · --features tun cross-build · cargo deny check all (advisories/bans/licenses/sources ok).

Signed-off-by: Sergio sergio@geiser.cloud

Created using Claude Code (Opus 4.8)


Cargo.lock note

Beyond adding ruzstd/twox-hash, the lockfile diff also (a) prunes stun-rs/dashmap/precis-* entries orphaned by the earlier StunProber removal and (b) resyncs the workspace-crate versions 0.32.00.39.0 that had drifted stale on main (the manifest already declares 0.39.0). Both are correct reconciliations that any cargo build regenerates; surfaced here so the lock diff's size is expected. cargo metadata --locked passes.

Review pass

Reviewed by a 3-lens panel (security / correctness+parity / simplicity) — verdict APPROVE, zero critical/high. Follow-up commit corrected the MAX_DECODED_NETMAP doc (the zip-bomb guard is the take(N+1) output cap; ruzstd allocates its window lazily from zero, verified in source + empirically — a multi-TB declared window drives no eager allocation) and added accept-side-boundary + mid-stream-failure tests.

Summary by CodeRabbit

  • New Features

    • Added zstd compression support for network responses to reduce payload sizes
    • Implemented decompression with safeguards against decompression attacks
    • Maintains backward compatibility with uncompressed responses
  • Tests

    • Added comprehensive test coverage for compression scenarios and fallback behavior

Set Compress to zstd on every map request and decompress each streaming
map-poll frame before deserializing, matching the Go control client which
sets request.Compress unconditionally and zstd-decodes every response frame.
An empty Compress is an observable not-Go fingerprint, and a control plane
honoring the request replies with per-frame zstd that the reader must decode.

The frame reader recognizes the zstd magic, stream-decompresses with a
separate decoded-size bound (64 MiB zip-bomb guard, independent of the
on-wire compressed-frame cap), and falls back to parsing an uncompressed
body verbatim when control ignores the request. ruzstd is pure-Rust, so the
ring-only, musl-static egress posture is unchanged.

Also prunes the now-dead stun-rs/dashmap lock entries left orphaned after the
StunProber removal so a locked build matches the manifests.

Tests: a foreign-encoder interop KAT (reference zstd CLI frame), self- and
uncompressed-frame decode, malformed-frame and zip-bomb rejection, and the
builder Compress assertion. Verified live against Tailscale SaaS: the node
joined and decoded the full real-zstd netmap (17 peers, self-node, MagicDNS).

Signed-off-by: GeiserX <9169332+GeiserX@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d84296ea-3043-4c8d-88c7-7125024d9f0a

📥 Commits

Reviewing files that changed from the base of the PR and between 26d5abc and f48a453.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock, !**/Cargo.lock
📒 Files selected for processing (4)
  • Cargo.toml
  • ts_control/Cargo.toml
  • ts_control/src/map_request_builder.rs
  • ts_control/src/tokio/map_stream.rs

📝 Walkthrough

Walkthrough

Adds ruzstd as a pure-Rust workspace dependency, sets compress = "zstd" as the default in MapRequestBuilder::new, and introduces a decompress_frame helper in map_stream with a MAX_DECODED_NETMAP amplification guard. Non-zstd frames pass through unchanged. Tests cover interop, fallback, amplification rejection, and malformed frame handling.

Changes

zstd Map-Poll Decompression

Layer / File(s) Summary
ruzstd dependency wiring
Cargo.toml, ts_control/Cargo.toml
Adds ruzstd = "0.8" to the workspace and ruzstd.workspace = true to ts_control with comments documenting decode-only, musl-static usage.
MapRequestBuilder advertises zstd
ts_control/src/map_request_builder.rs
MapRequestBuilder::new sets compress to "zstd" by default; a new test asserts the value and verifies the Compress JSON key is emitted.
decompress_frame, size cap, and map_stream integration
ts_control/src/tokio/map_stream.rs
Adds MAX_DECODED_NETMAP cap and ZSTD_MAGIC constant, implements decompress_frame with passthrough fallback and a MAX_DECODED_NETMAP+1 streaming read guard, wires it into map_stream before JSON deserialization, and adds/reworks test helpers and a large zstd test suite covering interop, fallback, zip-bomb rejection, and malformed frame stream termination.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title precisely reflects the main change: adding zstd compression advertisement and decoding to map responses, matching the core PR objective.
Description check ✅ Passed The description covers the What/How/Tests structure, explains implementation details, bounds checking, backward compatibility, and verification. It comprehensively documents the change and rationale.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/mapresponse-zstd-compress

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 and usage tips.

…ests

Review follow-up. The MAX_DECODED_NETMAP doc claimed ruzstd rejects an
over-large declared window at frame-init; that guard lives in the decoder
reuse path, not the one-shot StreamingDecoder::new path this code takes.
Verified in ruzstd source that the ring buffer allocates lazily from zero, so
a declared multi-terabyte window drives no eager allocation and the take(N+1)
output cap is the binding bound. Reword to state that accurately, including the
transient peak-memory characteristic, and document why the output Vec is grown
on demand rather than pre-reserved to the cap.

Add two edge-case tests: a large in-bounds decoded frame is accepted (pins no
off-by-one wrongly rejects a big netmap) and a good-then-malformed two-frame
stream ends cleanly after the first frame was delivered.

Signed-off-by: GeiserX <9169332+GeiserX@users.noreply.github.com>
@GeiserX GeiserX merged commit cc51772 into main Jun 15, 2026
18 of 25 checks passed
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