{"id":51002116,"url":"https://github.com/yeet-src/grpcsnoop","last_synced_at":"2026-06-20T15:33:13.657Z","repository":{"id":362132247,"uuid":"1257502389","full_name":"yeet-src/grpcsnoop","owner":"yeet-src","description":null,"archived":false,"fork":false,"pushed_at":"2026-06-02T21:17:30.000Z","size":47,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-02T21:23:36.633Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/yeet-src.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-02T18:34:37.000Z","updated_at":"2026-06-02T21:17:34.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/yeet-src/grpcsnoop","commit_stats":null,"previous_names":["yeet-src/protosnoop"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/yeet-src/grpcsnoop","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/yeet-src%2Fgrpcsnoop","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/yeet-src%2Fgrpcsnoop/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/yeet-src%2Fgrpcsnoop/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/yeet-src%2Fgrpcsnoop/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/yeet-src","download_url":"https://codeload.github.com/yeet-src/grpcsnoop/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/yeet-src%2Fgrpcsnoop/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34576043,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-20T02:00:06.407Z","response_time":98,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-06-20T15:33:13.596Z","updated_at":"2026-06-20T15:33:13.648Z","avatar_url":"https://github.com/yeet-src.png","language":"JavaScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# `grpcsnoop`\n\n\u003e **`tcpdump` for gRPC.** Watch the protobuf messages flowing between services, decoded to readable fields.\n\n\u003cp align=\"center\"\u003e\n  \u003cimg src=\"https://img.shields.io/badge/platform-Linux-1793D1\" alt=\"Linux\"\u003e\n  \u003cimg src=\"https://img.shields.io/badge/built%20with-yeet%20%2B%20eBPF-8A2BE2\" alt=\"yeet + eBPF\"\u003e\n  \u003cimg src=\"https://img.shields.io/badge/license-GPL--2.0-3DA639\" alt=\"GPL-2.0\"\u003e\n  \u003ca href=\"https://discord.gg/dYZu9PjKB\"\u003e\u003cimg src=\"https://img.shields.io/badge/chat-Discord-5865F2\" alt=\"Discord\"\u003e\u003c/a\u003e\n\u003c/p\u003e\n\n![grpcsnoop demo](assets/grpcsnoop.gif)\n\n**`grpcsnoop` turns the plaintext gRPC/protobuf flowing between containers into a live, decoded feed in your terminal.**\n\n\u003e [!TIP]\n\u003e **You can't `tcpdump` this.** gRPC is protobuf framed inside HTTP/2: binary, length-prefixed, header-compressed. Unencrypted, it's unreadable on the wire. Encrypted, a packet capture is just ciphertext. `grpcsnoop` hooks the TC layer on the container's veth, reads the plaintext payload with `bpf_skb_load_bytes`, reassembles TCP, and unwinds all three layers.\n\n## Quick start\n\n```sh\ncurl -fsSL https://yeet.cx | sh\nyeet run github:yeet-src/grpcsnoop -- --port 50051\n```\n\u003csub\u003e[Manual install guide](https://yeet.cx/docs/installation) | Linux only\u003c/sub\u003e\n\nEverything after `--` is passed to grpcsnoop. Useful flags:\n\n- `--port \u003cn\u003e` — the gRPC port to watch (required).\n- `--ifindex \u003cn\u003e` — pin to one interface (find it with `ip link`). Default: all interfaces.\n- `--hex` — also dump the raw bytes of each message.\n\nTry it against the bundled demo (two containers on a Docker bridge, talking plaintext gRPC):\n\n```sh\ngit clone https://github.com/yeet-src/grpcsnoop \u0026\u0026 cd grpcsnoop\nbash demo/up.sh\nyeet run . --port 50051\nbash demo/down.sh\n```\n\n## A 60-second primer on gRPC\n\ngRPC looks like one thing but it's three layers stacked, which is why a raw capture is useless:\n\n**1. protobuf — the message.** Your `EchoRequest{ message: \"hi\", repeat: 3 }` is encoded as a compact binary blob: a sequence of `(field-number, wire-type, value)` tuples. No field names, no types on the wire. `message` becomes \"field 1, length-delimited\"; `repeat` becomes \"field 2, varint\". To read it you walk the wire format; to *name* the fields you need the `.proto`.\n\n**2. gRPC framing — the envelope.** Each message gets a 5-byte prefix: 1 compression flag + a 4-byte big-endian length. That's how the receiver knows where one message ends.\n\n**3. HTTP/2 — the transport.** Those framed messages ride inside HTTP/2 `DATA` frames, multiplexed across streams, with request metadata (the `:path` that names the RPC method) in HPACK-compressed `HEADERS` frames. In production, usually wrapped in TLS.\n\nSo a `tcpdump` gives you TLS ciphertext (if encrypted) or, at best, opaque HTTP/2 byte soup. `grpcsnoop` captures the plaintext payload and unwinds all three layers.\n\n**Wire types** you'll see in the output:\n\n| Wire type | Used for |\n|---|---|\n| varint | ints, bools, enums |\n| 64-bit | doubles, fixed64 |\n| length-delimited | strings, bytes, **nested messages**, packed repeated |\n| 32-bit | floats, fixed32 |\n\n## Common use cases\n\n`grpcsnoop` is for developers and SREs debugging gRPC traffic between services they don't fully control on either end.\n\n- A service returns the wrong field. Logs don't show the payload. What's actually on the wire?\n- A new client is integrating against an existing service. Is it sending the fields it claims to?\n- An undocumented internal gRPC API needs reverse engineering. What does real east-west traffic look like?\n- A schema mismatch is suspected. Confirm before blaming the server.\n\n## What you're looking at\n\nEach captured message is one decoded protobuf, tagged with direction and the TCP flow. With a schema (this repo ships one for the demo, see below), you get names, real scalar types, nested messages, and enums:\n\n```\n→ REQ  10.89.0.3:43210 → 10.89.0.2:50051  stream:1  20b\n  EchoRequest\n  message (string) \"hello protosnoop\"\n  repeat (int32) 3\n← RESP 10.89.0.2:50051 → 10.89.0.3:43210  stream:7  161b\n  ListResponse\n  items Item {\n    name (string) \"Widget A\"\n    price (double) 9.99\n    status (Status) ACTIVE\n  }\n  ...\n```\n\nWithout a schema, fields show by number and wire type (`field 1 (string) \"…\"`).\n\n- **`→ REQ` / `← RESP`** — direction, inferred from which side owns the gRPC port.\n- **flow + `stream:N`** — the TCP 4-tuple and HTTP/2 stream id, so you can follow one RPC. A real `stream:N` means the bytes came from an HTTP/2 DATA frame (confirmed gRPC).\n- **fields** — protobuf, named via the schema or by number. Nested messages indent. `repeated` fields repeat. Non-UTF-8 bytes fall back to hex. Pass `--hex` to also dump raw bytes.\n\n## How it works\n\nThe technical core is in [`grpcsnoop.bpf.c`](grpcsnoop.bpf.c) and [`main.js`](main.js).\n\n### The BPF side\n\nOne BPF object attaches two SchedCls programs via `tcx`:\n\n| Program | Hook | What it does |\n|---|---|---|\n| `on_ingress` | `tcx/ingress` | Read every inbound TCP segment's payload via `bpf_skb_load_bytes` (handles nonlinear skbs, unlike a raw `skb-\u003edata` read), filter by port, push the segment + 4-tuple + seq up the ring buffer. |\n| `on_egress` | `tcx/egress` | Same, for outbound segments. |\n\nTwo maps connect kernel to userspace:\n\n- `events` — `BPF_MAP_TYPE_RINGBUF` (8 MiB), one event per captured segment payload (up to 8 KiB per segment).\n- `port_set` — `BPF_MAP_TYPE_HASH` keyed by port, populated from JS at startup. Lets the BPF side cheaply skip non-gRPC traffic.\n\nBoth programs return `TCX_NEXT`, so they're passive observers: the packet passes through untouched.\n\n### The JS side\n\n- `main.js` — attaches the TCX programs, writes the target port into `port_set`, subscribes to the ringbuf.\n- `tcpstream.js` — TCP reassembly. Orders segments by seq, dedupes copies seen across veths, walks HTTP/2 frames incrementally.\n- `proto.js` — protobuf wire decoder. Schema-free (field numbers) or, when `schema.js` is present, named/typed via content-aware best-fit.\n- `schema.js` — generated proto schema. Rebuild with `make schema PROTO=yours.proto`.\n- `render.js` — ANSI output and the hex dump.\n\n### Why TCX, not a syscall hook\n\nTCX hooks are per-interface, so the same setup catches gRPC across containers without naming a PID or a process. The trade-off is that `grpcsnoop` reassembles the TCP stream in JS (`tcpstream.js`) rather than getting message boundaries from the syscall. For gRPC across container boundaries, that's the right trade.\n\n## Requirements\n\n\u003e [!IMPORTANT]\n\u003e Linux kernel **6.6 or newer** for `tcx` attach, with BTF (`CONFIG_DEBUG_INFO_BTF=y`). Default on current Arch, Fedora, Ubuntu 24.04+, and Debian 13+.\n\u003e\n\u003e The yeet daemon, which handles the privileged BPF load. `curl -fsSL https://yeet.cx | sh` installs it.\n\n## Honest caveats\n\n\u003e [!NOTE]\n\u003e What `grpcsnoop` doesn't do, and what it gets wrong.\n\n- **Plaintext only.** If the gRPC channel uses TLS, the TC hook sees ciphertext on the wire. Reading TLS'd gRPC needs an in-process uprobe *before* encryption (a different hook). This tool is for mesh-internal traffic with TLS terminated at the edge, or any east-west path that isn't encrypted.\n- **Not loopback.** `bpf_program__attach_tcx` returns `EINVAL` on `lo`, so same-host `localhost` traffic isn't visible. Traffic has to cross a real interface (a container veth, as in the demo).\n- **Big or split messages.** TCP segments are reassembled, but gRPC is extracted per HTTP/2 DATA frame, so a single message split across multiple DATA frames is the known gap.\n- **No RPC method name.** HPACK header decoding isn't implemented, so the `:path` (e.g. `/svc/Method`) isn't shown. Only the message bodies and (via schema best-fit) the message type.\n- **Best-fit ambiguity.** Without the `:path`, the message *type* is inferred by matching field numbers, wire-types, and content. Look-alike messages (e.g. two single-`int32` requests) can still be misattributed.\n\n## Community questions\n\n**Why not just use Wireshark?**\nWireshark has a great gRPC dissector, if you can hand it the TLS keys and the `.proto`. `grpcsnoop` is for the case where you can't: it reads plaintext at the kernel boundary and decodes with one command, no capture file, no app changes.\n\n**Does it work with Go, Rust, or any language?**\nYes. It hooks the kernel, not a library, so it's language-agnostic as long as the traffic is plaintext. This is also why a libssl-uprobe approach doesn't generalize: Go and Rust don't use libssl.\n\n**Will it interfere with the traffic?**\nNo. The TCX programs return `TCX_NEXT`, so the packet passes through untouched.\n\n**Do I need the `.proto` file?**\nNo. Without it you get field numbers, wire types, and full structure. With it, you get field names and exact scalar types. Generate a schema module once:\n\n```sh\nmake schema PROTO=demo/test.proto   # writes schema.js\n```\n\nThen `grpcsnoop` imports `schema.js` and decodes against it. The message type isn't on the wire, so it's matched by best-fit; for anything it can't confidently match it falls back to field numbers, so a stale schema degrades gracefully rather than lying.\n\n**How do I scope it to one container?**\nBy default it attaches to all interfaces and filters by port. To pin it to one container's host-side veth, pass `--ifindex N` (find it with `ip link`).\n\n## The demo\n\n`demo/` runs two containers (a gRPC server and a looping client) on a Docker bridge, the same `veth ↔ bridge` plumbing the TCX hook needs. See [`demo/README.md`](demo/README.md):\n\n```sh\nbash demo/up.sh          # build image, start ps-server + ps-client\nyeet run . --port 50051  # watch the decoded gRPC (from the repo root)\nbash demo/down.sh\n```\n\n`testservice/` holds the same `.proto` and app, plus the poetry env `make schema` uses for `grpcio-tools`.\n\n## Building from source\n\n```sh\nmake          # generates vmlinux.h, builds grpcsnoop.bpf.o\nmake clean\n```\n\nNeeds `clang` (BPF target) and `bpftool`. The generated `vmlinux.h` and `*.bpf.o` are gitignored.\n\n## License\n\n[GPL-2.0](LICENSE). The BPF program declares `SEC(\"license\") = \"GPL\"` in [`grpcsnoop.bpf.c`](grpcsnoop.bpf.c), required for the kernel helpers it uses.\n\n---\n\nBuilt with [yeet](https://yeet.cx/docs/?utm_source=github\u0026utm_medium=readme\u0026utm_campaign=grpcsnoop), a JS runtime for writing eBPF programs on Linux machines. Join us on [discord](https://discord.gg/dYZu9PjKB?utm_source=github\u0026utm_medium=readme\u0026utm_campaign=grpcsnoop).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fyeet-src%2Fgrpcsnoop","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fyeet-src%2Fgrpcsnoop","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fyeet-src%2Fgrpcsnoop/lists"}