# 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. ## The link ### 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 :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 `. 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 `RelayEvent`s. 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. ### 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 `RelayEvent`s 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:Relay`s 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.`. - 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 `RelayEvent`s. ## 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: ... -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-.sig`: four lines, `inbuxa-relay release 1`, `version `, `target --` (like `x86_64-linux-musl`) and `sha256 `, then `signature `, 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: `/inbuxa/inbuxa-relay` under the server's tags. Before a tag moves, the amd64 image must print `inbuxa-relay ` 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).