Avahi for Zig: publish and browse mDNS services through the system's Avahi daemon
  • Zig 88.1%
  • Nix 11.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 864a63eb7d
All checks were successful
test / test (push) Successful in 4m13s
test / vm (push) Successful in 1m47s
test / docs (push) Successful in 4m27s
connect: on an error, release the connection the client holds
The connection was made into a local, copied into the client, and the
local released on an error. The match rules and the state query that
follow run on the client's copy and grow its buffers, so a failure in any
of them freed the stale local and leaked what the copy had allocated.
It is now connected straight into the client and released from there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PHfYYuEv1aiLvoPWhPUXsj
2026-10-07 02:05:56 -05:00
.forgejo/workflows Avahi for Zig: publish and browse through the system's daemon 2026-09-29 02:10:46 -05:00
LICENSES Avahi for Zig: publish and browse through the system's daemon 2026-09-29 02:10:46 -05:00
src connect: on an error, release the connection the client holds 2026-10-07 02:05:56 -05:00
tests Fuzz the client whole, against a daemon the fuzzer plays 2026-10-07 01:35:31 -05:00
tools Fuzz the client whole, against a daemon the fuzzer plays 2026-10-07 01:35:31 -05:00
.gitignore Avahi for Zig: publish and browse through the system's daemon 2026-09-29 02:10:46 -05:00
build.zig Fuzz the client whole, against a daemon the fuzzer plays 2026-10-07 01:35:31 -05:00
build.zig.zon Zig 0.17 2026-10-07 00:22:58 -05:00
build.zig.zon.nix Zig 0.17 2026-10-07 00:22:58 -05:00
flake.lock Zig 0.17 2026-10-07 00:22:58 -05:00
flake.nix Zig 0.17 2026-10-07 00:22:58 -05:00
package.nix Zig 0.17 2026-10-07 00:22:58 -05:00
README.md Fuzz the client whole, against a daemon the fuzzer plays 2026-10-07 01:35:31 -05:00
REUSE.toml Avahi for Zig: publish and browse through the system's daemon 2026-09-29 02:10:46 -05:00

zig-avahi

Avahi for Zig: publish a service over Multicast DNS, and browse for the services of a type, through the system's Avahi daemon on the system bus. It is written on mdbus, in Zig 0.17, with no C.

The API documentation, generated from the doc comments, is published at https://jeff.jcollie.page/zig-avahi/.

const avahi = @import("avahi");

const client = try avahi.Client.connect(io, gpa, environ);
defer client.deinit();
var running = try io.concurrent(avahi.Client.run, .{client});
defer {
    client.stop();
    running.await(io) catch {};
}

// Out on the network as `Kitchen._sendspin._tcp.local`, on port 8928.
const group = try client.publish(.{
    .name = "Kitchen",
    .type = "_sendspin._tcp",
    .port = 8928,
    .txt = &.{ "name=Kitchen", "path=/sendspin" },
});

// Every `_sendspin-server._tcp` on the network, resolved, as it comes and goes.
const browser = try client.browse("_sendspin-server._tcp", .{ .address_protocol = .inet }, handler);

What it does

Client talks to org.freedesktop.Avahi on the system bus: Server's EntryGroupNew, ServiceBrowserNew and ServiceResolverNew, and the objects they make. run reads what the daemon says about them; publish and browse may be called from any task, before run starts or while it runs.

  • Publishing. Each service is an entry group of its own, added and committed. The daemon probes for the name and announces it, and the group's currentState becomes established. A name another client of the same daemon holds is changed at once, and one another host holds is changed when the daemon reports the collision -- Kitchen #2, #3 -- the name the daemon offers through GetAlternativeServiceName. The group's name says what it became.
  • Refusal. A daemon may not let users publish at all: disable-user-service-publishing, which NixOS sets unless services.avahi.publish.userServices is on. publish then fails with error.NotPermitted, at once, which is a program's cue to answer mDNS itself. Browsing is allowed either way.
  • Browsing. Each service found is resolved, and the handler's found is given its host, address, port and TXT; lost is called when it goes. Both are called once for each interface and protocol a service is seen on, so a program that wants one of each keeps its own map by name. BrowseOptions.address_protocol = .inet asks for IPv4 addresses only, and include_own = false leaves out this host's own services.
  • Handlers run outside the lock, from run, so a handler may publish or browse in its turn.
  • The daemon's own state. A daemon still probing for its host's name will do: what is published before it is running is published once it is. If the daemon restarts, everything is published again and every browser made again, since the objects were the old daemon's.

Avahi addresses every signal about an object -- a group's state, a browser's items, a resolver's answer -- to the client that made it, so no match rule has to be installed before the object exists, and a signal that comes before the reply naming its object waits in mdbus's queue until that reply has been read. The two signals that are broadcast, the daemon's StateChanged and the bus's NameOwnerChanged for the daemon's name, are matched once, on connecting.

wire holds the message bodies -- AddService's, the browsers' and resolvers', and every signal's -- with no I/O, so they are tested and fuzzed without a bus.

Trying it

$ nix develop -c zig build
$ ./zig-out/bin/zavahi state
running
$ ./zig-out/bin/zavahi publish Kitchen _sendspin._tcp 8928 name=Kitchen path=/sendspin
published _sendspin._tcp as Kitchen
$ ./zig-out/bin/zavahi browse _sendspin._tcp 5
found Kitchen kitchen.local 192.168.1.20 8928 name=Kitchen path=/sendspin

Each runs until interrupted, or for the seconds given last.

Building and testing

$ nix develop
$ zig build test --summary all
$ zig build check      # everything compiles, including zavahi and the docs server
$ zig build docs-serve # the API documentation, at http://localhost:8000/
$ reuse lint

zavahi is tested against real Avahi daemons on three machines in a NixOS virtual machine test, all as an ordinary user. publisher and browser let users publish and refusing does not. The test checks that:

  • a published service is seen by avahi-browse, TXT and all;
  • zavahi browse resolves it;
  • a service avahi-publish makes is found;
  • a name taken on the network becomes Kitchen #2;
  • a service withdrawn is reported lost;
  • a service is published again after the daemon restarts;
  • the refusing daemon refuses with NotPermitted, while browsing there still works.
$ nix build -L .#checks.x86_64-linux.avahi

Fuzzing

Every signal body the client decodes -- an item found or lost, a resolved service, a state, the daemon's owner changing -- carries what answered on the network, so each is fuzzed: whatever arrives, decoding returns, reads only inside the body, and keeps at most wire.max_txt TXT strings. The seeds are real bodies, made at build time by tools/fuzz_corpus.zig.

The client is fuzzed whole as well, against a daemon the fuzzer plays through Client.initDaemon: a script of publishing, browsing, removing and every signal the daemon sends, with each of the client's calls answered as the fuzzer chooses -- a collision, a refusal, a timeout, an object path too long to keep, the daemon going away and coming back. Whatever happens, the client must not crash, leak or loop, must send only messages the bus would take, and must keep no resolver whose browser is gone. zig build test runs both targets on their seeds and a few hundred scripts.

$ zig build fuzz --fuzz                                # Zig's fuzzer, until stopped
$ zig build fuzz --fuzz=1M                             # a bounded run, then a report
$ zig build fuzz-run                                   # a minute of the loop
$ zig build fuzz-run -- --target client --seconds 300
$ zig build fuzz-run -Dfuzz-optimize=debug -- --input fuzz-findings/x.bin --target signals

Zig's fuzzer steers by coverage, and gets it because the test binaries are compiled with LLVM. fuzz-run is a loop of this project's own in tools/fuzz.zig, reproducible from the seed it prints and watched for hangs.

Zig 0.16 is supported on the zig-0.16 branch, cut at v0.1.0; only critical fixes go there.

Using it

$ zig fetch --save git+https://git.jcollie.dev/jeff/zig-avahi.git#main
const avahi = b.dependency("avahi", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("avahi", avahi.module("avahi"));

Where this lives

Where How to get it
Forgejo git clone https://git.jcollie.dev/jeff/zig-avahi.git
Tangled git clone https://tangled.org/jcollie.dev/zig-avahi
Radicle rad clone rad:zMrjiEVMyuSABNY1wK4McNyfUGgn

A Radicle repository is findable only by its ID, so that one is written out in full: rad:zMrjiEVMyuSABNY1wK4McNyfUGgn.

Licensing

MIT, following the REUSE specification; reuse lint checks it.

References cited

Kept in the zig-avahi Zotero collection.

  • Avahi project. Avahi: service discovery on Linux using mDNS/DNS-SD, version 0.8. 2020. https://avahi.org/. Its D-Bus API, from the daemon's introspection files, and the constants in avahi-common/defs.h and address.h, which wire follows.
  • Cheshire, S., and M. Krochmal. Multicast DNS. RFC 6762. Internet Engineering Task Force (IETF), February 2013. https://www.rfc-editor.org/info/rfc6762.
  • Cheshire, S., and M. Krochmal. DNS-Based Service Discovery. RFC 6763. Internet Engineering Task Force (IETF), February 2013. https://www.rfc-editor.org/info/rfc6763. Service instances, their SRV and TXT records, and TXT's key=value strings (§6).
  • Pennington, H., A. Carlsson, A. Larsson, S. Herzberg, S. McVittie, and D. Zeuthen. D-Bus Specification. https://dbus.freedesktop.org/doc/dbus-specification.html. The system bus, match rules, and a signal's destination.