Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# make bpf — compile bpf/*.bpf.c into bin/* only
# make veristat — load the built object with veristat (verifier check on this kernel)
# make bundle — bundle the JS entry with the vendored esbuild
# make verify — headless capture/parse self-test (no TUI), ~5s
# make postgen — finalize a freshly generated project (git init)
# make clangd — write a local .clangd pointing at the resolved toolchain
# make clean — remove build artifacts
Expand Down Expand Up @@ -39,10 +40,21 @@ all: bpf bundle
# one — esbuild then inlines node_modules at bundle time.
ESBUILD_FLAGS := --bundle --format=esm --platform=neutral \
--main-fields=module,main --conditions=import,module \
--outfile=src/index.jsx --jsx=automatic --jsx-import-source=yeet:tui
--jsx=automatic --jsx-import-source=yeet:tui \
'--external:yeet:*' '--external:*.bpf.o'

bundle: | toolchain
$(ESBUILD) src/main.jsx $(ESBUILD_FLAGS) '--external:yeet:*' '--external:*.bpf.o'
$(ESBUILD) src/main.jsx $(ESBUILD_FLAGS) --outfile=src/index.jsx

# Headless self-test of the capture + parse pipeline (src/verify.js). It is a
# separate entry, not an `import.meta.main` block inside a probe: esbuild
# inlines every module into one file, so inside the bundle `import.meta.main`
# is true for all of them and such a guard would hijack `yeet run .`. Bundled
# into .build/ so `../bin/probe.bpf.o` in probe.js resolves to bin/ just as it
# does from src/index.jsx.
verify: bpf | toolchain
$(ESBUILD) src/verify.js $(ESBUILD_FLAGS) --outfile=.build/verify.js
yeet run .build/verify.js

# Post-generation finalize: initialize a git repository with the vendored git
# (fetched via `vendored-git`). Idempotent — skipped if this is already a repo.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ Because it's encrypted before it hits the wire. At the TC layer the payload is c
That's the `Host:` header the client sent. Services addressed by name show their name; those addressed by IP show the IP.

**Can I get a quick check without the full TUI?**
Yes. `yeet run src/probes/probe.js` attaches the probe, aggregates for ~4s, and prints the counts before exiting — a headless sanity check of the capture + parse pipeline.
Yes. `make verify` bundles `src/verify.js`, attaches the probe, aggregates for ~4s, and prints the counts before exiting — a headless sanity check of the capture + parse pipeline.

## License

Expand Down
52 changes: 4 additions & 48 deletions src/probes/probe.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
// httptop attaches at the TC layer (TCX, ingress + egress) on every up
// interface. The TCX wildcard skips loopback, so we enumerate explicitly
// (incl. `lo`, where most local HTTP lives); `--iface a,b` narrows to named
// interfaces. This module imports only yeet:bpf — no `@/` aliases — so it
// stays runnable on its own for the import.meta.main self-test below.
// interfaces. This module imports only yeet:bpf — no `@/` aliases — so the
// headless self-test entry (src/verify.js, `make verify`) can import it by
// relative path and bundle it on its own.
import { BpfObject, RingBuf } from "yeet:bpf";

const wanted = yeet.args.iface
Expand All @@ -26,7 +27,7 @@ try {
yeet.exit();
}

const ifindexes = ifaces.map((i) => i.index);
export const ifindexes = ifaces.map((i) => i.index);
if (ifindexes.length === 0) {
console.error("[httptop] no matching up interfaces to watch");
yeet.exit();
Expand All @@ -52,48 +53,3 @@ export const control = await (async () => {
yeet.exit();
}
})();

// Standalone correctness probe — `yeet run src/probes/probe.js` dumps the
// endpoints it aggregates over a few seconds, so you can eyeball that the
// kernel filter, the btf_struct envelope, and the loopback dedup all behave
// before any UI exists. Dormant once httptop.js imports `control`.
if (import.meta.main) {
const REQ = /^([A-Z]+) +(\S+) +HTTP\/\d\.\d$/;
const parse = (bytes) => {
let t = "";
for (let i = 0; i < bytes.length; i++) { const c = bytes[i]; if (c === 0) break; t += String.fromCharCode(c); }
const lines = t.split("\r\n\r\n")[0].split("\r\n");
const m = REQ.exec(lines[0] || "");
if (!m) return null;
let host = "-";
for (let i = 1; i < lines.length; i++) {
const c = lines[i].indexOf(":");
if (c > 0 && lines[i].slice(0, c).toLowerCase() === "host") { host = lines[i].slice(c + 1).trim(); break; }
}
let path = m[2]; const q = path.indexOf("?"); if (q >= 0) path = path.slice(0, q);
return { method: m[1], host, path };
};

const stats = new Map();
const seen = new Set();
let dupes = 0;
await new RingBuf(control, "events").subscribe((raw) => {
const ev = raw.http_event ?? raw;
const k = `${ev.family}:${ev.sport}>${ev.dport}#${ev.seq}`;
if (seen.has(k)) { dupes++; return; }
seen.add(k);
const d = ev.data instanceof Uint8Array ? ev.data : Uint8Array.from(Object.values(ev.data));
const r = parse(d.subarray(0, Number(ev.captured)));
if (!r) return;
const key = `${r.method} ${r.host} ${r.path}`;
stats.set(key, (stats.get(key) || 0) + 1);
});

await new Promise((r) => setTimeout(r, 4500));
console.log(`[verify] watching ifindexes ${ifindexes.join(",")}`);
console.log(`[verify] deduped ${dupes} loopback double-sightings`);
console.log("[verify] aggregated endpoints (count desc):");
[...stats.entries()].sort((a, b) => b[1] - a[1]).forEach(([k, c]) => console.log(` ${String(c).padStart(3)} ${k}`));
await control.stop();
yeet.exit();
}
51 changes: 51 additions & 0 deletions src/verify.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Headless correctness check for the capture + parse pipeline — no UI.
// Attaches the probe, aggregates request lines for a few seconds, and prints
// the endpoints it saw. Build + run with `make verify`: it is bundled to
// .build/verify.js so probe.js's `../bin/probe.bpf.o` resolves to bin/ exactly
// as it does for the real bundle at src/index.jsx.
//
// This is a separate entry rather than an `if (import.meta.main)` block inside
// probe.js on purpose: esbuild inlines every module into one file, so inside a
// bundle `import.meta.main` is the bundle's own and is true for every module —
// the guard would fire under `yeet run .` and replace the dashboard with this.
import { RingBuf } from "yeet:bpf";
import { control, ifindexes } from "./probes/probe.js";

const REQ = /^([A-Z]+) +(\S+) +HTTP\/\d\.\d$/;
const parse = (bytes) => {
let t = "";
for (let i = 0; i < bytes.length; i++) { const c = bytes[i]; if (c === 0) break; t += String.fromCharCode(c); }
const lines = t.split("\r\n\r\n")[0].split("\r\n");
const m = REQ.exec(lines[0] || "");
if (!m) return null;
let host = "-";
for (let i = 1; i < lines.length; i++) {
const c = lines[i].indexOf(":");
if (c > 0 && lines[i].slice(0, c).toLowerCase() === "host") { host = lines[i].slice(c + 1).trim(); break; }
}
let path = m[2]; const q = path.indexOf("?"); if (q >= 0) path = path.slice(0, q);
return { method: m[1], host, path };
};

const stats = new Map();
const seen = new Set();
let dupes = 0;
await new RingBuf(control, "events").subscribe((raw) => {
const ev = raw.http_event ?? raw;
const k = `${ev.family}:${ev.sport}>${ev.dport}#${ev.seq}`;
if (seen.has(k)) { dupes++; return; }
seen.add(k);
const d = ev.data instanceof Uint8Array ? ev.data : Uint8Array.from(Object.values(ev.data));
const r = parse(d.subarray(0, Number(ev.captured)));
if (!r) return;
const key = `${r.method} ${r.host} ${r.path}`;
stats.set(key, (stats.get(key) || 0) + 1);
});

await new Promise((r) => setTimeout(r, 4500));
console.log(`[verify] watching ifindexes ${ifindexes.join(",")}`);
console.log(`[verify] deduped ${dupes} loopback double-sightings`);
console.log("[verify] aggregated endpoints (count desc):");
[...stats.entries()].sort((a, b) => b[1] - a[1]).forEach(([k, c]) => console.log(` ${String(c).padStart(3)} ${k}`));
await control.stop();
yeet.exit();
Loading