chaski
A small, stateless webhook relay for Kubernetes. POST any JSON to a named
route; chaski gates it with a CEL
expression, renders fields with Go templates, and relays the result to a
configured target — an apprise
notification (Pushover, ntfy, Discord, Gotify, Telegram, … 100+ services) or a
plain HTTP request.
Named after the Inca Empire's chasqui relay runners, who passed messages
station-to-station along the royal roads.
Features
- Routing by path —
POST /hooks/{route}; each route is configured
independently and can fan out to several targets concurrently, optionally
gating each target with its own whenExpr.
- CEL gate — a per-route
whenExpr decides whether a request is relayed.
- Go-template fields —
title, message, per-provider params, and HTTP
headers are rendered per request with sprout
helpers and {{ env "…" }} (no filesystem or network access).
- Two target kinds — an apprise notification or a generic HTTP forward.
- SMTP ingestion (optional) — accept notifications as email so appliances
that can only send mail reach the same routes; off by default.
- Signature verification — optional per-route HMAC or shared-token check
over the raw body, with a built-in
github preset.
- Layered config — a single YAML file or a
config.d directory of
fragments merged additively; secrets injected with {{ env "…" }} in any
field.
- Safe by default — optional bearer-token auth, body-size limits,
per-request deadlines, retry with backoff, and a
?dryRun=1 preview that
never sends.
- Operable — Prometheus metrics, a health probe, structured logs, graceful
shutdown, and a published JSON Schema for editor validation.
Usage
Define targets and routes in a config file:
# chaski.yaml
targets:
pushover:
apprise:
url: '{{ env "PUSHOVER_URL" }}' # pover://user@token
routes:
alertmanager:
target: pushover
whenExpr: payload.status == "firing"
title: "[{{ .payload.commonLabels.severity }}] {{ .payload.commonLabels.alertname }}"
message: "{{ .payload.commonAnnotations.summary }}"
Run chaski and send it a webhook (brew install home-operations/tap/chaski,
or grab a binary from the releases). Inbound token auth is off by default —
fine for cluster-internal senders; set CHASKI_WEBHOOK_TOKEN to require a
bearer token (see Configuration):
CHASKI_CONFIG=./chaski.yaml chaski
curl -X POST http://localhost:8080/hooks/alertmanager \
-H "Content-Type: application/json" \
-d '{"status":"firing","commonLabels":{"severity":"critical","alertname":"HighCPU"}}'
Append ?dryRun=1 to preview the matched targets and rendered fields without
sending — including "fired": false when the whenExpr gate doesn't match, so
you can see why a route wouldn't act. Every response also carries an
X-Chaski-Result header (relayed, skipped:gate, skipped:no_targets,
render_error, …) so the outcome is visible even on a bodyless status. Check a
config before deploying it (the same checks the server runs at boot):
chaski validate -c ./chaski.yaml
Render a route against a saved sample body offline — no running server — to see
the rendered fields (and catch a wrong field path or typo'd key before deploy):
chaski validate -c ./chaski.yaml --payload sample.json --route alertmanager
Or fire the route for real — the same pipeline with the delivery performed —
to see exactly what lands in the notification client (--payload - reads
stdin, here too):
chaski send -c ./chaski.yaml --payload sample.json --route alertmanager
Don't have a sample payload yet? chaski can capture one from the real sender.
Set logPayload: true on a route to log each verified inbound body (before
the whenExpr gate, so a gate miss still logs), or set
CHASKI_LOG_UNKNOWN_ROUTES=true to log bodies POSTed to routes that don't
exist yet — point the sender at its final URL, press its test button, and:
kubectl logs deploy/chaski | jq 'select(.route == "alertmanager") | .payload' > sample.json
Payload bodies can carry secrets — both switches are off by default; enable
them deliberately and turn them off when done.
Targets
A route relays to one or more named targets; a target is exactly one of two
kinds.
A target entry can be a bare name or a { name, whenExpr } object. The
whenExpr is a CEL boolean (the same variables as the route gate) that gates
that single target, so one route fans out only to the targets whose expression
matches — useful for mirroring an event to the right dev instance:
routes:
github:
whenExpr: payload.repository.full_name == "immich-app/immich" # optional route prefilter
target:
- prod-notify # bare name ⇒ always (when the route fires)
- { name: dev-alice, whenExpr: 'payload.sender.login == "alice"' }
- { name: dev-bob, whenExpr: "payload.pull_request.draft == false" }
The route fires on its own whenExpr, then each target sends only if its
whenExpr (default true) also matches; if none match, the request is skipped.
Target names and URLs are always config-defined — never taken from the payload.
apprise
A notification through apprise-go: the
URL scheme picks the provider and carries its credentials. A route's params
are URL-encoded onto the target URL's query, so the keys you set are whatever
the chosen provider recognizes — Pushover, for example, reads priority,
sound, url, url_title, and format (html | markdown; renders the
message instead of showing it as plain text — Pushover titles never render
HTML, so keep tags out of title).
targets:
pushover:
apprise:
url: '{{ env "PUSHOVER_URL" }}' # pover://user@token
routes:
backup:
target: pushover
title: "Backup {{ .payload.status }}"
message: "{{ .payload.detail }}"
params:
priority: high # low | normal | high | emergency (or -2..2)
sound: falling
url: "{{ .payload.dashboard }}"
url_title: Open dashboard
title is the notification title, message the body; an empty or omitted
message skips the send (a bodyless notification is pointless). See the
apprise-go provider source for the query
keys each service accepts.
http
A generic HTTP request — only url is required:
targets:
ops-bridge:
http:
url: https://ops.example.internal/ingest # required
method: POST # default: POST
headers: # static or templated values
Authorization: '{{ env "OPS_TOKEN" }}'
Content-Type: application/json
timeout: 5s # default: CHASKI_REQUEST_TIMEOUT
retry: # default: the CHASKI_RETRY_* env
attempts: 3
backoff: 1s
routes:
forward:
target: ops-bridge
# No `message`: the inbound request body is forwarded verbatim. Set `message`
# to send a rendered body instead. A route's `headers` merge onto the
# target's (the target wins a name clash).
A 2xx is success, a 4xx is permanent (not retried), and a 5xx or transport
error is retried with exponential backoff + jitter, bounded by the deadline.
Templating
Every rendered field — title, message, and the params/headers values —
is a Go text/template with
sprout helpers and {{ env "…" }}. The lone
exception is the per-route gate whenExpr, a
CEL boolean. Both see the same variables:
payload, headers, query, method, route, now.
Beyond core CEL, whenExpr has the
strings and
network extensions
(ip(), cidr(), isIP(), isCIDR(), containment — e.g.
cidr("10.0.0.0/8").containsIP(payload.client_ip)), base64.encode/base64.decode,
toJSON(v) (canonical JSON of any value), and truncate(s, n) (rune-safe prefix).
A richer apprise route — a severity-driven title, a bullet per alert, and a
priority/sound chosen from the payload:
routes:
alertmanager:
target: pushover
whenExpr: payload.status == "firing"
title: '[{{ .payload.commonLabels.severity | default "info" | toUpper }}] {{ .payload.commonLabels.alertname }} ({{ len .payload.alerts }} firing)'
message: |-
{{ len .payload.alerts }} firing — {{ .payload.commonLabels.alertname }}
{{ range .payload.alerts }}
• {{ .annotations.summary | trunc 140 }}{{ end }}
params:
priority: '{{ ternary "2" "0" (eq .payload.commonLabels.severity "critical") }}'
sound: '{{ ternary "alien" "pushover" (eq .payload.commonLabels.severity "critical") }}'
url: '{{ .payload.commonAnnotations.runbook_url | default "" }}'
url_title: Runbook
Renders [CRITICAL] HighMemory (2 firing) — default supplies a fallback,
toUpper caps it, len counts, range lists each alert, trunc bounds the
length, and ternary/eq map the severity to a Pushover priority and sound.
A richer http target — Basic auth assembled from two secrets, a normalized
JSON body, and a content-hash idempotency key:
targets:
bridge:
http:
url: https://hooks.example.internal/ingest
headers:
Content-Type: application/json
Authorization: 'Basic {{ printf "%s:%s" (env "BRIDGE_USER") (env "BRIDGE_PASS") | base64Encode }}'
routes:
forward:
target: bridge
message: |-
{{- $b := dict
"event" (.payload.eventType | default "unknown" | toLower)
"repo" (dig "repository" "full_name" .payload | default "n/a")
"url" (.payload.html_url | default "") -}}
{{ toJSON $b }}
headers:
X-Idempotency-Key: "{{ .payload.id | toString | sha256Sum }}"
X-Event: '{{ .payload.eventType | default "unknown" }}'
Body → {"event":"push","repo":"home-operations/chaski","url":"…"} — dict +
toJSON build the payload, dig … | default reads a nested field safely,
base64Encode assembles Basic auth, and sha256Sum derives a stable key.
Helpers come from sprout's safe registries — default/coalesce/ternary,
strings (trunc, toTitleCase, replace, trimAll), dict/dig/pluck,
toJSON/base64Encode, sha256Sum, and time (dateInZone, .now.UTC.Format);
filesystem and network helpers are excluded. See the
sprout registries for the full set.
Shared snippets
A top-level templates: block holds named Go-template snippets. Any route field
(title, message, params.*, headers.*) can reuse one with
{{ template "name" . }}, or {{ include "name" . }} when you need to pipe its
output. Snippets compose (one can call another) and render against the same
variables, so shared formatting lives in one place instead of being copied into
every route:
templates:
# A severity label reused by several routes.
label: '[{{ .payload.severity | default "info" | toUpper }}]'
routes:
alerts:
target: pushover
title: '{{ template "label" . }} {{ .payload.title }}'
message: "{{ .payload.detail }}"
audit:
target: pushover
title: '{{ include "label" . }} audit: {{ .payload.action }}'
Both routes render [CRITICAL] … from the one label snippet. Snippet wiring
is checked at boot / chaski validate, so the whole class of typos fails fast
rather than at request time: a malformed snippet, a reference to an undefined
snippet, and a reference cycle (a → b → a) are all rejected before the
server serves. An include name must be a string literal ({{ include "label" . }}), like the {{ template }} action. whenExpr is CEL, so snippets don't
apply there — only to the Go-template fields.
Verifying senders
A route may require an inbound signature over the raw body. verify is a
discriminated union — exactly one of github, hmac, or token:
routes:
github:
target: relay
verify:
github:
secret: '{{ env "GH_WEBHOOK_SECRET" }}' # HMAC-SHA256 in X-Hub-Signature-256
gitlab:
target: relay
verify:
token: # shared secret, presented verbatim in a header
header: X-Gitlab-Token
secret: '{{ env "GL_WEBHOOK_TOKEN" }}'
custom:
target: relay
verify:
hmac: # generic HMAC
header: X-Signature
algo: sha256 # sha256 (default) | sha512
encoding: hex # hex (default) | base64
prefix: "sha256=" # required on the header value, stripped before decoding
secret: '{{ env "HMAC_SECRET" }}'
secret accepts one value or a list (each is tried, so secrets rotate without
downtime). A failed or missing signature is a quiet 401. Comparisons are
constant-time; secrets are never exposed to whenExpr or templates.
SMTP ingestion
chaski can also accept notifications over SMTP, so devices that can only send
email (printers, NAS boxes, UPSes, legacy monitoring) reach the same routes. It is
off by default; enable it with CHASKI_SMTP_ENABLED=true.
The recipient's localpart selects the route: mail to printer@chaski is handled by
the route named printer. An unknown recipient is rejected, so the listener is never
an open relay. The parsed message is exposed to whenExpr and templates as payload:
| Field |
Meaning |
payload.subject |
decoded Subject |
payload.from |
first From address |
payload.to |
the recipient that selected this route |
payload.body |
the text part, falling back to HTML |
payload.text / payload.html |
the individual parts |
routes:
printer:
target: pushover
title: "{{ .payload.subject }}"
message: "{{ .payload.body }}"
Send it with any mail client:
swaks --server chaski:8025 --to printer@chaski --from device@lan \
--header "Subject: Toner low" --body "tray 1 empty"
Security. v1 has no TLS, so run the listener on a trusted network (a ClusterIP
behind a NetworkPolicy). Set CHASKI_SMTP_AUTH to a user:password,… list to require
SMTP AUTH (PLAIN/LOGIN); with no TLS, those credentials travel in clear text. Per-route
verify (HMAC/token) does not apply to SMTP; AUTH and network isolation are the gate.
Attachments are not forwarded.
Configuration
Routes and targets are loaded from CHASKI_CONFIG; operational settings come
from the environment:
| Variable |
Default |
Description |
CHASKI_CONFIG |
/config/chaski.yaml |
Route config file or config.d directory |
CHASKI_WEBHOOK_TOKEN |
(none) |
Optional inbound bearer token; unset = no auth |
CHASKI_PORT |
8080 |
Webhook listener (POST /hooks/{route}) |
CHASKI_MAX_BODY_BYTES |
1048576 |
Inbound body size cap |
CHASKI_REQUEST_TIMEOUT |
15s |
Whole-request deadline (overridden per target timeout) |
CHASKI_RETRY_ATTEMPTS |
3 |
Default per-target retry attempts (target retry wins) |
CHASKI_RETRY_BACKOFF |
200ms |
Default retry backoff (overridden per target retry) |
CHASKI_METRICS_ENABLED |
true |
Serve Prometheus /metrics; off ⇒ no metrics listener |
CHASKI_METRICS_PORT |
8081 |
Metrics listen port (/metrics only) |
CHASKI_LOG_LEVEL |
info |
debug | info | warn | error |
CHASKI_LOG_FORMAT |
json |
slog handler: json or text |
CHASKI_DISABLE_REQUEST_LOGS |
false |
Silence the per-request access log |
CHASKI_LOG_UNKNOWN_ROUTES |
false |
Log bodies POSTed to nonexistent routes (still a 404) |
CHASKI_SHUTDOWN_TIMEOUT |
15s |
Graceful drain bound on SIGINT/SIGTERM |
CHASKI_SMTP_ENABLED |
false |
Enable the SMTP ingestion listener |
CHASKI_SMTP_PORT |
8025 |
SMTP listener port (when enabled) |
CHASKI_SMTP_AUTH |
(none) |
user:password,… list; set ⇒ require AUTH |
CHASKI_SMTP_HOSTNAME |
chaski |
Name announced in the SMTP greeting |
CHASKI_SMTP_MAX_MESSAGE_BYTES |
1048576 |
Inbound SMTP message size cap |
CHASKI_SMTP_MAX_RECIPIENTS |
50 |
Max recipients per SMTP message |
A JSON Schema for the route config is published at
config.schema.json — point your editor's YAML language
server at it for completion and validation. GET /healthz (liveness) and
GET /readyz (readiness) are served on the main webhook port, and Prometheus
GET /metrics on its own optional port; requests to these monitoring endpoints
are logged at debug, so scrapes and probes don't fill the info access log
(set CHASKI_LOG_LEVEL=debug to see them).
Key metrics:
| Series |
Labels |
Meaning |
chaski_relays_total |
route, result |
Relay outcomes per route |
chaski_target_sends_total |
target, kind, outcome |
Per-target sends (success/permanent/retryable) |
chaski_target_retries_total |
target, kind |
Retried send attempts per target |
chaski_webhook_rejected_total |
reason |
Inbound rejects (token/signature/body/…) |
chaski_smtp_rejected_total |
reason |
SMTP rejects (auth/recipient) |
chaski_http_request_duration_seconds |
method |
Inbound request latency |
chaski_build_info |
version, commit |
Running build (value 1) |
Deployment
A Helm chart is published as an OCI artifact and runs the distroless
ghcr.io/home-operations/chaski image:
helm install chaski oci://ghcr.io/home-operations/charts/chaski
It defaults to a RollingUpdate strategy and rolls pods automatically when the
config changes.
Development
The toolchain is pinned with mise; mise tasks lists
everything.
mise run build # go build ./...
mise run test # go test -race ./... with coverage
mise run lint # golangci-lint
mise run helm-unittest # chart template tests
Releases
Release artifacts (archives, the Homebrew cask, SBOMs, and Cosign signatures) are built and published with GoReleaser Pro. If it's useful to your own projects, consider sponsoring its author.
License
AGPL-3.0.