Skip to content

Repository files navigation

hooklog

A self-hosted webhook inspector. Point any webhook at a URL you own and read exactly what arrived: the method, every header, the raw body bytes, the content type, the byte count and the arrival time.

Built with Topcoat, a server-rendered Rust web framework, six days after its public announcement. No JavaScript build step, no WebAssembly, one binary and a directory of assets next to it.

The five-day build is written up in ARTICLE.md: the measured numbers, every error I hit with the text it printed, and what each workaround cost in lines.

Why self-host it

Public inspector inboxes are fine for a curl test and wrong for anything carrying real payloads. A webhook body from a payment provider holds customer names, amounts and addresses. This runs on a machine you control, behind your own TLS, with every endpoint owned by the account that created it.

What it does not do

  • No tunnel to localhost. That is the expensive half, and the tool is useful without it.
  • No teams, sharing, quotas, retention policies or API.
  • No real-time push. The tail polls every two seconds, because the framework ships no broadcast primitive.
  • No file uploads. Bodies are stored and rendered as text.
  • No password reset, no email, no invitations. An account is an address and a password of at least ten characters, and nothing sends mail.
  • No request size limit of its own. Enforce one at the proxy; the shipped deploy/Caddyfile sets 1 MB.

Running it

cargo build
topcoat asset bundle
cargo run

cargo install topcoat-cli provides the topcoat command. topcoat asset bundle writes the stylesheet, the font files, the icon mark and the runtime script into target/assets, and the binary loads that directory at startup. A page that renders an asset missing from the bundle panics at render time, so run the bundler again after any build that changes a template.

The server prints nothing when it starts. That is what listening looks like. It binds 127.0.0.1:3000 unless HOST and PORT say otherwise.

Open the root page, create an account and sign in. Name an endpoint, give it a slug of lowercase letters, digits and dashes, and it answers GET, POST, PUT, PATCH and DELETE at /in/{slug}:

curl -X POST localhost:3000/in/demo -H 'Content-Type: application/json' -d '{"a":1}'

/e/{slug} lists what arrived, newest first, one card per request, and refreshes itself without a page reload. /e/{slug}/requests/{id} shows the full header table, the body pretty-printed when it parses as JSON, and the exact bytes as received underneath.

An unknown slug answers 404 and stores nothing. A URL that belongs to nothing gets the branded 404 page. /in/{slug} is the one route that takes no session: a webhook sender has no cookie, and demanding one would defeat the tool.

Deploying it

Four artifacts travel together: the binary, the asset bundle it was built with, the database and the cookie key.

cargo build --release
topcoat asset bundle --release --out dist/assets
cp target/release/hooklog dist/
cd dist && PORT=8080 ./hooklog

--release is not optional. topcoat asset bundle builds the debug profile by default, and the Tailwind stylesheet is declared as asset!(concat!(env!("OUT_DIR"), "/tailwind.css")), whose asset id therefore carries the profile directory. A debug-built bundle serves a debug binary and nothing else. Deploy the mismatch and every request panics a worker thread with failed to resolve asset Asset { id: AssetId(...) }, the process stays up, and the client gets Empty reply from server.

The bundle has to sit next to the binary. AssetBundle::load starts at the executable's own directory and walks up at most six ancestors looking for an assets directory holding a manifest.toml, so dist/hooklog finds dist/assets with no configuration.

Paths are relative to the working directory, not to the binary: started from dist, the app reads and writes dist/hooklog.db and dist/cookie.key. Copy both when you move an instance. The database carries the accounts, the endpoints, the stored requests and the sessions; the key signs the cookies that carry form errors across a redirect.

Environment:

Variable Default Notes
HOST 127.0.0.1 leave it on loopback behind a proxy
PORT 3000

TLS

The router binds a plain TCP or Unix listener and has no TLS. Terminate it in front. deploy/Caddyfile is 22 lines and takes its paths from the environment:

HOOKLOG_TLS_CERT=<cert> HOOKLOG_TLS_KEY=<key> HOOKLOG_ASSETS=<dist/assets> \
  caddy run --config deploy/Caddyfile --adapter caddyfile

It serves /_topcoat/assets/* straight off disk with a one-year immutable cache header, since the filenames already carry a content hash, and proxies everything else to 127.0.0.1:8080. It also caps request bodies at 1 MB, which the application cannot do for itself: every body extractor in the router reads the whole body with to_bytes(body, usize::MAX). A 2 MB POST to a slug that does not exist uploads all 2000000 bytes before the 404; through the proxy the same request stops at 1310552 bytes with a 413.

A certificate for a machine with no public name is self-signed:

openssl req -x509 -newkey rsa:2048 -sha256 -days 365 -nodes \
  -keyout dist/tls/hooklog.key -out dist/tls/hooklog.crt \
  -subj '/CN=localhost' -addext 'subjectAltName=DNS:localhost,IP:127.0.0.1'

curl -k https://localhost:8443/login then answers 200 over TLS 1.3 and HTTP/2. A browser will not: a self-signed certificate produces net::ERR_CERT_AUTHORITY_INVALID with no interstitial to click through.

A deployment reachable from the internet needs four things this one does not have. A public name and an address that accepts inbound connections, which rules out a machine behind carrier-grade NAT. A certificate from an issuer browsers trust, which is caddy's default behavior once a real domain replaces localhost: delete the tls line and let it request one. A supervisor that restarts the process, since the binary exits on SIGTERM and nothing brings it back. Backups of hooklog.db and cookie.key, which hold everything the application knows. There is no health endpoint; GET /login answers 200 without a session and works as one.

What a deploy costs an open tab

A #[shard] is served at /_topcoat/shards/{uuid}, and that uuid comes from Uuid::new_v4() evaluated when the macro expands. Every compilation of the crate publishes the live tail at a different URL, whether or not the source changed.

A tab open across a deploy keeps posting the old URL every two seconds. The new binary does not serve it, and the answer is a 405 with an empty body, because this application registers a catch-all GET page for the branded 404 and that makes the path exist with a method set of GET, HEAD. The browser runtime does not check the status: it takes the response text and swaps it into the widget, so an empty body deletes the list. The heading, the byte summary and the filter box stay, because they are rendered by the page rather than by the shard.

Restarting the same binary costs an open tab one refused connection and nothing else; recompiling is what breaks it. There is no fix from inside the application. Reload the tab after a deploy.

Accounts

An endpoint belongs to the account that created it. The console lists only your endpoints, and /e/{slug}, its request pages and its live tail answer 401 to everyone else, signed in or not.

Passwords are hashed with argon2id at the crate's default parameters, m=19456, t=2, p=1, on a blocking thread so a login never stalls the runtime. A sign-in that names an address with no account still runs a hash and discards it, so a wrong password and an unknown user cost the same time.

The framework carries the session token and the application owns the storage. A row in session_records holds the token's SHA-256 as hex, the user it authenticates and its expiry; the raw 32-byte token exists only in the client's __Host-session cookie, which is Secure, HttpOnly and SameSite=Lax. Every read checks the expiry, and signing out deletes the row. Sessions survive a restart because they live in the database.

One sharp edge is worth knowing before extending this app. A #[shard] and a #[procedure] are public HTTP endpoints under /_topcoat/, and a request to one never runs the page handler or the page's layouts, so no guard written there applies. The live tail and the replay procedure re-check the session and the endpoint's owner themselves, in the same line the page uses. Any new shard or procedure must do the same.

How it is put together

Routes come from the module tree, not from path strings. src/app.rs calls module_router!() and every module under it contributes one URL segment:

src/app.rs                                     /            root layout, branded 404 and 401
src/app/_auth.rs                               /            group layout for the credentials form
src/app/_auth/login.rs                         /login       page and POST
src/app/_auth/register.rs                      /register    page and POST
src/app/_console.rs                            /            group layout, endpoint list
src/app/_console/e/slug.rs                     /e/{slug}    endpoint layout, page and live tail
src/app/_console/e/slug/requests/request_id.rs /e/{slug}/requests/{id}, replay procedure
src/app/endpoints.rs                           /endpoints   create
src/app/logout.rs                              /logout      POST
src/app/ingest.rs                              /in          segment renamed, `in` is a keyword
src/app/catch_all.rs                           /{*path}     raises the 404 the layout catches

_auth and _console start with an underscore, so they are groups: each contributes a layout and no URL segment. Three layouts nest on the request detail page, and each one renders the next through (slot?).

The interface uses six components vendored with topcoat ui add: badge, button, card, input, label and spinner. They live in src/components/ as ordinary source files, not as a dependency. The kit ships no table, so the header table is hand-written HTML.

The tail is a #[shard]: a component the server re-renders whenever one of its arguments changes in the browser. It takes the endpoint slug, the filter text and a tick counter. The framework ships no timer and no push primitive, so two lines of hand-written JavaScript sit behind the widget, both through the raw! escape hatch: a setInterval that bumps the tick counter every two seconds, and a setTimeout that holds the filter for 250 ms of quiet before it reaches the shard. Without that second line, fourteen keystrokes send fourteen requests and abort twelve of them mid-flight.

The filter input lives in the parent component, not in the shard. A re-render replaces the shard's content wholesale and the runtime deletes the signals that content owned, so an input inside it loses its value and its focus on every poll.

Replay is a #[procedure]: a server function the browser calls from a click handler and awaits. It reloads the stored request, re-sends the method, the headers and the exact bytes with reqwest, and returns the status line, the elapsed time and the response body as text. The client comes from application context rather than from the call, which halved the round trip.

Rows live in hooklog.db. The schema is created once, on the first run against a missing file: the ORM can generate a migration but offers no way to apply one, so changing a model today means deleting the file.

Two comparisons that are computed on the server on purpose

A runtime expression, written $(...), is compiled twice: to Rust that runs during the render and to JavaScript that re-runs in the browser. Two of them disagreed here, so both moved to the server, and they are worth knowing about before writing a third.

Ordering strings. The endpoint list can skip names that sort before a cutoff. Written as $(name < cutoff.get()), the server compares UTF-8 bytes and the browser compares UTF-16 code units. An endpoint named ffmpeg exports, which is what pasting ffmpeg exports out of a PDF gives you, sorts before 😀 smoke test in Rust and after it in JavaScript. The server hid the card and the browser showed it, on the same page, with nothing typed. The cutoff is now a GET form and the list is filtered in Rust.

Rendering numbers. The endpoint summary divides total bytes by the span between the first and the last delivery. An endpoint with one delivery has a span of zero, and the same expression printed inf on the server and Infinity in the browser. The rate is now formatted in Rust, one string per unit, and the expression only picks between them.

Both are filed upstream as #236 and #237. Keep arithmetic and ordering on the server; keep the browser for choosing between values the server already produced.

Measured numbers

Measured on a 16-thread Ryzen 9 8945HS, recorded with every command in capture/metrics/:

Cold build, 31 lines of Rust, no database 51.8 s
Cold build, 711 lines, with Tailwind, the UI kit, icons and a self-hosted font 57.2 s
Incremental debug rebuild after a one-line edit 2.4 s
Release build from scratch 235.0 s
Release rebuild after touch, source unchanged 15.2 s
Release binary 23029728 bytes
The same binary, stripped 18177584 bytes
Debug binary, for scale 126042960 bytes
Asset bundle 77661 bytes across 7 files
target/ at the end of the build 4.4 GB
Resident memory, release binary, idle 22112 KB
Startup to first 200 45 ms
First byte, login page, straight at the binary 0.46 to 1.02 ms
First byte, the same page through TLS on loopback 5.76 to 7.12 ms
First byte, endpoint page, 117 stored requests 43 ms
Tail poll on the wire, 101 rows 130 KB every 2 s
Sign-in round trip, debug build, argon2id at m=19456 0.46 s
Hand-written JavaScript in the whole application 2 lines
Authentication, from password hashing to ownership checks 97 lines
Replay, from the procedure to the HTTP work 99 lines
Application Rust 1546 lines in 20 files

capture/ holds the full build record: every command with its exit code and duration, every error verbatim, the friction log, and a running list of what the framework does not have yet.

License

MIT

About

Self-hosted webhook inspector built with Topcoat

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages