diff --git a/README.md b/README.md index 4254c59..bbfe571 100644 --- a/README.md +++ b/README.md @@ -1,180 +1,240 @@ # `httpwatch` -> **`httpinspect`, served to your browser.** Every plaintext HTTP request crossing the box, decoded off the wire by eBPF and rendered live in the browser. No proxy, no sidecar, no app changes — one Docker command. +> **Every plaintext HTTP request crossing your host, live in a browser tab.** Decoded off the wire by eBPF. No proxy, no sidecar, no app changes, one container.

- Linux - yeet + eBPF - native browser components - GPL-2.0 + Linux: kernel 6.6+ with BTF and TCX + Built with yeet + eBPF + Native browser components, no framework and no CDN + GPL-2.0 + Discord

- httpwatch — a live HTTP endpoint dashboard in the browser + httpwatch: a live HTTP endpoint dashboard in the browser

-**httpwatch turns the plaintext HTTP crossing your host into a live web dashboard.** Every `METHOD host path` endpoint, ranked by traffic, with a running count, req/s, and p95 latency. Click a row for a live detail panel — that route's individual requests newest-first and color-coded by status class, plus percentiles, a status-code mix, and a req/s sparkline. Same eBPF capture as [`httpinspect`](https://github.com/yeet-src/httpinspect), rendered in the browser instead of a terminal. +**httpwatch is an eBPF HTTP traffic inspector for Linux: it ranks every `METHOD host path` crossing a host by traffic and lets you click one open and read the actual requests.** Count, req/s, p95 latency, status mix, and a live request stream you can click into for the decoded headers and body. A 500 shows you the error it returned, not just the number. + +It captures at the kernel's TC layer, so it sees what actually crossed the wire, including loopback. Your app's own access log tells you what your app thinks it served. This tells you what the box did. + +Where you'd otherwise reach for `tcpdump` piped into Wireshark, or bolt a sidecar proxy in front of a service to see its traffic, httpwatch attaches to the interfaces already there. Nothing is rerouted and nothing gets reconfigured. > [!TIP] -> **It's not a TUI piped to the browser.** The probe ships raw aggregated JSON out of the yeet isolate; the page receives it over SSE and draws its own table, panels, and sparklines in plain DOM — no framework, no CDN, no terminal emulator. +> **It's not a TUI piped to a browser.** The probe ships raw aggregated JSON out of the yeet isolate; the page receives it over SSE and draws its own table, panels and sparklines in plain DOM. No framework, no CDN, no terminal emulator. + +## Questions this tool answers + +**How can I see what HTTP requests my server is actually receiving, without changing the application?** +Run httpwatch on the box. Every request crossing any interface is decoded at the kernel's TC layer and ranked live by `METHOD host path`, with nothing added to the request path and no redeploy. What you get is what the machine received, which is not always what the app's access log says it served. + +**One of my endpoints is returning 500s and the logs aren't telling me why. How do I see the actual error response?** +Click the endpoint, then click the request in its stream. You get the status line, headers and body, dechunked, gunzipped and JSON pretty-printed, so a 500 shows the error it returned instead of just the number. Set `BODIES=both` and you get the request body too, which is usually what you need to read a 400. + +**My service is slow and I don't know which endpoint is causing it. How do I find out?** +Sort the table by `p95` and the tail contributors come to the top; sort by `REQ/S` to catch a spike that hasn't accumulated volume yet, which is the shape a retry storm has before it shows up in totals. Open a row for its full percentile spread and its live request stream. Latency here is on-the-wire, so for a remote caller it includes network RTT: that's what the client experienced, not what the handler spent. + +**How do I inspect HTTP traffic on a Linux server without tcpdump or Wireshark?** +httpwatch decodes and aggregates continuously and serves the result to a browser, so there's no capture file to open afterwards and no desktop needed. You can read a remote box from your laptop over a tailnet with no SSH session and no X forwarding. For anything non-HTTP, or for the packet detail this deliberately throws away, those two are still the right tools. + +**Can I monitor HTTP traffic with eBPF without adding a proxy, a sidecar, or an agent to my app?** +Yes, and that's the whole design. eBPF programs attach at `tcx/ingress` and `tcx/egress` and observe segments as the kernel moves them. Nothing is routed through httpwatch, no port is pointed at it, no application is reconfigured, and traffic is copied rather than held, modified or redirected. A service doesn't know it's being watched. + +**How do I see the traffic between two services running on the same host?** +Watch `lo`. Loopback crosses the TC layer like anything else, so east-west chatter on one box is captured without instrumenting either side. This is the traffic a sidecar proxy sees only half of and a network tap misses entirely. + +**How do I get request rate, error rate and latency for a route nobody instrumented?** +All three fall out of the capture: `REQ/S`, the per-class status tally, and p50 / p95 / max from pairing each response to its request on the wire. Three of the four golden signals with no client library, no exporter and no code change. Saturation isn't one of them, since httpwatch measures traffic rather than the resources serving it. + +**How do I get a Slack alert when an endpoint starts throwing 5xx?** +Set a rule on the endpoint, or a catchall across every endpoint on the host, and point it at a channel. It fires on the next tick carrying the specific codes, the contributing endpoints, and optionally the response body, so the alert delivers the error instead of just announcing one. See [Alerts to Slack](#alerts-to-slack). + +**Why can't I see my HTTPS traffic?** +Because TLS encrypts the payload before it reaches the wire, so at this layer there's no request line to parse. That's a property of capturing at the TC layer, not a limitation a flag turns off. Reading it would need a uprobe on `SSL_write`/`SSL_read`, which is a different tool. See [What it can't see](#what-it-cant-see). + +**Is this a replacement for Datadog, Prometheus, or my APM?** +No, and it isn't trying to be. There's no retention, no query language and no history beyond what's in memory, so it answers "what is this box doing right now," not "what happened last Tuesday." It's what you reach for when the dashboards say something is wrong and you need the actual bytes. One instance per host, too: there's no aggregation layer, so fleet-wide questions stay with your metrics stack. + +**When should I use this instead of tcpdump, mitmproxy, or an eBPF platform like Pixie?** +Use httpwatch when you want decoded HTTP for a whole Linux box, right now, with one `docker run` and nothing in the request path. Reach for something else when: you need HTTPS (mitmproxy terminates TLS and can decrypt it, at the cost of being a proxy in the path), you need non-HTTP protocols or packet-level detail (`tcpdump` and Wireshark), you need HTTP/2 or gRPC (see [`grpcsnoop`](https://github.com/yeet-src/grpcsnoop)), or you need cluster-wide service maps and retention across a fleet (Pixie, Coroot, or your APM). httpwatch is deliberately one box, one protocol, no storage, and that's what makes it something you can run in thirty seconds and turn off when you're done. ## Contents -**Run it** — [Quick start (Linux)](#quick-start) · [macOS](#running-on-macos-docker-desktop) · [From source](#from-source) +**Run it** — [Have an agent set it up](#have-an-agent-set-it-up) · [Run it with Docker](#run-it-with-docker) · [macOS](#running-on-macos-docker-desktop) · [From source](#from-source) + +**Learn it** — [Questions this tool answers](#questions-this-tool-answers) · [A 30-second primer](#a-30-second-primer-on-http-on-the-wire) · [What you're looking at](#what-youre-looking-at) · [Alerts to Slack](#alerts-to-slack) · [Without a browser](#reading-it-without-a-browser) -**Learn it** — [HTTP-on-the-wire primer](#a-30-second-primer-on-http-on-the-wire) · [Use cases](#common-use-cases) · [What you're looking at](#what-youre-looking-at) · [Without a browser](#reading-it-without-a-browser) · [How it works](#how-it-works) +**Reference** — [How it works](#how-it-works) · [Environment](#environment) · [Requirements](#requirements) · [What it can't see](#what-it-cant-see) · [FAQ](#faq) · [License](#license) -**Reference** — [Building from source](#building-from-source) · [Try it without traffic](#try-it-without-real-traffic) · [What the dashboard reports](#what-the-dashboard-reports) · [Requirements](#requirements) · [FAQ](#community-questions) · [License](#license) +## Have an agent set it up -## Quick start +Paste this into a coding agent on the box you want to watch. It runs the dashboard, gives it something to capture, and tells you whether it actually worked. -One command — no clone, no build. The image is multi-arch, so it pulls the right build for amd64 or arm64 automatically: +```text +Get https://github.com/yeet-src/httpwatch running on this machine, then tell me +whether it actually works. + +Run the prebuilt image (ghcr.io/yeet-src/httpwatch:latest) — the exact docker run +line, with all the capability flags, is in the README under "Run it with Docker". +Don't drop a flag to make it start; each one is load-bearing and the failure +modes are quiet. + +Start the traffic generator BEFORE you judge it: `bash agent/demo/traffic.sh` +(clone the repo for that part). An empty dashboard and a broken dashboard look +identical, so there has to be something on the wire first. + +Verify with `curl localhost:8080` — that returns markdown, not HTML, and it +should list endpoints like `GET shop.internal /api/orders` with non-zero counts. +"The container is up" is not the same as "it works". If the table is empty, +check `docker logs httpwatch` for a TCX attach failure, which means the kernel +is older than 6.6. + +This is Linux-only in the sense that matters: on Docker Desktop or OrbStack you +are watching the Linux VM, not the Mac. If that's where you are, say so, add +`-p 8080:8080`, and tell me you're showing me the VM's traffic. +``` + +Prefer to drive it yourself? It's one command. + +## Run it with Docker + +No clone, no build. The image is multi-arch, so it pulls the right build for amd64 or arm64: ```sh docker run --rm -it \ - --cap-add SYS_ADMIN \ - --cap-add NET_ADMIN \ - --cap-add BPF \ - --cap-add PERFMON \ + --cap-add SYS_ADMIN --cap-add NET_ADMIN --cap-add BPF --cap-add PERFMON \ --security-opt apparmor=unconfined \ - --pid=host \ - --network=host \ + --pid=host --network=host \ -v /sys/kernel/btf/vmlinux:/sys/kernel/btf/vmlinux:ro \ - -v "$HOME/.local/state/httpwatch:/data" -e STATE_UID="$(id -u)" -e STATE_GID="$(id -g)" \ + -v "$HOME/.local/state/httpwatch:/data" \ + -e STATE_UID="$(id -u)" -e STATE_GID="$(id -g)" \ ghcr.io/yeet-src/httpwatch:latest # → http://localhost:8080 ``` -`SYS_ADMIN` mounts the container-private bpffs, `NET_ADMIN` attaches the TCX programs, and `BPF`/`PERFMON` load the program and its maps. `apparmor=unconfined` lifts Docker's default profile, which otherwise denies the bpffs `mount` — it's the one thing `--privileged` relaxes that a capability can't. The read-only BTF mount (a world-readable kernel file) lets the probe CO-RE-relocate to your kernel. +Open **http://localhost:8080** and the table fills as plaintext HTTP flows. Add `sudo` if you're not in the `docker` group. -The last line is the only writable mount: it keeps your [Slack alert rules](#alerts) in `~/.local/state/httpwatch/alerts.json` so they survive `--rm`, and the two `STATE_*` vars just make that file yours rather than root's. **Drop that line entirely if you don't want alerts to persist** — everything else works the same, rules simply live and die with the container. +Every flag on that command is doing something, and dropping one mostly fails quietly: -Open **http://localhost:8080** (or `http://:8080` over your network) and the table fills as plaintext HTTP flows. On Linux, add `sudo` if you're not in the `docker` group; on **macOS**, see [Running on macOS](#running-on-macos-docker-desktop). +- **`SYS_ADMIN`** mounts the container-private bpffs. **`NET_ADMIN`** attaches the TCX programs. **`BPF`** and **`PERFMON`** load the program and its maps. +- **`apparmor=unconfined`** lifts Docker's default profile, which denies the bpffs `mount` even with `CAP_SYS_ADMIN`. This is the one thing `--privileged` relaxes that a capability can't, which is why it's here and `--privileged` isn't. +- **`--network=host`** is what makes the capture real: the probe attaches to your host's interfaces and the server binds your host's port. Without it you'd be inspecting an empty container network. +- **The BTF mount** is read-only and a world-readable kernel file. It's what lets the probe CO-RE-relocate to your kernel. +- **The `/data` mount** is the only writable one. It keeps [alert rules](#alerts-to-slack) in `~/.local/state/httpwatch/alerts.json` so they survive `--rm`, and the two `STATE_*` vars make that file yours instead of root's. Drop the line entirely if you don't want alerts to persist; everything else works the same. -Tune it with environment variables (`-e VAR=…`); the interface set and body capture are also editable live in the UI: +For a persistent deployment, swap `--rm -it` for `-d --name httpwatch --restart unless-stopped`, and add `-e PUBLIC_URL="http://$(hostname -f):8080"` so the links in Slack alerts work for people who aren't on the box. -| var | default | meaning | -| ------------ | ------------- | -------------------------------------------------------------------- | -| `PORT` | `8080` | port the dashboard is served on (bound on the host — must be free) | -| `IFACE` | all up ifaces | comma-separated interfaces to watch, e.g. `lo,eth0` (initial set) | -| `KEEP_QUERY` | off | keep query strings distinct — `/x?id=1` and `/x?id=2` stay separate rows | -| `BODIES` | `response` | which message bodies to capture: `none`, `response`, or `both`. `both` captures request bodies too — that's where passwords and tokens live, and anyone who can reach the dashboard can then read them | -| `BODY_STORE_BYTES` | `67108864` | how much captured body the server holds for reading back (64MB). Older exchanges are evicted first; a row whose body is gone says so | -| `BODY_STORE_MAX` | `4000` | how many exchanges the body store holds, whichever limit is reached first | -| `SLACK_CHANNEL` | `#alerts` | channel new alert rules default to | -| `PUBLIC_URL` | learned from the `Host` header | base URL for the "open in httpwatch" link in alerts, e.g. `https://watch.example.com`. A dotted host or IP also gets a real Slack button instead of a plain link — Slack rejects buttons pointing at `localhost` or any dotless name | -| `ALERTS_FILE` | `/data/alerts.json` | where alert rules are persisted inside the container; set to empty to keep them in memory only | -| `STATE` | `~/.local/state/httpwatch` | (make only) host directory bind-mounted at `/data`, so alert rules outlive the container. Any value containing `/` is a host path; a bare name uses a docker volume instead | -| `RECENT_ROWS` | `500` | how many individual exchanges the server keeps for the [markdown views](#reading-it-without-a-browser) (the browser accumulates its own from SSE; a one-shot reader can't). `0` disables the tail | -| `YEET_AUTH_KEY` | — | log the host in at startup | +### Give it something to capture -For a persistent deployment, run it detached and self-healing: +On a quiet box, an empty dashboard is indistinguishable from a broken one. `agent/demo/` is a self-contained loopback traffic source: ```sh -docker run -d \ - --name httpwatch \ - --restart unless-stopped \ - --cap-add SYS_ADMIN \ - --cap-add NET_ADMIN \ - --cap-add BPF \ - --cap-add PERFMON \ - --security-opt apparmor=unconfined \ - --pid=host \ - --network=host \ - -v /sys/kernel/btf/vmlinux:/sys/kernel/btf/vmlinux:ro \ - -v "$HOME/.local/state/httpwatch:/data" -e STATE_UID="$(id -u)" -e STATE_GID="$(id -g)" \ - -e PUBLIC_URL="http://$(hostname -f 2>/dev/null || hostname):8080" \ - ghcr.io/yeet-src/httpwatch:latest +bash agent/demo/traffic.sh # fake server + a steady request mix on 127.0.0.1:8731 ``` -For a long-lived deployment the state mount matters more, not less — it's what keeps alert rules across image updates — and `PUBLIC_URL` makes the links in Slack alerts work for people who aren't on the box. +It starts `demo/server.py` itself, sends a weighted mix of methods, hosts and latencies, prints a heartbeat every couple of seconds, and cleans up on `Ctrl-C`. Don't start `server.py` separately; the script refuses a busy port rather than quietly generating traffic for whatever else is listening. `PORT=9001` moves it. -### Running on macOS (Docker Desktop) +`/api/orders` fails about 8% of the time, so there's something red to click. The bodies are one-liners though. For exercising the response viewer properly, a route returning a real gzipped or chunked error is more interesting. -Docker Desktop runs a Linux VM shared by all your containers, so `--network=host` lets the probe watch your **other containers'** plaintext HTTP — not your Mac's own apps, which live outside the VM. Two things to get right: +### Running on macOS (Docker Desktop) -1. **Update Docker Desktop** — the VM kernel needs TCX (6.6+), or the probe fails to attach with `tcx: -EINVAL`. -2. **Publish the UI port** — `--network=host` captures but doesn't expose the UI to macOS, so add `-p $PORT:$PORT`. +Docker Desktop runs a Linux VM shared by all your containers, so `--network=host` lets the probe watch your **other containers'** plaintext HTTP. Not your Mac's own apps, which live outside the VM. Two things change: -```sh -export PORT=8080 # any free port on your Mac; export so $PORT expands below -docker run --rm -it \ - --cap-add SYS_ADMIN \ - --cap-add NET_ADMIN \ - --cap-add BPF \ - --cap-add PERFMON \ - --security-opt apparmor=unconfined \ - --pid=host \ - --network=host \ - -e PORT=$PORT \ - -p $PORT:$PORT \ - -v /sys/kernel/btf/vmlinux:/sys/kernel/btf/vmlinux:ro \ - ghcr.io/yeet-src/httpwatch:latest # → http://localhost:$PORT -``` +1. **Update Docker Desktop.** The VM kernel needs TCX (6.6+), or the probe fails to attach with `tcx: -EINVAL`. +2. **Publish the port.** `--network=host` captures but doesn't expose the UI to macOS, so add `-p 8080:8080`. ### From source -To build it yourself or hack on it, clone and drive it with the `Makefile` (`VAR=… make run` forwards the same env vars, and it falls back to `sudo docker` automatically): +To build it yourself or hack on it, clone and drive it with the `Makefile`. It forwards the same environment variables and falls back to `sudo docker` automatically: ```sh git clone git@github.com:yeet-src/httpwatch.git && cd httpwatch make run # build the image, run yeetd + server + probe, serve on :8080 -``` - -The first `make run` builds a self-contained image (base, yeet toolchain, eBPF object, yeetd) — a few minutes, internet needed **once**; after that it starts in seconds. Other targets: - -```sh -make up # detached + --restart unless-stopped (persistent deployment) +make up # same, detached and self-healing make down # stop and remove it ``` -## A 30-second primer on HTTP-on-the-wire +The first `make run` builds a self-contained image (base, yeet toolchain, eBPF object, yeetd). A few minutes, internet needed once; after that it starts in seconds. The eBPF object and JS bundle compile **inside** the build via the vendored yeet toolchain (clang, bpftool, esbuild), so you need no system C/BPF toolchain and no local Node. The build is multi-stage: the ~190MB toolchain stays in the build stage, and the runtime image ships only the compiled probe, the bundle and the server. -What the probe reads (identical to `httpinspect`): +`vmlinux.h` is **committed** here (unlike in [`httpinspect`](agent/README.md)) because the build sandbox has no `/sys/kernel/btf` to regenerate it from. CO-RE relocates the object to whatever kernel ends up running the container. -- **A request is text.** An HTTP/1.x request opens with a request line — `GET /path HTTP/1.1` — then headers, then a blank line. The first bytes of the TCP payload *are* that line. -- **The endpoint is `METHOD host path`.** Method and path from the request line; host from the `Host:` header (or the absolute-form target on a proxied/`CONNECT` request). Traffic is tallied by that triple. -- **Plaintext only.** It works because the bytes on the wire *are* the request. Under TLS the payload is ciphertext here, so HTTPS is invisible. - -## Common use cases +## A 30-second primer on HTTP-on-the-wire -A ground-truth view of the plaintext HTTP crossing a host — from a browser, over the network, no terminal on the box: +What the probe reads: -- A service is slow — which endpoint is getting hammered, at what rate? Open its detail for the request stream and p95. -- Suspected retry storm or 5xx wave — sort by `REQ/S`, click the route, watch responses tick past color-coded. -- Auditing a remote box over your tailnet — what plaintext HTTP is flowing, and to which hosts, with no SSH or TUI. -- Local microservices over `lo` — see the chatter without instrumenting any of them. +- **A request is text.** An HTTP/1.x request opens with a request line, `GET /path HTTP/1.1`, then headers, then a blank line. The first bytes of the TCP payload *are* that line. +- **The endpoint is `METHOD host path`.** Method and path from the request line; host from the `Host:` header (or the absolute-form target on a proxied or `CONNECT` request). Traffic is tallied by that triple. +- **Plaintext only.** This works because the bytes on the wire *are* the request. Under TLS the payload is ciphertext at this layer, so HTTPS is invisible. That's a property of where the capture sits, not a missing feature. ## What you're looking at -A **top bar** with the watched interfaces (click the `iface:` pill to change them live), a `bodies:` pill for what gets captured, an `alerts:` pill that opens [every alert rule in one place](#alerts) (it reads `3 · 1 failing` when one can't deliver), and a connection indicator; the **endpoints table**, one row per `METHOD host path` sorted busiest-first (click a header to re-sort, a row to open its detail); and a **footer** with totals — requests, endpoints, bytes on the wire, uptime. +A **top bar** with the watched interfaces (click the `iface:` pill to change them live), a `bodies:` pill for what gets captured, an `alerts:` pill that opens every rule in one place (it reads `3 · 1 failing` when one can't deliver), and a connection indicator. Then the **endpoints table**, one row per `METHOD host path`, busiest first. Click a header to re-sort, a row to open its detail. A **footer** carries totals: requests, endpoints, bytes on the wire, uptime. | column | meaning | | -------- | --------------------------------------------------------------- | | `#` | rank by the current sort | | `METHOD` | HTTP method | -| `HOST` | `Host:` header (or authority from an absolute-form target) | +| `HOST` | `Host:` header, or the authority from an absolute-form target | | `PATH` | request path, shown in full (wraps, never truncated); query string collapsed unless `KEEP_QUERY` | | `COUNT` | cumulative requests seen for this endpoint | | `REQ/S` | requests in the last second (`·` when idle) | | `p95` | 95th-percentile on-the-wire latency | | `LAST` | how long ago this endpoint was last hit | -Click any row for the **detail panel** — the live breakdown where the web version goes beyond the TUI: +### The detail panel + +Click any row. This is where the browser version goes past what a terminal can do: -- total requests and share of traffic, current and peak req/s -- **latency** p50 / p95 / max, from pairing each response with its request on the wire -- **status codes** by class (2xx / 3xx / 4xx / 5xx) -- a req/s sparkline over the last minute -- a **live request stream** — completed requests newest-first, color-coded by status class (2xx green · 3xx cyan · 4xx yellow · 5xx red), each with status, latency, and a ms timestamp -- **click any request in that stream to read it** — status line, headers, and body, un-chunked, un-gzipped and JSON pretty-printed (and syntax-highlighted) in the browser, so a 500 shows you the actual error instead of just the code. With request bodies enabled you get both halves of the exchange, request first — reading a 400 usually means reading the payload that caused it. Each half has **copy body** and **copy message** buttons (the message is the head exactly as captured plus the decoded body), and text inside an open response is freely selectable — clicking or dragging in there won't collapse it, only the row itself toggles +- Total requests and share of traffic, current and peak req/s. +- **Latency** p50 / p95 / max, from pairing each response with its request on the wire. +- **Status codes** by class, and a req/s sparkline over the last minute. +- A **live request stream**: completed requests newest-first, color-coded by status class (2xx green, 3xx cyan, 4xx yellow, 5xx red), each with status, latency and a ms timestamp. -- **Slack alerts** — "+ Set alert", pick a condition (any 5xx, any 4xx, either, or one specific code), a channel, a quiet period, and optionally **the response body** so the alert carries the actual error. Every alert links back to the endpoint's panel here. Tick **every endpoint on this host** for a catchall instead of a single route — the sane starting point before you know which routes matter. The rule takes effect on the next tick: nothing restarts and no counters reset. Matches keep counting while a rule is cooling down, and the next alert says how many it covered and where. +**Click any request in that stream to read it.** Status line, headers and body, un-chunked, un-gzipped, and JSON pretty-printed with syntax highlighting. With request bodies enabled you get both halves, request first, because reading a 400 usually means reading the payload that caused it. Each half has **copy body** and **copy message** buttons, and text inside an open response is freely selectable: dragging in there won't collapse it, only the row itself toggles. -Everything updates in place over SSE. Collapse the panel with the drawer icon or `Esc`; `Esc` closes an open response first. +**The stream holds still while you read it.** Scroll off the top or expand a request and new rows queue behind a "N new requests · click to resume" pill instead of shifting what you're looking at, and nothing is trimmed away underneath you. Scroll back to the top, collapse the request, or click the pill to start following again. -**Go full screen when the panel gets tight.** The expand icon (or `f`) hands the endpoint the whole window: the list steps aside, the aggregates take a column, and the request stream gets the full height — plus taller header and body views, so reading a response stops being a scroll through a 460px column. It's a real URL — **`/detail?endpoint=`** — so the full-screen view can be pasted to someone and it opens the way you left it. Expanding pushes a history entry, so `←` (or `Esc`) drops you back to the list with the panel still open. +**Go full screen when the panel gets tight.** The expand icon (or `f`) hands the endpoint the whole window, with taller header and body views so reading a response stops being a scroll through a 460px column. It's a real URL, `/detail?endpoint=`, so the view can be pasted to someone and it opens the way you left it. Expanding pushes a history entry, so `←` or `Esc` drops you back with the panel still open. -**The request stream holds still while you read it.** Scroll off the top or expand a request and new rows queue up behind a "N new requests · click to resume" pill instead of shifting what you're looking at — and nothing gets trimmed away underneath you. Scroll back to the top, collapse the request, or click the pill to start following again. +## Alerts to Slack + +Create a rule from an endpoint's detail panel ("+ Set alert") or from the `alerts:` pill, which opens every rule at once, each editable in place. Pick a condition (any 5xx, any 4xx, either, or one specific code), a channel, a quiet period, and optionally the response body so the alert carries the actual error. Rules take effect on the next tick: nothing restarts and no counters reset. + +Tick **every endpoint on this host** for a catchall, which is the sane starting point before you know which routes matter. A catchall keeps one cooldown for the whole rule, so a bad deploy across fifty routes is one message naming the worst offenders rather than fifty messages: + +``` +httpwatch: any 5xx on any endpoint +10 matching responses since the last alert across 3 endpoints: +GET shop.internal /api/orders ×7 +POST auth.internal /login ×2 +GET cdn.internal /a.js ×1 +``` + +**Rules live in a plain file you own.** `~/.local/state/httpwatch/alerts.json` survives the container being stopped, deleted and recreated, and it's the authoritative copy: edit it while the container is down and the change is picked up on the next start. It's meant to be hand-edited. It carries only configuration, explains itself in a `_readme` field, and skips a rule that doesn't validate rather than refusing to start. The minimum viable setup is one line: + +```json +{ "rules": [ { "key": "*", "when": "5xx" } ] } +``` + +Timing lives in a separate `alerts.state.json` that the server owns. That split is deliberate: keeping timestamps out of the rules file is what lets it stay short enough to read, and persisting them separately is what stops a restart from re-opening every quiet period. Without it, a container in a restart loop would alert on every loop. State is keyed by what a rule *is* (endpoint plus condition) rather than by its id, so reordering the file never applies one rule's timing to another. + +
+Why delivery is a subprocess, and other implementation notes + +**`yeet.alert` only exists inside a yeet isolate.** There's no CLI or HTTP equivalent, and the running exporter can't be told about new rules (no control channel into a live isolate, and restarting it would reset every counter). So rules live in the server and delivery is a one-shot `yeet run server/slack-alert.yeet.js` per notification. That buys runtime-editable rules for the cost of a process per alert. + +**What an alert looks like.** A [Block Kit](https://api.slack.com/block-kit) message with a coloured bar down its left edge (red for server errors, amber for client errors, blue for one specific code), a header naming the condition and endpoint, the match count with an **Open in httpwatch** button, **which** status codes it was (`500 ×2 · 503 ×1`, the thing a class-wide rule would otherwise never tell you), the contributing endpoints as a list of up to ten with the rest counted, the response body if the rule asked for one, and a footer with the interface, quiet period and time in each reader's own timezone. + +**The button needs a routable URL.** Slack validates a button's target when the message is posted and refuses a hostname with no dot, which is exactly what the link is by default since it's learned from the `Host` header. So the alert checks first and falls back to an ordinary mrkdwn link, which Slack doesn't validate that way and which works just as well: it opens in the reader's browser and Slack never fetches it, so an internal address is fine. Set `PUBLIC_URL` to a dotted name or an IP to get the button. + +Delivery walks down a ladder (coloured with a button → coloured with a link → blocks with a button → blocks with a link → plain text) and logs any downgrade, because Slack validates presentation as a whole and rejects it as a whole. The button is given up before the colour: a refused URL is the likeliest rejection, and losing the colour, the codes and the endpoint list over a link that renders fine as text would be a bad trade. + +**Bodies in alerts are opt-in per rule** because they post a payload into a Slack channel, and **Slack has no spoiler markup**, nothing that hides it behind a click the way Discord's `||…||` does. It goes in a preformatted block, the most contained thing Slack offers, and because that block carries literal text rather than markup, nothing in a payload can break out or be reinterpreted as formatting. When there's no body to send, the alert says which reason it was (capture off, body budget dropped it, or evicted from the store before the alert fired) rather than looking like an empty response. + +**Detection diffs each snapshot's status tallies** rather than reading the streamed request rows. The tallies are cumulative aggregates that never lose a response; rows are subject to the per-frame row budget. A probe restart re-baselines instead of alerting on the reset, but cooldowns deliberately survive it, so toggling interfaces isn't a way around the throttle. + +**Is Slack connected?** The exporter polls `yeet.caps()` every 30s (it's isolate-only, so nothing else can ask). Connected means nothing to report; definitely-not gets a banner linking to yeet.cx/settings and an `N failing` pill; unknown (not logged in, call failed or timed out) gets a softer note that delivery is unverified. Not-connected doesn't block creating rules, since configuring alerts before wiring Slack is a normal order to work in. Delivery is still attempted on *unknown*, because a capability hiccup must never silence alerting. + +
## Reading it without a browser @@ -192,38 +252,36 @@ $ curl localhost:8080 | 1 | `GET shop.internal /api/orders` | 4,120 | 3.2 | 4.2 | 31.0 | 200 ×4,102 · 500 ×16 | [open](/detail?endpoint=GET%20shop.internal%20%2Fapi%2Forders&format=md) | ``` -The page is a shell that hydrates from an inlined snapshot and then streams SSE, so without this the most convenient way to inspect a host — ask it over HTTP — was the one way that didn't work. Now the same three URLs answer in either representation, and **the markdown carries its own links**, so a reader that starts at `/` can reach a decoded response body without being told how: +The page is a shell that hydrates from an inlined snapshot and then streams SSE, so without this the most convenient way to inspect a host, asking it over HTTP, was the one way that didn't work. The same three URLs answer in either representation, and **the markdown carries its own links**, so a reader starting at `/` can reach a decoded response body without being told how: | Depth | URL | What it gives you | | --- | --- | --- | -| 1 | `/` | probe state, totals, the endpoint table — every row linking to its detail page. `&limit=` up to 500, `&sort=count\|rate\|p95\|p50\|bytes\|last\|errors` | -| 2 | `/detail?endpoint=` | percentiles, status-code shares, req/s for the last minute as numbers, and the tail of individual exchanges — each linking to its body. `&rows=` up to 500 | -| 3 | `/api/body/?format=md` | one exchange's head and body, **decoded** — dechunked, gunzipped, fenced, with a note when the capture cut it short. `&dir=req\|res\|both`, `&max=` up to 200,000 characters | - -Every page also lists the JSON routes (`/healthz`, `/api/alerts`, `/events`) for anything that would rather parse than read. +| 1 | `/` | probe state, totals, the endpoint table, every row linking to its detail page. `&limit=` up to 500, `&sort=count\|rate\|p95\|p50\|bytes\|last\|errors` | +| 2 | `/detail?endpoint=` | percentiles, status-code shares, req/s for the last minute as numbers, and the tail of individual exchanges, each linking to its body. `&rows=` up to 500 | +| 3 | `/api/body/?format=md` | one exchange's head and body, **decoded**: dechunked, gunzipped, fenced, with a note when the capture cut it short. `&dir=req\|res\|both`, `&max=` up to 200,000 characters | -**How the view is chosen.** For the two page routes, anything that doesn't ask for `text/html` in its `Accept` header gets markdown — plain `curl`, `wget`, a script, an agent — and a browser gets the app. No User-Agent sniffing: a list of bot names is wrong the day something new appears. Override it either way with `?format=md` or `?format=html`, or fetch `/index.md` and `/detail.md`. HTML responses advertise their twin with a `Link: <…?format=md>; rel=alternate` header, and both carry `Vary: Accept`. +**How the view is chosen.** For the two page routes, anything that doesn't ask for `text/html` gets markdown (plain `curl`, `wget`, a script, an agent) and a browser gets the app. No User-Agent sniffing: a list of bot names is wrong the day something new appears. Override either way with `?format=md` or `?format=html`, or fetch `/index.md` and `/detail.md`. HTML responses advertise their twin with a `Link: <…?format=md>; rel=alternate` header, and both carry `Vary: Accept`. -**`/api/*` is exempt** and keeps returning JSON unless the URL asks for markdown outright (`?format=md` or a `.md` suffix). Its callers are `fetch()`, which sends a wildcard `Accept` — including the dashboard's own body viewer — so negotiating there would hand the page markdown where it expected JSON. Nothing is lost: every markdown link to a body carries `format=md` already. +**`/api/*` is exempt** and keeps returning JSON unless the URL asks outright. Its callers are `fetch()`, which sends a wildcard `Accept`, including the dashboard's own body viewer, so negotiating there handed the page markdown where it expected JSON. Nothing is lost: every markdown link to a body carries `format=md` already. -**The login gate still applies.** Logged out, these return `401` with markdown explaining that the *host* needs `yeet login` — there is no header a caller could add, so a bare `401` would send someone hunting for a token that doesn't exist. `/healthz` needs no login and stays JSON. +**The login gate still applies.** Logged out, these return `401` with markdown explaining that the *host* needs `yeet login`. There's no header a caller could add, so a bare `401` would send someone hunting for a token that doesn't exist. `/healthz` needs no login and stays JSON. ## How it works -The eBPF capture is [`httpinspect`](https://github.com/yeet-src/httpinspect) verbatim, vendored under `agent/`. The one new piece is a headless entry that prints JSON instead of drawing a TUI; the rest is the node server and the browser app. +The eBPF capture is [`httpinspect`](agent/README.md) verbatim, vendored under `agent/`, and its README is the deep dive on the kernel side. The one new piece here is a headless entry that prints JSON instead of drawing a TUI; the rest is the node server and the browser app. ``` ┌──────── Docker container (--cap-add SYS_ADMIN,NET_ADMIN,BPF,PERFMON · host pid+net) ───────┐ │ yeetd ◄── privileged BPF load ── [ yeet isolate: the httpinspect exporter (agent/) ] │ - │ │ probe.js + httptop.js (unchanged capture) │ - │ │ export.js (NEW: samples signals → JSON/1s) │ - │ ▼ console.log(JSON) snapshot/1s + body parts │ - │ yeet console WebSocket portal │ - │ │ │ + │ │ probe.js + httptop.js (unchanged capture) │ + │ │ export.js (samples signals → JSON/1s) │ + │ ▼ console.log(JSON) snapshot/1s + body parts │ + │ yeet console WebSocket portal │ + │ │ │ │ node server ── connects as a ws client ── latest snapshot + captured-body store │ - │ :8080 ├─ GET / dashboard HTML, snapshot inlined for instant hydration │ - │ ├─ GET /events SSE: one snapshot per tick ─────────► browser (DOM) │ - │ └─ GET /api/body/N one exchange's head + body, on demand ◄── row expanded │ + │ :8080 ├─ GET / dashboard HTML, snapshot inlined for instant hydration │ + │ ├─ GET /events SSE: one snapshot per tick ─────────► browser (DOM) │ + │ └─ GET /api/body/N one exchange's head + body, on demand ◄── row expanded │ └────────────────────────────────────────────────────────────────────────────────────────────┘ ``` @@ -231,126 +289,89 @@ The eBPF capture is [`httpinspect`](https://github.com/yeet-src/httpinspect) ver agent/ the httpinspect exporter (vendored, unchanged capture) src/probes/probe.js loads the shared BPF object, attaches TCX, exposes `control` src/probes/httptop.js ingest: parse, pair responses for latency, aggregate → signals - src/lib/format.js pure formatters (percentiles, sparkline scaling) - src/export.js NEW headless entry: samples the signals → prints a JSON snapshot/1s + src/export.js headless entry: samples the signals → a JSON snapshot per second src/main.jsx the original TUI entry (kept for reference; unused by the web build) server/ - portal.js spawns the exporter isolate, connects its console WS portal, parses snapshots - bodies.js the captured-body store: reassembles the streamed parts, serves them by id - auth.js host login — yeet whoami / yeet login (scrapes the login URL) - alerts.js per-endpoint alert rules: status-tally diffing, cooldowns, delivery + portal.js spawns the exporter isolate, connects its console WS portal + bodies.js the captured-body store: reassembles streamed parts, serves by id + auth.js host login — yeet whoami / yeet login + alerts.js alert rules: status-tally diffing, cooldowns, delivery slack-alert.yeet.js one-shot yeet script that calls yeet.alert (the only place it can run) index.js HTTP + SSE server: inline hydration, live iface switching, respawn markdown.js the same pages as markdown for anything that isn't a browser decode.js dechunk + gunzip a captured body (shared by alerts and markdown) - public/ index.html · style.css · app.js (native dashboard, no framework/CDN) -docker/entrypoint.sh mount a private bpffs → start yeetd → wait for socket → start the server + public/ index.html · style.css · app.js (native dashboard, no framework/CDN) +docker/entrypoint.sh mount a private bpffs → start yeetd → wait for socket → start server Dockerfile · Makefile multi-stage build; one slim image (yeetd + server + probe) ``` -### The yeet side - -`agent/src/probes/` is the only BPF-aware code — it loads the object, attaches the two TC programs, and ships decoded `http_event`s over a ring buffer; `httptop.js` parses method + Host + path, pairs responses with requests for on-the-wire latency, and aggregates into reactive signals. `export.js` reads those signals and prints a JSON snapshot once a second (the endpoint table plus newly completed request/response pairs). The build points esbuild at `export.js` instead of the TUI's `main.jsx`, so the bundle *is* the exporter. For the capture internals, see [`httpinspect`](https://github.com/yeet-src/httpinspect). - -### Alerts - -Rules can be created from an endpoint's detail panel ("+ Set alert") or from the **`alerts:` pill** in the top bar, which opens every rule at once — each row editable in place (condition, channel, quiet period), deletable, and showing when it last fired or why it failed. Clicking a rule's endpoint jumps to that endpoint. Editing a rule keeps its cooldown if it still watches the same thing, and resets it if you repoint it, so a rule can't inherit a quiet period it never earned. - -`yeet.alert` only exists **inside** a yeet isolate — there's no CLI or HTTP equivalent — and the running exporter can't be told about new rules (no control channel into a live isolate, and restarting it would reset every counter). So rules live in the server and delivery is a one-shot `yeet run server/slack-alert.yeet.js` per notification, which buys runtime-editable rules for the cost of a process per alert. - -**What an alert looks like.** It's a [Block Kit](https://api.slack.com/block-kit) message with a coloured bar down its left edge — red for server errors, amber for client errors, blue for one specific code — carrying a header that names the condition and the endpoint, the match count with an **Open in httpwatch** button beside it, **which status codes** it was (`500 ×2 · 503 ×1` — the thing a class-wide rule like *any 5xx* would otherwise never tell you), the endpoints that contributed as a bulleted list of up to ten with the rest counted, the response body if the rule asked for one, and a footer with the interface, the quiet period, and the time rendered in each reader's own timezone. A single-endpoint rule skips the list, since its header already says where. - -**The button needs a routable URL.** Slack validates a button's target when the message is posted and refuses a hostname with no dot — `http://localhost:3000`, `http://httpwatch:8080`, a bare container or LAN name — which is exactly what the link is by default, since it's learned from the `Host` header. So the alert checks the URL first and falls back to an ordinary mrkdwn link, which Slack doesn't validate that way and which works just as well: it opens in the reader's own browser, and Slack never fetches it, so pointing at an internal address is fine. Set `PUBLIC_URL` to a dotted name or an IP to get the button. IPs and IPv6 literals pass. - -The server passes those parts separately and the yeet script owns the layout, so the message can be restyled without touching detection. Slack validates presentation as a whole and rejects it as a whole, so delivery walks down a ladder — coloured with a button → coloured with a link → blocks with a button → blocks with a link → plain text — and logs any downgrade. The button is given up before the colour is: a refused URL is the likeliest rejection, and losing the colour, the codes and the endpoint list over a link that renders fine as text would be a bad trade. Being logged out, rate limited, or pointed at a channel that doesn't exist skips the ladder — a simpler message would fail identically. - -**Bodies in alerts.** Tick *include the response body* on a rule and the alert carries the payload of one matching response, dechunked and gunzipped (a gzip cut short by the capture still yields its readable prefix), truncated to ~1200 chars. It's opt-in per rule because it posts that payload into a Slack channel, and **Slack has no spoiler markup** — nothing hides it behind a click the way Discord's `||…||` does. It goes in a preformatted block, which is the most contained thing Slack offers — and because that block carries literal text rather than markup, nothing in a payload can break out of it or be reinterpreted as formatting; if you'd rather not put payloads in the channel at all, leave it off and use the link. When there's no body to send, the alert says which reason it was — capture off, the body budget dropped it, or the body was evicted from the store before the alert fired — rather than looking like an empty response. - -**Every alert links back.** `GET /?endpoint=` opens that endpoint's detail panel directly, and that's what the link in each alert points at — click it in Slack and you land on the failing route with its live request stream. The open endpoint is kept in the address bar too, so copying the URL shares what you're looking at, and back/forward work (`/detail?endpoint=…` is the same link, full screen). The base URL is learned from the `Host` header of a real browser request; set `PUBLIC_URL` when that guess would be wrong (behind a proxy, or for links that need to work off-network). - -Detection diffs each snapshot's `endpoints[].status` tallies rather than reading the streamed request rows: the tallies are cumulative aggregates that never lose a response, while rows are subject to the per-frame row budget. A probe restart re-baselines instead of alerting on the reset — but cooldowns deliberately survive it, so toggling interfaces isn't a way around the throttle. - -**Is Slack connected?** The exporter polls `yeet.caps()` every 30s (it's isolate-only, so nothing else can ask) and reports the providers in each snapshot. Both places a rule can be created — the endpoint popover and the rules dialog — say the same thing about it: **connected** → nothing to report; **definitely not** → a banner with a link to yeet.cx/settings, and the `alerts:` pill reads `N failing`; **unknown** (not logged in, call failed or timed out) → a softer note that delivery is unverified. Not-connected doesn't block creating rules — configuring alerts before wiring Slack is a normal order to work in, and a rule that can't deliver shows the reason on its row. Delivery is still attempted on *unknown*, because a capability hiccup must never silence alerting. Connecting Slack is picked up within 30s — no restart. - -**Rules are persisted to a file on your host.** `make run` / `make up` bind-mount `~/.local/state/httpwatch` at `/data`, so the rules live in a plain file you can read, back up, edit or delete: - -``` -~/.local/state/httpwatch/alerts.json -``` - -It survives the container being stopped, deleted and recreated, and it's the authoritative copy — edit it while the container is down and the change is picked up on the next start. The container has to run as root (BPF), which would normally leave the file root-owned; `make` passes your uid so it's written back to you and stays editable. `make run STATE=/srv/httpwatch` puts it elsewhere, `STATE=httpwatch-data` (any name without a `/`) switches to a docker named volume instead, and deleting the file clears your rules. - -There are **two files**, and the split is deliberate: - -| file | what | who edits it | -| ---- | ---- | ------------ | -| `alerts.json` | the rules — endpoint, condition, channel, quiet period | **you** | -| `alerts.state.json` | when each rule last fired, and how often | the server | - -Keeping timing out of the rules file is what lets it stay short enough to read, and persisting it separately is what stops a restart from re-opening every quiet period — without it, a container in a restart loop would alert on every loop. State is keyed by what a rule *is* (endpoint + condition) rather than by its id, so reordering the rules file or dropping ids never applies one rule's timing to another. Delete the state file to clear all cooldowns; delete a rule and its state is pruned with it. - -The rules file is meant to be edited by hand. It carries only configuration (no counters or timestamps to get stale), explains itself in a `_readme` field, and skips any rule that doesn't validate rather than refusing to start. `id` can be omitted and `channel`/`cooldownSec` fall back to the defaults, so the minimum viable setup is one line: - -```json -{ "rules": [ { "key": "*", "when": "5xx" } ] } -``` - -`key` is either an endpoint exactly as the dashboard shows it (`GET shop.internal /api/orders`) or `"*"` for **every endpoint**. A catchall keeps one cooldown for the whole rule, so a bad deploy across fifty routes is one message naming the worst offenders rather than fifty messages: +`agent/src/probes/` is the only BPF-aware code. It loads the object, attaches the two TC programs, and ships decoded `http_event`s over a ring buffer. `httptop.js` parses method, `Host` and path, pairs responses with requests for on-the-wire latency, and aggregates into reactive signals. `export.js` reads those signals and prints a JSON snapshot once a second. The build points esbuild at `export.js` instead of the TUI's `main.jsx`, so the bundle *is* the exporter. -``` -httpwatch: any 5xx on any endpoint -10 matching responses since the last alert across 3 endpoints: -GET shop.internal /api/orders ×7 -POST auth.internal /login ×2 -GET cdn.internal /a.js ×1 -``` +## Environment -## Building from source +Pass with `-e VAR=…`. The interface set and body capture are also editable live in the UI. -`make build` produces the image. The eBPF object and JS bundle compile **inside** the build via the vendored yeet toolchain (clang + bpftool + esbuild), so you need no system C/BPF toolchain and no local Node. It's multi-stage — the toolchain stays in the build stage, and the runtime image (`node:22-bookworm-slim` + yeetd) ships only the compiled probe, the bundle, and the server (~515 MB). - -`vmlinux.h` is **committed** (unlike in `httpinspect`): the build sandbox has no `/sys/kernel/btf` to regenerate it, and CO-RE relocates the object to whatever kernel runs the container. - -## Try it without real traffic - -`agent/demo/` is `httpinspect`'s self-contained loopback traffic source, so you can watch the dashboard fill on a quiet box: +| var | default | meaning | +| ------------ | ------------- | -------------------------------------------------------------------- | +| `PORT` | `8080` | port the dashboard is served on (bound on the host, so it must be free) | +| `IFACE` | all up ifaces | comma-separated interfaces to watch, e.g. `lo,eth0` (initial set) | +| `KEEP_QUERY` | off | keep query strings distinct, so `/x?id=1` and `/x?id=2` stay separate rows | +| `BODIES` | `response` | which bodies to capture: `none`, `response`, or `both`. `both` includes request bodies, which is where passwords and tokens live, and anyone who can reach the dashboard can then read them | +| `BODY_STORE_BYTES` | `67108864` | how much captured body the server holds for reading back (64MB). Oldest evicted first; a row whose body is gone says so | +| `BODY_STORE_MAX` | `4000` | how many exchanges the body store holds, whichever limit is hit first | +| `SLACK_CHANNEL` | `#alerts` | channel new alert rules default to | +| `PUBLIC_URL` | learned from the `Host` header | base URL for the "open in httpwatch" link in alerts. A dotted host or IP also gets a real Slack button; see [Alerts](#alerts-to-slack) | +| `ALERTS_FILE` | `/data/alerts.json` | where rules are persisted inside the container; empty keeps them in memory only | +| `STATE` | `~/.local/state/httpwatch` | (`make` only) host directory bind-mounted at `/data`. Any value containing `/` is a host path; a bare name uses a docker volume | +| `RECENT_ROWS` | `500` | how many exchanges the server keeps for the [markdown views](#reading-it-without-a-browser). The browser accumulates its own from SSE; a one-shot reader can't. `0` disables the tail | +| `YEET_AUTH_KEY` | — | log the host in at startup | -```sh -bash agent/demo/traffic.sh # fake server + a steady request mix on 127.0.0.1:8731 -make run IFACE=lo # in another shell: watch it on loopback -``` +## Requirements -`traffic.sh` runs the whole thing — it starts `demo/server.py` itself, prints a heartbeat every couple of seconds, and on `Ctrl-C` stops the server and exits. Don't start `server.py` separately; the script refuses a port that's already busy rather than generating traffic for whatever else is listening. Move it with `PORT=9001`. +> [!IMPORTANT] +> - **A Linux host** (or a Linux VM you want to observe) with **BTF + TCX**, kernel **6.6+**. The default on current Fedora, Arch, Ubuntu and Debian 12+. CO-RE means no per-kernel recompile. +> - **Docker** that can grant the eBPF capabilities and lift AppArmor. The container runs with `SYS_ADMIN`, `NET_ADMIN`, `BPF`, `PERFMON`, `--security-opt apparmor=unconfined` and `--pid=host --network=host`, but **not** `--privileged`, plus a read-only mount of the host's kernel BTF. The bpffs is private to the container; nothing else is shared. +> - **macOS/Windows Docker Desktop** watches the VM, not your laptop. Handy for inspecting your other containers, but for host-level capture use Linux. See [Running on macOS](#running-on-macos-docker-desktop). -The routes it hits give the dashboard a spread of methods, hosts and latencies, with `/api/orders` failing about 8% of the time so there's something red to click. The bodies are one-liners, though — for exercising the response viewer, a route returning a real gzipped or chunked error is more interesting. +**Where captured data goes.** The heads and bodies stay in the server's memory and travel to exactly two places you choose: your browser, and any Slack channel you point an alert at. Nothing is shipped anywhere else. Which also means the dashboard's port is the boundary that matters: anyone who can reach it can read the bodies you captured, so put it on a tailnet or behind a reverse proxy rather than on the public internet. -## What the dashboard reports +## What it can't see -The captured heads and bodies stay in the server's memory and go to exactly two places you choose: your browser, and any Slack channel you point an alert at. +httpwatch is observability, not enforcement. It tells you what crossed the wire; it does not stop, hold or modify anything. -## Requirements +- **Plaintext HTTP only.** TLS payloads are ciphertext at this layer, so HTTPS is invisible. Capturing it would need a uprobe on `SSL_write`/`SSL_read`, which is a different tool. ([Talk to us](https://yeet.cx/?utm_source=github&utm_medium=readme&utm_campaign=httpwatch&utm_content=caveats-tls) about custom yeet scripts.) +- **HTTP/1.x only, and nine methods.** Detection matches a leading ASCII method token (`GET`, `PUT`, `HEAD`, `POST`, `TRACE`, `PATCH`, `DELETE`, `OPTIONS`, `CONNECT`) or a `HTTP/` status line. HTTP/2 and HTTP/3 are binary with compressed headers, so cleartext h2c and prior-knowledge gRPC don't match and won't appear. If your internal services speak h2c, expect an empty table. For gRPC specifically, see [`grpcsnoop`](https://github.com/yeet-src/grpcsnoop). +- **Bodies are bounded by a budget, not by the message.** Roughly 64KB per message, ~256KB for a 4xx/5xx, ~32KB from any single segment, and metered in aggregate. What doesn't fit is truncated and **flagged as such**, with the UI reporting how much of the sender's `Content-Length` you're actually looking at. It's never silently shortened. +- **Latency is on-the-wire, not server-internal.** It's the time between request and response segments as seen at this host's TC layer, so it includes network RTT for remote hosts. Responses pair to requests FIFO per flow, which is correct for ordered HTTP/1.x but approximate under pipelining. Unmatched requests are dropped after 10s. +- **Counts are a close lower bound, not an exact tally.** Under heavy load or a slow link some segments aren't captured. Rows that exceed the per-frame budget are counted in `recentDropped` rather than disappearing quietly. +- **The endpoint table survives what the request stream doesn't.** Aggregates are diffed from cumulative tallies, so they never lose a response even when individual rows get dropped. If a number in the table and a count in the stream disagree, trust the table. +- **IPv6 packets carrying TCP behind extension-header chains** (rare) are skipped. +- **Changing the watched interfaces restarts the probe**, which resets every counter and invalidates held bodies. That's a spawn-time argument with no control channel into a live isolate, not an oversight. -> [!IMPORTANT] -> - **A Linux host** (or a Linux VM you want to observe) with **BTF + TCX** (kernel **6.6+**) — the default on current Fedora, Arch, Ubuntu, and Debian 12+. CO-RE means no per-kernel recompile. -> - **Docker** that can grant the eBPF caps and lift AppArmor — the container runs with `SYS_ADMIN`, `NET_ADMIN`, `BPF`, `PERFMON`, `--security-opt apparmor=unconfined`, and `--pid=host --network=host` (no `--privileged`), plus a read-only mount of the host's kernel BTF. The bpffs is private to the container; nothing else is shared. -> - **macOS/Windows Docker Desktop** watches the VM, not your laptop — handy for inspecting your other containers, but for host-level capture use Linux. See [Running on macOS](#running-on-macos-docker-desktop). +## FAQ -## Community questions +**Do I have to clone the repo?** +No. `docker run … ghcr.io/yeet-src/httpwatch:latest` runs the prebuilt image. Cloning is only for building, hacking, or getting the demo traffic generator. -**Do I have to clone the repo?** No — `docker run … ghcr.io/yeet-src/httpwatch:latest` runs the prebuilt image. Cloning is only for building or hacking. +**Why did my counts reset?** +You changed the watched interfaces in the UI, which restarts the probe. Capture settings are spawn-time arguments and there's no control channel into a running isolate, so changing them means a restart, and a restart means new counters. -**Does it need a proxy or sidecar?** No. It reads off the wire from the kernel's TC layer — nothing to route through, nothing to reconfigure. +**Do I need to be logged in to yeet?** +The host does, and the dashboard gates on it. Logged out, the UI offers a sign-in button that drives the ordinary device flow; `YEET_AUTH_KEY` skips the click for a deployment. `/healthz` answers either way. -**Why don't I see my HTTPS traffic?** It's encrypted before it hits the wire; at the TC layer there's no request line to parse. A fundamental limit, not a bug. +**Is it safe to put this on the internet?** +No. The dashboard shows decoded request and response bodies, and with `BODIES=both` those include credentials. Treat the port as the security boundary and keep it on a tailnet, a VPN, or behind a reverse proxy with real auth. -**Why `--network=host`?** So the probe attaches to your *host's* interfaces and the server binds your host's port. Without it you'd inspect an empty container network. +**The dashboard is empty but I know there's traffic. What's wrong?** +Three usual causes, in order. It's HTTPS, so there's nothing to parse at this layer. It's HTTP/2 or h2c, which this doesn't decode. Or the probe never attached, which means a kernel older than 6.6 and a `tcx: -EINVAL` in `docker logs httpwatch`. On a quiet box, confirm the pipeline first with `bash agent/demo/traffic.sh`. -**Why did my counts reset?** You changed the watched interfaces in the UI, which restarts the probe. +**How is this different from `tcpdump` or Wireshark?** +`tcpdump` gives you packets and leaves you to reassemble streams; Wireshark does that well but wants a capture file and a desktop. httpwatch decodes HTTP continuously for the whole box, including loopback, and serves it to a browser over the network. What it gives up is everything non-HTTP and everything encrypted, which is what those two are still for. ## License -GPL-2.0. The vendored eBPF program under `agent/` declares `char LICENSE[] SEC("license") = "GPL"`, required for the kernel helpers it uses. +GPL-2.0. The vendored eBPF program under `agent/` declares `char LICENSE[] SEC("license") = "GPL"`, required for the kernel helpers it uses. *(No `LICENSE` file is present in the repo — see reviewer notes.)* --- -Built with [yeet](https://yeet.cx/docs/), a JS runtime for writing eBPF programs and live system dashboards on Linux, wrapped for the browser over a WebSocket-portal bridge. +Built with [yeet](https://yeet.cx/docs/?utm_source=github&utm_medium=readme&utm_campaign=httpwatch), a JS runtime for writing eBPF programs on Linux machines. Join us on [discord](https://discord.gg/JxVseaAVAU?utm_source=github&utm_medium=readme&utm_campaign=httpwatch).