Files
jcoffey-dev e26dea3665
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 11m6s
Relay: the relay keys wrapped under a key file (RL-35)
With INBUXA_RELAY_KEY_FILE naming a file of 32 random bytes in base64,
the link CA key (and the next one's, during a replacement), the sealing
key and each relay's recipient key are stored wrapped with AES-256-GCM,
as {"wrapped": ...} where each was a string. A copy of the store or a
backup alone then can't issue node certificates or open held mail.

The link service wraps any key still in the clear at its next scan.
Without the file, with one that can't be read, or with another key, a
node starts no relay link, and each link's lastError says why; while the
setting names a file that can't be read, no key is written in the clear.
Without the setting, keys are stored as before. inbuxa:Relay shows
keysWrapped.

Tested: unit tests of the wrapping, the stored forms and the key file;
the server's relay link test against the real relay binary (RL-T27: no
key in the clear in the stored bytes once wrapped, still linked; with
the file taken away, no link and the reason; with it back, linked again
and the mail held meanwhile delivered), and relay_tests.
2026-10-30 19:51:03 -07:00

61 KiB
Raw Permalink Blame History

Feature spec: inbuxa-relay, the edge appliance

Status: draft, 2026-10-29. Not a rebuild: inbuxa's own design (SPEC.md §4). Its open questions were settled on 2026-10-29: see "Decisions".

Provenance

inbuxa's own design. No upstream Enterprise file or snippet was used, and there is no upstream equivalent to rebuild. Sources:

Source Used for
RFC 5321 (SMTP), RFC 3461–3464 (DSN), RFC 1870 (SIZE), RFC 3207 (STARTTLS) The SMTP door and outbound delivery
RFC 8620 (JMAP core), RFC 8887 (JMAP over WebSocket) The link
RFC 8621 §7 (EmailSubmission) The Envelope and DeliveryStatus shapes the link reuses
RFC 7672 (DANE for SMTP), RFC 8461 (MTA-STS), RFC 8460 (TLS-RPT) Outbound TLS
RFC 9180 (HPKE) Sealing the inbound spool
RFC 2645 (ODMR/ATRN) Considered for the link and not chosen (see "The link")
This repository's AGPL code: crates/smtp (inbound stages, outbound delivery, DANE and MTA-STS), crates/jmap-proto (urn:inbuxa:jmap:* capabilities), crates/jmap/src/inbuxa (inbuxa: objects) What the relay reuses, and where the downstream hooks in
docs.inbuxa.org "An edge server in front" (run/edge-server), and the lab behind it, 2026-10-14 What the settings-only edge does today, and what it costs

What it is

inbuxa-relay is a small, single-purpose appliance that sits on a machine the internet can reach, such as a small cloud server or an edge box. It takes mail in on port 25 for one inbuxa server or cluster, called the downstream, and sends that downstream's outgoing mail on. The downstream can sit on a protected network, or on one that can't expose mail ports at all. It opens no port to the relay. Instead it dials out to the relay and keeps that connection open.

The relay stores no one's mail. It keeps a spool of mail waiting to move in either direction, and nothing else.

In copy it is inbuxa Relay. The binary and the container image are inbuxa-relay.

The settings-only edge in the docs works, but leaves the operator to get a lot right by hand. The relay removes each of those jobs:

Settings-only edge (docs, run/edge-server) inbuxa-relay
The edge must reach the internal server on port 25 The downstream dials out; no inbound port on the protected network
No recipient check without LDAP or SQL, so unknown addresses are accepted and bounced later Unknown addresses refused at RCPT, always (RL-1)
A full second inbuxa with its own console and administrators No console; administered only from the downstream's inbuxa-admin (RL-2)
A relay account with a password, and a "Must match sender" exception No SMTP AUTH anywhere; outbound only over the pinned link (RL-4)
SPF, DMARC and reverse IP turned off on the internal server for the edge's IP; two spam filters to reconcile The downstream sees the real sender's session and runs its own checks: one spam brain
A 100 MB server binary and a data store One small static binary and a spool directory (RL-3)

The four rules

These are invariants. No setting, on either side, turns any of them off.

  • RL-1. Real recipients only. The relay accepts a recipient only if the downstream says it exists: an account, alias, list, masked address, or an address matched by a catch-all or sub-addressing rule the downstream pushed. Anything else is refused at RCPT with 550 5.1.1. A recipient at a domain the relay doesn't serve is refused with 550 5.7.1.
  • RL-2. Administered only from the assigned downstream. The relay has no web interface, no local accounts, no API other than the link, and no configuration file beyond its state directory and listen address. All settings arrive over the link from the downstream it was joined to. The local command line can join, show status, and reset (see "Joining and reset"). Reset un-assigns the relay; it doesn't administer it.
  • RL-3. Smallest footprint. A single static binary with no database, no web server, no spam filter, no directory and no message store. Budgets are in "Footprint" and are checked in CI.
  • RL-4. One downstream only. The relay is pinned to exactly one downstream identity at join, and refuses every other link client until it is reset. It accepts inbound mail only for that downstream's domains, and outbound mail only over that downstream's link, only from envelope senders at that downstream's domains (or the null sender). Port 25 never relays: there is no AUTH, so no SMTP client can send through it.

Roles and topology

 internet ──25──▶ inbuxa-relay ◀══ JMAP over mTLS, dialed OUT by the downstream ══ downstream
                   spool only                                                   directory, spam filter,
                                                                                DKIM keys, mail store
  • One relay serves one downstream. One downstream may have several relays, for example two relays as MX 10 and MX 20. Each relay has its own settings, and inbuxa-admin can copy one relay's settings to another.
  • A downstream cluster is one downstream. Every node that runs the link service holds its own link to each relay. The relay treats them as one client and hands each message to exactly one of them (RL-15).
  • The relay is a JMAP server, and the downstream is a JMAP client. That is JMAP's own direction, and it is what lets the downstream dial out.

Transport

  • RL-5. The relay listens for the link on one TCP port: 443 by default, changed with --link-listen. The downstream is the side that dials out, and outbound 443 is the one port a locked-down network almost always allows. TLS 1.3 only. JMAP method calls and push go over a WebSocket (RFC 8887) on HTTP/1.1, at the session's webSocketUrl. A WebSocket takes over its HTTP/1.1 connection, so blobs go over separate kept-alive HTTP/1.1 connections to uploadUrl and downloadUrl on the same port, up to 4 per node.
  • RL-6. The relay requires a client certificate during the handshake, and completes the handshake only if the certificate chains to the pinned downstream link CA. A client without one gets a TLS alert and no HTTP response, so the port says nothing about what is behind it.
  • RL-7. The downstream pins the relay's link key: the SHA-256 of its SubjectPublicKeyInfo, learned at join. Names in the relay's link certificate are not checked; the pin is the identity.
  • The downstream sends a WebSocket ping every 30 seconds, so NAT and firewalls between the two keep the connection open. After two missed pongs the downstream reconnects, with backoff from 1 s up to 60 s.

The link doesn't use WebSockets over HTTP/2 (RFC 8441): few WebSocket libraries support them, and the relay would carry more code to save a few TLS connections. It is not WireGuard (inbuxa VPN), because a tun device needs privileges an unprivileged container doesn't have, and a control channel would still be needed on top. It is not ATRN (RFC 2645), because ATRN only reverses delivery: configuration, recipient sync, status and outbound results would still need another channel. JMAP carries all of them, and is the protocol the rest of the suite already speaks.

  • RL-32. WireGuard underneath, optional. The link can run over a WireGuard network the operator already has, with nothing changed in the link itself: the relay and every node are peers on it, the relay runs with --link-listen <its WireGuard address>:443, and its address in inbuxa-admin is that WireGuard address. The link port is then not on the internet at all, and WireGuard answers nothing to a packet from a key it doesn't know. The pins and the join proof stay: WireGuard says which key sent a packet, not that it came from this downstream's current node. The docs page shows the setup (step 6). Built-in WireGuard, with the keys exchanged at join and a userspace implementation in the relay, is a later option if hand setup proves a burden. It would cost the relay a tun device or a userspace network stack, and on a cluster one peer per node on every relay.

Identities

Key Lives on Pinned by Purpose
Downstream link CA Downstream registry (secret), shared by all cluster nodes Relay, at join Issues each node's link client certificate (30 days, renewed at 20)
Relay link key Relay state directory Downstream, at join The relay's link server certificate (self-signed)
Downstream sealing key (X25519) Downstream registry (secret); public half pushed to the relay — The relay seals held inbound mail to it (RL-21)
Relay SMTP certificate Relay state directory — STARTTLS on port 25, under the relay's public hostname. Issued by the downstream (RL-27)

Hardening

  • RL-33. Who may dial the link. The setting linkAllowFrom (a list of addresses and CIDR ranges, pushed with the others) limits the link port to those sources. Others are dropped before TLS, with no reply. Empty means anyone, the default, since a cluster's outbound addresses aren't always known in advance. Until its first settings, a relay takes the link from anyone, so it can join. A list that leaves the downstream out would lock it out for good, so: inbuxa-admin warns when no node's address it knows is in the list, and inbuxa-relay run --link-allow-any ignores the list until the downstream pushes a new one. The addresses it knows are the relay's: Relay/get answers each link with linkFrom, the address that connection comes from, which each node keeps with its link status. A connection already open stays open when the list changes. Entries are addresses or ranges (192.0.2.0/24, 2001:db8::/48), at most 64; an IPv4 client of a [::] listener is matched as IPv4.

  • RL-34. A new link CA without rejoining. inbuxa-admin can replace the link CA, for instance when a node's certificate may have leaked, since a node certificate can't be revoked one by one. The downstream makes the new CA and pushes its pin as nextLinkCaPin. A relay then trusts both, and says so in its status. Once every enabled relay does, nodes take certificates from the new CA, and the downstream pushes it as the only CA. From then on a certificate from the old one fails the handshake. A relay that missed the change, because it was down meanwhile, still has the old pin and needs a new join; inbuxa-admin lists it.

    How it goes:

    • An update of any inbuxa:Relay with replaceLinkCa, sent on its own, acts on the CA they all share: start makes the next CA, finish makes it the link CA without waiting, and cancel drops it. One that changes nothing (a second start, a finish with none under way) is refused. Every relay shows linkCaReplacing, when the replacement began, and its own linkCa: current, both, or old (only a CA this server replaced, from what its links last said, or a join whose token named one: it needs a new join), null before it has said.
    • The link service finishes it by itself once every enabled relay that has joined says both, and records relay.link-ca-replaced. A relay that never does holds it until an administrator finishes it.
    • Each node then takes a certificate from the new CA and links again with it. Its settings push carries nextLinkCaPin: null; from a link on the next CA, that makes the next CA the relay's only one: the relay saves it, ends every link on the old CA, and records relay.link-ca-replaced too. From a link on the current CA, null just drops the next one (a cancel). A link on the next CA can't name another. Settings a server has cleared go to the relay as null, as a setting left out of a push stays as it was.
    • A join started before the replacement finished names the old CA in its token and can't complete: start it again.
  • RL-35. The downstream's relay keys, wrapped. The link CA key, the sealing key and each relay's recipient key are stored encrypted under a wrapping key the store doesn't hold: a file every node reads, named by INBUXA_RELAY_KEY_FILE. A copy of the store or a backup alone then can't issue node certificates or open held mail. Without the file, relay links don't start, and the node says why. The file must be backed up on its own: losing it loses held mail, which is sealed to the sealing key, and means a new join for every relay. inbuxa-admin says so when it is turned on. Without the setting, keys are stored as today, as registry secrets.

    How it goes:

    • The file holds 32 random bytes in base64, as openssl rand -base64 32 writes them. Each key is wrapped with AES-256-GCM and a random nonce, stored as {"wrapped": …} where it was a string.
    • Once the setting names a file, the link service wraps every key still stored in the clear, within a scan. Keys stay wrapped: taking the setting away later doesn't unwrap them.
    • A node that can't open them, because the setting is missing, the file can't be read, or it holds another key, starts no link. Each of its links shows why in lastError. While the setting names a file that can't be read, no key is written in the clear.
    • inbuxa:Relay shows keysWrapped.

Joining and reset

  1. In inbuxa-admin, an administrator adds a relay: a name and the address the downstream should dial (host and link port). The downstream creates the inbuxa:Relay object and returns a join token once, in the create response. It is never shown again.
  2. The token is base64url of {v: 1, relayId, caSpki, secret, expiresAt}, where secret is 32 random bytes and the token expires after 24 hours.
  3. On the relay host: inbuxa-relay join <token>. The relay generates its link key, stores the CA pin and the secret, and starts listening on the link port. Port 25 stays closed.
  4. The downstream dials the relay. It can't check the relay's key yet, so on this one connection it makes only one call: Relay/join with a random nonce. The relay answers with HMAC-SHA256(secret, nonce || relayLinkSpki).
  5. If the proof checks out, the downstream pins the relay's link key, the relay deletes the secret, and the downstream pushes the first settings and recipient set. The relay opens port 25 only once it has both.

A relay that was reset joins again with a new token. In inbuxa-admin, an update with rejoin: true returns one, once, and forgets the relay's pinned link key and its recipient key.

  • RL-8. A join token works once. A wrong proof, an expired token, or a second join attempt fails, and the relay stays unjoined.
  • RL-9. inbuxa-relay status prints the link state, the number of linked clients, queue counts, the version and the last error. It prints no address, subject or message content.
  • RL-10. inbuxa-relay reset deletes the state directory and returns the relay to unjoined. It refuses while held inbound mail is in the spool, printing the count, unless --discard-held is given. Held mail is sealed to the old downstream and is unreadable afterward, so discarding it is final.

The relay's JMAP API

Session

The session lists urn:ietf:params:jmap:core and urn:inbuxa:jmap:relay. There is one account, the relay's own. The urn:inbuxa:jmap:relay capability object:

Field Meaning
protocolVersion 1. The downstream refuses a relay with a higher major version than it knows
relayVersion The relay's release, e.g. 27.1.0
maxBlobSize Largest upload the relay accepts, in bytes
features Optional features, as strings. Empty in version 1

The relay does not claim urn:ietf:params:jmap:mail or urn:ietf:params:jmap:submission. It has no mailboxes and no Email objects, and a partial claim would mislead real JMAP clients. It reuses RFC 8621's Envelope and DeliveryStatus shapes field for field.

The relay's type names sit in their own capability, so they don't collide with the server's own inbuxa: objects. In particular, the relay's Inbound is not the server's inbuxa:HeldMessage (the DLP review queue).

Types

Relay: one object, id singleton.

Property Set by Meaning
hostname downstream Public name: the EHLO name, the banner, and the SMTP certificate's name
smtpListen downstream Addresses for port 25. Default [::]:25
domains downstream The domains served (RL-1, RL-4)
maxMessageSize downstream SIZE limit. Default 50 MiB
maxRecipients downstream Per message. Default 100
rateLimits downstream Connections and messages per minute per remote IP, and concurrent sessions per IP
dnsbl downstream DNSBL zones checked at connect in held mode. Default empty (RL-14)
liveVerdictTimeout downstream Seconds to wait for a live verdict. Default 30, maximum 120 (RL-13)
heldMaxAge downstream How long held inbound mail waits for the downstream. Default 30 days, maximum 90
spoolMaxBytes downstream Spool size cap
outboundRetry, outboundExpiry downstream Retry schedule and give-up time. Defaults match the server's (three days)
outboundTls downstream Where neither DANE nor MTA-STS applies: opportunistic (the default; STARTTLS when offered) or required (no delivery without STARTTLS). The certificate isn't checked either way. DANE and MTA-STS are always enforced where published
smtpCertificate downstream PEM chain and private key. /get never returns the key
sealingKey downstream The downstream's public sealing key
linkAllowFrom downstream Addresses and CIDR ranges that may dial the link port. Default empty: anyone (RL-33)
nextLinkCaPin downstream A second link CA to trust during a replacement (RL-34). Default null
status relay {startedAt, linkClients, mode, spool: {inbound, outbound, bytes}, recipientState, certificateNotAfter, linkCaPins, lastError, selfUpgrade, linkFrom}; linkCaPins lists the CAs it trusts, its own first (RL-34); selfUpgrade is false in a container image (RL-29); linkFrom is the address the asking link connection comes from (RL-33)

Settings are cached in the state directory as the downstream last set them, so a reboot without the link comes back with the same settings (RL-22).

RecipientDomain and Recipient

Type Properties Meaning
RecipientDomain domain, catchAll (bool), subaddressSeparator (string or null) Domain-wide rules
Recipient hash One accepted address
  • RL-11. hash is HMAC-SHA256(recipientKey, address) over the address lowercased and with any sub-address removed. recipientKey is made by the downstream per relay and pushed with the settings. This keeps the directory from being read off a seized relay as a list. It does not stop someone holding the relay's key from testing guessed addresses offline; nothing on the relay could, since the relay has to check real ones.
  • A domain whose recipients an external directory (LDAP, SQL, OIDC) answers for can't be listed. Its recipient set holds the accounts the downstream has already seen from that directory, and nothing more: no catch-all, which would break RL-1. Anyone else there is refused at the relay until they first sign in (decided 2026-10-29). A live lookup over the link may come later.
  • Deltas use Recipient/set (create and destroy by hash). A full refresh uses the custom Recipient/replace {blobId}: a sorted file of 32-byte hashes, swapped in atomically. The relay's Recipient state string lets the downstream tell whether the relay is current.

Inbound: a message the relay accepted on port 25.

Property Meaning
id Stable for the message's whole life. The downstream deduplicates on it (RL-16)
receivedAt When the relay accepted it (or got the final dot, if pending)
state pending (live, awaiting a verdict, claimed or not), held, or claimed (held and leased)
claimedUntil End of the current lease, when leased
envelope RFC 8621 Envelope: mailFrom and rcptTo, with parameters
session {remoteIp, remotePort, localIp, helo, tls: {version, cipher} or null, ptr}
size Bytes
blobId The sealed message (RL-21)
doorChecks What the relay checked at the door, e.g. DNSBL results in held mode

Outbound: a message the downstream handed to the relay to send.

Property Meaning
id, createdAt
blobId The message as the downstream signed it
envelope RFC 8621 Envelope
size Bytes
deliveryStatus RFC 8621 DeliveryStatus per recipient: smtpReply (empty before the first try), delivered (queued, yes, no, unknown), and mx, the host the reply came from when one answered (not in RFC 8621)
nextAttemptAt, attempts, expiresAt Retry state. nextAttemptAt only while a recipient is queued

A create takes blobId and envelope only; the relay sets the rest, and no update is allowed. The upload moves into the spool, so it can't be used twice. Besides forbiddenFrom (RL-19), a create is refused as tooLarge over maxMessageSize or maxRecipients, overQuota when the spool is full, and invalidProperties for an empty rcptTo, a recipient listed twice, or an address or SMTP parameter that couldn't go on a command line as it is.

RelayEvent: read-only, {id, at, type, data}. The relay's sessions, deliveries, rejections and errors, for the downstream's history and tracer.

Methods

Standard RFC 8620 methods, plus five of its own:

Method What it does
Relay/get, Relay/set Read status; push settings
Relay/join Join proof (see "Joining and reset"). Answers only on an unjoined relay
Relay/upgrade {blobId, signature}: install a signed release, the binary as an upload and its signature file's text (RL-29)
RecipientDomain/get, /set; Recipient/set, /changes Recipient sync
Recipient/replace Full recipient refresh from a blob
Inbound/changes, /get, /query See what is waiting
Inbound/claim {limit, leaseSeconds} → ids leased to this connection (RL-15)
Inbound/set update sets a live verdict; destroy acknowledges a message as taken
Outbound/set create hands a message over; destroy cancels one still queued, or clears a finished one
Outbound/get, /changes Delivery status
RelayEvent/changes, /get, /set (destroy only) Read and acknowledge events

Push: the relay sends a StateChange for Relay, Inbound, Outbound and RelayEvent to every linked client with push enabled.

Inbound

At the door, always

In this order, whatever the mode:

  1. Connection rate limits per IP.
  2. EHLO, and protocol hygiene: early talkers are dropped, and the session ends after the error limit.
  3. MAIL FROM: syntax and SIZE.
  4. RCPT TO: RL-1, then maxRecipients.
  5. DATA: the size limit.

The relay advertises no AUTH, and refuses ETRN and ATRN.

Live mode

The relay is in live mode while at least one link client has push enabled for Inbound.

  • RL-12. After the final dot, the relay seals the message to the spool (RL-21) as Inbound state pending, and pushes. It holds the SMTP reply.
  • The downstream claims the message, downloads it, and runs its own inbound pipeline on it, with the session context (RL-23). It answers with Inbound/set update verdict:
    • {accept}: the relay replies 250, and deletes the message once the downstream destroys it.
    • {reject: {code, enhanced, text}}: the relay replies with that 5xx, and deletes the message.
    • {tempfail: {code, enhanced, text}}: the relay replies with that 4xx, and deletes the message.
  • RL-13. If no verdict arrives within liveVerdictTimeout, the relay replies 250 and the message becomes held. It is then handled as in held mode, and the downstream deduplicates it if it had already taken it (RL-16). liveVerdictTimeout must be at least the downstream's spamCallCeiling (20 s by default, see the AI spam classification spec) plus 5 s, so a slow model still answers in time. inbuxa-admin warns when it isn't. Senders wait up to 10 minutes after DATA (RFC 5321 §4.5.3.2.6), so the wait costs nothing.
  • The verdict is for the whole message. If the downstream accepts it for some recipients and not others, it handles the rest as it does today for a failure after DATA, under RL-18's limit on DSNs.

So in live mode the downstream's directory, spam filter, AI classifier and Sieve decide before the sender is told yes, and a refused message is refused by the sender's own server: no bounce from inbuxa.

Held mode

When no link is up, or after RL-13:

  • The door checks above, plus any dnsbl zones at connect.
  • The relay seals the message to the spool with fsync, then replies 250.
  • RL-14. Spam is never refused in held mode. The relay has no spam filter, and it doesn't guess. There is no greylisting and no reverse DNS requirement either: both add state or turn away real mail, and the downstream judges held mail later anyway.
  • dnsbl is empty by default. Most lists refuse queries that come through public resolvers, which small servers often use, so a default list would either do nothing or refuse everything. The add-a-relay wizard offers zones, with that warning.

Handing held mail down

  • RL-15. Inbound/claim leases messages to the calling connection for leaseSeconds (default 300). A leased message is offered to no other connection until the lease runs out. This is how a cluster takes each message on exactly one node.
  • The downstream downloads the blob, unseals it, ingests it with the session context (RL-23), and then sends Inbound/set destroy. The relay deletes the message only on destroy.
  • RL-16. If anything fails between ingest and destroy (a crash, a link drop, a node loss), the lease expires and the message is offered again. The downstream records every Inbound id it has ingested for at least heldMaxAge plus one day, and destroys a repeat without delivering it again.
  • RL-17. Held mail is never bounced for a spam verdict. Under the reject threshold, the downstream files it by its usual rules. Over it, the message goes to Junk, never dropped, with the header X-Inbuxa-Relay: held, over reject threshold. The sender was already told 250, so dropping it would lose mail without anyone knowing. Junk expires on the downstream's own schedule. There is no setting for this.
  • RL-18. The downstream sends a DSN for held mail only when the sender passed SPF or DMARC in its own checks. A forged sender gets no bounce, so the relay can't become a backscatter source.
  • Held mail older than heldMaxAge is deleted, with an inbound.expired event and no DSN.
  • The downstream raises an alert when one of its nodes has had no link to an enabled relay for an hour, so held mail doesn't sit unnoticed. It can't alert while it is down itself. An outside monitor on the relay's port 25 covers that.

Outbound

  • RL-19. The relay accepts outbound mail only through Outbound/set create over the link, never on port 25. It refuses a create whose mailFrom is neither empty nor at one of domains (forbiddenFrom).
  • The downstream DKIM-signs every message before handing it over. Signing keys never leave the downstream.
  • The relay delivers by MX, with DANE (RFC 7672) where the destination has DNSSEC-signed TLSA records, MTA-STS (RFC 8461) where it publishes a policy, and otherwise opportunistic TLS per outboundTls. That logic is taken from the server's own outbound delivery code, not re-invented.
  • After each attempt the relay updates deliveryStatus and pushes. The downstream writes any DSN itself, and its history shows the remote server's real reply. The relay never writes a message of its own.
  • TLS results for each destination are sent as RelayEvents. The downstream builds TLS-RPT reports from them, as it does from its own deliveries: the same report frequency, sent only for a domain that publishes a TLS-RPT record. Each event carries the policy itself (tlsa, the TLSA records, or sts, the MTA-STS policy) beside its policyStrings.
  • How a domain is tried: its MX hosts best first (no MX means the domain itself; a null MX, RFC 7505, refuses every recipient with 556 5.1.10), at most five hosts and two addresses each. With the MX answer and the host's addresses both DNSSEC-signed, signed TLSA records make it a DANE delivery, which takes precedence over MTA-STS. A signed zone whose TLSA records are unusable still requires encryption (RFC 7672 §2.2). Bogus TLSA records skip the host. Otherwise an MTA-STS enforce policy skips hosts it doesn't list and requires a WebPKI certificate valid for the host; testing only reports. Opportunistic TLS that fails to set up is tried once more in the clear. A host that can't be reached, or fails its TLS policy, passes the attempt to the next.
  • Each recipient's reply is the remote server's own: RCPT's for a refusal or a deferral, the final reply after DATA for the rest. When no host could take the attempt, the recipients stay queued with the reason, as a 4.4.x reply. The next try follows outboundRetry, the last interval repeating. At expiresAt what is still queued becomes delivered: no, keeping the last reply seen. A finished message the downstream never clears is deleted after seven days. destroy is refused (forbidden) while an attempt is under way.
  • Events: outbound.attempt per session that reached MAIL FROM (id, domain, mx, ip, tls, and counts delivered, refused and deferred); outbound.tls per session where TLS applied or failed (domain, mx, ip, policyType as TLS-RPT names it, policyStrings, result as a TLS-RPT result type or success, and reason); outbound.expired (id, recipients).
  • RL-20. If no relay can be reached, the downstream keeps the message in its own queue and retries. With several relays, it hands messages to the linked ones in turn, skipping any marked inboundOnly.

The spool

  • One file per message, in spool/in/ and spool/out/. Each is written to a temporary name, fsynced, renamed into place, and the directory fsynced, before the relay acknowledges it in any way. A small header in each file holds the object's properties, so the index is rebuilt by reading the directory at start. There is no database.
  • RL-21. Inbound mail is sealed with HPKE (RFC 9180, base mode, X25519-HKDF-SHA256, ChaCha20-Poly1305) to the downstream's sealing key, in streaming chunks as the message arrives. Plaintext never reaches the relay's disk, and the relay can't read held mail afterward. Outbound mail is stored as handed over, since the relay has to read it to send it.
  • When the spool passes 90% of spoolMaxBytes, or 90% of its disk, the relay answers new inbound mail with 452 4.3.1, and Outbound/set create with overQuota. Senders retry, and nothing already accepted is dropped.
  • RL-22. The state directory holds the link key, the pins, the cached settings, the recipient set, the SMTP certificate, the spool and the unacknowledged events. Everything else is rebuilt at start.

The downstream

Registry objects

Object What it holds
inbuxa:Relay One per relay: name, address, linkPort, enabled, the settings pushed to the relay, sendThrough (route outbound mail through relays), inboundOnly (takes mail in but sends none, e.g. while its IP isn't warmed up or isn't in SPF yet), mxPreference (its MX preference, 10 by default, RL-25), certificateNotAfter and certificateError (when its certificate runs out, and why the last order failed, RL-27), olderThanServer (a link last heard an older release than this server's, RL-28), linkCa and linkCaReplacing (RL-34), keysWrapped (RL-35), and read-only status mirrored from the relay: link state per node, version, queue counts, lastSeen. Create, or an update with rejoin: true, returns joinToken once. An update with upgrade: {blobId, signature}, sent on its own, hands the relay a release over the node's link (RL-29). An update with replaceLinkCa, sent on its own, starts, finishes or cancels a replacement of the shared link CA (RL-34)
inbuxa:RelayMessage Read-only view of a relay's Inbound and Outbound queues, fetched over the link when asked. Never message content. /get, /query (filter relayId, direction; newest first) and /set destroy only: discards held mail, or cancels an outbound message, whose recipients fail here with no DSN as a delivery cancelled in the queue does. Each id is the relay's id and a hash of the relay's own message id, so nothing is stored

Permissions: sysRelayGet, sysRelayUpdate, sysRelayQueueGet, sysRelayQueueUpdate (cancel an outbound message, discard held mail). The admin role gets all four. Nobody in a tenant gets any. A relay serves the whole downstream, not a tenant.

Relay settings live in the downstream's registry, so on a cluster they are in the shared store, backed up and audited like any other setting. inbuxa-admin changes them through urn:inbuxa:jmap:registry, and the link service applies them to the relay. inbuxa-admin never talks to a relay.

Runs on every node. On a cluster, every node keeps a link to every enabled relay. It:

  • keeps the WebSocket open and pushes settings and recipient deltas when they change. A directory change reaches every relay within seconds;
  • claims, unseals and ingests inbound mail;
  • hands outbound mail to a relay, and follows its status;
  • maps RelayEvents into the downstream's own trace events, with the relay named, so they show in history and the tracer;
  • renews node link certificates.

Ingest with session context

  • RL-23. Mail from a relay is judged as if the downstream had taken the SMTP session itself. Its inbound pipeline runs with the relay's session and envelope: remote_ip is the original sender's address, and SPF, reverse IP, DMARC, DNSBL, the spam filter, the AI classifier, and Sieve all see the real facts. In expressions, relay is the relay's name, or empty for mail that didn't come through one.
  • The downstream accepts session context only over a pinned relay link. Mail arriving at its own port 25 (if it has one) is never treated as relayed, whatever its headers say.
  • DKIM is checked on the downstream as usual. A relay can't fake a signature.

This is the main server change. It replaces the docs edge recipe's steps of allowing the edge's IP and disabling SPF, DMARC and reverse IP for it.

Outbound routing

  • RL-24. A new route type, InbuxaRelay, alongside the existing route types, names one or more inbuxa:Relays in relays, by name (every relay with that name), or none, for any relay. The queue's route expression picks it per recipient like any other route, so some domains can go through relays while the rest go direct. Its recipients go through the relays named, chosen as below; a relay renamed stops matching. With sendThrough on, every non-local recipient is routed through relays, to the relays its route names if it is an InbuxaRelay route, to any otherwise.
  • sendThrough on any enabled relay sends every non-local recipient through relays: the enabled ones not marked inboundOnly, that the node delivering is linked to, each message starting with the next in turn. A relay that refuses a message for now (its spool full, its link failing) passes it to the next; one that refuses it for good (tooLarge, forbiddenFrom) fails those recipients. With no relay linked, the recipients wait in the queue and are retried as usual.
  • The downstream's queue keeps the message until a relay reports a final status. The item shows "handed to relay ‹name›", and then the remote server's reply. A handed-over recipient isn't handed over again while it waits, except when the relay no longer has the message (a reset). A message cancelled from the downstream's queue is destroyed on the relay too, unless an attempt is under way, and then at the next status.
  • The relay's statuses are followed by one node, the lowest linked to that relay, for every message handed to it from any node. A final status is recorded in the downstream's queue, which sends any DSN as for its own deliveries, and the message is then destroyed on the relay.
  • Mail sent with REQUIRETLS (RFC 8689) is handed over with the parameter, and the relay treats it as the server's own delivery does: TLS is required, with a WebPKI certificate valid for the MX host unless DANE applies, never in the clear, and REQUIRETLS is passed on where the next server offers it. Its TLS failures are reported as no-policy-found unless an MTA-STS policy applied.

DNS and certificates

  • RL-25. When a domain's DNS management is Automatic and relays are enabled, the published MX set is the relays, each with its own preference, and SPF covers the sending addresses of every relay not marked inboundOnly. Publishing must keep every relay in the set: never replace a multi-host MX set with one host.
  • RL-26. A relay's public hostname needs an A or AAAA record and a reverse DNS name that matches it. The deliverability check runs against each relay's address, not the downstream's.
  • RL-27. The downstream gets each relay's SMTP certificate by ACME DNS-01 through its DNS server settings, pushes it with Relay/set, and renews it 30 days before it expires. Without DNS automation, an administrator uploads one. The relay never runs ACME, and opens no port 80.

Updates

  • RL-28. The downstream shows when a relay is older than itself, and refuses to link a relay whose link protocolVersion it doesn't know.
  • RL-29. Relay/upgrade takes a release binary as a blob and a signature by the inbuxa release key, which is compiled into the relay. The relay checks the signature, refuses a downgrade, replaces its binary, and exits for its supervisor to restart it. In a container image the relay reports selfUpgrade: false, and inbuxa-admin says to update the image instead.

inbuxa-admin

A Relays page under Settings › Mail flow:

  • Add a relay is guided or manual, like every job with a wizard. The join token shows once, with a copy button and the one command to run on the relay host.
  • The list shows a status line only when something needs attention: link down on a node, spool filling, an old version, a certificate near expiry, a stale recipient set. No line at all when everything is fine.
  • Each relay has its settings, its queues (from inbuxa:RelayMessage), and its events in the existing history view.
  • Every option gets a tooltip, keyed inbuxa:Relay.<field>.
  • Security checks: a relay with sendThrough on but not linked from every node; MX records that don't match the enabled relays; a relay whose version is older than the downstream's.

Footprint

  • RL-30. A separate binary, inbuxa-relay, in this workspace, so the link protocol and the server's ingest change land in the same PR and ship in the same release. It is built without the smtp, common, jmap, store, directory or spam-filter crates. It uses smtp-proto, rustls, hickory (with DNSSEC, for DANE), tokio, hyper, a WebSocket library, serde_json, an HPKE implementation, and the outbound DANE and MTA-STS logic moved from crates/smtp into a small shared crate.

  • It has a small JMAP core of its own: the session, the request, response and error envelope, /get, /set, /changes, /query, push, upload and download. It doesn't use jmap-proto, which depends on registry: about 88,000 lines, most of it generated schema (measured 2026-10-29), for five types. If the envelope is worth sharing later, it moves into a small crate both use.

  • Budgets, checked in CI on the release build:

    Measure Budget
    Static musl binary, stripped, x86_64 and aarch64 ≤ 15 MB
    Resident memory, joined and idle ≤ 32 MB
    Resident memory, 50 concurrent SMTP sessions ≤ 128 MB
    Container image FROM scratch: the binary and CA roots only (12.4 MB on amd64, 2026-10-30)
  • RL-31. It ships as its own artifacts, from the same release tag as the server: a multi-arch image (linux/amd64, linux/arm64) named inbuxa-relay, tagged like the server's, and static inbuxa-relay binaries for x86_64 and aarch64 attached to the same Release. Running a relay never needs the server image or binary. publish.yml builds both products, so a relay and a server with the same version always speak the same link protocol.

  • It runs as a non-root user, with CAP_NET_BIND_SERVICE only.

  • It logs to stderr only: start, stop, link up and down, and errors. It logs no address and no content. Everything else goes up the link as RelayEvents.

Failure behavior

What fails What happens
The link Held mode. Outbound mail already handed over keeps being delivered. The downstream keeps new outbound in its own queue, or uses another relay
The downstream, for days Held mail waits up to heldMaxAge. When the spool is full, senders get 452 and retry
The relay Senders try the next MX, or retry. The downstream holds outbound or uses another relay
A relay reboot It resumes from the spool and cached settings, with port 25 open, before the link returns
A cluster node mid-ingest The lease expires and another node takes the message (RL-15, RL-16)
A stale recipient set An address removed since the last sync is accepted and refused downstream, with RL-18's limit on DSNs. A new address is refused until the next delta, normally seconds
The relay's clock TLS on the link and on port 25 depends on it. The relay reports clock skew larger than 60 s against the downstream as lastError

Security considerations

  • No path in. The protected network accepts no connection from the relay. The link is dialed out, and speaks only the methods above.
  • The link port. Pinned mTLS before any HTTP. TLS handshakes are rate limited per IP. linkAllowFrom (RL-33) limits who reaches it, and over WireGuard (RL-32) it isn't on the internet at all.
  • A leaked node certificate is good until it expires, up to 30 days, unless the link CA is replaced (RL-34).
  • Port 25 can't relay. There is no AUTH and no outbound path from port 25 (RL-4, RL-19).
  • A compromised relay gives an attacker mail in flight, the outbound queue, the hashed recipient set with its key, and the relay's SMTP certificate key. It doesn't give them held inbound mail (sealed), DKIM keys, the directory as a list, or any way into the downstream. It can forge session facts for mail it injects. The downstream's DKIM check still holds, and a relay can be disabled from inbuxa-admin at once, which drops its link.
  • Secrets on the downstream: the link CA key, the sealing key and the recipient key, as registry secrets, or wrapped under a key outside the store (RL-35). inbuxa-admin never shows them.
  • The join window. Anyone holding the token who can also take over the relay's address before the real relay joins could join a relay of their own. Tokens last 24 hours and work once; an IP address in address, or a WireGuard address (RL-32), narrows it further.

Acceptance tests

  • RL-T1. A RCPT for a foreign domain, unauthenticated, is refused with 550 5.7.1. AUTH is not advertised.
  • RL-T2. A RCPT for an unknown address at a served domain is refused with 550 5.1.1, in live mode and in held mode.
  • RL-T3. Live: a message the downstream scores over its reject threshold is refused at DATA with the downstream's reply, and leaves nothing in the spool.
  • RL-T4. Live: the downstream withholds its verdict. The sender gets 250 after liveVerdictTimeout, and the message is delivered once.
  • RL-T5. The downstream is killed between ingest and destroy. After the lease expires the message is offered again, and is delivered exactly once.
  • RL-T6. The downstream is offline for an hour, with ham and spam arriving. On reconnect everything is ingested, spam goes to Junk, and no DSN is sent.
  • RL-T7. A held message containing a known marker string: the spool on disk doesn't contain the marker.
  • RL-T8. A link client with a certificate from another CA fails the TLS handshake and gets no HTTP response.
  • RL-T9. A join token used twice, used after expiry, or presented with a wrong proof fails, and the relay stays unjoined.
  • RL-T10. Outbound/set create with a mailFrom outside domains is refused with forbiddenFrom.
  • RL-T11. Outbound to a destination answering 550 shows delivered: no and the reply in deliveryStatus, and the downstream sends one DSN.
  • RL-T12. Outbound to a DANE destination with a wrong TLSA record is not delivered. To an MTA-STS enforce destination with a bad certificate, not delivered either.
  • RL-T13. reset with held mail refuses without --discard-held.
  • RL-T14. A relay rebooted with no link comes back with its settings and accepts mail for known recipients.
  • RL-T15. Two cluster nodes linked: 1,000 messages are each ingested once. A node killed mid-lease: its messages are taken by the other after the lease.
  • RL-T16. Relay/upgrade with a bad signature, or with an older version, is refused.
  • RL-T17. A held message from a sender that fails both SPF and DMARC, to a recipient removed since the last sync: no DSN is sent.
  • RL-T18. The footprint budgets in RL-30 hold.
  • RL-T24. A release tag publishes the inbuxa-relay image for both architectures and both binaries, and the relay image contains no server binary.
  • RL-T19. The downstream's DNS publish with two relays keeps both MX records, at their preferences.
  • RL-T20. Held mail over the downstream's reject threshold lands in Junk with the X-Inbuxa-Relay header, and no DSN is sent.
  • RL-T21. inbuxa-admin warns when liveVerdictTimeout is under the downstream's spamCallCeiling plus 5 s.
  • RL-T22. With two relays, one marked inboundOnly, every outbound message goes through the other, and SPF lists only the other's address.
  • RL-T23. A node with no link to an enabled relay for an hour raises an alert.
  • RL-T25. With linkAllowFrom set, a link connection from another address is closed before TLS. --link-allow-any lets it in again.
  • RL-T26. After a link CA replacement (RL-34), every relay stays linked without a new join, and a node certificate from the old CA fails the handshake.
  • RL-T27. With INBUXA_RELAY_KEY_FILE set, a copy of the store holds none of the relay keys in the clear, and a node started without the file starts no relay link and says why.

Decisions

Settled by John on 2026-10-29:

  1. Name. "inbuxa Relay" in copy; inbuxa-relay for the binary and image. It lives in this workspace, not its own repository (RL-30), and ships as its own image and binaries at release time (RL-31).
  2. Held-mode door checks. Domain, recipients, size and rate limits; DNSBL only when an administrator lists zones. No greylisting, no reverse DNS requirement (RL-14).
  3. Several relays per downstream. Yes, each with its own settings, each serving one downstream. Outbound takes turns among linked relays; inboundOnly keeps one out of the rotation (RL-20).
  4. Live mode is in the first release. Held mode is built first, but doesn't ship alone (see "Implementation status").
  5. liveVerdictTimeout. 30 s by default, at most 120 s, and at least spamCallCeiling plus 5 s (RL-13).
  6. Link port. 443, changed with --link-listen (RL-5).
  7. JMAP core. The relay's own, not jmap-proto ("Footprint").
  8. heldMaxAge. 30 days by default, at most 90, with a link-down alert after an hour.
  9. Held mail over the reject threshold. Junk, with a header; never dropped, and no setting (RL-17).
  10. WebSocket transport. HTTP/1.1, with blobs on separate connections (RL-5).

No questions are open.

Implementation status

In progress. Done so far:

  • crates/relay-proto: the link's shared types, joining, recipient hashing and sealing (PR #244).
  • crates/relay: the relay binary, inbound in held and live mode (PR #245).
  • On the downstream: inbuxa:Relay and its permissions, the link CA and sealing key, and join tokens (PR #249).
  • The downstream's link service: every node dials each enabled relay, completes the join and pins its key, pushes settings, domains and the recipient set (whole, then deltas), and reports each node's link in the relay's links (PR #253). Node link certificates last 30 days and are renewed after 20.
  • Ingest and live verdicts: the link service asks for Inbound push, so the relay is live while a node is linked. Each message is claimed, unsealed, and played through the downstream's own SMTP handlers with the relay's session on port 25 (RL-23), with relay in expressions. Live, the downstream's reply is the verdict; held, a refusal for good is reported only to a sender who passed SPF or DMARC (RL-18), one for now is retried after the lease, and spam over the reject threshold goes to Junk (RL-17). Every message taken is recorded until heldMaxAge and a day have passed (RL-16). A pending message stays pending when claimed.
  • Relay events: the link service asks for RelayEvent push too, and the linked node with the lowest id turns each event into a relay.* trace event (relay.session, relay.message-accepted, -refused, -expired, relay.joined, relay.recipients-replaced, and relay.event for any other type), then acknowledges it. A node with no link to a joined relay for an hour raises relay.link-down (a warning), once, and relay.link-up when it is back (RL-T23). Both reach the log, webhooks and OpenTelemetry. relay.link-ca-replaced (659) records a finished link CA replacement (RL-34).
  • Outbound on the relay: Outbound/set create and destroy, /get and /changes, with push, in spool/out/ (RL-19, RL-T10).
  • Outbound delivery on the relay: MX, DANE, MTA-STS and outboundTls, per recipient status from the remote server's replies, retries, expiry, and outbound.* events (RL-T12 tested against a scripted MX with fixed DNS answers). The release binary went from 5.4 MB to 8.1 MB with it (glibc, measured 2026-10-30).
  • Outbound through relays on the downstream: sendThrough and inboundOnly decide the route (RL-20, RL-24); each message is uploaded and created with Outbound/set over the delivering node's link, its recipients are marked in the queue as handed to the relay, and the link service follows Outbound with push, records each reply, and destroys the relay's copy once every recipient is final. Tested end to end with the relay sending to a scripted MX (RL-T11): a test build of the relay, with the test-mx feature, sends every domain to one local port. outboundTls is set like any other setting, settings/outboundTls.
  • REQUIRETLS through relays: handed over with the parameter, delivered by the relay only over TLS with a verified certificate (tested against a scripted MX with a self-signed one).
  • TLS-RPT from relays: the node that takes a relay's events turns each outbound.tls into a session in this server's TLS-RPT reports, with the policy, result, MX host, address and reason (tested with a relay's STARTTLS failure for REQUIRETLS mail).
  • The InbuxaRelay route type (x:MtaRoute, stored as its fourth variant, so routes saved before read as they were), in the served schema for the console's route form. Tested end to end with sendThrough off: a domain routed to a route naming the relay goes through it, and one routed to a route naming no linked relay waits, saying so.
  • crates/outbound-tls (RL-30): the TLSA and MTA-STS types, the DANE certificate check, MTA-STS policy parsing and matching, and the DNSSEC lookups (with the bogus-answer recheck), moved out of crates/smtp and crates/common. The server keeps its caches, trace events, policy fetch and test mocks around them, so its behavior is unchanged. The relay will use the same code to deliver.
  • inbuxa:RelayMessage (step 5, for inbuxa-admin's Relays page): get, query and destroy, over the asking node's link, with sysRelayQueueGet and sysRelayQueueUpdate, and every destroy audited. A relay this node can't reach is left out of a query of all relays; asked for by name, it is an error (serverUnavailable). An outbound message cancelled while the relay is sending it is dropped at the relay's next status. Tested end to end: the deferred message read back with the remote server's reply and its queue id, then cancelled, gone from both queues, with no DSN.
  • The served schema lists the Relays page (CustomComponent/Relays) in Settings, beside Routes, so inbuxa-admin can show it.
  • inbuxa-admin's Relays page: the list with what needs attention, adding a relay with its join token, each relay's settings with their help, its queues, a new join, and removing it. Still to come there: the guided add, the security checks, and the link to the relay's events.
  • DNS (RL-25). A relay is in a domain's records when it is enabled, joined, and has a public name, and its domains name the domain or name none. With any such relay, the domain's MX set is those relays, each at its mxPreference, and the server's own MX hosts are left out. The SPF record names each sending host by its address records (v=spf1 a:<host> ... -all): the relays not marked inboundOnly, and this server's own MX hosts unless an enabled relay has sendThrough on. Each host under the domain gets v=spf1 a -all for its HELO name. More than ten hosts would break SPF's lookup limit: the domain's SPF record is then left as it is, with a dns.build-error event. With no such relay, a domain's records are made as before.
  • What the relays put in any domain's records is kept as a hash. When it changes (a relay joins or is removed, is turned on or off, gets another public name, domains or preference, or inboundOnly or sendThrough changes), the link service queues the MX and SPF records again for every domain whose DNS is Automatic and publishes them. The same records are in the zone file a domain with Manual DNS exports.
  • A publish never cuts a published MX set of two or more hosts down to one. That MX record set is left as it is, and the task fails with a reason that says so. This holds with or without relays.
  • Tested: the joined test relay is example.com's only MX host, at preference 10 and then 20 after a change. SPF names it and this server, and the relay's host gets its own a policy.
  • Certificates (RL-27). A joined, enabled relay needs one when it has none, when its certificate doesn't name its public name (a wildcard names one level down), or when it runs out within 30 days. Its public name must be under a domain whose DNS and certificates are both Automatic here; the closest such domain is used, with its ACME provider and its DNS server. The order is always DNS-01, whatever challenge the provider uses for the domain itself, and is for the public name alone, with a key of its own: never the domain's wildcard key, which mustn't leave this server. A renewal keeps the relay's key; a new name gets a new one.
  • The link service checks at each scan, and one node orders: it claims the order first under c + relay id, and a running claim lasts an hour. A failed order waits six hours before the next, for the ACME provider's rate limits, and its error is the relay's certificateError. The certificate goes on the relay unless its public name changed meanwhile, and every link pushes it with the settings. Without such a domain nothing is ordered, and an administrator uploads smtpCertificate and smtpPrivateKey. One uploaded that still does is left alone; one that stops doing is replaced, when a domain allows it.
  • Tested against pebble: a joined relay named under a DNS-01 domain gets a certificate for its name alone, isn't among this server's own certificates, shows certificateNotAfter, isn't ordered again, and its order record goes with it when it's removed.
  • Upgrades (RL-29). A release binary comes with a signature file, inbuxa-relay-<target>.sig: four lines, inbuxa-relay release 1, version <Cargo version>, target <arch>-<os>-<libc> (like x86_64-linux-musl) and sha256 <hex>, then signature <base64url>, an Ed25519 signature over those four lines. tools/release/sign-relay.sh makes it with openssl. The release keys' public halves are compiled into crates/relay-proto/src/release.rs; a build without one refuses every upgrade.
  • An administrator uploads the binary and sends upgrade with its blob and the signature file's text. This server checks the signature first, then uploads the binary over its link and calls Relay/upgrade; with no link from this node to the relay, the update is refused. The relay refuses it in a container image (selfUpgrade: false), while another upgrade is on its way, when the signature fails, when the target isn't its own, and when the version isn't newer than its own. Otherwise it writes the new binary beside its own, runs it with --version, which must name the signed version within 10 seconds, keeps the old one as inbuxa-relay.previous, and moves the new one into place. It answers, records relay.upgraded (or relay.upgrade-refused, with the reason), and exits with status 75 (EX_TEMPFAIL) for its supervisor to start the new binary: one set to restart on failure restarts it too.
  • olderThanServer compares each link's last relayVersion with this server's version, by SemVer precedence, so a release candidate comes before its release.
  • Tested: over the relay's real link, a forged signature, an older version, another target and an unreadable file are refused and leave the binary alone (RL-T16); a good release goes in, the old one is kept, the run loop is told to exit, and a second upgrade waits. A binary that says another version is never put in place. The signing script's output checks out against the Rust code. On this server, an upgrade with anything else, an unknown blob, or a signature by no release key is refused before any relay sees it.
  • The release keys are made with tools/release/new-relay-keys.sh: a CI key, kept as the Gitea Actions secret RELAY_RELEASE_KEY, and an offline key, kept off any server, for signing by hand when the CI key has to be replaced. Both public halves are in RELEASE_KEYS (made 2026-10-30), so every build from then on takes signed upgrades.

Packaging (step 6, RL-31):

  • Dockerfile.relay builds the relay alone on Alpine, as a static musl binary, natively on each architecture's runner. The image is FROM scratch: the binary, the CA roots and the state directory, owned by uid 2001; about 12 MB. It runs as 2001, with cap_net_bind_service on the binary and nothing else, sets INBUXA_RELAY_CONTAINER (so selfUpgrade: false), and joins from INBUXA_RELAY_JOIN_TOKEN on its first run.
  • A release tag builds it beside the server, on whichever forge builds: <registry>/inbuxa/inbuxa-relay under the server's tags. Before a tag moves, the amd64 image must print inbuxa-relay <Cargo version> and hold no inbuxa binary (RL-T24). The Release gets the binaries taken out of the image, inbuxa-relay-x86_64-linux-musl and inbuxa-relay-aarch64-linux-musl, in SHA256SUMS with the server's.
  • Gitea's relay-sign job then signs both with RELAY_RELEASE_KEY, and attaches the .sig files, whichever forge built them. Without the secret it warns and signs nothing. With GitHub building, a binary is only as trustworthy as that build: GitHub uploads it, Gitea signs what it finds.
  • resources/systemd/inbuxa-relay.service runs a host install from /opt/inbuxa-relay/bin, owned by the inbuxa-relay user so an upgrade can replace the binary, with Restart=always, the one capability, and the filesystem read-only but for that directory and the state directory.
  • The installer installs a relay on a machine of its own with inbuxa relay install --token - (inbuxa-installer #45): the release binary, checked against a pinned checksum and its signature, the user, the unit, the join. The docs page, "inbuxa Relay" under Running it, covers the installer, a host by hand, the container image and the WireGuard setup (RL-32). Step 6 is done.

Order:

  1. This spec (settled 2026-10-29).
  2. The relay: the link (join, pins, settings, recipients) and held-mode inbound with the sealed spool. On the downstream: the link service, inbuxa:Relay, ingest with session context (RL-23).
  3. Live mode (RL-12, RL-13). Steps 2 and 3 ship together: held mode alone can't refuse spam at the door, which the settings-only edge already can.
  4. Outbound: Outbound, the InbuxaRelay route, status and DSNs, TLS-RPT events.
  5. inbuxa-admin's Relays page, certificates (RL-27), DNS (RL-25), and upgrades (RL-29).
  6. Packaging (RL-31, and an installer path) and a docs page. The edge-server page points to it. The docs page shows the WireGuard setup (RL-32).
  7. Hardening: who may dial the link (RL-33), a new link CA without rejoining (RL-34), and the relay keys wrapped (RL-35).