sdvarlink¶
Call systemd from Go with typed structs instead of hand-rolled JSON.
sdvarlink generates clients for every io.systemd.* Varlink interface, streams replies as Go iterators,
passes file descriptors in both directions and ships a fake server for your tests. Pure Go, no cgo.
Thirty seconds of sdvarlink¶
package main
import (
"context"
"fmt"
"log"
"github.com/rossedman/sdvarlink/systemd/latest/resolve"
)
func main() {
ctx := context.Background()
c, err := resolve.Dial(ctx) // (1)!
if err != nil {
log.Fatal(err)
}
defer c.Close()
out, err := c.ResolveHostname(ctx, resolve.ResolveHostnameIn{Name: "systemd.io"})
if err != nil {
log.Fatal(err) // (2)!
}
for _, a := range out.Addresses {
fmt.Println(out.Name, a.Family, a.Address)
}
}
- Connects to
/run/systemd/resolve/io.systemd.Resolve. If the socket does not exist, the error matchesvarlink.ErrUnsupported, so you can fall back to D-Bus. - Error replies are typed:
errors.Is(err, resolve.ErrNoSuchResourceRecord)orerrors.As(err, &dnssecErr)work as you'd expect.
// GetUserRecordMore sets Varlink's "more" flag: systemd-userdbd streams
// one reply per user, and the client yields them as an iter.Seq2.
c, err := userdatabase.Dial(ctx)
if err != nil {
return err
}
defer c.Close()
in := userdatabase.GetUserRecordIn{Service: "io.systemd.Multiplexer"}
for rec, err := range c.GetUserRecordMore(ctx, in) {
if err != nil {
return err
}
fmt.Printf("%s\n", rec.Record) // break any time: the call is cleaned up
}
img, err := os.Open("app.raw")
if err != nil {
return err
}
defer img.Close() // you keep ownership of files you send
c, err := mountfilesystem.Dial(ctx)
if err != nil {
return err
}
defer c.Close()
// The *os.File travels as SCM_RIGHTS ancillary data; the JSON carries its index.
out, err := c.MountImage(ctx, mountfilesystem.MountImageIn{
ImageFileDescriptor: img,
ReadOnly: varlink.Ptr(true),
})
if err != nil {
return err
}
for _, p := range out.Partitions {
fmt.Println(p.Designator, p.FileSystemType)
p.MountFileDescriptor.Close() // files you receive are yours to close
}
func TestLookup(t *testing.T) {
srv := varlinktest.NewServer(t) // temp socket, closed with the test
varlinktest.On(srv, "io.systemd.Resolve.ResolveHostname",
func(ctx context.Context, in resolve.ResolveHostnameIn) (resolve.ResolveHostnameOut, error) {
if in.Name != "example.com" {
return resolve.ResolveHostnameOut{}, &resolve.NoSuchResourceRecordError{}
}
return resolve.ResolveHostnameOut{
Name: in.Name,
Addresses: []resolve.ResolvedAddress{{Family: 2, Address: []int64{93, 184, 215, 14}}},
}, nil
})
c, err := resolve.DialPath(context.Background(), srv.Path())
if err != nil {
t.Fatal(err)
}
defer c.Close()
_, err = c.ResolveHostname(context.Background(), resolve.ResolveHostnameIn{Name: "nope.invalid"})
if !errors.Is(err, resolve.ErrNoSuchResourceRecord) {
t.Fatalf("err = %v", err)
}
}
What you get¶
-
Typed, generated clients
One package per interface, generated from systemd's own IDL: input and output structs, enums with constants, doc comments carried over.
-
Streaming as iterators
Methods that answer with many replies return
iter.Seq2[*Out, error]. Range over them;breakand context cancellation clean up for you. -
File-descriptor passing
*os.Filefields travel viaSCM_RIGHTSin both directions, attached to the exact message that references them. Leak-tested. -
Typed errors
Every IDL error becomes a Go type plus an
Err<Name>sentinel.io.systemd.Systemunwraps tosyscall.Errno. -
Versioned per systemd release
Import
systemd/v257/...to pin the oldest systemd you support, orsystemd/latest/...to follow upstream. Feature-detect withSupports. -
Serve interfaces too
varlink.Serverserves your own interfaces, or systemd's such asio.systemd.Metrics, with typed handlers, streams, peer credentials and socket activation. -
A fake server for tests
varlinktestserves typed handlers on a temp socket, records calls, and records/replays sessions against real systemd as golden files. -
Pooled & concurrent
Generated clients wrap a connection pool, so goroutines never queue behind each other on one socket. Contexts set deadlines.
-
Raw calls when you need them
The
varlinkcore calls any method withjson.RawMessage, introspects services and parses IDL. Likevarlinkctl, in Go.
Why Varlink?¶
D-Bus routes every call through a broker daemon. Varlink is a direct
conversation: NUL-terminated JSON over a Unix socket the service owns. No
broker means it works in early boot, in the initrd and in minimal containers,
where dbus-daemon isn't running, and it adds streaming replies and
file-descriptor passing.
D-Bus: brokered¶
flowchart TB
A1["Go program<br/>go-systemd/dbus"] -- "marshal" --> B(("dbus-daemon"))
B -- "route + policy" --> C1["systemd-resolved"]
B -- "route + policy" --> D1["PID 1"]
Varlink: direct¶
flowchart TB
A2["Go program<br/>sdvarlink"] -- "JSON + NUL" --> C2["systemd-resolved<br/>io.systemd.Resolve"]
A2 -- "JSON + NUL + fds" --> D2["systemd-mountfsd<br/>io.systemd.MountFileSystem"]
systemd v257 shipped 19 Varlink interfaces, against 11 D-Bus services, and newer subsystems such as userdb, metrics, mountfsd and machine image management speak Varlink first, or only. Learn the protocol in Core concepts, or jump into the examples.
-
New here?
Install, then write and run your first program in five minutes.
-
Coming from go-systemd?
Side-by-side D-Bus vs Varlink code, and when to keep D-Bus.
-
Looking something up?
Every interface, method, type and error, with the release that added it.