Skip to content

This page covers the transport, the secrets, the outbound calls and the exposed endpoints. Read it before you put QueueBox on a network that you do not control.

QueueBox listens on plain HTTP. It does not terminate TLS. Put a reverse proxy or an ingress in front of it, and terminate TLS there. Certificate rotation then stays out of the application.

Never expose the QueueBox port directly to the internet. Publish only the inbox path. /metrics, /health and /admin stay inside your network. Set server.managementPort to move them to a separate port, so that an ingress rule on the data port cannot publish them by mistake.

The example terminates TLS at the ingress and sends plain HTTP to the service inside the cluster. It limits the request body to the same value as inbox.maxBodyBytes.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: queuebox
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/proxy-body-size: 1m
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: nginx
tls:
- hosts:
- webhooks.example.com
secretName: queuebox-tls
rules:
- host: webhooks.example.com
http:
paths:
- path: /inbox
pathType: Prefix
backend:
service:
name: queuebox
port:
number: 8080
---
apiVersion: v1
kind: Service
metadata:
name: queuebox
spec:
selector:
app: queuebox
ports:
- port: 8080
targetPort: 8080
server {
listen 443 ssl http2;
server_name webhooks.example.com;
ssl_certificate /etc/ssl/certs/queuebox.crt;
ssl_certificate_key /etc/ssl/private/queuebox.key;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 1m;
location /inbox/ {
proxy_pass http://queuebox:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

An HTTP source can require a bearer token, an API key or an HMAC signature. A request that fails the check gets 401, and QueueBox stores nothing. See Authenticate requests.

Authenticate every source that the internet can reach. A source without auth accepts any caller that reaches the path.

QueueBox calls an HTTP destination with the scheme that destinations.<name>.baseUrl names. Use https:// for every destination that leaves your network. QueueBox refuses a base URL that is not an absolute HTTP or HTTPS URL.

QueueBox also refuses:

  • a destination URL that carries a user name or a password,
  • a destination path that carries a . or a .. segment.

QueueBox never follows a redirect for a destination. A 3xx answer fails the publish, and the retry or the dead-letter path runs. Without this rule, a public destination could redirect QueueBox to a metadata address with the destination credentials attached.

Set http.blockPrivateAddresses: true when the destination configuration comes from a layer that you trust less. QueueBox then refuses a destination that resolves to a loopback address, a link-local address or a private range. The same check covers the OAuth2 tokenUrl, which carries the client secret in its request body.

# fragment
http:
blockPrivateAddresses: true

Every credential field accepts a file: reference. QueueBox reads the file once, at start, and removes the trailing newline.

# fragment
database:
password: file:/run/secrets/queuebox-db-password
sources:
stripe:
type: http
path: /stripe
idempotencyKeyPath: $.id
eventTypePath: $.type
auth:
type: hmac
secret: file:/run/secrets/stripe-webhook-secret

A file: reference works only on a field that is a credential and nothing else. database.url, the RabbitMQ destination url and the RabbitMQ source connectionUrl carry a password inside a URL. Supply those through an environment variable, for example QUEUEBOX_DATABASE_URL. On one of those fields, QueueBox reads a file: string literally, and database.url then fails validation at start.

Mount the secret as a file, not as an environment variable. Another process cannot read a file from /proc/<pid>/environ. You can also rotate a file without a change to the pod template.

apiVersion: v1
kind: Secret
metadata:
name: queuebox-secrets
type: Opaque
stringData:
db-password: the-real-password
stripe-webhook-secret: whsec_the_real_secret
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: queuebox
spec:
selector:
matchLabels:
app: queuebox
template:
metadata:
labels:
app: queuebox
spec:
containers:
- name: queuebox
image: ghcr.io/alternayte/queuebox:1.2.3
env:
- name: QUEUEBOX_DATABASE_PASSWORD
value: file:/run/secrets/db-password
volumeMounts:
- name: secrets
mountPath: /run/secrets
readOnly: true
volumes:
- name: secrets
secret:
secretName: queuebox-secrets

An external secret manager works the same way. Have it write a file, and point the configuration at the path.

A credential field prints as a mask. A log line, an exception message or a crash dump that prints a configuration object therefore shows no secret.

  • A value that is not empty prints as Secret(***).
  • An empty value prints as Secret(empty), which tells an operator that the credential is absent.
  • A JDBC URL or an AMQP URI prints with its password masked.

The publisher also redacts the reason for a failed delivery before it stores it in last_error. It masks the value of every known secret-bearing key and truncates the text.

The admin routes change data and run code that the caller supplies:

  • POST /admin/transform/test evaluates a JSONata expression on the host that processes your messages.
  • POST /admin/replay sends delivered and dead outbox rows again. See Replay dead letters.

QueueBox protects them as follows:

  • The routes do not exist until you set admin.enabled: true.
  • They need authentication. QueueBox refuses to start with admin.enabled: true and no admin.auth, unless you set admin.insecure: true. Never set admin.insecure in production.
  • QueueBox clamps the caller’s transform timeout to admin.maxTransformTimeoutMs (default 1000 ms).
  • QueueBox rejects a request body larger than admin.maxPayloadBytes (default 65536 bytes) with 413.
# fragment
admin:
enabled: true
auth:
type: bearer
token: file:/run/secrets/queuebox-admin-token

Never publish /admin through the ingress.

Setting Default Purpose
inbox.maxBodyBytes 1048576 The largest accepted request body. A larger body gets 413.
sources.<name>.rateLimit.requestsPerMinute Not set The request rate that one source accepts. A caller over the limit gets 429 with Retry-After.
http.maxErrorBodyBytes 2048 The largest error body that a failed delivery keeps.

The rate limit uses one bucket per source. It protects the database from one busy source. It does not separate one caller from another, so keep the per-client limit at the ingress.