The host service

hostd is the one MixOS service that runs as root on a host. It does two jobs:

  • It describes the host: hardware, OS, the MixOS mode, and who manages the network.
  • It makes privileged changes through a short list of closed, typed verbs. Each change is checked, recorded, and reversed if it doesn't work.

Agents and session code ask hostd instead of running sudo. The rules behind it are in the hostd decision.

Calling it

Locally, hostd listens on /run/hostd/hostd.sock (mode 0660, group hostd). Frames are JSON, each ended by a NUL byte, many per connection:

-> {"verb":"net.state","body":{}}\0
<- {"rc":0,"body":{...}}\0

rc is 0 on success and 10 on refusal, with body.status saying why. From a shell:

hostd call host.info
hostd call net.link.apply '{"link":"eth1","config":{"mode":"dhcp"}}'

Read verbs are also on the Bus, owned by hostd. Changes are never taken from the Bus.

There is one hostd per host, run as root and socket-activated:

hostd serve                      # local socket, plus the Bus when noded is up
hostd serve --no-bus             # local socket only

hostd serve refuses to take over a socket another hostd is serving.

Verbs

VerbTierWhat
host.inforeadhostname, kernel, OS, MixOS mode, container, uptime, CPU, memory
net.statereadwhich network managers run, networkd links and files, resolved DNS, iwd stations, hostd's own files
hostd.statusreadfrozen or not, the policy's state, the caller's identity, and the grants it holds: held (names) and held_grants (each in full: verbs, limits, expiry, rate)
hostd.receiptreadone change's receipt: what it changed, under which grant, how it ended (never file contents)
hostd.freezecontrolrefuse every change until thawed: root on the host, or a grant naming it
hostd.thawcontrolaccept changes again and lift grant suspensions: root on the host only
net.link.applywriteconfigure one link through networkd, rolled back unless it comes up
hostd.undowriteput back what an applied change replaced; needs no grant of its own (see below)
net.wg.applywriteconfigure a WireGuard link through networkd, rolled back unless it comes up and its peers complete a handshake
desk.restartwriterestart the desk machine that the caller's grant names, session and console included (starts desk-restart@DESK.service); the host stays up; no undo
desk.stopwritepower off the desk machine that the caller's grant names and give the console back (starts desk-stop@DESK.service); the host stays up; no undo

The desk verbs take no machine name. A grant gives the caller one desk through its desks limit, which must name exactly one machine. A grant with no desk, or with two, is refused.

A desk power action is a cycle that outlives the caller: a restart stops the desk's session, reboots the machine, starts the session again and switches the console back to the desk's VT if it was in front (returning it to the previous VT if the compositor does not come up); a stop returns the console to the VT that was in front, stops the session and powers the machine off. hostd runs none of these steps. It starts the host's template unit for the action with systemctl start --no-block, records a receipt that the cycle started, and answers. The host installs desk-restart@.service and desk-stop@.service; the unit's journal says how the cycle ended. If either unit is missing, the verb is refused unit_failed.

Only hostd on the host can do this. A hostd running inside a desk refuses both verbs in_container. The desk reaches the host's hostd through its socket bound into the machine, so the grant names the caller by its cgroup as the host sees it, under the machine's payload: /machine.slice/systemd-nspawn@DESK.service/payload/system.slice/system-scenes.slice/scenes@VT.service/*. Every refusal is logged at warn under hostd::audit. | net.wifi.scan | write | scan for Wi-Fi networks on one device (iwd). It changes nothing durable, but it drives the radio, so it needs a grant | | net.wifi.connect | write | connect a Wi-Fi device to a network (iwd), put back unless connected in time |

host.info is also published as the retained topic hostd.info.

Who may change what

Reads are open to anyone who can reach hostd. Changes need a standing grant in /etc/hostd/policy.mx, a strict-data document that only root can change:

{
  schema: 1,
  grants: [
    {name: "desk-network",
     caller: {cgroup: "/machine.slice/systemd-nspawn@desk.service/*"},
     verbs: ["net.link.apply"],
     limits: {links: ["eth0", "wlan0"]},
     expires: "2027-01-01T00:00:00Z",
     rate: {count: 3, per_s: 600}}
  ]
}
  • The caller is matched by what the kernel says, never by what it sends: its uid and its cgroup. A cgroup only counts when hostd holds a pidfd for the caller and the caller was alive after the cgroup was read. Both are read again for every request.
  • X/* matches the cgroup X and everything below it. Name a subtree the caller can't move itself into, such as one under machine.slice.
  • Verbs are exact names or a family such as net.*. No grant can name hostd.thaw.
  • Limits are allow-lists the verb checks its arguments against (links for net.link.apply). A key the grant doesn't list is unrestricted.
  • Rate applies per caller, with a grant-wide cap of four callers' worth.
  • Default deny. No policy file means no grants.

The policy is read for every change, so an edit takes effect at once. A file that anyone but root could change, or that sits in a directory anyone but root could change, is refused.

What a change does

Every write follows the same pattern:

  1. Check: the request is typed and validated (no free text reaches a file), then the grant, limits, rate and freeze state.
  2. Record: a receipt keeps the previous content of every file the change replaces.
  3. Apply: hostd writes only files it names itself, and never edits a file it didn't write.
  4. Prove: a health check runs within the change's window (5 to 60 seconds, 20 by default).
  5. Keep or revert: a change that doesn't pass is rolled back automatically.

One change runs at a time, host-wide. A change waits at most 15 seconds behind others (busy after that), and the whole gate runs again once it holds the lock, so a freeze, a suspension, an expiry or a policy edit that lands while it waits is obeyed. A change runs to the end, rollback included, even if the caller hangs up.

A stop waits for a running change (up to 80 seconds), and a change that a crash or a forced kill cuts short is rolled back when hostd next starts, before it accepts any change.

Recovery looks at what each file holds before it acts. It puts back the backup only over content hostd itself installed. A file someone else has changed since is left alone, and the receipt is marked abandoned. A rollback, undo or recovery that can't finish, or a receipt that can't be recorded, blocks all changes (recovery_pending). Every later change retries it first, and changes resume once it finishes.

Undo puts back exactly what a receipt replaced, and only while the file still holds what that change installed. If a later change, or anyone's edit, has replaced it since, undo is refused (superseded): undo the later change first.

Two rollbacks in a row under one grant suspend that grant until root on the host thaws hostd.

net.link.apply gives one link a networkd configuration:

{"link": "eth1", "config": {"mode": "dhcp"}}
{"link": "eth1", "config": {"mode": "static",
  "addresses": ["192.0.2.10/24"], "gateway": "192.0.2.1", "dns": ["192.0.2.53"]},
 "window_s": 30}
  • hostd writes /etc/systemd/network/50-hostd-<link>.network, reloads networkd and reconfigures the link.
  • route_metric (static with a gateway, or DHCP) sets the metric of the default route the link installs. A higher metric loses to a lower one, so a second uplink with a high metric never takes over the host's default route.
  • It passes when networkd reports the link configured from hostd's file, carrying every requested address with its prefix length. With DHCP or a gateway, the default route must also be installed (through the requested gateway, if static) and the gateway must answer: hostd sends it one UDP datagram and reads the kernel's neighbour table until the gateway's address resolves. A gateway that doesn't exist fails the change, even one inside the link's own subnet.
  • A link another file already configures is refused (foreign_config), and so is a link that carries addresses nobody's networkd file set, by hand or by another manager such as NetworkManager (unmanaged_configured). hostd's rollback restores files, so it can't give such a link its old runtime configuration back, and it doesn't take it over.

The reply to a change that passed (rc 0):

{"receipt": "<id>", "outcome": "applied", "reason": null, "grant": "<grant>",
 "grant_suspended": false, "link": {"admin": "configured", "operational": "routable"}}

A change that failed and was reversed comes back with rc 10, as {"status": "rolled_back", "detail": {...}}, where detail has the same keys. Keep the receipt id to undo the change later, or to look it up with hostd.receipt.

Undo needs no grant of its own. hostd.undo {receipt} is allowed to whoever holds a grant that covers the original change: its verb and its limits (for net.link.apply, that link). A grant that may configure hd0 may undo its own changes to hd0. Naming hostd.undo in a grant adds nothing.

net.wg.apply gives one WireGuard link its networkd configuration:

{"name": "wg-home", "addresses": ["10.99.0.1/24"], "listen_port": 51820,
 "peers": [{"public_key": "<base64>", "endpoint": "vpn.example.com:51820",
            "allowed_ips": ["10.99.0.0/24"], "keepalive_s": 25}],
 "window_s": 25}
  • hostd writes 50-hostd-<name>.netdev, 50-hostd-<name>.network and the private key 50-hostd-<name>.key in /etc/systemd/network.
  • The key never leaves the host. hostd makes it the first time, readable only by root and group systemd-network (mode 0640), and keeps it across later applies. Replies carry only the public_key, for the peers' side.
  • It passes when the link comes up configured from hostd's file with every address, and every peer with an endpoint completes a handshake within the window. A peer that never answers puts the change back.
  • require_handshake: false skips the handshake wait. Use it for the first side of a new pair, whose peer doesn't exist yet.
  • A link of that name hostd didn't create is refused (foreign_config).
  • A changed .netdev takes effect because hostd deletes its own link and networkd creates it again. Peers reconnect within a keepalive.
  • Grants limit WireGuard links by links, like other links.

hostd's unit has two capabilities, both for this verb:

  • CAP_CHOWN, to give the key file to group systemd-network;
  • CAP_NET_ADMIN, to read peer handshakes over WireGuard netlink and to delete its own WireGuard link.

Wi-Fi

Wi-Fi goes through iwd. hostd never starts iwd: if it isn't running, the Wi-Fi verbs answer no_iwd.

{"device": "wlan0"}                                         net.wifi.scan
{"device": "wlan0", "ssid": "home", "passphrase": "…"}      net.wifi.connect
{"device": "wlan0", "ssid": "cafe-open", "window_s": 30}    net.wifi.connect
  • net.wifi.scan replies with each network's SSID, security (open, psk), signal in dBm, and whether iwd already knows it.
  • net.wifi.connect writes no files.
    • The passphrase never leaves hostd except to iwd. It reaches iwd through an agent that hostd registers for that one connect. The agent answers only the process that owns iwd's bus name, and only for the network being connected. iwd saves the network itself, after a successful connect. Receipts and the audit never hold the passphrase.
    • It passes when the device is connected to that SSID within the window.
    • If it doesn't, hostd forgets the network (if this change made it known) and reconnects the network the device was on before, or disconnects if it was on none.
    • A known network takes no passphrase. To change one, forget it first. An open network takes none, and 802.1X networks are unsupported.
  • Undo puts the device back the same way. It is refused (superseded) once the device has moved to another network since. If someone else moved the device during a change, the put-back leaves it alone.
  • Grants limit Wi-Fi by links (the device) and ssids.

Stopping it

hostd.freeze stops every change at once and survives restarts and reboots. An unreadable freeze record counts as frozen. Only root on the host itself thaws it, never root inside a container.

Refusals

StatusMeaning
not_servedno such verb
bad_requestthe body doesn't fit the verb's schema
local_onlya control or write verb arrived over the Bus
caller_unverifiedhostd couldn't prove the caller's cgroup
no_grantno unexpired grant covers this caller and verb
outside_granta grant covers the verb but not these arguments
grant_suspendedthe circuit breaker suspended the grant
rate_limitedthe grant's rate is used up
frozenhostd is frozen
policy_invalidthe policy can't be loaded; changes are refused
root_onlyonly root on the host may do this
foreign_config, unmanaged_configured, no_such_linknet.link.apply or net.wg.apply won't touch this link
no_iwd, no_such_station, no_such_network, unsupportedWi-Fi: iwd isn't running, the device isn't an iwd station, the SSID isn't in range, or the security type isn't handled
rolled_backthe change failed its health check and was reversed
supersededundo refused: the file no longer holds what that change installed
not_appliedundo refused: the change was rolled back or already undone
rollback_failed, undo_incompletea rollback or undo couldn't finish; it is retried, and writes wait
recovery_pendingan unfinished change is being retried and hasn't finished yet; writes wait
timeout, busytoo slow, or too many requests at once

Source layout

The source is services/hostd:

FileWhat
src/local.rsthe local socket: NUL-framed JSON, per-uid and per-connection limits
src/caller.rscaller identity from SO_PEERCRED, a held pidfd, cgroup and namespaces, read per request
src/service.rsthe verb table, tiers, dispatch and the authorise gate
src/policy.rsgrants: loading (one O_NOFOLLOW open, root-only), matching, rate limits
src/freeze.rsthe kill switch, failing closed
src/receipt.rsreceipts, durable atomic writes, restore, the circuit breaker
src/txn.rsthe transaction every write runs through: receipt first, conclude (keep or put back, breaker, durable outcome), recovery, undo
src/link.rsnet.link.apply: validation, rendering, the change, its proof
src/wg.rsnet.wg.apply: WireGuard netdev, network and key files, the handshake proof
src/wgnl.rsWireGuard generic netlink (peer handshakes) and deleting a link
src/wifi.rsnet.wifi.scan and net.wifi.connect through iwd: the one-connect passphrase agent, runtime put-back
src/networkd.rsnetworkd over D-Bus and Varlink, the event-driven health wait
src/net.rs, src/info.rsthe read verbs
units/hostd.socket, hostd.service (sandboxed), sysusers.conf

Adding a write verb

  1. A typed request with deny_unknown_fields, validated into a plan before anything else happens. Anything the plan reads from the host (a key, a file) is read inside the writer lock.
  2. service::write(host, origin, peer, verb, limits, ...) with the allow-list keys the verb checks: it gates the call, takes the writer lock, gates it again and runs the change in its own task.
  3. The change through txn: FileChange::backup for every file (with the mode and group a new file needs), txn::begin, the change and its proof, then txn::conclude. Never order the breaker or the receipt by hand.
  4. A reapply for the verb in txn::put_back, so rollback, recovery and undo can make the host use the restored files. Without it the verb doesn't ship.
  5. The paths it writes added to ReadWritePaths=, and any capability it needs to CapabilityBoundingSet=, in units/hostd.service, with the reason beside it.
  6. Registered in docs/docs/spec/bus/verbs.conf.mix (class intent), in this page, and reviewed on its own.

Tests

cargo test -p hostd: no test needs root. The live gate for network writes runs as root on a disposable networkd host, on dummy links only, and removes everything it made.

This page as Markdown · This page describes the current main branch of MixOS.