# MUD (RFC 8520)

**Every MUD file in the world is a claim about what a device *intends* to do. Ours is a statement of what is *being enforced right now*, at an address you can verify from the DNS root without asking us.**

```sh
curl -s https://whisper.online/.well-known/mud/{agent-128}.json
```

No account, no key. The file is `application/mud+json` and any RFC 8520 MUD manager can read it.

## What MUD is, and the one thing it never had

[RFC 8520](https://www.rfc-editor.org/rfc/rfc8520) lets a device declare the hosts it should ever talk to: *"my manufacturer's cloud, my controller, the local subnet, nothing else."* The device emits a URL, the network fetches a JSON file, and the file lists what is permitted. Everything not listed is denied. It is one of the sharpest ideas in device security.

The RFC is also candid about the catch. The declaration is validated and enforced by a **MUD manager at the nearest hop**. That manager is site-scoped, the identity behind the declaration is whatever the LAN believes, and there is no shared revocation. The file says what a device *wants*; whether anything enforces it is a separate question you cannot answer by reading the file.

## Why ours is a different kind of statement

We issue the `/128` the ACL is keyed to, and we are the egress path the traffic actually takes. So the file is not a declaration we are passing along. It is a projection of the rule set that is in force, generated from it on read:

```
op:firewall  ──►  FirewallRuleSet  ──►  enforced on every CONNECT
                        │
                        └───────────►  rendered as the MUD file you fetch
```

One object, read twice. There is no second copy to drift, no publish step to forget, and a diff in the bytes means the policy moved. The same pattern as the [RDAP record](/docs/rdap), which is generated from the live zone rather than maintained beside it.

And the identity the ACL is keyed to is not a LAN's opinion. It is a routable address with reverse DNS, a DANE-EE pin in DNSSEC-signed DNS and a public registration, all checkable from the IANA root by someone who has never heard of us. See [Verify an agent](/docs/verify).

## A file, in full

This is the complete output of the renderer for an agent whose policy denies one hostname, then
permits another and a prefix. Nothing here is abridged or hand-written; it is what the code emits.

The address is `2a04:2a01:9::50`, from `2a04:2a01:9::/48`, which we reserve for documentation. Fetch
it and you will get the documented `404` rather than this file, because no agent is assigned there.
That is the endpoint behaving exactly as the table below describes.

```json
{
  "ietf-mud:mud" : {
    "mud-version" : 1,
    "mud-url" : "https://whisper.online/.well-known/mud/2a04:2a01:9::50.json",
    "last-update" : "2026-09-27T09:14:02Z",
    "cache-validity" : 24,
    "is-supported" : true,
    "systeminfo" : "Whisper agent a0000000000000050 (plant-historian-bridge)",
    "documentation" : "https://whisper.online/docs/mud",
    "from-device-policy" : {
      "access-lists" : {
        "access-list" : [ {
          "name" : "mud-a0000000000000050-v6fr"
        }, {
          "name" : "mud-a0000000000000050-v4fr"
        } ]
      }
    }
  },
  "ietf-access-control-list:acls" : {
    "acl" : [ {
      "name" : "mud-a0000000000000050-v6fr",
      "type" : "ipv6-acl-type",
      "aces" : {
        "ace" : [ {
          "name" : "deny-host-updates-historian-example-plant-internal-and-subdomains",
          "matches" : {
            "ipv6" : {
              "ietf-acldns:dst-dnsname" : "updates.historian.example-plant.internal",
              "protocol" : 6
            },
            "tcp" : {
              "ietf-mud:direction-initiated" : "from-device"
            }
          },
          "actions" : {
            "forwarding" : "drop"
          }
        }, {
          "name" : "host-historian-example-plant-internal-and-subdomains",
          "matches" : {
            "ipv6" : {
              "ietf-acldns:dst-dnsname" : "historian.example-plant.internal",
              "protocol" : 6
            },
            "tcp" : {
              "ietf-mud:direction-initiated" : "from-device"
            }
          },
          "actions" : {
            "forwarding" : "accept"
          }
        }, {
          "name" : "net-2001-db8-5-48",
          "matches" : {
            "ipv6" : {
              "destination-ipv6-network" : "2001:db8:5::/48",
              "protocol" : 6
            },
            "tcp" : {
              "ietf-mud:direction-initiated" : "from-device"
            }
          },
          "actions" : {
            "forwarding" : "accept"
          }
        } ]
      }
    }, {
      "name" : "mud-a0000000000000050-v4fr",
      "type" : "ipv4-acl-type",
      "aces" : {
        "ace" : [ {
          "name" : "deny-host-updates-historian-example-plant-internal-and-subdomains",
          "matches" : {
            "ipv4" : {
              "ietf-acldns:dst-dnsname" : "updates.historian.example-plant.internal",
              "protocol" : 6
            },
            "tcp" : {
              "ietf-mud:direction-initiated" : "from-device"
            }
          },
          "actions" : {
            "forwarding" : "drop"
          }
        }, {
          "name" : "host-historian-example-plant-internal-and-subdomains",
          "matches" : {
            "ipv4" : {
              "ietf-acldns:dst-dnsname" : "historian.example-plant.internal",
              "protocol" : 6
            },
            "tcp" : {
              "ietf-mud:direction-initiated" : "from-device"
            }
          },
          "actions" : {
            "forwarding" : "accept"
          }
        } ]
      }
    } ]
  }
}
```

Each `ace` is one rule from the agent's egress policy. A host rule becomes `ietf-acldns:dst-dnsname`,
a prefix becomes `destination-ipv6-network` or `destination-ipv4-network`, a port becomes a
`destination-port`. `ALLOW` becomes `forwarding: accept` and `DENY` becomes `forwarding: drop`.

An `ace` is named after the permit it states, never after its position in the list. Remove one rule
and every other entry stays byte-identical, so a diff between two fetches shows the one thing that
moved instead of renumbering the whole tail.

### Denials are in the file, and the order is the meaning

The rule set is **first-match-wins**: the first rule whose destination matches decides, and nothing
after it is consulted. So `deny updates.historian.example-plant.internal` ahead of
`allow historian.example-plant.internal` is not redundant under a default deny - it is the whole
carve-out, and a file that listed only the permit would have claimed the agent may reach a name we
block.

That is an overstatement of containment, which is the direction every refusal on this page exists to
avoid, so it is not something to leave to a footnote. The file states denials, in place, as
`forwarding: drop`.

It works because the two models are the same model. An RFC 8519 ACL, which RFC 8520 builds on, has an
`ace` list that is `ordered-by user`, and the rule is *"Actions on the first matching ACE are applied
with no processing of subsequent ACEs"*. That is our `decide()`, written in YANG. RFC 8520 section 2
admits `drop` alongside `accept` for exactly this. So the **ordering and the actions** mean in a MUD
manager what they mean in our egress path. The *matches* are a separate question, and the next
section is where this file and the enforced set come apart.

One consequence worth stating: a rule that names the same destination as an earlier one can never
fire, so it is not in the file, however it is spelled - `ip 203.0.113.5` and `cidr 203.0.113.5/32`
are one destination and appear once. A rule an earlier one merely *contains* is a harder case and it
is still emitted: `deny ip 10.1.2.3` after `allow cidr 10.0.0.0/8` cannot fire either, but a reader
evaluating the list first-match-wins reaches the same verdict we do, so the file stays faithful while
carrying an entry that never decides anything.

### Why the hostname appears in both ACLs

Because a name is not an address family, and RFC 8520 makes that distinction load-bearing. The
specification replicates the `ietf-acldns` augment across `ipv4` and `ipv6` deliberately, "to allow
MUD file authors the ability to control the IP version that the Thing may utilize". So a
`dst-dnsname` under `ipv6` alone is not shorthand: it is the positive claim *"over IPv6 only"*.

Our enforcement makes no such claim. A host rule matches on the name with no family test, and the
egress resolves prefer-IPv6 with an IPv4 fallback. Emitting the name under one family would describe
a narrower permitted set than is actually enforced, and a MUD manager acting on that file would block
traffic we allow. The prefix rule, by contrast, names its family and stays in it.

The same holds for a bare port rule, for the same reason: it constrains the port on *any*
destination, which is not a statement about IPv4 or IPv6 either, so it appears in both ACLs too.

### The one place this file says less than we enforce

A host rule is a suffix match: permitting `api.example.com` permits every name under it. RFC 8520
has no way to write that down. `dst-dnsname` is a plain `inet:host`, *"domain name to be matched
against"*, with no wildcard and no suffix form, so the closest expressible statement is the bare
name.

We emit the bare name, which makes the file a strict **subset** of what is enforced. That is the
safe direction for anything acting on the file - a MUD manager enforcing it denies traffic we allow,
which inconveniences the agent rather than weakening the containment - and the unsafe direction for
anyone reading it as a complete inventory. So the entry says so in its own name:
`host-api-example-com-and-subdomains`.

A namespaced extension would carry it to a machine, and RFC 8520 makes that safe: *"Implementations
MUST ignore any node in this file that they do not understand"*. We do not use one, because the same
section requires every declared extension to be *"registered with the IANA and described in an
RFC"*. Declaring an unregistered extension in order to fix an overstatement would be one.

## What publishing this costs you

Setting a default-deny rule set publishes this file. There is no separate switch and no key on the
door: the agent's id, its label and **every destination it is permitted to reach** become readable by
anyone who can work out the address, and the address is derivable from the agent's name.

That is the design, not an oversight - a containment claim nobody can check is worth nothing, which
is the criticism this page opens with. But it is worth knowing before you set the policy, because the
destinations are often internal names. The example above is `historian.example-plant.internal`, and a
plant's security team may reasonably not expect that name to be world-readable.

The only way not to publish is not to govern, which is the worse trade. If you need the enforcement
without the projection, say so and we will build the flag; today it does not exist, and we would
rather write that down than let you find out from a search engine.

## When you get a 404, and why that is the honest answer

A MUD file is a machine-readable containment claim, and other people's networks act on it. So the endpoint refuses in every case where answering would mean overstating, and says which case it is:

| you asked about | answer |
|---|---|
| an address that is not a Whisper agent | `404` - there is nothing to describe |
| an agent with **no default-deny policy** | `404` - see below, this is the important one |
| an agent whose policy permits nothing yet | `404` - usually an agent mid-provision |
| an agent **nested behind a transit parent** | `404` - see below |
| a path that does not end `.json` | `400` - you addressed it wrong, which is a different answer |
| a tail that is not an IPv6 literal | `400` - same, and the reason names the input |
| **another spelling of the same address** | `301` to the one we restate - see below |

**No default-deny policy.** MUD is default-deny *by enumeration*: the file lists what is permitted and the denial of everything else is implicit. RFC 8520's own worked examples each end "Deny all other access". There is therefore no way to write *"this device may talk to anything"*, and an empty `access-list` does not mean unrestricted - it means **deny everything**, the exact inverse. Publishing an empty file for an ungoverned agent would advertise maximum containment for the one agent that has none. So we publish nothing. Set a default-deny rule set with [`op:firewall`](/docs/egress-governance#the-per-agent-firewall-opfirewall) and the file appears.

**Nested behind a transit parent.** A [nested agent's](/docs/nested-identity) effective policy is its own rule set *intersected* with every live ancestor's, most-restrictive-wins. Its own rule set therefore describes a **larger** permitted set than is actually enforced, and for a containment claim, overstating is the dangerous direction. Rather than publish a generous file we publish none, until the intersection is enumerable.

**Another spelling.** An IPv6 address has many spellings and RFC 8520 has a manager compare the
`mud-url` inside the file against the URL it fetched from. We restate one spelling, so a `200` is
served at that one and every other redirects to it: liberal in what we accept, conservative in what
we emit. Follow the `Location` and the file will name the URL you used.

Every one of those answers carries a JSON `reason`, the redirect included, because the reader is another network's MUD manager deciding whether we are broken, whether it addressed us wrongly, or whether this agent simply has nothing to declare - and those are three different answers.

## What this does not do yet

- **`mud-signature`.** RFC 8520 defines a detached CMS signature. The leaf is omitted rather than pointed at a signature that is not there.
- **Ingesting a third-party MUD URL.** Handing us a manufacturer's MUD file and having its ACLs become your agent's policy is the consumer half, and it is not built.
- **`to-device-policy`.** We govern egress. Inbound is a different enforcement point that we do not sit on, and claiming it would be the same overstatement as the two refusals above.

## Next

- [Egress governance](/docs/egress-governance) - setting the policy this file projects
- [Verify an agent](/docs/verify) - checking the identity the ACL is keyed to
- [Nested identity](/docs/nested-identity) - why a nested agent's file is withheld
