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.
61 KiB
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 with550 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.
The link
Transport
- RL-5. The relay listens for the link on one TCP port:
443by 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'swebSocketUrl. A WebSocket takes over its HTTP/1.1 connection, so blobs go over separate kept-alive HTTP/1.1 connections touploadUrlanddownloadUrlon 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 itsaddressin 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, andinbuxa-relay run --link-allow-anyignores the list until the downstream pushes a new one. The addresses it knows are the relay's:Relay/getanswers each link withlinkFrom, 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:RelaywithreplaceLinkCa, sent on its own, acts on the CA they all share:startmakes the next CA,finishmakes it the link CA without waiting, andcanceldrops it. One that changes nothing (a secondstart, afinishwith none under way) is refused. Every relay showslinkCaReplacing, when the replacement began, and its ownlinkCa:current,both, orold(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 recordsrelay.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 recordsrelay.link-ca-replacedtoo. 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.
- An update of any
-
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 32writes 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:RelayshowskeysWrapped.
- The file holds 32 random bytes in base64, as
Joining and reset
- 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:Relayobject and returns a join token once, in the create response. It is never shown again. - The token is base64url of
{v: 1, relayId, caSpki, secret, expiresAt}, wheresecretis 32 random bytes and the token expires after 24 hours. - 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. - 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/joinwith a randomnonce. The relay answers withHMAC-SHA256(secret, nonce || relayLinkSpki). - 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 statusprints 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 resetdeletes the state directory and returns the relay to unjoined. It refuses while held inbound mail is in the spool, printing the count, unless--discard-heldis 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.
hashisHMAC-SHA256(recipientKey, address)over the address lowercased and with any sub-address removed.recipientKeyis 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 customRecipient/replace {blobId}: a sorted file of 32-byte hashes, swapped in atomically. The relay'sRecipientstate 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:
- Connection rate limits per IP.
- EHLO, and protocol hygiene: early talkers are dropped, and the session ends after the error limit.
- MAIL FROM: syntax and SIZE.
- RCPT TO: RL-1, then
maxRecipients. - 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
Inboundstatepending, 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/setupdateverdict:{accept}: the relay replies250, and deletes the message once the downstream destroys it.{reject: {code, enhanced, text}}: the relay replies with that5xx, and deletes the message.{tempfail: {code, enhanced, text}}: the relay replies with that4xx, and deletes the message.
- RL-13. If no verdict arrives within
liveVerdictTimeout, the relay replies250and the message becomesheld. It is then handled as in held mode, and the downstream deduplicates it if it had already taken it (RL-16).liveVerdictTimeoutmust be at least the downstream'sspamCallCeiling(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
dnsblzones 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.
dnsblis 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/claimleases messages to the calling connection forleaseSeconds(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
Inboundid it has ingested for at leastheldMaxAgeplus 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 told250, 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
heldMaxAgeis deleted, with aninbound.expiredevent 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/setcreate over the link, never on port 25. It refuses a create whosemailFromis neither empty nor at one ofdomains(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
deliveryStatusand 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, orsts, the MTA-STS policy) beside itspolicyStrings. - 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-STSenforcepolicy skips hosts it doesn't list and requires a WebPKI certificate valid for the host;testingonly 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.xreply. The next try followsoutboundRetry, the last interval repeating. AtexpiresAtwhat is still queued becomesdelivered: no, keeping the last reply seen. A finished message the downstream never clears is deleted after seven days.destroyis refused (forbidden) while an attempt is under way. - Events:
outbound.attemptper session that reached MAIL FROM (id,domain,mx,ip,tls, and counts delivered, refused and deferred);outbound.tlsper session where TLS applied or failed (domain,mx,ip,policyTypeas TLS-RPT names it,policyStrings,resultas a TLS-RPT result type orsuccess, andreason);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/andspool/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 with452 4.3.1, andOutbound/setcreate withoverQuota. 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.
The link service
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
sessionandenvelope:remote_ipis 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,relayis 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 moreinbuxa:Relays inrelays, by name (every relay with that name), or none, for any relay. The queue'srouteexpression 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. WithsendThroughon, every non-local recipient is routed through relays, to the relays its route names if it is anInbuxaRelayroute, to any otherwise. sendThroughon any enabled relay sends every non-local recipient through relays: the enabled ones not markedinboundOnly, 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
REQUIRETLSis passed on where the next server offers it. Its TLS failures are reported asno-policy-foundunless 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
protocolVersionit doesn't know. - RL-29.
Relay/upgradetakes 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 reportsselfUpgrade: 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
sendThroughon 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 thesmtp,common,jmap,store,directoryorspam-filtercrates. 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 fromcrates/smtpinto 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 usejmap-proto, which depends onregistry: 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 staticinbuxa-relaybinaries for x86_64 and aarch64 attached to the same Release. Running a relay never needs the server image or binary.publish.ymlbuilds 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_SERVICEonly. -
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
250afterliveVerdictTimeout, 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/setcreate with amailFromoutsidedomainsis refused withforbiddenFrom. - RL-T11. Outbound to a destination answering
550showsdelivered: noand the reply indeliveryStatus, 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
enforcedestination with a bad certificate, not delivered either. - RL-T13.
resetwith 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/upgradewith 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-relayimage 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-Relayheader, and no DSN is sent. - RL-T21. inbuxa-admin warns when
liveVerdictTimeoutis under the downstream'sspamCallCeilingplus 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
linkAllowFromset, a link connection from another address is closed before TLS.--link-allow-anylets 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_FILEset, 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:
- Name. "inbuxa Relay" in copy;
inbuxa-relayfor 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). - 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).
- Several relays per downstream. Yes, each with its own settings, each
serving one downstream. Outbound takes turns among linked relays;
inboundOnlykeeps one out of the rotation (RL-20). - Live mode is in the first release. Held mode is built first, but doesn't ship alone (see "Implementation status").
liveVerdictTimeout. 30 s by default, at most 120 s, and at leastspamCallCeilingplus 5 s (RL-13).- Link port. 443, changed with
--link-listen(RL-5). - JMAP core. The relay's own, not
jmap-proto("Footprint"). heldMaxAge. 30 days by default, at most 90, with a link-down alert after an hour.- Held mail over the reject threshold. Junk, with a header; never dropped, and no setting (RL-17).
- 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:Relayand 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
Inboundpush, 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), withrelayin 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 untilheldMaxAgeand a day have passed (RL-16). A pending message stayspendingwhen claimed. - Relay events: the link service asks for
RelayEventpush too, and the linked node with the lowest id turns each event into arelay.*trace event (relay.session,relay.message-accepted,-refused,-expired,relay.joined,relay.recipients-replaced, andrelay.eventfor any other type), then acknowledges it. A node with no link to a joined relay for an hour raisesrelay.link-down(a warning), once, andrelay.link-upwhen 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). Outboundon the relay:Outbound/setcreate and destroy,/getand/changes, with push, inspool/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, andoutbound.*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:
sendThroughandinboundOnlydecide the route (RL-20, RL-24); each message is uploaded and created withOutbound/setover the delivering node's link, its recipients are marked in the queue as handed to the relay, and the link service followsOutboundwith 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 thetest-mxfeature, sends every domain to one local port.outboundTlsis 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.tlsinto 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
InbuxaRelayroute 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 withsendThroughoff: 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 ofcrates/smtpandcrates/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, withsysRelayQueueGetandsysRelayQueueUpdate, 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
domainsname the domain or name none. With any such relay, the domain's MX set is those relays, each at itsmxPreference, 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 markedinboundOnly, and this server's own MX hosts unless an enabled relay hassendThroughon. Each host under the domain getsv=spf1 a -allfor 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 adns.build-errorevent. 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
inboundOnlyorsendThroughchanges), 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
apolicy. - 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'scertificateError. 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 uploadssmtpCertificateandsmtpPrivateKey. 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>(likex86_64-linux-musl) andsha256 <hex>, thensignature <base64url>, an Ed25519 signature over those four lines.tools/release/sign-relay.shmakes it with openssl. The release keys' public halves are compiled intocrates/relay-proto/src/release.rs; a build without one refuses every upgrade. - An administrator uploads the binary and sends
upgradewith its blob and the signature file's text. This server checks the signature first, then uploads the binary over its link and callsRelay/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 asinbuxa-relay.previous, and moves the new one into place. It answers, recordsrelay.upgraded(orrelay.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. olderThanServercompares each link's lastrelayVersionwith 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 secretRELAY_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 inRELEASE_KEYS(made 2026-10-30), so every build from then on takes signed upgrades.
Packaging (step 6, RL-31):
Dockerfile.relaybuilds the relay alone on Alpine, as a static musl binary, natively on each architecture's runner. The image isFROM scratch: the binary, the CA roots and the state directory, owned by uid 2001; about 12 MB. It runs as 2001, withcap_net_bind_serviceon the binary and nothing else, setsINBUXA_RELAY_CONTAINER(soselfUpgrade: false), and joins fromINBUXA_RELAY_JOIN_TOKENon its first run.- A release tag builds it beside the server, on whichever forge builds:
<registry>/inbuxa/inbuxa-relayunder the server's tags. Before a tag moves, the amd64 image must printinbuxa-relay <Cargo version>and hold noinbuxabinary (RL-T24). The Release gets the binaries taken out of the image,inbuxa-relay-x86_64-linux-muslandinbuxa-relay-aarch64-linux-musl, inSHA256SUMSwith the server's. - Gitea's
relay-signjob then signs both withRELAY_RELEASE_KEY, and attaches the.sigfiles, 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.serviceruns a host install from/opt/inbuxa-relay/bin, owned by theinbuxa-relayuser so an upgrade can replace the binary, withRestart=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:
- This spec (settled 2026-10-29).
- 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). - 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.
- Outbound:
Outbound, theInbuxaRelayroute, status and DSNs, TLS-RPT events. - inbuxa-admin's Relays page, certificates (RL-27), DNS (RL-25), and upgrades (RL-29).
- 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).
- Hardening: who may dial the link (RL-33), a new link CA without rejoining (RL-34), and the relay keys wrapped (RL-35).