# Introducing sdvarlink and vlx: systemd's Varlink APIs from Go and the Terminal

> systemd is moving its APIs from D-Bus to Varlink. sdvarlink gives Go programs typed, generated clients for them, and vlx is a terminal explorer for finding, reading and calling them.

- Author: Ross Edman
- Published: 2026-10-16
- Tags: go, linux
- URL: https://rossedman.io/blog/computers/introducing-sdvarlink-and-vlx/

**TL;DR:** systemd now serves most of its APIs over Varlink: JSON over a Unix socket, no broker. `sdvarlink` is a Go library with typed clients generated from systemd's own interface definitions, with streaming as iterators, file-descriptor passing, typed errors and a fake server for tests. `vlx` is a terminal UI built on it that finds every Varlink socket on a machine, shows the docs for each method and calls them for you, read-only unless you say otherwise.

If you've written Go that talks to systemd, you've probably used [go-systemd](https://github.com/coreos/go-systemd) or [godbus](https://github.com/godbus/dbus) and spent some time with D-Bus type signatures like `a(iiay)` and a lot of type assertions on variants. That still works. But over the last few releases systemd has been quietly moving to a different IPC system, and as of v257 it ships more [Varlink](https://varlink.org/) interfaces than D-Bus services.

Varlink is a lot simpler than D-Bus. A call is a JSON object, terminated by a NUL byte, sent over a Unix socket straight to the service. There's no `dbus-daemon` in the middle, so it works in early boot, in the initrd and in minimal containers. Replies can stream, and file descriptors can ride along with a message. Newer parts of systemd, like userdb, metrics, and machine and image management, are being built Varlink-first, and some of them are Varlink-only.

The tooling hasn't caught up yet. On the command line there's `varlinkctl`, which is great if you already know the socket path, the method name and the exact JSON it wants. In Go there was nothing, so you hand-roll JSON over a socket or shell out to `varlinkctl`. I wanted something better for both, so I built two projects:

- **[sdvarlink](https://github.com/rossedman/sdvarlink)** is a Go library with typed clients for every `io.systemd.*` Varlink interface. It does for Varlink what go-systemd does for D-Bus.
- **[vlx](https://github.com/rossedman/vlx)** is a terminal explorer for Varlink services, built on sdvarlink. It finds the sockets, shows you what they do and calls them.

---

## sdvarlink: Typed Clients for Go

Here's the whole thing in one example. Dial `systemd-resolved` and resolve a name:

```go
c, err := resolve.Dial(ctx) // /run/systemd/resolve/io.systemd.Resolve
if err != nil {
	return err
}
defer c.Close()

out, err := c.ResolveHostname(ctx, resolve.ResolveHostnameIn{Name: "github.com"})
if err != nil {
	return err
}
for _, a := range out.Addresses {
	fmt.Println(a.Family, a.Address)
}
```

No socket paths to look up, no JSON to build and no `map[string]any` to pick apart. `ResolveHostnameIn` and its output type are real Go structs with the doc comments systemd wrote for them.

That's possible because every Varlink service describes itself in an interface definition language (IDL). sdvarlink captures the IDL from systemd's sources for each release and generates a Go package per interface: structs, enums, methods and errors. Right now that's every `io.systemd.*` interface in systemd v257, v258 and v260, 33 interfaces in all.

```shell
go get github.com/rossedman/sdvarlink@latest
```

It needs Go 1.24 or newer, since it uses range-over-func iterators. There's no cgo, and the only dependencies outside the standard library are in `golang.org/x/sys`.

### Streaming Is Just a Loop

Varlink methods can return more than one reply. Listing every user in systemd-userdbd, every unit in PID 1 or every DNS query resolved answers are all streams. sdvarlink returns those as `iter.Seq2`, so you range over them:

```go
for u, err := range c.GetUserRecordMore(ctx, userdatabase.GetUserRecordIn{
	Service: "io.systemd.Multiplexer",
}) {
	if err != nil {
		return err
	}
	fmt.Println(string(u.Record))
}
```

`break` out of the loop or cancel the context and the stream stops. The `dns-monitor` example uses this to rebuild `resolvectl monitor` in about 50 lines: subscribe to resolved's query stream and print every lookup until you hit Ctrl-C.

### File Descriptors Are `*os.File`

Some systemd APIs hand file descriptors back and forth. `systemd-mountfsd` takes a disk image as an open fd and gives you back a mount fd per partition. In sdvarlink those are plain `*os.File` fields, sent and received with `SCM_RIGHTS` and attached to the right message:

```go
img, _ := os.Open("app.raw")
defer img.Close() // you keep ownership of what you send

out, err := mountfsd.MountImage(ctx, mountfilesystem.MountImageIn{ImageFileDescriptor: img})
for _, p := range out.Partitions {
	defer p.MountFileDescriptor.Close() // you own what you receive
}
```

The ownership rule is simple: you keep what you send and you own what you receive. Anything you don't claim gets closed for you, and received descriptors are close-on-exec. The tests run thousands of fd round trips and check that the open-fd count stays flat.

### Errors You Can Match On

Varlink errors have a name and typed parameters, so sdvarlink generates a Go type for each one. You can match them with `errors.As` or `errors.Is`, and errors from `io.systemd.System` unwrap to a `syscall.Errno`:

```go
_, err := c.ResolveHostname(ctx, in)
if errors.Is(err, resolve.ErrNoNameServers) {
	// resolved has nothing to ask
}
```

### Older systemd, and Testing Without It

Varlink coverage depends on the systemd version, so packages are versioned per release. Import `systemd/v257/...` to support the oldest systemd you care about, or `systemd/latest/...` if you don't. For a method that showed up later, ask first:

```go
c, err := unit.Dial(ctx)
if errors.Is(err, varlink.ErrUnsupported) {
	return listUnitsOverDBus(ctx) // systemd too old, or not running
}
if ok, _ := c.Supports(ctx, "List"); !ok {
	return listUnitsOverDBus(ctx) // io.systemd.Unit.List arrived in v258
}
```

The part I'm happiest with is `varlinktest`. It's a fake Varlink server on a real Unix socket, so you can test code that talks to systemd in CI without systemd:

```go
func TestLookup(t *testing.T) {
	srv := varlinktest.NewServer(t)
	srv.Handle("io.systemd.Resolve.ResolveHostname",
		func(in resolve.ResolveHostnameIn) (resolve.ResolveHostnameOut, error) {
			return resolve.ResolveHostnameOut{Name: in.Name, Addresses: fakeAddrs}, nil
		})

	c, _ := resolve.DialPath(ctx, srv.Path())
	// ... exercise your code against the fake
}
```

It can serve canned or scripted replies, streams and errors, and it can record a session against the real thing and replay it later as a golden file.

None of this is worth much if it doesn't agree with systemd's own implementation, so the integration suite checks both directions. sdvarlink's client drives a real `sd-varlink` service (systemd-journald), and systemd's `varlinkctl` drives sdvarlink's fake server. CI runs that against systemd booted as PID 1 in containers.

---

## vlx: A Varlink Explorer for the Terminal

sdvarlink covers the code. But before I write any code against a systemd API, I want to poke at it. What sockets are on this machine? What does `io.systemd.Machine` actually have? What does `ResolveHostname` want, and what comes back? D-Bus has `busctl` and d-feet for that. Varlink didn't have anything, so I built vlx.

Run `vlx` and it finds every Varlink socket on the machine. It looks in `/run/varlink/registry`, under `/run/systemd`, in `/run/udev` and in your user session, then groups the services and describes them in plain language. You don't need to know a single socket path. From there you can drill down into a service and its interfaces, methods, types and errors, with the documentation that ships in the IDL right next to each one.

![The vlx home screen, listing the Varlink services it found](home.png)

The line along the bottom is always there. Start typing to jump to any service, method, type or error, or type a whole call like `resolve ResolveHostname name=example.com` and hit Enter.

### Calling a Method

When you open a method you get a form with one field per parameter. Enums get a picker, optional fields are marked and every field shows its doc comment. It validates as you type, and the form and the command line stay in sync, so editing one updates the other. Ctrl+S sends it and the replies show up underneath. Streaming methods keep going until you press Esc.

![Calling GetGreeting on the vlx demo service, with the parameter form and the reply](method.png)

There's a built-in demo service, so you can try all of this on a Mac or anywhere else without systemd:

```shell
go install github.com/rossedman/vlx/cmd/vlx@latest
vlx demo
```

### Read-Only by Default

vlx talks to privileged services, often as root, so I tried hard to make it safe to point at a production box. Every method gets a class: READ, WRITE or DANGER. Each `io.systemd.*` method from v257 through v260 has an explicit class, and any method vlx doesn't know about is treated as WRITE.

By default, only READ methods are sent. Here's the demo refusing to change its default language:

```shell
➜ vlx call demo SetDefaultLanguage language=german
vlx: io.vlx.demo.Greeter.SetDefaultLanguage is a WRITE method and vlx is read-only; rerun with --allow-writes to enable it
```

With `--allow-writes`, vlx asks before every WRITE, and for a DANGER method you have to type the method name back to confirm. On top of that, it strips escape sequences from replies so a service can't mess with your terminal, and it redacts password hashes and tokens on screen. You'd be surprised how many user records have a password hash in them.

### Scripting

Everything in the UI is also a plain command, which is handy in scripts and in CI:

```shell
➜ vlx call demo GetGreeting name=Ada
{"greeting":"Hello, Ada!","language":"english"}

➜ vlx call demo WatchCountdown from=3 --more
{"remaining":3}
{"remaining":2}
{"remaining":1}
{"remaining":0}

➜ vlx call demo GetGreeting name=
vlx: io.vlx.demo.Greeter.GetGreeting returned io.vlx.demo.Greeter.EmptyName
```

Parameters can be `name=value` words, converted using the IDL, or a JSON object. Streams come out as one JSON object per line, and exit codes are stable, so a refused write and a method error are easy to tell apart. There's also `vlx list --probe` to see every socket and what's behind it, `vlx describe` to print the IDL and `vlx template` to get a JSON skeleton for a method's parameters.

And when you find the call you wanted, vlx can copy it as either a `vlx call` or a `varlinkctl call`. So it's also a decent way to learn `varlinkctl` without reading the man page first.

On a real system the recipes look like this:

```shell
vlx call hostname io.systemd.Hostname.Describe
vlx call resolve io.systemd.Resolve.DumpDNSConfiguration
vlx call multiplexer io.systemd.UserDatabase.GetUserRecord \
  '{"service":"io.systemd.Multiplexer"}' --more
sudo vlx call manager io.systemd.Unit.List --more   # systemd v258+
```

---

## How They Fit Together

vlx is the first real user of sdvarlink. It uses the core `varlink` package to dial sockets and stream replies, the `idl` package to parse what each service says about itself, and the generated packages for every systemd release to know the interfaces it classifies. Building the two side by side was the point. vlx has to talk to services it knows nothing about ahead of time, which is a good stress test for the untyped half of the library, and anything awkward there turned into a better API.

- sdvarlink/
  - varlink/ — the core: Conn, Pool, streaming, fds, errors
  - systemd/v257, v258, v260, latest/ — generated clients
  - varlinktest/ — the fake server
  - idl/ — the Varlink IDL parser
  - cmd/sdvarlink-gen/ — the generator
  - examples/ — 15 runnable programs
- vlx/
  - cmd/vlx/ — the explorer and CLI

A rough rule of thumb: if you're writing a program, use sdvarlink. If you're trying to figure out what to write, use vlx.

---

## Try Them

Both need a Linux machine with systemd v257 or newer to talk to systemd. The `vlx demo`, the sdvarlink fake and the IDL parser work anywhere.

```shell
# Explore your machine (sudo for services that only talk to root)
go install github.com/rossedman/vlx/cmd/vlx@latest
vlx

# Use it from Go
go get github.com/rossedman/sdvarlink@latest
git clone https://github.com/rossedman/sdvarlink
cd sdvarlink && go run ./examples/resolve-hostname
```

The examples cover resolving names, listing users, units and machines, the DNS monitor, mounting images by fd, metrics, a mini `varlinkctl`, feature detection, and testing with fakes and recorded golden files. Each project also has a docs site: [sdvarlink](https://rossedman.github.io/sdvarlink/) has guides on streaming, fds, errors, versioning, testing and migrating from D-Bus, plus a reference for every interface and what changed between systemd releases. [vlx](https://rossedman.github.io/vlx/) has a QuickStart, guides for the explorer, scripting and safety, and a page of systemd recipes.

[rossedman/sdvarlink](https://github.com/rossedman/sdvarlink)

[rossedman/vlx](https://github.com/rossedman/vlx)

Both are MIT licensed and pre-1.0, so the APIs may still change. A new systemd release is one command to add to sdvarlink, and a weekly job does it automatically, so the bindings should keep up. If you've been [writing Go services that run under systemd](https://rossedman.io/blog/computers/go-service-with-systemd/), or you just looked at [polkit](https://rossedman.io/blog/computers/polkit-basics/) and wondered what else is behind those sockets, give them a try. If something doesn't work on your distro, or there's an interface you'd like better support for, open an issue. I'd love to hear about it.
