English | 한국어
A generic relay hub: receives any inbound protocol/format, applies CEL/Expr expression-based filter, transform, and routing rules, then forwards messages to outbound channels.
Any inbound (HTTP REST / WebSocket / TCP / ...)
↓
Parser pipeline (JSON / Form / XML / Logfmt / Regex)
↓
CEL / Expr expression filter + transform + routing
↓
Any outbound (Webhook / ...)
- Multi-protocol inbound — HTTP REST, WebSocket, TCP
- Parser pipeline — JSON, Form, XML, Logfmt, Regex (with custom pattern) per input
- Expression-based routing — per-input CEL/Expr filter, mapping, and routing rules
- At-least-once delivery — file-queue backed; messages survive restarts
- Exponential backoff retry — per-output
retryCount/retryDelayMs - Config hot-reload — change outputs and rules without restarting
- Bearer token auth — per-input independent secret
- Dot-notation templates — produce nested JSON output via
parent.childkeys
- Go 1.25+
# Clone and build
go build -o relaybox ./cmd/server/
# Copy example config
cp docs/config.example.yaml config.yaml
# Edit config.yaml, then:
# Start server
./relaybox start --config config.yaml# Build for current platform
make build
# Cross-compile all platforms (output to dist/)
make build-all
# Run tests
make test
# Full release build (clean + build-all + checksums)
make release VERSION=1.0.0config.yaml example (see docs/config.example.yaml for full reference):
server:
port: 8080
readTimeout: 30s
writeTimeout: 30s
tls:
enabled: false
certFile: ""
keyFile: ""
log:
level: INFO # DEBUG, INFO, WARN, ERROR
format: JSON # JSON, TEXT
inputs:
- id: beszel
engine: CEL # required — CEL or EXPR
parser: JSON # JSON, FORM, XML, LOGFMT, REGEX
secret: "change-me"
rules:
# Rule 1: conditional routing
- filter: 'data.severity == "HIGH"'
mapping:
level: '"CRITICAL"'
routing:
- condition: 'data.level == "CRITICAL"'
outputIds: [ops-webhook]
# Rule 2: always forward (no filter)
- outputIds: [notify-bot]
- id: tcp-input
engine: CEL
address: ":9001"
delimiter: "\n"
parser: JSON
rules:
- outputIds: [ops-webhook] # simple: no filter, send all
outputs:
- id: ops-webhook
type: WEBHOOK
engine: CEL # required — CEL or EXPR
url: "https://hooks.example.com/xyz"
template:
text: 'data.input + ": " + data.payload'
retryCount: 3
retryDelayMs: 1000
skipTLSVerify: false
# Dot-notation keys produce nested JSON
- id: notify-bot
type: WEBHOOK
engine: CEL
url: "https://example.com/api/v1/bots/1/text"
secret: "bearer-token" # sent as Authorization: Bearer <secret>
template:
content.type: '"text"'
content.text: 'data.input + " alert: " + data.payload'
retryCount: 3
retryDelayMs: 1000
timeoutSec: 10
storage:
type: SQLITE
path: "./data/relaybox.db"
queue:
type: FILE
path: "./data/queue"
workerCount: 2
worker:
defaultRetryCount: 3 # fallback when output has no retryCount
defaultRetryDelay: "1s" # fallback base retry delay (Go duration)
pollBackoff: "500ms" # sleep between empty-queue polls| Field | Type | Required | Default | Description |
|---|---|---|---|---|
port |
int | No | 8080 |
HTTP server listen port |
readTimeout |
duration | No | 30s |
Maximum time to read a full request. Go duration string (e.g. 10s, 1m). |
writeTimeout |
duration | No | 30s |
Maximum time to write a full response. Go duration string. |
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
enabled |
bool | No | false |
Enable TLS. When true, certFile and keyFile must be set. |
certFile |
string | No | "" |
Path to PEM-encoded TLS certificate file. |
keyFile |
string | No | "" |
Path to PEM-encoded TLS private key file. |
| Field | Type | Required | Default | Values | Description |
|---|---|---|---|---|---|
level |
string | No | INFO |
DEBUG, INFO, WARN, ERROR |
Minimum log level to emit. DEBUG includes all levels; ERROR emits only errors. |
format |
string | No | JSON |
JSON, TEXT |
Log output format. JSON outputs structured JSON lines; TEXT outputs human-readable key=value pairs. |
Each entry in the inputs list defines one inbound endpoint.
| Field | Type | Required | Default | Values | Description |
|---|---|---|---|---|---|
id |
string | Yes | — | Any unique string | Identifier used in API URLs (/inputs/{id}/messages). Must be unique across all inputs. Stored on every message as data.input. |
engine |
string | Yes | — | CEL, EXPR |
Expression engine for evaluating filter/mapping/routing rules on this input. See Expression Engine Reference. |
parser |
string | No | JSON |
JSON, FORM, XML, LOGFMT, REGEX |
Parser applied to the raw request body before expression evaluation. See Parser Reference. |
secret |
string | No | "" |
Any string | Bearer token required in Authorization: Bearer <secret>. If empty, all requests are accepted without auth. |
address |
string | No | "" |
e.g. :9001 |
TCP listen address. Only applies to TCP inputs. If set, relaybox listens for TCP connections at this address. Not used for HTTP/WebSocket inputs. |
delimiter |
string | No | "\n" |
Any string | Record delimiter for TCP streams. Messages are split on this string. Only applies to TCP inputs. |
pattern |
string | No | "" |
RE2 regex | Named capture group regex for the REGEX parser. Required when parser: REGEX. Example: (?P<level>\w+) (?P<msg>.+). |
rules |
list | No | [] |
— | Ordered list of routing rules evaluated for each received message. See inputs[].rules[]. |
Rules are evaluated in order. A rule matches when its filter expression returns true (or when filter is absent). When a rule matches, its mapping is applied and outputs are selected via outputIds or routing.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
filter |
string | No | "" (always true) |
CEL/Expr boolean expression. If false, this rule is skipped for the current message. If absent, the rule always matches. |
mapping |
map[string]string | No | {} |
Key-value pairs where each value is a CEL/Expr expression. Evaluated results are added to data before routing is evaluated. Existing fields can be overwritten. |
outputIds |
list[string] | No | [] |
Static list of output IDs to send to when this rule matches and no routing condition fires. Acts as a fallback. |
routing |
list | No | [] |
Conditional output selection evaluated after mapping. See inputs[].rules[].routing[]. |
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
condition |
string | No | "" (always true) |
CEL/Expr boolean expression evaluated against data (including mapped fields). If true, the listed outputIds receive the message. |
outputIds |
list[string] | Yes | — | Output IDs to send to when condition is true. |
Each entry defines one outbound destination.
| Field | Type | Required | Default | Values | Description |
|---|---|---|---|---|---|
id |
string | Yes | — | Any unique string | Referenced by outputIds in rules. Must be unique across all outputs. |
type |
string | Yes | — | WEBHOOK |
Output type. Currently only WEBHOOK is supported. |
engine |
string | Yes | — | CEL, EXPR |
Expression engine used to evaluate template expressions. See Expression Engine Reference. |
url |
string | Yes (WEBHOOK) | — | HTTP/HTTPS URL | Destination URL for webhook delivery. |
template |
map[string]string | No | {} |
— | Key-value pairs where each value is a CEL/Expr expression rendered into the outbound JSON body. Dot-notation keys (e.g. content.type) produce nested JSON objects. |
secret |
string | No | "" |
Any string | If set, sent as Authorization: Bearer <secret> in the outbound webhook request. |
retryCount |
int | No | 0 (uses worker.defaultRetryCount) |
0–N |
Number of retry attempts after the first failure. 0 means use the worker-level default. Retries use exponential backoff starting at retryDelayMs. |
retryDelayMs |
int | No | 0 (uses worker.defaultRetryDelay) |
0–N |
Base delay in milliseconds between retries. Each retry doubles the delay. 0 means use the worker-level default. |
timeoutSec |
int | No | 0 (no timeout) |
0–N |
HTTP request timeout in seconds. 0 means no timeout. |
skipTLSVerify |
bool | No | false |
true, false |
Skip TLS certificate verification for HTTPS destinations. Use only in development or with self-signed certificates. |
| Field | Type | Required | Default | Values | Description |
|---|---|---|---|---|---|
type |
string | Yes | — | SQLITE |
Storage backend type. Currently only SQLITE is supported. |
path |
string | Yes | — | File path | Path to the SQLite database file. Created automatically if it does not exist. Example: ./data/relaybox.db. |
| Field | Type | Required | Default | Values | Description |
|---|---|---|---|---|---|
type |
string | Yes | — | FILE |
Queue backend type. Currently only FILE is supported. Messages are persisted as JSON files and survive restarts. |
path |
string | Yes | — | Directory path | Directory where queued message files are stored. Created automatically if it does not exist. Example: ./data/queue. |
workerCount |
int | No | 1 |
1–N |
Number of concurrent relay worker goroutines processing the queue. Higher values increase throughput but consume more CPU/memory. |
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
defaultRetryCount |
int | No | 3 |
Fallback retry count used when an output's retryCount is 0 or not set. If set to 0 or negative, no retries are performed. |
defaultRetryDelay |
duration | No | 1s |
Fallback base retry delay used when an output's retryDelayMs is 0 or not set. Go duration string (e.g. 500ms, 2s). |
pollBackoff |
duration | No | 500ms |
Sleep duration between queue polls when the queue is empty. Lower values reduce latency but increase CPU usage. Go duration string. |
All expressions (filter, mapping, routing, template) share the same data context:
| Variable | Type | Description |
|---|---|---|
data.id |
string | Message ULID — unique identifier assigned at receive time |
data.input |
string | Input ID value (e.g. beszel, dozzle) |
data.payload |
string | Raw payload string as received |
data.createdAt |
string | Receive timestamp in RFC3339 format |
data.<field> |
any | Fields added or overwritten by mapping expressions |
Filter — boolean expression; false drops the message for this rule:
filter: 'data.input == "beszel"'Mapping — enrich data with computed fields:
mapping:
severity: '"HIGH"'
label: 'data.input + "-alert"'Routing — conditional output selection (evaluated after mapping):
routing:
- condition: 'data.severity == "HIGH"'
outputIds: [ops-webhook]Template — render output fields with expressions. Dot-notation keys generate nested JSON:
template:
text: 'data.input + ": " + data.payload'
content.type: '"text"'
content.text: 'data.payload'| Engine | Value | Library | Description |
|---|---|---|---|
| CEL | CEL |
google/cel-go | Google Common Expression Language. Strongly typed, compiled at startup. Recommended for production — compile errors are caught at startup, not at runtime. |
| Expr | EXPR |
expr-lang/expr | Lightweight expression evaluator. Duck-typed, simpler syntax. Suitable for simple filtering and mapping. |
Both engines receive the same data map and return the same result types. Compiled programs are cached per expression string — repeated evaluation of the same expression has near-zero overhead.
CEL example:
filter: 'data.input == "beszel" && data.payload.contains("error")'Expr example:
filter: 'data.input == "beszel" && contains(data.payload, "error")'The parser field on each input determines how the raw request body is parsed before expression evaluation. Parsed fields are available as data.<field> in expressions.
| Parser | Value | Applicable Content-Type | Output | Description |
|---|---|---|---|---|
| JSON | JSON |
application/json |
Fields from JSON object | Parses the body as a JSON object. Top-level keys become data.<key>. Nested objects are accessible via dot notation in CEL (data.host.name). |
| Form | FORM |
application/x-www-form-urlencoded |
Form fields | Parses URL-encoded form data. Each key becomes data.<key>. Multi-value keys use the first value. |
| XML | XML |
application/xml, text/xml |
XML element values | Parses XML and flattens element text content into data.<element>. Attributes are not parsed. |
| Logfmt | LOGFMT |
text/plain |
Logfmt key-value pairs | Parses logfmt format (key=value key2="value2"). Each key becomes data.<key>. |
| Regex | REGEX |
text/plain |
Named capture groups | Applies the pattern regex to the raw body. Named capture groups ((?P<name>...)) become data.<name>. Requires pattern to be set. |
If parsing fails (malformed body), the message is still accepted but data contains only the base fields (id, input, payload, createdAt).
Config hot-reload watches the config file for changes and applies updates without restarting the server.
| Config Section | Hot-Reloadable | Notes |
|---|---|---|
inputs[].rules |
Yes | Rule changes take effect on the next message processed |
outputs[] |
Yes | Output URL, template, retry settings updated immediately |
inputs[] (new/removed) |
No | Adding or removing inputs requires a restart |
server |
No | Port and timeout changes require a restart |
log |
No | Log level/format changes require a restart |
storage |
No | Storage path changes require a restart |
queue |
No | Queue path/workerCount changes require a restart |
worker |
No | Worker defaults require a restart |
validateConfig checks the following at startup (and on hot-reload):
- Each input must have a non-empty
idandtype - Each input
enginemust beCELorEXPR - Each output must have a non-empty
idandtype - Each output
enginemust beCELorEXPR - Output IDs referenced in
outputIdsmust exist in theoutputslist storage.pathmust be non-empty whenstorage.typeisSQLITEqueue.pathmust be non-empty whenqueue.typeisFILE
POST /inputs/{inputId}/messages
Authorization: Bearer <secret>
Content-Type: application/json
{"host": "server1", "status": "down"}
Response 201 Created:
{"id": "01J...", "status": "PENDING"}Header: Location: /inputs/{inputId}/messages/{messageId}
Retrieve a message by ID. The inputId is used only for authentication (Bearer token lookup); message lookup is by messageId alone.
GET /inputs/{inputId}/messages/{messageId}
Authorization: Bearer <secret>
Response 200 OK:
{
"id": "01JXXXXXXXXXXXXXXXXXXXXXX",
"version": 1,
"input": "beszel",
"payload": {"host": "server1", "status": "down"},
"createdAt": "2026-03-24T12:00:00Z",
"status": "PENDING",
"retryCount": 0
}status values:
| Value | Description |
|---|---|
PENDING |
Queued, not yet processed |
DELIVERED |
Successfully delivered to all matched outputs |
FAILED |
All retry attempts exhausted without successful delivery |
Note: lastAttemptAt is omitted from the response when no delivery has been attempted yet.
Error responses (RFC 7807):
| Status | Condition |
|---|---|
401 Unauthorized |
Missing or invalid Bearer token |
404 Not Found |
Message ID does not exist |
GET /inputs/{inputId}/messages/ws
Authorization: Bearer <secret>
Send JSON messages over the connection; handled identically to HTTP POST.
Connect to the configured address and send newline-delimited (or custom delimiter) messages. No token auth — secure via network policy.
GET /healthz
→ 200 OK
GET /docs → Redoc HTML UI
GET /docs/openapi → OpenAPI spec (JSON)
GET /docs/asyncapi → AsyncAPI spec (JSON)
All HTTP responses include an X-API-Version header.
Hexagonal Architecture (Ports & Adapters). Dependencies always flow inward toward the domain.
domain (0 deps)
↑
application/port/{input,output} ← interface definitions
↑
application/service ← business logic
↑
adapter/{input,output} ← external world
↑
cmd/server/main.go ← DI assembly, cobra CLI
| Path | Role |
|---|---|
internal/domain/ |
Entities (Message, Output), enums (MessageStatus, OutputType), sentinel errors |
internal/application/port/input/ |
ReceiveMessageUseCase, GetMessageUseCase interfaces |
internal/application/port/output/ |
MessageRepository, MessageQueue, OutputSender, OutputRegistry, RuleConfigReader interfaces |
internal/application/service/ |
MessageService (Receive, GetByID), RelayWorker (Start) |
internal/config/ |
Viper-based YAML loader, InMemoryRuleConfigReader, hot-reload (Watch) |
internal/adapter/input/http/ |
chi router, RFC 7807 errors, X-API-Version middleware |
internal/adapter/input/websocket/ |
gorilla/websocket inbound handler |
internal/adapter/output/sqlite/ |
sqlc-based SQLite repository |
internal/adapter/output/filequeue/ |
File-based at-least-once queue |
internal/adapter/output/webhook/ |
HTTP Webhook sender |
cmd/server/ |
cobra start command, full DI assembly |
test/e2e/ |
End-to-end flow tests |
Push a version tag to trigger GitHub Actions — it builds all platform binaries and creates a GitHub Release automatically.
git tag 1.0.0
git push origin 1.0.0Supported platforms: linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64, windows/arm64
Each release includes SHA256 checksums in checksums.txt.
# Full test suite (with race detector)
go test -race ./... -timeout 60s
# Static analysis
go vet ./...
# Regenerate sqlc code (after SQL changes)
cd internal/adapter/output/sqlite && sqlc generate
# Build for current platform
make build
# Cross-compile all platforms
make build-all