Patchwork is a small HTTP relay for connecting two requests as a live byte stream. It is inspired by patchbay.pub and duct, and is particularly useful as a webhook receiver when the real consumer is behind NAT or only runs on demand.
Patchwork does not store relay bodies. A queue producer and consumer rendezvous, then bytes flow directly from the producer request to the consumer response with end-to-end backpressure. Cancellation on either side tears down the transfer.
Builds use the vendored dependency tree and Go 1.26.2:
go build -mod=vendor -o patchwork .
SECRET_KEY="$(openssl rand -hex 32)" ./patchwork start --port 8080In separate terminals, connect a consumer and producer:
curl --no-buffer http://localhost:8080/public/events
curl --data-binary @payload.json http://localhost:8080/public/eventsEither side may arrive first. Both requests remain open until they are paired and the body has been consumed. There is no offline message queue.
Queue mode is the default. Each producer is paired with exactly one consumer. All of these select queue behavior:
/public/name
/public/queue/name
/public/./name
Producers use POST, PUT, or PATCH; consumers use GET. The legacy /p/
prefix is an alias for /public/. A GET with a non-empty body query parameter
acts as a small producer for shell and browser use:
curl 'http://localhost:8080/public/demo?body=hello'Pub/sub sends one live body stream to every subscriber already waiting when the publisher starts:
curl --no-buffer http://localhost:8080/public/pubsub/events
curl --no-buffer http://localhost:8080/public/pubsub/events
curl -d event http://localhost:8080/public/pubsub/events/public/./events?pubsub=true is equivalent. A subscription receives one
publication and then closes. A publisher with no current subscribers succeeds
without publishing or retaining the body. Memory use is bounded; the slowest
connected subscriber applies backpressure while disconnected subscribers are
removed independently.
GET /h and GET /r create an unguessable channel name and a deterministic
HMAC secret. SECRET_KEY must remain stable across restarts if existing hook
URLs should remain valid.
Forward hooks protect the producing side:
hook=$(curl -s http://localhost:8080/h)
# Read `channel` and `secret` from the JSON response.
# Public consumer, normally kept connected before the webhook arrives:
curl --no-buffer http://localhost:8080/h/CHANNEL
# Protected producer URL configured at the webhook source:
curl --data-binary @event.json \
'http://localhost:8080/h/CHANNEL?secret=SECRET'Reverse hooks protect the consuming side:
curl -d event http://localhost:8080/r/CHANNEL
curl --no-buffer 'http://localhost:8080/r/CHANNEL?secret=SECRET'Hook secrets are accepted only in the secret query parameter. Request logs
record query parameter names, not values.
Delivery mode is sender-selected per producer request:
mode=queue(default): block until exactly one consumer takes the message.mode=pubsub: fan out to all currently waiting consumers; succeed immediately with the message dropped when none are waiting. The legacy?pubsubflag is equivalent.
Consumers just GET the channel with no mode parameter and accept whichever
mode the producer chose:
curl --no-buffer http://localhost:8080/h/CHANNEL
curl --data-binary @event.json \
'http://localhost:8080/h/CHANNEL?secret=SECRET&mode=pubsub'discard=true on the producer drains and drops the body and delivers only
metadata (Patch-Method, Patch-Uri, Patch-H-*) with an empty body. Use it
for notify-only pings or payloads the relay should not see. Combined with
mode=pubsub it is a fire-and-forget ping that succeeds even with no
consumer waiting.
The consumer receives the original request body plus:
Patch-Method: the exact producer method.Patch-Uri: the exact producer path and query string.- Every end-to-end request header under its original name, including repeated
webhook signature headers and
Authorization.
Connection-specific and framing headers are discarded. This includes headers
named by Connection, Content-Length, Transfer-Encoding, and Upgrade.
Patchwork owns response framing and flushes chunks as they arrive.
Patch-Uri can contain credentials supplied in the producer URL. Treat relay
consumers as trusted data-plane peers even though Patchwork redacts those values
from its own logs.
Paths below /req/ and /res/ form a paired exchange. A requester first sends
its body on /req/name, then waits for a stream on /res/name.
A regular responder can predeclare a fixed response while waiting for a request:
# Start first. The body and controls become the eventual requester response.
curl -H 'Patch-Status: 201' -H 'Patch-H-X-Result: created' \
-d '{"created":true}' http://localhost:8080/public/res/create
curl -d '{"name":"example"}' http://localhost:8080/public/req/createUse switch mode when a worker must inspect the request before constructing a response:
# Requester; blocks for the final response.
curl -d '{"task":"build"}' http://localhost:8080/public/req/jobs
# Worker selects a temporary response channel. Its response contains the
# request body and the relayed Patch-Method, Patch-Uri, and Patch-H-* metadata.
curl -d worker-42 'http://localhost:8080/public/res/jobs?switch=true'
# Worker posts the computed response. Only these explicit controls are decoded
# into the original requester's response.
curl -H 'Patch-Status: 202' -H 'Patch-H-X-Worker: worker-42' \
-d accepted http://localhost:8080/public/worker-42Patch-Status must contain exactly one status from 200 through 599.
Patch-H-Name supplies a final response header. Invalid or unsafe metadata is
rejected before a regular responder can claim a request. Switch workers time out
after 30 seconds by default and produce a 504 response.
/u/{username}/... is optional and backed by the local sqlite identity
store. Supply a token using Authorization: Bearer TOKEN; an omitted token
selects a literal token named public. Users, tokens, and notification
backends are managed through the admin API (see below) instead of files in
git repositories.
# Bootstrap the first admin (uses PATCHWORK_DB_PATH, default ./patchwork.db)
patchwork admin create --username alice --admin
# Then issue tokens via the admin API, e.g.
curl -b session-cookie -X POST http://localhost:8080/api/v1/users/alice/tokens \
-d '{"name":"webhook-client","patterns":{"POST":["/incoming/*","/_/ntfy"]}}'Permissions use OpenSSH-style pattern lists and are selected by HTTP method
(GET, POST, PUT, DELETE, PATCH, plus huproxy targets). Token
lookups hit the local database on every request, so revocation takes effect
immediately — there is no cache to invalidate.
See configuration and notifications for the full formats.
Token, user, notification, session, and group management lives behind a
versioned admin API under /api/v1, authenticated by WebUI sessions. The
WebUI at /admin (same binary) is a thin consumer of exactly this API.
Browser login uses Authentik OIDC when PATCHWORK_OIDC_ISSUER is set;
otherwise the API is driven with the bootstrap admin plus direct store
access. Inbound SCIM provisioning (/scim/v2/...) is an optional,
off-by-default capability for Users and Groups.
/huproxy/{user}/{host}/{port} tunnels a TCP connection over a binary WebSocket
after checking the user's huproxy ACL:
wss://patchwork.example/huproxy/alice/git.internal.example/22
Authorization: Bearer TOKEN
Only binary WebSocket messages are accepted. Tunnel cancellation closes both the WebSocket and TCP sides so blocked reads do not leak connections.
| Variable | Default | Purpose |
|---|---|---|
SECRET_KEY |
required | HMAC key for hook secrets |
PATCHWORK_DB_PATH |
./patchwork.db |
sqlite identity store location (created/tightened to 0600) |
H2C |
unset | Set to true/1/yes to serve HTTP/2 cleartext directly instead of HTTP/1.1 |
TLS_CERT_FILE / TLS_KEY_FILE |
unset | Serve HTTPS directly when both are set; Go negotiates HTTP/2 automatically. Mutually exclusive with H2C |
PATCHWORK_OIDC_ISSUER |
unset | OIDC issuer for WebUI login when set (plus PATCHWORK_OIDC_CLIENT_ID, PATCHWORK_OIDC_CLIENT_SECRET) |
PATCHWORK_SCIM_ENABLED |
unset | Set to true to enable inbound SCIM provisioning (requires PATCHWORK_SCIM_TOKEN) |
METRICS_TOKEN |
unset | Enables authenticated /metrics when set |
TRUSTED_PROXY_CIDRS |
unset | Comma-separated proxies trusted to supply client-IP headers |
LOG_LEVEL |
INFO |
DEBUG, INFO, WARN, or ERROR |
LOG_SOURCE |
false |
Include source locations in logs |
Only SECRET_KEY is required for a public or hook-only deployment.
Forwarding headers are ignored unless the direct network peer
belongs to TRUSTED_PROXY_CIDRS.
GET /healthz and GET /status are liveness endpoints. /metrics returns 404
unless METRICS_TOKEN is configured, then requires
Authorization: Bearer METRICS_TOKEN.
By default patchwork serves plain HTTP/1.1, which is correct behind a
TLS-terminating reverse proxy (the Quadlet/Fly layout). Admins serving it
directly can set H2C or TLS_CERT_FILE/TLS_KEY_FILE for HTTP/2-capable
endpoints; point healthcheck --url at the matching scheme in that case.
Podman builds the image entirely from vendored dependencies. The default image
is native to the host; container-push builds and publishes an amd64/arm64 OCI
manifest:
make container-smoke REGISTRY=localhost
podman run --rm -p 8080:8080 \
--volume patchwork-data:/var/lib/patchwork:U,Z \
-e SECRET_KEY="a-long-random-secret" localhost/patchwork:latestThe image sets PATCHWORK_DB_PATH=/var/lib/patchwork/patchwork.db. Mount that
directory, rather than only the database file, so SQLite can create its WAL and
shared-memory sidecars. The :U option initializes named-volume ownership for
the image's non-root user; :Z gives it a private SELinux label.
deployments/patchwork.container is a
production-oriented system Quadlet example. Install it under
/etc/containers/systemd/, create the root-readable environment file it
references, then reload and start it:
sudo install -m 0644 deployments/patchwork.container /etc/containers/systemd/
sudo install -d -m 0750 /etc/patchwork
sudo install -m 0600 /dev/null /etc/patchwork/patchwork.env
sudoedit /etc/patchwork/patchwork.env
sudo systemctl daemon-reload
sudo systemctl enable --now patchwork.serviceThe environment file must contain SECRET_KEY=.... The Quadlet creates the
patchwork-data named volume, binds only to loopback for a local reverse proxy,
runs the image read-only with no added Linux capabilities, performs
application-level health checks, and uses Podman's registry auto-update
integration. Adjust PublishPort and EnvironmentFile for a rootless user
unit.
For backups, stop Patchwork cleanly before copying patchwork.db, or use
SQLite's online-backup tooling. Do not copy only the main database while the
service is running in WAL mode.
The process handles SIGINT and SIGTERM with graceful HTTP shutdown. Active relays are canceled if they do not complete within the shutdown window.
The default verification suite formats, vets, runs shuffled race-enabled tests, and enforces the coverage floor:
make verifyThe relay stress target repeats its concurrency suite 100 times under the race detector:
make test-stressThe tests cover live pre-EOF streaming, exact-once queue delivery, pub/sub backpressure and disconnects, mid-transfer cancellation, shutdown, local token validation/rotation/revocation, admin API guards, OIDC login against a stub provider, SCIM provisioning, HTTP metadata validation, hooks, notifications, metrics authentication, rate-limit spoofing and bounds, and WebSocket/TCP tunnel lifecycle behavior.
MIT