WhisperDocs
Standards

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.

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 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, 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.

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.

{
  "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 and the file appears.

Nested behind a transit parent. A nested agent's 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

Next