chaski

module
v0.0.0-...-57ebe6e Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 20, 2026 License: AGPL-3.0

README

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 pathPOST /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 fieldstitle, 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 (aba) 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.

Directories

Path Synopsis
cmd
chaski command
Command chaski runs a stateless webhook relay: it accepts JSON over HTTP, gates each request with a CEL expression, renders fields with Go templates, and relays the result to a configured target (an apprise notification or a generic HTTP request).
Command chaski runs a stateless webhook relay: it accepts JSON over HTTP, gates each request with a CEL expression, renders fields with Go templates, and relays the result to a configured target (an apprise notification or a generic HTTP request).
schema command
Command schema prints the JSON Schema for chaski's route-config file (chaski.yaml: routes + targets) to stdout.
Command schema prints the JSON Schema for chaski's route-config file (chaski.yaml: routes + targets) to stdout.
internal
config
Package config loads chaski's runtime configuration from CHASKI_*-prefixed environment variables.
Package config loads chaski's runtime configuration from CHASKI_*-prefixed environment variables.
gate
Package gate compiles and evaluates a route's whenExpr — the single CEL boolean that decides whether chaski acts on a request.
Package gate compiles and evaluates a route's whenExpr — the single CEL boolean that decides whether chaski acts on a request.
relay
Package relay is the heart of chaski: it compiles every route once at load (gate + field templates + verify + resolved target sinks — fail-fast) and runs the per-request pipeline — verify, gate, render, relay (with concurrent fan-out) — mapping the outcome to the response contract.
Package relay is the heart of chaski: it compiles every route once at load (gate + field templates + verify + resolved target sinks — fail-fast) and runs the per-request pipeline — verify, gate, render, relay (with concurrent fan-out) — mapping the outcome to the response contract.
render
Package render compiles and evaluates a route's Go-template fields — title, message, and the params/headers values.
Package render compiles and evaluates a route's Go-template fields — title, message, and the params/headers values.
server
Package server wires chaski's webhook and metrics listeners together and manages their lifecycle.
Package server wires chaski's webhook and metrics listeners together and manages their lifecycle.
sink
Package sink delivers a rendered relay to a configured target — an apprise notification (through the swappable Notifier seam) or a generic HTTP request.
Package sink delivers a rendered relay to a configured target — an apprise notification (through the swappable Notifier seam) or a generic HTTP request.
smtp
Package smtp is chaski's optional SMTP front-door: it accepts mail and relays it through the same compiled route engine the webhook listener uses, selecting the route by the recipient localpart (sonarr@...
Package smtp is chaski's optional SMTP front-door: it accepts mail and relays it through the same compiled route engine the webhook listener uses, selecting the route by the recipient localpart (sonarr@...
tmpl
Package tmpl builds the go-sprout/sprout function map that chaski renders every template with — config-load fields (target URLs/headers, verify secrets) and per-request route fields (title, message, params, headers) alike.
Package tmpl builds the go-sprout/sprout function map that chaski renders every template with — config-load fields (target URLs/headers, verify secrets) and per-request route fields (title, message, params, headers) alike.
verify
Package verify implements a route's optional inbound signature check: an HMAC over the raw request body, or a shared-secret token in a header.
Package verify implements a route's optional inbound signature check: an HMAC over the raw request body, or a shared-secret token in a header.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL