diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 460d378a..aa6d0c50 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -63,16 +63,16 @@ jobs:
tree /tmp || true
- name: Check dependencies
run: |
- ldd /tmp/sbin/finit
+ LD_LIBRARY_PATH=/tmp/lib ldd /tmp/sbin/finit
size /tmp/sbin/finit
- ldd /tmp/sbin/initctl
+ LD_LIBRARY_PATH=/tmp/lib ldd /tmp/sbin/initctl
size /tmp/sbin/initctl
- ldd /tmp/sbin/reboot
+ LD_LIBRARY_PATH=/tmp/lib ldd /tmp/sbin/reboot
size /tmp/sbin/reboot
- name: Verify starting and showing usage text
run: |
- sudo /tmp/sbin/finit -h
- sudo /tmp/sbin/initctl -h
+ sudo env LD_LIBRARY_PATH=/tmp/lib /tmp/sbin/finit -h
+ sudo env LD_LIBRARY_PATH=/tmp/lib /tmp/sbin/initctl -h
- name: Enable unprivileged userns (unshare)
run: |
sudo sysctl kernel.apparmor_restrict_unprivileged_userns=0
diff --git a/Makefile.am b/Makefile.am
index a5333be6..b3afffea 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -1,5 +1,16 @@
ACLOCAL_AMFLAGS = -I m4
-SUBDIRS = man plugins src system tmpfiles.d
+
+# libink must precede src in SUBDIRS because finit links against
+# libink at build time. Automake recurses subdirs strictly in
+# declaration order, so the typical "explicit dependency" pattern
+# (foo: bar) doesn't help here — ordering is the only thing that
+# does. libsystemd is consumed by test/serv only, so its position
+# after src is fine.
+SUBDIRS = man plugins
+if DBUS
+SUBDIRS += libink dbus-1
+endif
+SUBDIRS += src system tmpfiles.d
dist_doc_DATA = README.md LICENSE contrib/finit.conf
if CONTRIB
diff --git a/configure.ac b/configure.ac
index 7b0943ac..8f4349f3 100644
--- a/configure.ac
+++ b/configure.ac
@@ -12,6 +12,8 @@ AC_CONFIG_FILES([Makefile
contrib/debian/Makefile contrib/debian/finit.d/Makefile contrib/debian/finit.d/available/Makefile
contrib/void/Makefile contrib/void/finit.d/Makefile contrib/void/finit.d/available/Makefile
doc/Makefile doc/config/Makefile
+ dbus-1/Makefile
+ libink/Makefile libink/libink.pc
libsystemd/Makefile libsystemd/libsystemd.pc
man/Makefile
plugins/Makefile
@@ -88,6 +90,10 @@ AC_ARG_ENABLE(logrotate,
AS_HELP_STRING([--disable-logrotate], [Disable built-in rotation of /var/log/wtmp]),,[
enable_logrotate=yes])
+AC_ARG_ENABLE(dbus,
+ AS_HELP_STRING([--disable-dbus], [Disable D-Bus support (libink + Finit object tree)]),,[
+ enable_dbus=yes])
+
AC_ARG_ENABLE(doc,
AS_HELP_STRING([--disable-doc], [Disable build and install of doc/ section]),,[
enable_doc=yes])
@@ -237,6 +243,10 @@ AS_IF([test "x$enable_rescue" != "xno"], [
AM_CONDITIONAL(LOGROTATE, [test "x$enable_logrotate" = "xyes"])
+AS_IF([test "x$enable_dbus" = "xyes"], [
+ AC_DEFINE(HAVE_DBUS, 1, [Build D-Bus support via libink])])
+AM_CONDITIONAL(DBUS, [test "x$enable_dbus" = "xyes"])
+
### With features ##############################################################################
AS_IF([test "x$bash_dir" = "xyes"], [
PKG_CHECK_MODULES([BASH_COMPLETION], [bash-completion >= 2.0],
@@ -432,6 +442,7 @@ Optional features:
Built-in sulogin......: $with_sulogin $sulogin
Built-in watchdogd....: $with_watchdog $watchdog
Built-in logrotate....: $enable_logrotate
+ D-Bus support (libink): $enable_dbus
Replacement libsystemd: $with_libsystemd
Use cgroup v2.........: $enable_cgroup
Use libcap............: $enable_libcap
diff --git a/dbus-1/.gitignore b/dbus-1/.gitignore
new file mode 100644
index 00000000..b336cc7c
--- /dev/null
+++ b/dbus-1/.gitignore
@@ -0,0 +1,2 @@
+/Makefile
+/Makefile.in
diff --git a/dbus-1/Makefile.am b/dbus-1/Makefile.am
new file mode 100644
index 00000000..dec4a333
--- /dev/null
+++ b/dbus-1/Makefile.am
@@ -0,0 +1,6 @@
+EXTRA_DIST = org.finit.conf
+
+if DBUS
+dbuspolicydir = $(sysconfdir)/dbus-1/system.d
+dist_dbuspolicy_DATA = org.finit.conf
+endif
diff --git a/dbus-1/org.finit.conf b/dbus-1/org.finit.conf
new file mode 100644
index 00000000..ea730e9b
--- /dev/null
+++ b/dbus-1/org.finit.conf
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md
index 35c61552..3597609b 100644
--- a/doc/ChangeLog.md
+++ b/doc/ChangeLog.md
@@ -8,6 +8,55 @@ All relevant changes are documented in this file.
### Changes
+- Finit now ships with a built-in brokerless D-Bus implementation,
+ **libink**, exposing the running init system as a peer on its own
+ private bus at `/run/finit/bus`, and -- opportunistically --
+ registering `org.finit` on the standard system bus when a
+ `dbus-daemon` is reachable. No external `libdbus`/`sd-bus`/`GIO`
+ dependency.
+
+ The bus implements the stock `org.freedesktop.DBus`,
+ `org.freedesktop.DBus.Peer`, `org.freedesktop.DBus.Introspectable`,
+ and `org.freedesktop.DBus.Properties` interfaces, plus three
+ Finit-specific ones:
+
+ * `org.finit.Manager1` at `/org/finit/manager` --
+ `ListServices`, `GetService`, `Start`/`Stop`/`Restart`/`Reload`,
+ `SetRunlevel`, `SetDebug`, `Signal`, `Suspend`, and the
+ `Reboot`/`Halt`/`Poweroff` triplet. Read-only properties
+ `Runlevel`, `PrevRunlevel`, `Version`. Signals
+ `ServiceStateChanged (sss)` and `RunlevelChanged (ss)`.
+
+ * `org.finit.Service1` at `/org/finit/service/` -- one
+ object per loaded service, with `Start`/`Stop`/`Restart`/`Reload`
+ for working off an object handle rather than passing the
+ identity string around.
+
+ * `org.finit.Cond1` at `/org/finit/cond` -- `Get`, `Set`, `Clear`,
+ `List`, `Dump` for [user-defined conditions](conditions.md),
+ with a `ConditionChanged (ss)` signal.
+
+ Privileged methods reject non-root callers based on the kernel-
+ authenticated peer uid (`SO_PEERCRED`); read-only methods are open.
+ See [D-Bus Integration](dbus.md) for the full surface, build flag,
+ and `dbus-send`/`dbus-monitor` examples.
+
+- `initctl` now transparently routes through D-Bus when the bus is
+ reachable, with the legacy `INIT_SOCKET` transport as a fallback:
+ `start`, `stop`, `restart`, `reload`, `reload `, `reboot`,
+ `halt`, `poweroff`, `suspend`, `debug`, `signal`, `runlevel`, and
+ `cond {get,set,clr}` all use the new path. Two new subcommands
+ show up that have no legacy equivalent:
+
+ * `initctl monitor` -- streams every signal on the bus to the
+ terminal, one line per delivery (`HH:MM:SS iface.member(args)`),
+ until interrupted. Same idea as `dbus-monitor`, but scoped to
+ Finit and with no address plumbing required.
+
+ * Issuing `initctl cond set/clr` over D-Bus also fires the
+ `Cond1.ConditionChanged` signal, so observers see user-driven
+ state changes the same way they see service-driven ones.
+
- Restart log now spells out the signal name and flags core dumps,
e.g. `killed by SIGKILL` or `killed by SIGSEGV, core dumped`, in
place of the bare numeric `by signal: N`. Gives operators a much
diff --git a/doc/dbus.md b/doc/dbus.md
new file mode 100644
index 00000000..3b035909
--- /dev/null
+++ b/doc/dbus.md
@@ -0,0 +1,294 @@
+D-Bus Integration
+=================
+
+Finit ships with a built-in, brokerless [D-Bus][] implementation,
+**libink**, that exposes the running init system as a peer on its own
+private bus, and optionally on the system bus when `dbus-daemon` is
+available. Everything `initctl` does is also reachable from any
+generic D-Bus tooling — `dbus-send`, `dbus-monitor`, `gdbus`,
+language bindings, dashboards, monitoring agents, etc.
+
+> [!NOTE]
+> D-Bus support is enabled at build time with `--enable-dbus`. See
+> [Building](build.md) for details. When disabled, `initctl` keeps
+> using the legacy `INIT_SOCKET` transport and Finit exposes no bus.
+
+Bus address
+-----------
+
+| Bus | Address |
+| --- | --- |
+| Local (always) | `unix:path=/run/finit/bus` |
+| System (opportunistic) | `unix:path=/var/run/dbus/system_bus_socket`, well-known name `org.finit` |
+
+The **local** bus is brokerless: clients connect straight to Finit
+over a Unix-domain socket using the standard D-Bus SASL EXTERNAL
+handshake. No `dbus-daemon` is required, which makes it suitable for
+embedded systems that don't ship one.
+
+The **system** bus is best-effort: at start-up Finit probes for a
+running `dbus-daemon` and, if reachable, registers `org.finit` so that
+standard tooling sees Finit just like any other system service:
+
+```sh
+dbus-send --system --print-reply --dest=org.finit \
+ /org/finit/manager \
+ org.finit.Manager1.ListServices
+
+dbus-monitor --system "sender='org.finit'"
+```
+
+If no system bus is present (the common case on embedded targets),
+this step is silently skipped.
+
+Object tree
+-----------
+
+```
+/
+├── org/
+│ └── finit/
+│ ├── manager Manager1
+│ ├── cond Cond1
+│ └── service/
+│ ├── keventd Service1 (one per service)
+│ ├── sshd
+│ └── …
+└── org/freedesktop/DBus Standard well-known interfaces
+```
+
+Every node implements the usual stock interfaces:
+
+| Interface | Purpose |
+| ------------------------------------ | ------- |
+| `org.freedesktop.DBus` | `Hello`, `AddMatch`, `RemoveMatch` (on `/org/freedesktop/DBus`) |
+| `org.freedesktop.DBus.Peer` | `Ping`, `GetMachineId` |
+| `org.freedesktop.DBus.Introspectable`| `Introspect()` — XML description |
+| `org.freedesktop.DBus.Properties` | `Get`, `GetAll` (Set not yet implemented) |
+
+`org.finit.Manager1`
+--------------------
+
+Lives at **`/org/finit/manager`**. Owns the global init operations
+and the service registry.
+
+### Methods
+
+| Method | In sig | Out sig | Privileged | Notes |
+| ----------------------- | ------ | ------- | ---------- | ----- |
+| `ListServices` | — | `as` | no | Returns the identities (`name`, `name:id`) of every loaded service. |
+| `GetService` | `s` | `o` | no | Resolves a service identity to its `Service1` object path. |
+| `Start` | `s` | — | yes | Start the service(s) matching the identity. |
+| `Stop` | `s` | — | yes | Stop the service(s) matching the identity. |
+| `Restart` | `s` | — | yes | Restart (stop + start) the service(s). |
+| `Reload` | — | — | yes | Re-read all `*.conf` and apply changes (same as `initctl reload`). |
+| `SetRunlevel` | `u` | — | yes | Transition to runlevel `u` (0–6). |
+| `SetDebug` | — | — | yes | Toggle Finit's runtime debug flag. |
+| `Signal` | `su` | — | yes | Send signal number `u` (1–31) to every running service matching identity `s`. Halted matches are silently skipped. |
+| `Suspend` | — | — | yes | `sync()` + suspend-to-RAM. |
+| `Reboot` / `Halt` / `Poweroff` | — | — | yes | Trigger the corresponding shutdown sequence. |
+
+### Properties
+
+All read-only strings; observable via `Properties.Get` and
+`Properties.GetAll`.
+
+| Property | Type | Returns |
+| -------------- | ---- | ------- |
+| `Runlevel` | `s` | Current runlevel as a digit (`"2"`, `"3"`, …) or `"S"`. |
+| `PrevRunlevel` | `s` | Previous runlevel, same encoding. |
+| `Version` | `s` | Finit's version string (`PACKAGE_VERSION`). |
+
+### Signals
+
+| Signal | Body | Fires when |
+| ----------------------- | ---- | ---------- |
+| `ServiceStateChanged` | `(sss)` — identity, old state, new state | A service transitions between supervisor states. |
+| `RunlevelChanged` | `(ss)` — old level, new level | The system enters a new runlevel. |
+
+State names emitted by `ServiceStateChanged` are stable wire strings:
+`halted`, `done`, `dead`, `cleanup`, `teardown`, `stopping`, `setup`,
+`paused`, `waiting`, `starting`, `running`.
+
+`org.finit.Service1` (per-service objects)
+------------------------------------------
+
+Lives at **`/org/finit/service/`**, one object per loaded
+service. `` is the service identity (name, or `name:id` for
+templated services) put through systemd-style `_HH` hex escaping —
+ASCII alphanumerics and `_` pass through, anything else becomes `_HH`
+where `HH` is the hex byte. Use `Manager1.GetService(identity)` to
+look up the exact path rather than constructing it by hand.
+
+| Method | In sig | Out sig | Privileged | Notes |
+| --------- | ------ | ------- | ---------- | ----- |
+| `Start` | — | — | yes | Equivalent to `Manager1.Start()` for this service. |
+| `Stop` | — | — | yes | … |
+| `Restart` | — | — | yes | … |
+| `Reload` | — | — | yes | Reload (SIGHUP if supported, else restart). |
+
+The per-service surface lets generic tooling supply an object handle
+once and then invoke methods on it, instead of repeatedly passing the
+identity string.
+
+`org.finit.Cond1`
+-----------------
+
+Lives at **`/org/finit/cond`**. Exposes Finit's
+[condition system](conditions.md) to bus clients.
+
+### Methods
+
+| Method | In sig | Out sig | Privileged | Notes |
+| -------- | ------ | ------- | ---------- | ----- |
+| `Get` | `s` | `s` | no | Returns `"on"`, `"off"`, or `"flux"` for the named condition. |
+| `Set` | `s` | — | yes | Assert a `usr/` condition. Non-`usr/*` paths are rejected with `InvalidArgs` (system conditions belong to Finit's state machine). |
+| `Clear` | `s` | — | yes | Deassert a `usr/` condition. |
+| `List` | — | `as` | no | Names of all known conditions. |
+| `Dump` | — | `a(ss)` | no | `(name, state)` pairs for everything `List` returns. |
+
+### Signals
+
+| Signal | Body | Fires when |
+| ------------------- | ---- | ---------- |
+| `ConditionChanged` | `(ss)` — name, new state | A condition is asserted or deasserted. |
+
+Authorization
+-------------
+
+Privileged methods reject any caller whose peer `uid` isn't 0.
+On the **local** bus the kernel's `SO_PEERCRED` socket option tells
+Finit exactly who's calling, so privilege escalation through the bus
+is impossible.
+
+On the **system** bus, all incoming traffic is treated as
+unprivileged: it arrives through `dbus-daemon` (typically running as
+root) and Finit cannot yet ask the daemon for the real requester's
+uid via `GetConnectionUnixUser`. This means external tooling can
+freely `Get`/`Introspect`/`ListServices`, but every state-changing
+method returns `org.freedesktop.DBus.Error.AccessDenied`. Per-sender
+uid lookup is on the roadmap.
+
+When a privileged method is rejected the error name is exactly
+`org.freedesktop.DBus.Error.AccessDenied`, and the body carries a
+short reason string (e.g. `"permission denied: Start requires root"`).
+
+`initctl` integration
+---------------------
+
+`initctl` transparently routes through D-Bus when the bus socket is
+present, and falls back to the legacy `INIT_SOCKET` transport
+otherwise. Concretely, the following subcommands use the bus first:
+
+| Subcommand | Method |
+| ------------------- | ------------------------------- |
+| `initctl start` | `Manager1.Start(svc)` |
+| `initctl stop` | `Manager1.Stop(svc)` |
+| `initctl restart` | `Manager1.Restart(svc)` |
+| `initctl reload` | `Manager1.Reload()` |
+| `initctl reload S` | `Service1.Reload()` (per-svc) |
+| `initctl reboot` | `Manager1.Reboot()` |
+| `initctl halt` | `Manager1.Halt()` |
+| `initctl poweroff` | `Manager1.Poweroff()` |
+| `initctl suspend` | `Manager1.Suspend()` |
+| `initctl debug` | `Manager1.SetDebug()` |
+| `initctl signal` | `Manager1.Signal(svc, signo)` |
+| `initctl runlevel` | `Properties.Get(Manager1.Runlevel/PrevRunlevel)` |
+| `initctl cond set/get/clr` | `Cond1.{Set,Get,Clear}` |
+
+Two `initctl` subcommands are pure D-Bus features without legacy
+equivalents:
+
+* `initctl monitor` — subscribes to every signal on the local bus and
+ prints one line per delivery (with timestamp, interface and
+ member). Same idea as `dbus-monitor`, but scoped to Finit and with
+ no need to pass `--address`.
+
+* `initctl cond` (when D-Bus is reachable) emits the standard
+ `Cond1.ConditionChanged` signal as a side effect, so subscribers
+ observe user-driven state changes the same way they observe
+ service-driven ones.
+
+Examples
+--------
+
+The examples below use `dbus-send` and `dbus-monitor`, which ship as
+part of the [dbus][] reference implementation; they're widely
+packaged and don't pull in any extra runtime. Any tool that speaks
+D-Bus over an AF_UNIX socket works equally well — `gdbus`, Python's
+`jeepney`/`dasbus`, etc. — substitute their syntax for setting the
+bus address.
+
+When `org.finit` is registered on the system bus you can replace
+`--address=unix:path=/run/finit/bus` with `--system` in any example
+below.
+
+List the running services:
+
+```sh
+dbus-send --address=unix:path=/run/finit/bus \
+ --type=method_call --print-reply --dest=org.finit \
+ /org/finit/manager \
+ org.finit.Manager1.ListServices
+```
+
+Read the current runlevel via the Properties interface:
+
+```sh
+dbus-send --address=unix:path=/run/finit/bus \
+ --type=method_call --print-reply --dest=org.finit \
+ /org/finit/manager \
+ org.freedesktop.DBus.Properties.Get \
+ string:org.finit.Manager1 string:Runlevel
+```
+
+Subscribe to every state change on the manager object:
+
+```sh
+dbus-monitor --address=unix:path=/run/finit/bus \
+ "type='signal',interface='org.finit.Manager1'"
+```
+
+Or use `initctl monitor`, which does the same without any address
+plumbing.
+
+Restart a service by its object path:
+
+```sh
+dbus-send --address=unix:path=/run/finit/bus \
+ --type=method_call --dest=org.finit \
+ /org/finit/service/sshd \
+ org.finit.Service1.Restart
+```
+
+Trigger a `usr/`-condition assertion that wakes any dependent service:
+
+```sh
+dbus-send --address=unix:path=/run/finit/bus \
+ --type=method_call --dest=org.finit \
+ /org/finit/cond \
+ org.finit.Cond1.Set string:"data-ready"
+```
+
+The `--dest=org.finit` argument is informational on the local
+brokerless bus — Finit accepts any destination because there's no
+broker to route by name — but `dbus-send` requires it syntactically.
+
+[dbus]: https://gitlab.freedesktop.org/dbus/dbus
+
+Implementation notes
+--------------------
+
+The D-Bus server library lives in `libink/`. It speaks the binary
+D-Bus 1.0 wire format directly, has no `libdbus`/`sd-bus`/`GIO`
+dependency, and is tiny — a few thousand lines of C. The Finit-side
+glue in `src/dbus.c` registers vtables for Manager1/Service1/Cond1,
+emits the four signals from the appropriate hook points (state
+transitions, runlevel transitions, condition flips), and bridges the
+event loop to the libink server.
+
+The `initctl` client uses the same library — `link_client_open`,
+`link_client_call_v`, `link_client_reply`, `link_reader_*` — so the
+single wire-format implementation serves both ends.
+
+[D-Bus]: https://dbus.freedesktop.org/doc/dbus-specification.html
diff --git a/libink/.gitignore b/libink/.gitignore
new file mode 100644
index 00000000..8f7eb2a5
--- /dev/null
+++ b/libink/.gitignore
@@ -0,0 +1,8 @@
+.deps/*
+.libs/*
+.dirstamp
+*.lo
+libink.*
+!libink.pc.in
+Makefile
+Makefile.in
diff --git a/libink/Makefile.am b/libink/Makefile.am
new file mode 100644
index 00000000..7f908a9b
--- /dev/null
+++ b/libink/Makefile.am
@@ -0,0 +1,22 @@
+# libink — brokerless D-Bus library (server + client), born inside Finit
+lib_LTLIBRARIES = libink.la
+libink_la_SOURCES = server.c auth.c connection.c \
+ proto.c proto.h \
+ marshal.c marshal.h \
+ dispatch.c builtin.c \
+ match.c \
+ path.c \
+ client.c io.c \
+ internal.h
+
+libink_la_LDFLAGS = -version-info 0:0:0
+libink_la_CPPFLAGS = -D_GNU_SOURCE -D_DEFAULT_SOURCE -D_BSD_SOURCE
+libink_la_CFLAGS = -W -Wall -Wextra -Wno-unused-parameter -std=gnu99
+
+# pkg-config support
+pkgconfigdir = $(libdir)/pkgconfig
+pkgconfig_DATA = libink.pc
+
+# Public headers install to $(includedir)/ink/
+inkdir = $(includedir)/ink
+ink_HEADERS = link.h path.h
diff --git a/libink/auth.c b/libink/auth.c
new file mode 100644
index 00000000..76baee13
--- /dev/null
+++ b/libink/auth.c
@@ -0,0 +1,300 @@
+/* libink — D-Bus AUTH EXTERNAL handshake
+ *
+ * Implements the line-based SASL-style exchange described in the
+ * D-Bus specification, section "Authentication Protocol". Only the
+ * AUTH EXTERNAL mechanism is offered; everything else is rejected.
+ *
+ * The exchange:
+ *
+ * client --> [nul byte]
+ * client --> "AUTH EXTERNAL \r\n"
+ * server <-- "OK \r\n"
+ * client --> "NEGOTIATE_UNIX_FD\r\n" [optional]
+ * server <-- "ERROR \r\n" (no fd-passing yet)
+ * client --> "BEGIN\r\n"
+ *
+ * Copyright (c) 2026 Joachim Wiberg
+ * SPDX-License-Identifier: MIT
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "internal.h"
+
+static const char rejected_ext[] = "REJECTED EXTERNAL\r\n";
+
+#define write_all(fd, buf, len) __io_write_all((fd), (buf), (len))
+
+/* Shared by __auth_generate_guid (server) and __auth_client
+ * (client) for hex-encoding GUIDs and uid claims. */
+static const char hex_digits[] = "0123456789abcdef";
+
+static int reply(int fd, const char *line)
+{
+ return write_all(fd, line, strlen(line));
+}
+
+static int reject(link_connection_t *conn)
+{
+ return write_all(conn->fd, rejected_ext, sizeof(rejected_ext) - 1);
+}
+
+void __auth_generate_guid(char out[33])
+{
+ uint8_t raw[16];
+ size_t i;
+
+ if (getrandom(raw, sizeof(raw), 0) != (ssize_t)sizeof(raw)) {
+ /* Extraordinarily unlikely; GUID is informational, not a
+ * security primitive — fall back to something deterministic
+ * rather than uninitialized memory. */
+ for (i = 0; i < sizeof(raw); i++)
+ raw[i] = (uint8_t)(i ^ 0xa5);
+ }
+
+ for (i = 0; i < sizeof(raw); i++) {
+ out[i * 2] = hex_digits[raw[i] >> 4];
+ out[i * 2 + 1] = hex_digits[raw[i] & 0xf];
+ }
+ out[32] = '\0';
+}
+
+static int hexval(int c)
+{
+ if (c >= '0' && c <= '9') return c - '0';
+ if (c >= 'a' && c <= 'f') return c - 'a' + 10;
+ if (c >= 'A' && c <= 'F') return c - 'A' + 10;
+ return -1;
+}
+
+/* Parse "AUTH EXTERNAL " payload into a uid. The argument is
+ * an even-length hex string whose decoded form is a decimal uid in
+ * ASCII. Returns 0 on success, -1 on malformed input. */
+static int parse_external_uid(const char *arg, size_t arglen, uid_t *out)
+{
+ char decoded[24];
+ char *ep = NULL;
+ unsigned long v;
+ size_t i, dlen;
+
+ if (arglen == 0 || (arglen & 1) || arglen / 2 >= sizeof(decoded))
+ return -1;
+
+ dlen = arglen / 2;
+ for (i = 0; i < dlen; i++) {
+ int hi = hexval((unsigned char)arg[i * 2]);
+ int lo = hexval((unsigned char)arg[i * 2 + 1]);
+
+ if (hi < 0 || lo < 0)
+ return -1;
+ decoded[i] = (char)((hi << 4) | lo);
+ }
+ decoded[dlen] = '\0';
+
+ errno = 0;
+ v = strtoul(decoded, &ep, 10);
+ if (errno || !ep || *ep != '\0' || v > (unsigned long)((uid_t)-1))
+ return -1;
+
+ *out = (uid_t)v;
+ return 0;
+}
+
+static int handle_line(link_connection_t *conn, const char *line, size_t len)
+{
+ if (len >= 14 && memcmp(line, "AUTH EXTERNAL ", 14) == 0) {
+ uid_t claimed;
+ char ok[64];
+
+ if (parse_external_uid(line + 14, len - 14, &claimed) < 0)
+ return reject(conn);
+ if (conn->peer_uid == (uid_t)-1 || claimed != conn->peer_uid)
+ return reject(conn);
+
+ snprintf(ok, sizeof(ok), "OK %s\r\n", conn->guid);
+ return reply(conn->fd, ok);
+ }
+
+ if (len == 4 && memcmp(line, "AUTH", 4) == 0)
+ return reject(conn);
+
+ if (len == 17 && memcmp(line, "NEGOTIATE_UNIX_FD", 17) == 0)
+ return reply(conn->fd, "ERROR fd-passing not supported\r\n");
+
+ if (len == 5 && memcmp(line, "BEGIN", 5) == 0) {
+ conn->auth = LINK_AUTH_DONE;
+ return 0;
+ }
+
+ if (len == 6 && memcmp(line, "CANCEL", 6) == 0)
+ return reject(conn);
+
+ if (len >= 5 && memcmp(line, "ERROR", 5) == 0)
+ return reject(conn);
+
+ return reply(conn->fd, "ERROR Unknown command\r\n");
+}
+
+/* Pull one CR+LF-terminated line out of conn->linebuf. Returns the
+ * line length (without the CR+LF), or 0 if no complete line is
+ * present yet. Consumes the line on success. */
+static size_t take_line(link_connection_t *conn, char *out, size_t outsz)
+{
+ size_t i;
+
+ for (i = 0; i + 1 < conn->linelen; i++) {
+ if (conn->linebuf[i] == '\r' && conn->linebuf[i + 1] == '\n') {
+ size_t linelen = i;
+ size_t consumed = i + 2;
+
+ if (linelen >= outsz)
+ linelen = outsz - 1;
+
+ memcpy(out, conn->linebuf, linelen);
+ out[linelen] = '\0';
+
+ memmove(conn->linebuf, conn->linebuf + consumed,
+ conn->linelen - consumed);
+ conn->linelen -= consumed;
+ return linelen;
+ }
+ }
+ return 0;
+}
+
+int __auth_process(link_connection_t *conn)
+{
+ uint8_t buf[256];
+ ssize_t n;
+ size_t off = 0;
+
+ n = read(conn->fd, buf, sizeof(buf));
+ if (n == 0)
+ return -1; /* peer closed */
+ if (n < 0) {
+ if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR)
+ return 0;
+ return -1;
+ }
+
+ if (conn->auth == LINK_AUTH_NUL) {
+ if (buf[0] != 0x00) {
+ conn->auth = LINK_AUTH_FAILED;
+ return -1;
+ }
+ off = 1;
+ conn->auth = LINK_AUTH_LINE;
+ }
+
+ if (conn->auth == LINK_AUTH_LINE) {
+ size_t take = (size_t)n - off;
+ char line[LINK_AUTH_LINEBUF_SIZE];
+ size_t linelen;
+
+ if (conn->linelen + take > sizeof(conn->linebuf)) {
+ conn->auth = LINK_AUTH_FAILED;
+ return -1;
+ }
+ memcpy(conn->linebuf + conn->linelen, buf + off, take);
+ conn->linelen += take;
+
+ while ((linelen = take_line(conn, line, sizeof(line))) > 0) {
+ if (handle_line(conn, line, linelen) < 0)
+ return -1;
+ if (conn->auth != LINK_AUTH_LINE)
+ break;
+ }
+
+ /* If BEGIN flipped us to DONE, any remaining linebuf bytes
+ * are the first bytes of the binary D-Bus stream — move
+ * them to rxbuf so the dispatcher can pick them up on the
+ * next process() call. */
+ if (conn->auth == LINK_AUTH_DONE && conn->linelen > 0) {
+ if (conn->linelen > sizeof(conn->rxbuf))
+ return -1;
+ memcpy(conn->rxbuf, conn->linebuf, conn->linelen);
+ conn->rxlen = conn->linelen;
+ conn->linelen = 0;
+ }
+ }
+
+ return 0;
+}
+
+/* ---- client-side SASL composer ---- */
+
+/* Read a single CR+LF (or just LF) terminated line from fd into buf.
+ * Returns the line length (without the terminator), or -1 on EOF or
+ * buffer overflow. Blocks until a complete line arrives.
+ *
+ * Used only by __auth_client; the server-side parser does its
+ * own line extraction out of conn->linebuf. */
+static ssize_t client_read_line(int fd, char *buf, size_t bufsz)
+{
+ size_t off = 0;
+
+ while (off + 1 < bufsz) {
+ ssize_t n = read(fd, buf + off, 1);
+
+ if (n == 0)
+ return -1;
+ if (n < 0) {
+ if (errno == EINTR)
+ continue;
+ return -1;
+ }
+ if (buf[off] == '\n') {
+ buf[off] = '\0';
+ if (off > 0 && buf[off - 1] == '\r')
+ buf[--off] = '\0';
+ return (ssize_t)off;
+ }
+ off++;
+ }
+ return -1;
+}
+
+int __auth_client(int fd, uid_t uid)
+{
+
+ char uidstr[16];
+ char hexuid[32];
+ char line[64];
+ char reply_line[256];
+ size_t i, n;
+ int rc;
+
+ if (write_all(fd, "\0", 1) < 0)
+ return -1;
+
+ n = (size_t)snprintf(uidstr, sizeof(uidstr), "%u", (unsigned)uid);
+ if (n * 2 >= sizeof(hexuid))
+ return -1;
+ for (i = 0; i < n; i++) {
+ unsigned c = (unsigned char)uidstr[i];
+
+ hexuid[i * 2] = hex_digits[c >> 4];
+ hexuid[i * 2 + 1] = hex_digits[c & 0xf];
+ }
+ hexuid[n * 2] = '\0';
+
+ rc = snprintf(line, sizeof(line), "AUTH EXTERNAL %s\r\n", hexuid);
+ if (rc < 0 || (size_t)rc >= sizeof(line))
+ return -1;
+ if (write_all(fd, line, (size_t)rc) < 0)
+ return -1;
+
+ if (client_read_line(fd, reply_line, sizeof(reply_line)) < 0)
+ return -1;
+ if (strncmp(reply_line, "OK ", 3) != 0)
+ return -1;
+
+ if (write_all(fd, "BEGIN\r\n", 7) < 0)
+ return -1;
+ return 0;
+}
diff --git a/libink/builtin.c b/libink/builtin.c
new file mode 100644
index 00000000..5a5a2ae5
--- /dev/null
+++ b/libink/builtin.c
@@ -0,0 +1,468 @@
+/* libink — built-in implementations of the well-known
+ * org.freedesktop.DBus.* interfaces (Hello, Peer, Introspectable).
+ *
+ * These run before object-tree lookup in the dispatcher; returning
+ * 0 means "handled, reply sent"; <0 means "not a built-in, fall
+ * through to user-registered handlers".
+ *
+ * Copyright (c) 2026 Joachim Wiberg
+ * SPDX-License-Identifier: MIT
+ */
+
+#include
+#include
+#include
+#include
+#include
+
+#include "internal.h"
+
+/* ---------- helpers ---------- */
+
+static int member_is(const struct link_msg *m, const char *iface, const char *member)
+{
+ if (!m->member || strcmp(m->member, member) != 0)
+ return 0;
+ if (m->interface && strcmp(m->interface, iface) != 0)
+ return 0;
+ return 1;
+}
+
+static int send_string_reply(link_connection_t *conn, const struct link_msg *req,
+ const char *s)
+{
+ struct link_writer w;
+ ssize_t blen;
+
+ __w_init(&w, conn->txbuf, sizeof(conn->txbuf));
+ __w_string(&w, s);
+ blen = __w_finish(&w);
+ if (blen < 0) {
+ errno = EMSGSIZE;
+ return -1;
+ }
+ return __send_method_return(conn, req, "s", conn->txbuf, (size_t)blen);
+}
+
+/* ---------- Hello ---------- */
+
+static int handle_hello(link_connection_t *conn, const struct link_msg *m)
+{
+ if (!conn->unique_name[0]) {
+ uint32_t n = ++conn->server->next_unique_id;
+
+ snprintf(conn->unique_name, sizeof(conn->unique_name),
+ ":1.%u", n);
+ }
+ return send_string_reply(conn, m, conn->unique_name);
+}
+
+/* ---------- Ping / GetMachineId ---------- */
+
+static int handle_ping(link_connection_t *conn, const struct link_msg *m)
+{
+ return __send_method_return(conn, m, NULL, NULL, 0);
+}
+
+static int handle_get_machine_id(link_connection_t *conn, const struct link_msg *m)
+{
+ /* D-Bus mandates a 32-char hex machine-id. Use the per-server
+ * GUID-style identifier we already generate for each connection,
+ * promoted to a per-server constant on first call. Good enough
+ * for the brokerless case where clients use this only as a
+ * sanity hint. */
+ static char machine_id[33];
+
+ if (!machine_id[0])
+ __auth_generate_guid(machine_id);
+ return send_string_reply(conn, m, machine_id);
+}
+
+/* ---------- Introspect ---------- */
+
+struct xbuf {
+ char *buf;
+ size_t cap;
+ size_t off;
+ int err;
+};
+
+static void xprintf(struct xbuf *x, const char *fmt, ...)
+{
+ va_list ap;
+ int n;
+
+ if (x->err)
+ return;
+ va_start(ap, fmt);
+ n = vsnprintf(x->buf + x->off, x->cap - x->off, fmt, ap);
+ va_end(ap);
+ if (n < 0 || (size_t)n >= x->cap - x->off) {
+ x->err = 1;
+ return;
+ }
+ x->off += (size_t)n;
+}
+
+/* Emit a single stanza for one method definition. */
+static void emit_method(struct xbuf *x, const link_method_t *m)
+{
+ const char *p;
+
+ xprintf(x, " \n", m->name);
+ for (p = m->in_sig ? m->in_sig : ""; *p; p++)
+ xprintf(x, " \n", *p);
+ for (p = m->out_sig ? m->out_sig : ""; *p; p++)
+ xprintf(x, " \n", *p);
+ xprintf(x, " \n");
+}
+
+static void emit_property(struct xbuf *x, const link_property_t *p)
+{
+ /* Setters are not implemented, so every property advertises
+ * access="read" today. When Properties.Set lands, switch on
+ * a writable flag. */
+ xprintf(x, " \n",
+ p->name, p->sig ? p->sig : "s");
+}
+
+/* Introspection limitation: emit_method prints one per
+ * character of the signature, which is wrong for compound types
+ * (an "a(ss)" arg appears as four args). Good enough for the
+ * "s", "u", "as" signatures we expose today; replace with a
+ * signature parser when the first compound argument lands. */
+
+static const char STANDARD_INTERFACES_XML[] =
+ " \n"
+ " \n"
+ " \n"
+ " \n"
+ " \n"
+ " \n"
+ " \n"
+ " \n"
+ " \n"
+ " \n"
+ " \n"
+ " \n"
+ " \n"
+ " \n"
+ " \n"
+ " \n"
+ " \n"
+ " \n"
+ " \n"
+ " \n"
+ " \n"
+ " \n";
+
+/* Is `child` a path under `parent`? If so, write the first segment
+ * of the relative remainder into out (max outsz) and return 1. */
+static int child_segment(const char *parent, const char *child,
+ char *out, size_t outsz)
+{
+ size_t plen = strlen(parent);
+ const char *rest, *slash;
+ size_t seglen;
+
+ if (strncmp(parent, child, plen) != 0)
+ return 0;
+ /* Special case for "/" */
+ if (plen == 1 && parent[0] == '/')
+ rest = child + 1;
+ else if (child[plen] != '/')
+ return 0;
+ else
+ rest = child + plen + 1;
+ if (!*rest)
+ return 0;
+
+ slash = strchr(rest, '/');
+ seglen = slash ? (size_t)(slash - rest) : strlen(rest);
+ if (seglen + 1 > outsz)
+ return 0;
+ memcpy(out, rest, seglen);
+ out[seglen] = '\0';
+ return 1;
+}
+
+static int handle_introspect(link_connection_t *conn, const struct link_msg *m)
+{
+ static char xml[8192]; /* static keeps the stack small in PID 1 */
+ struct xbuf x = { .buf = xml, .cap = sizeof(xml) };
+ struct link_object *o;
+ const char *path = m->path;
+
+ xprintf(&x,
+ "\n"
+ "\n");
+
+ xprintf(&x, "%s", STANDARD_INTERFACES_XML);
+
+ o = NULL;
+ {
+ struct link_object *p;
+
+ TAILQ_FOREACH(p, &conn->server->objects, link) {
+ if (strcmp(p->path, path) == 0) {
+ o = p;
+ break;
+ }
+ }
+ }
+
+ if (o) {
+ struct link_vtable_entry *e;
+ const link_method_t *meth;
+
+ TAILQ_FOREACH(e, &o->vtables, link) {
+ const link_property_t *prop;
+
+ xprintf(&x, " \n",
+ e->vt->interface);
+ if (e->vt->methods)
+ for (meth = e->vt->methods; meth->name; meth++)
+ emit_method(&x, meth);
+ if (e->vt->properties)
+ for (prop = e->vt->properties; prop->name; prop++)
+ emit_property(&x, prop);
+ xprintf(&x, " \n");
+ }
+ }
+
+ {
+ struct link_object *p;
+ char prev_seg[LINK_PATH_MAX] = { 0 };
+ char seg [LINK_PATH_MAX];
+
+ TAILQ_FOREACH(p, &conn->server->objects, link) {
+ if (!child_segment(path, p->path, seg, sizeof(seg)))
+ continue;
+ if (strcmp(prev_seg, seg) == 0)
+ continue;
+ xprintf(&x, " \n", seg);
+ memcpy(prev_seg, seg, sizeof(prev_seg));
+ }
+ }
+
+ xprintf(&x, "\n");
+
+ if (x.err)
+ return __send_error(conn, m,
+ "org.freedesktop.DBus.Error.Failed",
+ "Introspection XML overflow");
+
+ return send_string_reply(conn, m, xml);
+}
+
+/* ---------- Properties.Get / GetAll ---------- */
+
+/* Find the (object, vtable-entry) pair matching `path` and `interface`.
+ * Returns NULL if the path is unknown or the interface isn't exposed
+ * on it. */
+static struct link_vtable_entry *
+find_vtable(link_connection_t *conn, const char *path, const char *interface)
+{
+ struct link_object *o;
+
+ if (!path || !interface)
+ return NULL;
+ TAILQ_FOREACH(o, &conn->server->objects, link) {
+ struct link_vtable_entry *e;
+
+ if (strcmp(o->path, path) != 0)
+ continue;
+ TAILQ_FOREACH(e, &o->vtables, link) {
+ if (strcmp(e->vt->interface, interface) == 0)
+ return e;
+ }
+ }
+ return NULL;
+}
+
+static int handle_properties_get(link_connection_t *conn, const struct link_msg *m)
+{
+ const char *iface, *prop_name;
+ struct link_reader r;
+ struct link_writer w;
+ struct link_vtable_entry *e;
+ const link_property_t *p;
+ ssize_t blen;
+
+ if (!m->signature || strcmp(m->signature, "ss") != 0)
+ return __send_error(conn, m,
+ "org.freedesktop.DBus.Error.InvalidArgs",
+ "Properties.Get takes (interface, property)");
+
+ __r_init(&r, m->body, m->body_avail);
+ if (__r_string(&r, &iface) < 0 || __r_string(&r, &prop_name) < 0)
+ return __send_error(conn, m,
+ "org.freedesktop.DBus.Error.InvalidArgs",
+ "Malformed argument");
+
+ e = find_vtable(conn, m->path, iface);
+ if (!e || !e->vt->properties)
+ return __send_error(conn, m,
+ "org.freedesktop.DBus.Error.UnknownInterface",
+ "No such interface on this object");
+
+ for (p = e->vt->properties; p->name; p++) {
+ if (strcmp(p->name, prop_name) != 0)
+ continue;
+ if (!p->getter)
+ break;
+ __w_init(&w, conn->txbuf, sizeof(conn->txbuf));
+ if (p->getter(&w, e->userdata) != 0 || (blen = __w_finish(&w)) < 0)
+ return __send_error(conn, m,
+ "org.freedesktop.DBus.Error.Failed",
+ "Property getter failed");
+ return __send_method_return(conn, m, "v",
+ conn->txbuf, (size_t)blen);
+ }
+
+ return __send_error(conn, m,
+ "org.freedesktop.DBus.Error.UnknownProperty",
+ "No such property on this interface");
+}
+
+static int handle_properties_get_all(link_connection_t *conn, const struct link_msg *m)
+{
+ const char *iface;
+ struct link_reader r;
+ struct link_writer w;
+ struct link_vtable_entry *e;
+ const link_property_t *p;
+ ssize_t blen;
+
+ if (!m->signature || strcmp(m->signature, "s") != 0)
+ return __send_error(conn, m,
+ "org.freedesktop.DBus.Error.InvalidArgs",
+ "Properties.GetAll takes one string");
+
+ __r_init(&r, m->body, m->body_avail);
+ if (__r_string(&r, &iface) < 0)
+ return __send_error(conn, m,
+ "org.freedesktop.DBus.Error.InvalidArgs",
+ "Malformed argument");
+
+ e = find_vtable(conn, m->path, iface);
+ if (!e)
+ return __send_error(conn, m,
+ "org.freedesktop.DBus.Error.UnknownInterface",
+ "No such interface on this object");
+
+ __w_init(&w, conn->txbuf, sizeof(conn->txbuf));
+ __w_array_begin(&w, '{');
+ if (e->vt->properties) {
+ for (p = e->vt->properties; p->name; p++) {
+ if (!p->getter)
+ continue;
+ __w_struct_begin(&w);
+ __w_string(&w, p->name);
+ if (p->getter(&w, e->userdata) != 0)
+ return __send_error(conn, m,
+ "org.freedesktop.DBus.Error.Failed",
+ "Property getter failed");
+ __w_struct_end(&w);
+ }
+ }
+ __w_array_end(&w);
+
+ blen = __w_finish(&w);
+ if (blen < 0)
+ return __send_error(conn, m,
+ "org.freedesktop.DBus.Error.Failed",
+ "Reply too large");
+
+ return __send_method_return(conn, m, "a{sv}",
+ conn->txbuf, (size_t)blen);
+}
+
+/* ---------- AddMatch / RemoveMatch ---------- */
+
+static int handle_add_match(link_connection_t *conn, const struct link_msg *m)
+{
+ const char *rule;
+ struct link_reader r;
+
+ if (!m->signature || strcmp(m->signature, "s") != 0)
+ return __send_error(conn, m,
+ "org.freedesktop.DBus.Error.InvalidArgs",
+ "AddMatch takes a single string");
+
+ __r_init(&r, m->body, m->body_avail);
+ if (__r_string(&r, &rule) < 0)
+ return __send_error(conn, m,
+ "org.freedesktop.DBus.Error.InvalidArgs",
+ "Malformed argument");
+
+ if (__match_add(conn, rule) < 0) {
+ if (errno == ENOSPC)
+ return __send_error(conn, m,
+ "org.freedesktop.DBus.Error.LimitsExceeded",
+ "Too many active match rules");
+ return __send_error(conn, m,
+ "org.freedesktop.DBus.Error.MatchRuleInvalid",
+ "Unrecognised key or malformed rule");
+ }
+ return __send_method_return(conn, m, NULL, NULL, 0);
+}
+
+static int handle_remove_match(link_connection_t *conn, const struct link_msg *m)
+{
+ const char *rule;
+ struct link_reader r;
+
+ if (!m->signature || strcmp(m->signature, "s") != 0)
+ return __send_error(conn, m,
+ "org.freedesktop.DBus.Error.InvalidArgs",
+ "RemoveMatch takes a single string");
+
+ __r_init(&r, m->body, m->body_avail);
+ if (__r_string(&r, &rule) < 0)
+ return __send_error(conn, m,
+ "org.freedesktop.DBus.Error.InvalidArgs",
+ "Malformed argument");
+
+ if (__match_remove(conn, rule) < 0)
+ return __send_error(conn, m,
+ "org.freedesktop.DBus.Error.MatchRuleNotFound",
+ "No such match rule on this connection");
+
+ return __send_method_return(conn, m, NULL, NULL, 0);
+}
+
+/* ---------- entry point ---------- */
+
+int __handle_builtin(link_connection_t *conn, const struct link_msg *m)
+{
+ if (member_is(m, "org.freedesktop.DBus", "Hello") &&
+ m->path && strcmp(m->path, "/org/freedesktop/DBus") == 0)
+ return handle_hello(conn, m);
+
+ if (member_is(m, "org.freedesktop.DBus", "AddMatch") &&
+ m->path && strcmp(m->path, "/org/freedesktop/DBus") == 0)
+ return handle_add_match(conn, m);
+
+ if (member_is(m, "org.freedesktop.DBus", "RemoveMatch") &&
+ m->path && strcmp(m->path, "/org/freedesktop/DBus") == 0)
+ return handle_remove_match(conn, m);
+
+ if (member_is(m, "org.freedesktop.DBus.Peer", "Ping"))
+ return handle_ping(conn, m);
+
+ if (member_is(m, "org.freedesktop.DBus.Peer", "GetMachineId"))
+ return handle_get_machine_id(conn, m);
+
+ if (member_is(m, "org.freedesktop.DBus.Introspectable", "Introspect"))
+ return handle_introspect(conn, m);
+
+ if (member_is(m, "org.freedesktop.DBus.Properties", "Get"))
+ return handle_properties_get(conn, m);
+
+ if (member_is(m, "org.freedesktop.DBus.Properties", "GetAll"))
+ return handle_properties_get_all(conn, m);
+
+ return -1; /* not a built-in */
+}
diff --git a/libink/client.c b/libink/client.c
new file mode 100644
index 00000000..fbfbe70f
--- /dev/null
+++ b/libink/client.c
@@ -0,0 +1,351 @@
+/* libink — synchronous client-side D-Bus calls.
+ *
+ * Pairs with server.c / connection.c on the receiving end. The
+ * intent is for short-lived CLI tools (initctl) and tests to use
+ * libink as their D-Bus client rather than reimplementing the
+ * wire format.
+ *
+ * Copyright (c) 2026 Joachim Wiberg
+ * SPDX-License-Identifier: MIT
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "internal.h"
+
+struct link_client {
+ int fd;
+ uint32_t next_serial;
+ link_reply_t reply; /* most recent reply view (points into rxbuf) */
+ /* Distinct from "reply.type == 0": LINK_MSG_INVALID is 0, which
+ * is a wire-valid (if malformed) type, so we need an out-of-band
+ * "have we ever produced a reply?" flag. */
+ int have_reply;
+ /* Re-use the server-side rx buffer size for incoming replies.
+ * Replies to our methods are bounded by the same per-message
+ * sanity cap as everything else. */
+ uint8_t rxbuf[LINK_RX_BUF_SIZE];
+ size_t rxlen;
+};
+
+link_client_t *link_client_open_timeout(const char *path, int timeout_ms)
+{
+ struct sockaddr_un sun = { .sun_family = AF_UNIX };
+ link_client_t *c;
+ int fd;
+
+ if (!path || strlen(path) >= sizeof(sun.sun_path))
+ return NULL;
+ memcpy(sun.sun_path, path, strlen(path) + 1);
+
+ fd = socket(AF_UNIX, SOCK_STREAM, 0);
+ if (fd < 0)
+ return NULL;
+
+ if (timeout_ms > 0) {
+ struct timeval tv = {
+ .tv_sec = timeout_ms / 1000,
+ .tv_usec = (timeout_ms % 1000) * 1000,
+ };
+ /* Cover both directions so the AUTH write and the
+ * subsequent read both honour the budget. setsockopt
+ * failure is non-fatal -- the bus may still respond
+ * quickly enough; we just lose the safety net. */
+ (void)setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
+ (void)setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
+ }
+
+ if (connect(fd, (struct sockaddr *)&sun, sizeof(sun)) < 0) {
+ close(fd);
+ return NULL;
+ }
+ if (__auth_client(fd, geteuid()) < 0) {
+ close(fd);
+ return NULL;
+ }
+
+ c = calloc(1, sizeof(*c));
+ if (!c) {
+ close(fd);
+ return NULL;
+ }
+ c->fd = fd;
+ c->next_serial = 1;
+ return c;
+}
+
+link_client_t *link_client_open(const char *path)
+{
+ return link_client_open_timeout(path, 0);
+}
+
+void link_client_close(link_client_t *c)
+{
+ if (!c)
+ return;
+ if (c->fd >= 0)
+ close(c->fd);
+ free(c);
+}
+
+int link_client_steal_fd(link_client_t *c)
+{
+ int fd;
+
+ if (!c)
+ return -1;
+ fd = c->fd;
+ c->fd = -1;
+ free(c);
+ return fd;
+}
+
+/* read_full / send_all live in libink/io.c. */
+#define read_full(fd, buf, len) __io_read_full ((fd), (buf), (len))
+#define send_all(fd, buf, len) __io_write_all((fd), (buf), (len))
+
+#define ALIGN_UP(x, n) (((x) + (n) - 1) & ~((size_t)((n) - 1)))
+
+/* Read one complete D-Bus message: the 16-byte fixed header tells
+ * us fields_len + body_len, so we then issue exactly one more read
+ * for the remainder. Both lengths are bounded against rxbuf before
+ * arithmetic so a malformed wire u32 can't wrap into a near-4-GiB
+ * read. */
+static int read_one(link_client_t *c, struct link_msg *msg)
+{
+ uint32_t body_len, fields_len, body_off, total;
+ ssize_t consumed;
+
+ memset(msg, 0, sizeof(*msg));
+
+ if (read_full(c->fd, c->rxbuf, 16) < 0)
+ return -1;
+ if (c->rxbuf[0] != 'l')
+ return -1;
+
+ body_len = (uint32_t)c->rxbuf[4]
+ | ((uint32_t)c->rxbuf[5] << 8)
+ | ((uint32_t)c->rxbuf[6] << 16)
+ | ((uint32_t)c->rxbuf[7] << 24);
+ fields_len = (uint32_t)c->rxbuf[12]
+ | ((uint32_t)c->rxbuf[13] << 8)
+ | ((uint32_t)c->rxbuf[14] << 16)
+ | ((uint32_t)c->rxbuf[15] << 24);
+
+ /* Bound the wire-supplied lengths before any arithmetic on
+ * them. Without this, fields_len = 0xFFFFFFF0 would wrap
+ * 16u + fields_len to near zero, bypass the total < rxbuf
+ * check, and trigger an out-of-bounds read. */
+ if (fields_len > sizeof(c->rxbuf) || body_len > sizeof(c->rxbuf))
+ return -1;
+
+ body_off = (uint32_t)ALIGN_UP(16u + fields_len, 8u);
+ total = body_off + body_len;
+ if (total > sizeof(c->rxbuf) || total < 16)
+ return -1;
+
+ if (read_full(c->fd, c->rxbuf + 16, total - 16) < 0)
+ return -1;
+ c->rxlen = total;
+
+ consumed = __msg_parse(c->rxbuf, c->rxlen, msg);
+ if (consumed <= 0)
+ return -1;
+ return 0;
+}
+
+static void publish_reply(link_client_t *c, const struct link_msg *m)
+{
+ c->reply.type = m->type;
+ c->reply.signature = m->signature;
+ c->reply.error_name = m->error_name;
+ c->reply.path = m->path;
+ c->reply.interface = m->interface;
+ c->reply.member = m->member;
+ c->reply.body = m->body_avail ? m->body : NULL;
+ c->reply.body_len = m->body_avail;
+ c->have_reply = 1;
+}
+
+/* The reply view in c->reply points into c->rxbuf and is invalidated
+ * the moment we touch that buffer again -- clear it at every entry,
+ * even on the bad-args path, so link_client_reply() cannot return
+ * stale dangling pointers from a previous call. */
+static void clear_reply(link_client_t *c)
+{
+ if (!c)
+ return;
+ memset(&c->reply, 0, sizeof(c->reply));
+ c->have_reply = 0;
+}
+
+/* Wait up to timeout_ms (-1 = forever) for one full inbound frame
+ * and publish it. Returns 0 on success, 1 on timeout, -1 on error. */
+static int read_and_publish(link_client_t *c, int timeout_ms)
+{
+ struct link_msg msg;
+
+ if (timeout_ms >= 0) {
+ struct pollfd pfd = { .fd = c->fd, .events = POLLIN };
+ int rc;
+
+ do {
+ rc = poll(&pfd, 1, timeout_ms);
+ } while (rc < 0 && errno == EINTR);
+ if (rc < 0)
+ return -1;
+ if (rc == 0)
+ return 1;
+ }
+
+ if (read_one(c, &msg) < 0)
+ return -1;
+ publish_reply(c, &msg);
+ return 0;
+}
+
+int link_client_call(link_client_t *c,
+ const char *obj_path,
+ const char *interface,
+ const char *member,
+ const char *signature,
+ const uint8_t *body, size_t body_len)
+{
+ /* Generous: Manager1 headers fit in ~150 B, but the buffer is
+ * shared with whatever future callers throw at us, and an
+ * overflow only manifests as a silent LINK_CALL_FAIL via
+ * __msg_build_method_call returning -1. 1 KiB on stack
+ * is cheap insurance. */
+ uint8_t hdr[1024];
+ ssize_t hlen;
+ uint32_t serial;
+
+ clear_reply(c);
+ if (!c || c->fd < 0 || !obj_path || !member)
+ return LINK_CALL_FAIL;
+
+ serial = c->next_serial++;
+ hlen = __msg_build_method_call(hdr, sizeof(hdr), serial,
+ obj_path, interface, member,
+ signature, (uint32_t)body_len);
+ if (hlen < 0)
+ return LINK_CALL_FAIL;
+
+ if (send_all(c->fd, hdr, (size_t)hlen) < 0)
+ return LINK_CALL_FAIL;
+ if (body_len > 0 && send_all(c->fd, body, body_len) < 0)
+ return LINK_CALL_FAIL;
+
+ if (read_and_publish(c, -1) != 0)
+ return LINK_CALL_FAIL;
+
+ if (c->reply.type == LINK_MSG_METHOD_RETURN)
+ return LINK_CALL_OK;
+ if (c->reply.type == LINK_MSG_ERROR)
+ return LINK_CALL_ERROR;
+ return LINK_CALL_FAIL;
+}
+
+const link_reply_t *link_client_reply(link_client_t *c)
+{
+ if (!c || !c->have_reply)
+ return NULL;
+ return &c->reply;
+}
+
+int link_reply_get_string(const link_reply_t *r, const char **out)
+{
+ link_reader_t reader;
+
+ if (out)
+ *out = NULL;
+ if (!r || !r->body || !out)
+ return -1;
+ link_reader_init(&reader, r->body, r->body_len);
+ return link_r_string(&reader, out);
+}
+
+int link_reply_get_u32(const link_reply_t *r, uint32_t *out)
+{
+ link_reader_t reader;
+
+ if (out)
+ *out = 0;
+ if (!r || !r->body || !out)
+ return -1;
+ link_reader_init(&reader, r->body, r->body_len);
+ return link_r_u32(&reader, out);
+}
+
+/* Marshal varargs into `body` (capacity `cap`) according to `sig`.
+ * Returns the marshalled length on success, -1 on overflow or
+ * unsupported type code. */
+static ssize_t marshal_va(uint8_t *body, size_t cap,
+ const char *sig, va_list ap)
+{
+ link_writer_t w;
+ const char *s;
+
+ link_writer_init(&w, body, cap);
+ for (s = sig; *s; s++) {
+ switch (*s) {
+ case 'y':
+ link_w_byte(&w, (uint8_t)va_arg(ap, int));
+ break;
+ case 'b':
+ link_w_bool(&w, va_arg(ap, int));
+ break;
+ case 'u':
+ link_w_u32(&w, va_arg(ap, uint32_t));
+ break;
+ case 's':
+ link_w_string(&w, va_arg(ap, const char *));
+ break;
+ case 'o':
+ link_w_path(&w, va_arg(ap, const char *));
+ break;
+ default:
+ return -1;
+ }
+ }
+ return link_writer_finish(&w);
+}
+
+int link_client_call_v(link_client_t *c,
+ const char *obj_path,
+ const char *interface,
+ const char *member,
+ const char *signature, ...)
+{
+ uint8_t body[1024];
+ ssize_t body_len = 0;
+
+ if (signature && *signature) {
+ va_list ap;
+
+ va_start(ap, signature);
+ body_len = marshal_va(body, sizeof(body), signature, ap);
+ va_end(ap);
+ if (body_len < 0)
+ return LINK_CALL_FAIL;
+ }
+
+ return link_client_call(c, obj_path, interface, member,
+ signature, body, (size_t)body_len);
+}
+
+int link_client_wait(link_client_t *c, int timeout_ms)
+{
+ clear_reply(c);
+ if (!c || c->fd < 0)
+ return -1;
+ return read_and_publish(c, timeout_ms);
+}
diff --git a/libink/connection.c b/libink/connection.c
new file mode 100644
index 00000000..f88afda8
--- /dev/null
+++ b/libink/connection.c
@@ -0,0 +1,119 @@
+/* libink — per-connection lifecycle and dispatch entry point
+ *
+ * Copyright (c) 2026 Joachim Wiberg
+ * SPDX-License-Identifier: MIT
+ */
+
+#include
+#include
+#include
+#include
+
+#include "internal.h"
+
+int link_connection_get_fd(const link_connection_t *conn)
+{
+ return conn ? conn->fd : -1;
+}
+
+uid_t link_connection_get_uid(const link_connection_t *conn)
+{
+ return conn ? conn->peer_uid : (uid_t)-1;
+}
+
+void link_connection_close(link_connection_t *conn)
+{
+ size_t i;
+
+ if (!conn)
+ return;
+
+ for (i = 0; i < conn->matches_count; i++)
+ __match_free(conn->matches[i]);
+
+ if (conn->fd >= 0)
+ close(conn->fd);
+ free(conn);
+}
+
+/* Process buffered binary D-Bus messages, dispatching each complete
+ * message and shifting consumed bytes out of rxbuf. Returns -1 if
+ * we should drop the connection (peer closed, protocol error,
+ * downstream send failure). */
+static int process_binary(link_connection_t *conn)
+{
+ while (conn->rxlen > 0) {
+ struct link_msg msg;
+ ssize_t consumed;
+
+ consumed = __msg_parse(conn->rxbuf, conn->rxlen, &msg);
+ if (consumed == 0)
+ break; /* incomplete; wait for more bytes */
+ if (consumed < 0)
+ return -1;
+
+ if (__dispatch_message(conn, &msg) < 0)
+ return -1;
+
+ memmove(conn->rxbuf, conn->rxbuf + consumed,
+ conn->rxlen - (size_t)consumed);
+ conn->rxlen -= (size_t)consumed;
+ }
+ return 0;
+}
+
+int link_connection_process(link_connection_t *conn)
+{
+ if (!conn) {
+ errno = EINVAL;
+ return -1;
+ }
+
+ if (conn->auth == LINK_AUTH_FAILED)
+ return -1;
+
+ if (conn->auth != LINK_AUTH_DONE) {
+ if (__auth_process(conn) < 0)
+ return -1;
+
+ /* Still in SASL phase — wait for more bytes. */
+ if (conn->auth != LINK_AUTH_DONE)
+ return 0;
+
+ /* Fall through: BEGIN may have arrived in the same read
+ * as the first binary message. auth_process moved those
+ * bytes into rxbuf; they must be dispatched now, because
+ * no further wake-up is guaranteed (the kernel has
+ * already delivered everything that was readable). */
+ if (process_binary(conn) < 0)
+ return -1;
+ }
+
+ /* Read additional bytes and dispatch any complete messages.
+ * process_binary is called inside the loop after every
+ * successful read; no second call after EAGAIN because the
+ * buffer hasn't changed. */
+ for (;;) {
+ ssize_t n;
+ size_t room = sizeof(conn->rxbuf) - conn->rxlen;
+
+ if (room == 0) {
+ errno = E2BIG;
+ return -1;
+ }
+
+ n = read(conn->fd, conn->rxbuf + conn->rxlen, room);
+ if (n == 0)
+ return -1; /* peer closed */
+ if (n < 0) {
+ if (errno == EINTR)
+ continue;
+ if (errno == EAGAIN || errno == EWOULDBLOCK)
+ return 0;
+ return -1;
+ }
+ conn->rxlen += (size_t)n;
+ if (process_binary(conn) < 0)
+ return -1;
+ }
+}
diff --git a/libink/dispatch.c b/libink/dispatch.c
new file mode 100644
index 00000000..7e9536fa
--- /dev/null
+++ b/libink/dispatch.c
@@ -0,0 +1,421 @@
+/* libink — object tree, vtable registration, and method dispatch.
+ *
+ * Copyright (c) 2026 Joachim Wiberg
+ * SPDX-License-Identifier: MIT
+ */
+
+#include
+#include
+#include
+#include
+#include
+
+#include "internal.h"
+
+/* ---------- object/vtable registration ---------- */
+
+static struct link_object *find_object(link_server_t *srv, const char *path)
+{
+ struct link_object *o;
+
+ TAILQ_FOREACH(o, &srv->objects, link)
+ if (strcmp(o->path, path) == 0)
+ return o;
+ return NULL;
+}
+
+int link_server_remove_object(link_server_t *srv, const char *path)
+{
+ struct link_object *o;
+ struct link_vtable_entry *e;
+
+ if (!srv || !path) {
+ errno = EINVAL;
+ return -1;
+ }
+
+ o = find_object(srv, path);
+ if (!o) {
+ errno = ENOENT;
+ return -1;
+ }
+
+ while ((e = TAILQ_FIRST(&o->vtables))) {
+ TAILQ_REMOVE(&o->vtables, e, link);
+ free(e);
+ }
+ TAILQ_REMOVE(&srv->objects, o, link);
+ free(o);
+ return 0;
+}
+
+int link_server_add_object(link_server_t *srv, const char *path,
+ const link_vtable_t *vt, void *userdata)
+{
+ struct link_object *o;
+ struct link_vtable_entry *e;
+ size_t plen;
+
+ if (!srv || !path || !*path || !vt || !vt->interface) {
+ errno = EINVAL;
+ return -1;
+ }
+ plen = strlen(path);
+ if (plen >= LINK_PATH_MAX) {
+ errno = ENAMETOOLONG;
+ return -1;
+ }
+
+ o = find_object(srv, path);
+ if (!o) {
+ o = calloc(1, sizeof(*o));
+ if (!o)
+ return -1;
+ memcpy(o->path, path, plen + 1);
+ TAILQ_INIT(&o->vtables);
+ TAILQ_INSERT_TAIL(&srv->objects, o, link);
+ }
+
+ e = calloc(1, sizeof(*e));
+ if (!e)
+ return -1;
+ e->vt = vt;
+ e->userdata = userdata;
+ TAILQ_INSERT_TAIL(&o->vtables, e, link);
+ return 0;
+}
+
+/* ---------- lookup ---------- */
+
+static const link_method_t *find_method(const link_vtable_t *vt, const char *name)
+{
+ const link_method_t *m;
+
+ if (!vt->methods)
+ return NULL;
+ for (m = vt->methods; m->name; m++)
+ if (strcmp(m->name, name) == 0)
+ return m;
+ return NULL;
+}
+
+/* If incoming.interface is NULL, search every interface on the
+ * object for a member with this name. Returns the matching method
+ * and writes back its vtable_entry in *out_e. */
+static const link_method_t *resolve(struct link_object *o,
+ const char *iface, const char *member,
+ struct link_vtable_entry **out_e)
+{
+ struct link_vtable_entry *e;
+ const link_method_t *m;
+
+ if (iface) {
+ TAILQ_FOREACH(e, &o->vtables, link) {
+ if (strcmp(e->vt->interface, iface) != 0)
+ continue;
+ m = find_method(e->vt, member);
+ if (m) {
+ *out_e = e;
+ return m;
+ }
+ return NULL;
+ }
+ return NULL;
+ }
+
+ TAILQ_FOREACH(e, &o->vtables, link) {
+ m = find_method(e->vt, member);
+ if (m) {
+ *out_e = e;
+ return m;
+ }
+ }
+ return NULL;
+}
+
+/* ---------- send helpers ---------- */
+
+/* send_all is blocking. A slow or paused peer that lets its
+ * socket buffer fill will stall PID 1 here. The 64-peer cap +
+ * kernel buffer (~256 KB) hide the symptom for now; for production
+ * hardening, make the fd non-blocking and drop the peer on EAGAIN.
+ * The shared implementation lives in libink/io.c so the eventual
+ * fix lands in one place. */
+#define send_all(fd, buf, len) __io_write_all((fd), (buf), (len))
+
+int __send_method_return(link_connection_t *conn, const struct link_msg *req,
+ const char *out_sig,
+ const uint8_t *body, size_t body_len)
+{
+ uint8_t hdr[512];
+ ssize_t hlen;
+ uint32_t serial = ++conn->next_serial;
+
+ hlen = __msg_build_return(hdr, sizeof(hdr), serial,
+ req->serial,
+ req->sender,
+ out_sig, (uint32_t)body_len);
+ if (hlen < 0) {
+ errno = EMSGSIZE;
+ return -1;
+ }
+
+ if (send_all(conn->fd, hdr, (size_t)hlen) < 0)
+ return -1;
+ if (body_len > 0 && send_all(conn->fd, body, body_len) < 0)
+ return -1;
+ return 0;
+}
+
+int link_connection_emit_signal(link_connection_t *conn,
+ const char *path,
+ const char *interface,
+ const char *member,
+ const char *signature,
+ const uint8_t *body, size_t body_len)
+{
+ uint8_t hdr[512];
+ ssize_t hlen;
+ uint32_t serial;
+ size_t i;
+ int matched = 0;
+
+ if (!conn || !path || !interface || !member) {
+ errno = EINVAL;
+ return -1;
+ }
+ if (conn->auth != LINK_AUTH_DONE)
+ return 0; /* peer hasn't finished the SASL phase */
+
+ for (i = 0; i < conn->matches_count; i++) {
+ if (__match_matches(conn->matches[i], path,
+ interface, member)) {
+ matched = 1;
+ break;
+ }
+ }
+ if (!matched)
+ return 0; /* peer didn't subscribe — nothing to do */
+
+ serial = ++conn->next_serial;
+ hlen = __msg_build_signal(hdr, sizeof(hdr), serial,
+ path, interface, member,
+ signature, (uint32_t)body_len);
+ if (hlen < 0) {
+ errno = EMSGSIZE;
+ return -1;
+ }
+
+ if (send_all(conn->fd, hdr, (size_t)hlen) < 0)
+ return -1;
+ if (body_len > 0 && send_all(conn->fd, body, body_len) < 0)
+ return -1;
+ return 0;
+}
+
+int __send_error(link_connection_t *conn, const struct link_msg *req,
+ const char *error_name, const char *text)
+{
+ uint8_t hdr[512];
+ uint8_t body[256];
+ ssize_t hlen;
+ size_t blen = 0;
+ uint32_t serial = ++conn->next_serial;
+ const char *sig = NULL;
+
+ if (text && *text) {
+ struct link_writer w;
+ ssize_t n;
+
+ __w_init(&w, body, sizeof(body));
+ __w_string(&w, text);
+ n = __w_finish(&w);
+ if (n < 0) {
+ errno = EMSGSIZE;
+ return -1;
+ }
+ blen = (size_t)n;
+ sig = "s";
+ }
+
+ hlen = __msg_build_error(hdr, sizeof(hdr), serial,
+ req->serial, req->sender,
+ error_name, sig, (uint32_t)blen);
+ if (hlen < 0) {
+ errno = EMSGSIZE;
+ return -1;
+ }
+
+ if (send_all(conn->fd, hdr, (size_t)hlen) < 0)
+ return -1;
+ if (blen > 0 && send_all(conn->fd, body, blen) < 0)
+ return -1;
+ return 0;
+}
+
+/* ---------- link_call public surface ---------- */
+
+const char *link_call_path (const link_call_t *c) { return c ? c->incoming.path : NULL; }
+const char *link_call_interface(const link_call_t *c) { return c ? c->incoming.interface : NULL; }
+const char *link_call_member (const link_call_t *c) { return c ? c->incoming.member : NULL; }
+uid_t link_call_uid (const link_call_t *c) { return c ? c->conn->peer_uid : (uid_t)-1; }
+
+link_writer_t *link_call_reply(link_call_t *call)
+{
+ if (!call || call->reply_consumed || call->error_sent)
+ return NULL;
+ call->reply_consumed = 1;
+ __w_init(&call->reply_writer,
+ call->conn->txbuf, sizeof(call->conn->txbuf));
+ return &call->reply_writer;
+}
+
+int link_call_reply_error(link_call_t *call, const char *name, const char *message)
+{
+ if (!call || call->error_sent) {
+ errno = EINVAL;
+ return -1;
+ }
+ call->error_sent = 1;
+ return __send_error(call->conn, &call->incoming, name, message);
+}
+
+/* ---------- public reader wrappers ---------- */
+
+int link_call_read_byte (link_call_t *c, uint8_t *o) { return __r_byte (&c->read_cursor, o); }
+int link_call_read_bool (link_call_t *c, int *o) { return __r_bool (&c->read_cursor, o); }
+int link_call_read_u32 (link_call_t *c, uint32_t *o) { return __r_u32 (&c->read_cursor, o); }
+int link_call_read_string(link_call_t *c, const char **o) { return __r_string(&c->read_cursor, o); }
+int link_call_read_path (link_call_t *c, const char **o) { return __r_path (&c->read_cursor, o); }
+
+/* ---------- public writer wrappers ---------- */
+
+void link_writer_init (link_writer_t *w, uint8_t *buf, size_t cap) { __w_init(w, buf, cap); }
+ssize_t link_writer_finish(link_writer_t *w) { return __w_finish(w); }
+
+void link_w_byte (link_writer_t *w, uint8_t v) { __w_byte(w, v); }
+void link_w_bool (link_writer_t *w, int v) { __w_bool(w, v); }
+void link_w_u32 (link_writer_t *w, uint32_t v) { __w_u32(w, v); }
+void link_w_string (link_writer_t *w, const char *s) { __w_string(w, s); }
+void link_w_path (link_writer_t *w, const char *s) { __w_path(w, s); }
+void link_w_variant_string(link_writer_t *w, const char *s) { __w_variant_string(w, s); }
+void link_w_array_begin (link_writer_t *w, char ec) { __w_array_begin(w, ec); }
+void link_w_array_end (link_writer_t *w) { __w_array_end(w); }
+void link_w_struct_begin(link_writer_t *w) { __w_struct_begin(w); }
+void link_w_struct_end (link_writer_t *w) { __w_struct_end(w); }
+
+/* ---------- public reader wrappers ---------- */
+
+void link_reader_init(link_reader_t *r, const uint8_t *body, size_t len) { __r_init(r, body, len); }
+int link_r_byte (link_reader_t *r, uint8_t *o) { return __r_byte (r, o); }
+int link_r_bool (link_reader_t *r, int *o) { return __r_bool (r, o); }
+int link_r_u32 (link_reader_t *r, uint32_t *o) { return __r_u32 (r, o); }
+int link_r_string(link_reader_t *r, const char **o) { return __r_string(r, o); }
+int link_r_path (link_reader_t *r, const char **o) { return __r_path (r, o); }
+int link_r_variant_string(link_reader_t *r, const char **o) { return __r_variant_string(r, o); }
+int link_r_align (link_reader_t *r, size_t n) { return __r_align (r, n); }
+int link_r_array_begin(link_reader_t *r, size_t *e) { return __r_array_begin(r, e); }
+int link_r_done (const link_reader_t *r) { return __r_done (r); }
+size_t link_r_pos (const link_reader_t *r) { return r->off; }
+
+/* ---------- dispatch entry point ---------- */
+
+int __dispatch_message(link_connection_t *conn, const struct link_msg *m)
+{
+ struct link_object *o;
+ struct link_vtable_entry *e = NULL;
+ const link_method_t *meth;
+ struct link_call call;
+ ssize_t blen;
+ int rc;
+
+ if (m->type != LINK_MSG_METHOD_CALL) {
+ /* Signals and replies from a client to PID 1 are nonsense;
+ * silently drop. */
+ return 0;
+ }
+
+ if (!m->path || !m->member) {
+ return __send_error(conn, m,
+ "org.freedesktop.DBus.Error.InvalidArgs",
+ "Method call without path or member");
+ }
+
+ /* Built-in DBus interfaces (Hello, Ping, Introspect, Properties)
+ * are handled here before object-tree lookup, which means they
+ * also run before the LINK_METHOD_PRIVILEGED authz gate further
+ * down. The current set is read-only; do NOT introduce a
+ * state-changing built-in without first adding equivalent
+ * authorisation inside __handle_builtin. */
+ rc = __handle_builtin(conn, m);
+ if (rc >= 0)
+ return rc; /* 0 = handled OK, 1 = built-in but failed; <0 = not a built-in */
+
+ o = find_object(conn->server, m->path);
+ if (!o) {
+ return __send_error(conn, m,
+ "org.freedesktop.DBus.Error.UnknownObject",
+ "No such object");
+ }
+
+ meth = resolve(o, m->interface, m->member, &e);
+ if (!meth) {
+ return __send_error(conn, m,
+ "org.freedesktop.DBus.Error.UnknownMethod",
+ "No such method on this object");
+ }
+
+ /* Validate signature: client must match the declared in_sig. */
+ {
+ const char *got = m->signature ? m->signature : "";
+ const char *want = meth->in_sig ? meth->in_sig : "";
+
+ if (strcmp(got, want) != 0)
+ return __send_error(conn, m,
+ "org.freedesktop.DBus.Error.InvalidArgs",
+ "Argument signature mismatch");
+ }
+
+ /* Per-method authorization. PRIVILEGED methods require uid 0;
+ * the peer's uid was captured via SO_PEERCRED at accept time
+ * and verified against the AUTH EXTERNAL claim, so we can trust
+ * conn->peer_uid here. */
+ if ((meth->flags & LINK_METHOD_PRIVILEGED) && conn->peer_uid != 0) {
+ return __send_error(conn, m,
+ "org.freedesktop.DBus.Error.AccessDenied",
+ "Method requires root privileges");
+ }
+
+ memset(&call, 0, sizeof(call));
+ call.conn = conn;
+ call.incoming = *m;
+ __r_init(&call.read_cursor, m->body, m->body_avail);
+
+ rc = meth->handler(&call, e->userdata);
+ if (rc < 0 && !call.reply_consumed && !call.error_sent) {
+ /* Handler returned an error without sending one. */
+ __send_error(conn, m,
+ "org.freedesktop.DBus.Error.Failed",
+ "Handler failed");
+ return 0;
+ }
+
+ if (!call.reply_consumed && !call.error_sent) {
+ /* Handler returned 0 but never produced a reply; treat as
+ * empty reply with out_sig "". */
+ __send_method_return(conn, m, NULL, NULL, 0);
+ return 0;
+ }
+
+ if (call.reply_consumed && !call.error_sent) {
+ blen = __w_finish(&call.reply_writer);
+ if (blen < 0)
+ return __send_error(conn, m,
+ "org.freedesktop.DBus.Error.Failed",
+ "Reply marshalling overflow");
+ return __send_method_return(conn, m, meth->out_sig,
+ conn->txbuf, (size_t)blen);
+ }
+
+ return 0;
+}
diff --git a/libink/internal.h b/libink/internal.h
new file mode 100644
index 00000000..ac5b7153
--- /dev/null
+++ b/libink/internal.h
@@ -0,0 +1,142 @@
+/* libink internal types — not for external consumers.
+ *
+ * Copyright (c) 2026 Joachim Wiberg
+ * SPDX-License-Identifier: MIT
+ */
+#ifndef LIBINK_INTERNAL_H_
+#define LIBINK_INTERNAL_H_
+
+#include
+#include
+
+#include "link.h"
+#include "marshal.h"
+#include "proto.h"
+
+typedef enum {
+ LINK_AUTH_NUL = 0,
+ LINK_AUTH_LINE,
+ LINK_AUTH_DONE,
+ LINK_AUTH_FAILED,
+} link_auth_state_t;
+
+#define LINK_PATH_MAX 108
+#define LINK_AUTH_LINEBUF_SIZE 256
+#define LINK_RX_BUF_SIZE (64 * 1024)
+#define LINK_TX_BUF_SIZE (16 * 1024)
+#define LINK_UNIQUE_NAME_LEN 16
+#define LINK_MATCH_RULE_MAX 256 /* per-peer match rule cap */
+#define LINK_MATCH_PEER_CAP 16 /* max active match rules per peer */
+
+/* Per-vtable record attached to an object's interface list. */
+struct link_vtable_entry {
+ const link_vtable_t *vt;
+ void *userdata;
+ TAILQ_ENTRY(link_vtable_entry) link;
+};
+
+TAILQ_HEAD(link_vtable_list, link_vtable_entry);
+
+/* An object exposed at one path. */
+struct link_object {
+ char path[LINK_PATH_MAX];
+ struct link_vtable_list vtables;
+ TAILQ_ENTRY(link_object) link;
+};
+
+TAILQ_HEAD(link_object_list, link_object);
+
+struct link_server {
+ int fd;
+ char path[LINK_PATH_MAX];
+ struct link_object_list objects;
+ uint32_t next_unique_id; /* for ":1.N" names */
+};
+
+/* The reply being assembled inside a method handler.
+ *
+ * The reply body lives in conn->txbuf, not on this struct, so a
+ * stack-allocated link_call (in dispatch) stays small. Sharing the
+ * connection's txbuf is safe: the event loop is single-threaded and
+ * a connection only ever has one in-flight method call at a time. */
+struct link_call {
+ link_connection_t *conn;
+ struct link_msg incoming;
+ struct link_reader read_cursor;
+ struct link_writer reply_writer; /* writes into conn->txbuf */
+ int reply_consumed;
+ int error_sent;
+};
+
+/* A parsed AddMatch rule. Fields are NULL when the rule omits the
+ * key, meaning "match anything"; non-NULL means "must equal". */
+struct link_match {
+ char *raw; /* original string, for RemoveMatch */
+ char *type; /* "signal", or NULL */
+ char *interface;
+ char *member;
+ char *path;
+};
+
+struct link_connection {
+ int fd;
+ uid_t peer_uid;
+
+ char guid[33];
+ char unique_name[LINK_UNIQUE_NAME_LEN]; /* ":1.N" */
+
+ link_auth_state_t auth;
+ char linebuf[LINK_AUTH_LINEBUF_SIZE];
+ size_t linelen;
+
+ /* Match rules registered via org.freedesktop.DBus.AddMatch.
+ * Bounded for PID 1 hygiene; a peer that exceeds the cap gets
+ * a LimitsExceeded error reply. */
+ struct link_match *matches[LINK_MATCH_PEER_CAP];
+ size_t matches_count;
+
+ uint8_t rxbuf[LINK_RX_BUF_SIZE];
+ size_t rxlen;
+
+ /* Scratch for outgoing reply bodies. Shared by the dispatch
+ * path (writes through call.reply_writer) and built-in handlers
+ * (send_string_reply). Lifetime ends with each send_method_*
+ * call. */
+ uint8_t txbuf[LINK_TX_BUF_SIZE];
+
+ uint32_t next_serial;
+
+ struct link_server *server; /* back-pointer for dispatch */
+};
+
+/* io.c — shared blocking I/O loops. See the TODO by send_all
+ * callers about future non-blocking + drop-on-EAGAIN hardening. */
+int __io_write_all(int fd, const void *buf, size_t len);
+int __io_read_full(int fd, void *buf, size_t len);
+
+/* auth.c */
+int __auth_process(link_connection_t *conn);
+void __auth_generate_guid(char out[33]);
+int __auth_client(int fd, uid_t uid);
+
+/* dispatch.c */
+int __dispatch_message(link_connection_t *conn, const struct link_msg *m);
+int __send_error(link_connection_t *conn, const struct link_msg *req,
+ const char *error_name, const char *text);
+int __send_method_return(link_connection_t *conn, const struct link_msg *req,
+ const char *out_sig,
+ const uint8_t *body, size_t body_len);
+
+/* builtin.c */
+int __handle_builtin(link_connection_t *conn, const struct link_msg *m);
+
+/* match.c */
+struct link_match *__match_parse (const char *rule);
+void __match_free (struct link_match *m);
+int __match_matches(const struct link_match *m,
+ const char *path, const char *iface,
+ const char *member);
+int __match_add (link_connection_t *conn, const char *rule);
+int __match_remove (link_connection_t *conn, const char *rule);
+
+#endif /* LIBINK_INTERNAL_H_ */
diff --git a/libink/io.c b/libink/io.c
new file mode 100644
index 00000000..cf29e651
--- /dev/null
+++ b/libink/io.c
@@ -0,0 +1,54 @@
+/* libink — shared blocking I/O helpers.
+ *
+ * Server send paths (libink/dispatch.c, libink/auth.c) and the
+ * client (libink/client.c) all need EINTR-resilient write_all /
+ * read_full. Consolidate them here so a future hardening change
+ * (e.g. non-blocking sends with drop-peer-on-EAGAIN, see the TODO
+ * by send_all callers) only has to land in one place.
+ *
+ * Copyright (c) 2026 Joachim Wiberg
+ * SPDX-License-Identifier: MIT
+ */
+
+#include
+#include
+
+#include "internal.h"
+
+int __io_write_all(int fd, const void *buf, size_t len)
+{
+ const char *p = buf;
+
+ while (len > 0) {
+ ssize_t n = write(fd, p, len);
+
+ if (n < 0) {
+ if (errno == EINTR)
+ continue;
+ return -1;
+ }
+ p += n;
+ len -= (size_t)n;
+ }
+ return 0;
+}
+
+int __io_read_full(int fd, void *buf, size_t len)
+{
+ char *p = buf;
+
+ while (len > 0) {
+ ssize_t n = read(fd, p, len);
+
+ if (n == 0)
+ return -1;
+ if (n < 0) {
+ if (errno == EINTR)
+ continue;
+ return -1;
+ }
+ p += n;
+ len -= (size_t)n;
+ }
+ return 0;
+}
diff --git a/libink/libink.pc.in b/libink/libink.pc.in
new file mode 100644
index 00000000..f84248de
--- /dev/null
+++ b/libink/libink.pc.in
@@ -0,0 +1,10 @@
+prefix=@prefix@
+exec_prefix=@exec_prefix@
+libdir=@libdir@
+includedir=@includedir@
+
+Name: libink
+Description: Brokerless D-Bus server library, born inside Finit
+Version: @PACKAGE_VERSION@
+Libs: -L${libdir} -link
+Cflags: -I${includedir}
diff --git a/libink/link.h b/libink/link.h
new file mode 100644
index 00000000..11e4a2c4
--- /dev/null
+++ b/libink/link.h
@@ -0,0 +1,350 @@
+/* libink — brokerless D-Bus server library, born inside Finit
+ *
+ * Copyright (c) 2026 Joachim Wiberg
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+#ifndef LIBINK_LINK_H_
+#define LIBINK_LINK_H_
+
+#include
+#include
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct link_server link_server_t;
+typedef struct link_connection link_connection_t;
+typedef struct link_call link_call_t;
+typedef struct link_client link_client_t;
+
+/* D-Bus message type codes -- see link_reply_t.type. */
+#define LINK_MSG_INVALID 0
+#define LINK_MSG_METHOD_CALL 1
+#define LINK_MSG_METHOD_RETURN 2
+#define LINK_MSG_ERROR 3
+#define LINK_MSG_SIGNAL 4
+
+/* Writer is exposed so callers can stack-allocate one for marshalling
+ * signal/reply bodies. Treat the fields as opaque; use link_writer_init
+ * + the link_w_* helpers + link_writer_finish. Sized for typical D-Bus
+ * messages -- the array stack supports up to 8 levels of nesting. */
+#define LINK_WRITER_MAX_NESTING 8
+typedef struct link_writer {
+ uint8_t *buf;
+ size_t cap;
+ size_t off;
+ int err;
+ struct {
+ size_t lenpos;
+ size_t elemstart;
+ } arrays[LINK_WRITER_MAX_NESTING];
+ size_t array_depth;
+} link_writer_t;
+
+/* Reader is exposed so callers can stack-allocate one for decoding
+ * reply or signal bodies received from a peer. Treat fields as
+ * opaque; use link_reader_init + the link_r_* helpers. */
+typedef struct link_reader {
+ const uint8_t *base;
+ size_t off;
+ size_t cap;
+ int err; /* sticky */
+} link_reader_t;
+
+/* View of an inbound message (method-return, error, or signal),
+ * populated by link_client_call(_v) and link_client_wait(), and
+ * returned by link_client_reply(). All pointers reference internal
+ * client storage and are invalidated by the next call or wait on the
+ * same client, or by link_client_close(). `body` is NULL iff
+ * body_len==0; `error_name` is non-NULL only when type == LINK_MSG_ERROR;
+ * `path`/`interface`/`member` are non-NULL on signals. */
+typedef struct {
+ uint8_t type; /* LINK_MSG_METHOD_RETURN, _ERROR, or _SIGNAL */
+ const char *signature;
+ const char *error_name;
+ const char *path;
+ const char *interface;
+ const char *member;
+ const uint8_t *body;
+ size_t body_len;
+} link_reply_t;
+
+/* ---------- server / connection lifecycle ---------- */
+
+int link_server_new (link_server_t **server, const char *path);
+void link_server_free (link_server_t *server);
+int link_server_get_fd(const link_server_t *server);
+
+int link_server_accept(link_server_t *server, link_connection_t **conn);
+
+/* Insert an externally-authenticated fd into the server's connection
+ * set. Used to integrate an outbound peer (e.g. a client-side
+ * handshake against an external dbus-daemon) so the same dispatch +
+ * signal-fan-out machinery covers it. `peer_uid` becomes what
+ * privileged-method checks see; pass (uid_t)-1 to make all
+ * LINK_METHOD_PRIVILEGED methods reject by default.
+ *
+ * On success the connection takes ownership of `fd`. On any failure
+ * `fd` is closed before the function returns NULL, so callers never
+ * have to track partial state. */
+link_connection_t *link_server_attach(link_server_t *server, int fd, uid_t peer_uid);
+
+int link_connection_get_fd (const link_connection_t *conn);
+uid_t link_connection_get_uid (const link_connection_t *conn);
+int link_connection_process (link_connection_t *conn);
+void link_connection_close (link_connection_t *conn);
+
+/* ---------- object registration ---------- */
+
+typedef int (*link_method_fn)(link_call_t *call, void *userdata);
+
+/* Method flags for link_method_t.flags */
+#define LINK_METHOD_PRIVILEGED (1u << 0) /* peer must be uid 0 (root) */
+
+typedef struct {
+ const char *name; /* member name */
+ const char *in_sig; /* input signature (D-Bus, e.g. "" or "s") */
+ const char *out_sig; /* output signature */
+ unsigned flags; /* OR of LINK_METHOD_* */
+ link_method_fn handler;
+} link_method_t;
+
+/* A read-only property descriptor. Set via the Properties.Set side
+ * is not yet implemented; only Get and GetAll are. The getter writes
+ * the property's value as a D-Bus variant (use link_w_variant_string
+ * for "s"-typed properties) into the provided writer. */
+typedef int (*link_property_getter_fn)(link_writer_t *w, void *userdata);
+
+typedef struct {
+ const char *name; /* property name */
+ const char *sig; /* D-Bus signature, e.g. "s" */
+ link_property_getter_fn getter;
+} link_property_t;
+
+typedef struct {
+ const char *interface; /* e.g. "org.finit.Manager1" */
+ const link_method_t *methods; /* terminated by {NULL, ...}, or NULL */
+ const link_property_t *properties; /* terminated by {NULL, ...}, or NULL */
+} link_vtable_t;
+
+/* Register one (interface, methods) at `path`. Calling repeatedly
+ * with the same path and different vtables adds more interfaces at
+ * that object. The vtable pointer must outlive the server (typically
+ * a static table). */
+int link_server_add_object(link_server_t *server, const char *path,
+ const link_vtable_t *vt, void *userdata);
+
+/* Remove every vtable registered at `path` and free the object.
+ * Returns 0 if the object existed, -1 (errno=ENOENT) otherwise. */
+int link_server_remove_object(link_server_t *server, const char *path);
+
+/* ---------- call accessors ---------- */
+
+const char *link_call_path (const link_call_t *call);
+const char *link_call_interface(const link_call_t *call);
+const char *link_call_member (const link_call_t *call);
+uid_t link_call_uid (const link_call_t *call);
+
+/* ---------- reading method-call arguments ----------
+ *
+ * Cursor starts at the beginning of the request body. Each
+ * function returns 0 on success and advances the cursor; on
+ * failure it returns -1 and leaves the cursor in an error state
+ * (subsequent reads also fail). Strings reference the
+ * connection's rx buffer and are valid for the duration of the
+ * method handler. */
+
+int link_call_read_byte (link_call_t *call, uint8_t *out);
+int link_call_read_bool (link_call_t *call, int *out);
+int link_call_read_u32 (link_call_t *call, uint32_t *out);
+int link_call_read_string(link_call_t *call, const char **out); /* "s" */
+int link_call_read_path (link_call_t *call, const char **out); /* "o" */
+
+/* ---------- reply construction ---------- */
+
+/* Get the writer for the reply body, write args into it, return 0
+ * from the handler. Dispatch finalizes and sends the reply with
+ * the out_sig declared on the vtable. May be called once per
+ * call. */
+link_writer_t *link_call_reply(link_call_t *call);
+
+/* Send a D-Bus error reply. `name` must be a valid D-Bus error
+ * name (e.g. "org.freedesktop.DBus.Error.UnknownMethod"); `message`
+ * may be NULL. */
+int link_call_reply_error(link_call_t *call, const char *name, const char *message);
+
+/* ---------- signal emission ----------
+ *
+ * Send a signal to a single peer if its AddMatch rules accept it.
+ * Callers marshal the body separately and pass the resulting bytes.
+ * Returns 0 on success (or "filtered out, nothing sent"), -1 on
+ * underlying transport failure. */
+int link_connection_emit_signal(link_connection_t *conn,
+ const char *path,
+ const char *interface,
+ const char *member,
+ const char *signature,
+ const uint8_t *body, size_t body_len);
+
+/* ---------- client (outgoing method calls) ----------
+ *
+ * Connect, authenticate as the current effective uid, send BEGIN.
+ * Returns NULL on any failure (caller can fall back to another
+ * transport if it has one). */
+link_client_t *link_client_open(const char *path);
+
+/* As link_client_open but applies SO_SNDTIMEO + SO_RCVTIMEO before
+ * the connect/AUTH handshake. After link_server_attach flips the fd
+ * to non-blocking the timeout is silently inert; it only protects
+ * the synchronous open path against a hung peer. timeout_ms == 0
+ * disables the budget (same behaviour as link_client_open). */
+link_client_t *link_client_open_timeout(const char *path, int timeout_ms);
+
+void link_client_close(link_client_t *c);
+
+/* Detach the authenticated socket from the client and return the raw
+ * fd; subsequent link_client_close on `c` is invalid because the
+ * structure has already been freed. Used by callers (e.g. system-bus
+ * integration) that want to promote an outbound client connection
+ * into a server-attached peer via link_server_attach(). */
+int link_client_steal_fd(link_client_t *c);
+
+/* Status codes returned by link_client_call(_v). */
+#define LINK_CALL_OK 0 /* method-return received */
+#define LINK_CALL_ERROR 1 /* server replied with an error */
+#define LINK_CALL_FAIL (-1) /* transport, parse, or invalid-arg failure */
+
+/* Send a METHOD_CALL and read the reply synchronously.
+ *
+ * `signature` and `body`/`body_len` describe the outgoing body --
+ * marshal it yourself with link_writer_init + the link_w_* helpers
+ * + link_writer_finish. Pass signature=NULL and body=NULL for
+ * methods that take no arguments.
+ *
+ * After the call, inspect the reply via link_client_reply() -- it
+ * exposes the body bytes (for callers that want to decode them with
+ * link_reader_init + link_r_*) and the error name on LINK_CALL_ERROR.
+ * The reply view is invalidated by the next call on the same client
+ * or by link_client_close(). */
+int link_client_call(link_client_t *c,
+ const char *obj_path,
+ const char *interface,
+ const char *member,
+ const char *signature,
+ const uint8_t *body, size_t body_len);
+
+/* Convenience wrapper that marshals the outgoing body from varargs
+ * matching `signature`. Supported type codes (one per arg):
+ * 'y' -> int (promoted uint8_t)
+ * 'b' -> int (0/non-zero)
+ * 'u' -> uint32_t
+ * 's' -> const char *
+ * 'o' -> const char * (object path)
+ *
+ * Pass signature=NULL or "" for void calls. Return value matches
+ * link_client_call; an unsupported type code returns LINK_CALL_FAIL
+ * with no message sent. */
+int link_client_call_v(link_client_t *c,
+ const char *obj_path,
+ const char *interface,
+ const char *member,
+ const char *signature, ...);
+
+const link_reply_t *link_client_reply(link_client_t *c);
+
+/* Convenience accessors for the common case where a reply carries
+ * exactly one string ("s" or "o") or one u32 ("u"). They wrap the
+ * link_reader_init + link_r_* pattern; on success return 0 and
+ * populate *out, on parse failure or missing body return -1. Use
+ * link_client_reply + link_reader_init directly for richer payloads. */
+int link_reply_get_string(const link_reply_t *r, const char **out);
+int link_reply_get_u32 (const link_reply_t *r, uint32_t *out);
+
+/* Wait up to `timeout_ms` milliseconds for the next inbound message
+ * (typically a SIGNAL delivered after an AddMatch subscription), and
+ * populate the same view returned by link_client_reply().
+ * timeout_ms < 0 : block forever
+ * timeout_ms == 0 : non-blocking (returns 1 immediately if no data)
+ * timeout_ms > 0 : wait that long
+ * Returns 0 on success, 1 on timeout, -1 on transport/parse error.
+ *
+ * Note: the timeout gates only the wait for the first byte of the
+ * next frame. Once data starts arriving the rest of the message is
+ * read blockingly; callers that need a hard upper bound should pass
+ * a positive timeout AND have a watchdog at a higher level. */
+int link_client_wait(link_client_t *c, int timeout_ms);
+
+/* ---------- standalone writer ----------
+ *
+ * For marshalling bodies outside a method-call handler (signals,
+ * pre-computed replies). Initialise on a caller-owned buffer,
+ * write args via link_w_*, then call link_writer_finish which
+ * returns the body length or -1 on overflow. */
+void link_writer_init (link_writer_t *w, uint8_t *buf, size_t cap);
+ssize_t link_writer_finish(link_writer_t *w);
+
+/* ---------- writer (mirrors the internal marshaller) ---------- */
+
+void link_w_byte (link_writer_t *w, uint8_t v);
+void link_w_bool (link_writer_t *w, int v);
+void link_w_u32 (link_writer_t *w, uint32_t v);
+void link_w_string (link_writer_t *w, const char *s); /* "s" */
+void link_w_path (link_writer_t *w, const char *s); /* "o" */
+void link_w_variant_string(link_writer_t *w, const char *s); /* "v" containing "s" */
+void link_w_array_begin (link_writer_t *w, char element_sig);
+void link_w_array_end (link_writer_t *w);
+void link_w_struct_begin(link_writer_t *w);
+void link_w_struct_end (link_writer_t *w);
+
+/* ---------- standalone reader ----------
+ *
+ * For decoding bodies received off the wire (reply or signal).
+ * Initialise on the body pointer + length, read via link_r_*,
+ * check link_r_done() to confirm everything was consumed. */
+void link_reader_init(link_reader_t *r, const uint8_t *body, size_t len);
+int link_r_byte (link_reader_t *r, uint8_t *out);
+int link_r_bool (link_reader_t *r, int *out);
+int link_r_u32 (link_reader_t *r, uint32_t *out);
+int link_r_string (link_reader_t *r, const char **out); /* "s" */
+int link_r_path (link_reader_t *r, const char **out); /* "o" */
+int link_r_variant_string(link_reader_t *r, const char **out); /* "v" containing "s" */
+int link_r_align (link_reader_t *r, size_t n); /* skip to next n-byte boundary */
+int link_r_done (const link_reader_t *r);
+
+/* Begin reading an "a" array. On success returns 0 and sets
+ * *out_end to the absolute reader offset at which the array ends;
+ * caller loops while link_r_pos < *out_end. For dict-entry arrays
+ * ("a{T}") call link_r_align(r, 8) at the top of each iteration --
+ * the element-alignment skip from the array prefix only covers the
+ * first entry. */
+int link_r_array_begin(link_reader_t *r, size_t *out_end);
+
+/* Byte offset of the next read inside the original body buffer.
+ * Use together with the *out_end returned by link_r_array_begin to
+ * walk the elements of an "a" payload. */
+size_t link_r_pos (const link_reader_t *r);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* LIBINK_LINK_H_ */
diff --git a/libink/marshal.c b/libink/marshal.c
new file mode 100644
index 00000000..ae215893
--- /dev/null
+++ b/libink/marshal.c
@@ -0,0 +1,321 @@
+/* libink — D-Bus body marshalling (writer side).
+ *
+ * Copyright (c) 2026 Joachim Wiberg
+ * SPDX-License-Identifier: MIT
+ */
+
+#include
+#include
+
+#include "marshal.h"
+
+#define ALIGN_UP(x, n) (((x) + (n) - 1) & ~((size_t)((n) - 1)))
+
+void __w_init(struct link_writer *w, uint8_t *buf, size_t cap)
+{
+ w->buf = buf;
+ w->cap = cap;
+ w->off = 0;
+ w->err = 0;
+ w->array_depth = 0;
+}
+
+ssize_t __w_finish(struct link_writer *w)
+{
+ if (w->err || w->array_depth != 0)
+ return -1;
+ return (ssize_t)w->off;
+}
+
+static int reserve(struct link_writer *w, size_t align, size_t bytes)
+{
+ size_t pad;
+
+ if (w->err)
+ return -1;
+
+ pad = ALIGN_UP(w->off, align) - w->off;
+ if (w->off + pad + bytes > w->cap) {
+ w->err = 1;
+ return -1;
+ }
+ while (pad-- > 0)
+ w->buf[w->off++] = 0;
+ return 0;
+}
+
+static void put_u32_at(struct link_writer *w, size_t pos, uint32_t v)
+{
+ w->buf[pos] = (uint8_t)(v & 0xff);
+ w->buf[pos + 1] = (uint8_t)((v >> 8) & 0xff);
+ w->buf[pos + 2] = (uint8_t)((v >> 16) & 0xff);
+ w->buf[pos + 3] = (uint8_t)((v >> 24) & 0xff);
+}
+
+static void put_u32(struct link_writer *w, uint32_t v)
+{
+ put_u32_at(w, w->off, v);
+ w->off += 4;
+}
+
+void __w_byte(struct link_writer *w, uint8_t v)
+{
+ if (reserve(w, 1, 1) < 0)
+ return;
+ w->buf[w->off++] = v;
+}
+
+void __w_bool(struct link_writer *w, int v)
+{
+ if (reserve(w, 4, 4) < 0)
+ return;
+ put_u32(w, v ? 1u : 0u);
+}
+
+void __w_u32(struct link_writer *w, uint32_t v)
+{
+ if (reserve(w, 4, 4) < 0)
+ return;
+ put_u32(w, v);
+}
+
+static void write_lenprefixed(struct link_writer *w, const char *s, int onebyte_len)
+{
+ size_t len = s ? strlen(s) : 0;
+
+ if (onebyte_len) {
+ if (reserve(w, 1, 1 + len + 1) < 0)
+ return;
+ w->buf[w->off++] = (uint8_t)len;
+ } else {
+ if (reserve(w, 4, 4 + len + 1) < 0)
+ return;
+ put_u32(w, (uint32_t)len);
+ }
+ if (s && len)
+ memcpy(w->buf + w->off, s, len);
+ w->off += len;
+ w->buf[w->off++] = 0;
+}
+
+void __w_string(struct link_writer *w, const char *s) { write_lenprefixed(w, s, 0); }
+void __w_path (struct link_writer *w, const char *s) { write_lenprefixed(w, s, 0); }
+void __w_sig (struct link_writer *w, const char *s) { write_lenprefixed(w, s, 1); }
+
+/* Variant "v" containing a string. Wire form:
+ * 1-byte sig length (1), 's', NUL, then the string per __w_string. */
+void __w_variant_string(struct link_writer *w, const char *s)
+{
+ __w_sig (w, "s");
+ __w_string(w, s);
+}
+
+static size_t element_align(char c)
+{
+ switch (c) {
+ case 'y': case 'g': case 'v': return 1;
+ case 'n': case 'q': return 2;
+ case 'b': case 'i': case 'u':
+ case 's': case 'o': case 'h': case 'a': return 4;
+ case 'x': case 't': case 'd':
+ case '(': case '{': return 8;
+ default: return 1;
+ }
+}
+
+void __w_array_begin(struct link_writer *w, char element_sig_first_char)
+{
+ size_t lenpos;
+
+ if (w->err)
+ return;
+ if (w->array_depth >= LINK_WRITER_MAX_NESTING) {
+ w->err = 1;
+ return;
+ }
+
+ if (reserve(w, 4, 4) < 0)
+ return;
+ lenpos = w->off;
+ put_u32(w, 0); /* placeholder */
+
+ /* Pad to the element's alignment. These pad bytes are NOT
+ * counted in the array length per the D-Bus spec. */
+ if (reserve(w, element_align(element_sig_first_char), 0) < 0)
+ return;
+
+ w->arrays[w->array_depth].lenpos = lenpos;
+ w->arrays[w->array_depth].elemstart = w->off;
+ w->array_depth++;
+}
+
+void __w_array_end(struct link_writer *w)
+{
+ size_t elemstart, lenpos;
+ uint32_t actual;
+
+ if (w->err || w->array_depth == 0) {
+ w->err = 1;
+ return;
+ }
+ w->array_depth--;
+ lenpos = w->arrays[w->array_depth].lenpos;
+ elemstart = w->arrays[w->array_depth].elemstart;
+ actual = (uint32_t)(w->off - elemstart);
+ put_u32_at(w, lenpos, actual);
+}
+
+void __w_struct_begin(struct link_writer *w)
+{
+ reserve(w, 8, 0);
+}
+
+void __w_struct_end(struct link_writer *w)
+{
+ (void)w;
+}
+
+/* ---- reader ---- */
+
+void __r_init(struct link_reader *r, const uint8_t *body, size_t len)
+{
+ r->base = body;
+ r->off = 0;
+ r->cap = len;
+ r->err = 0;
+}
+
+static int r_skip_align(struct link_reader *r, size_t align)
+{
+ size_t pad;
+
+ if (r->err)
+ return -1;
+ pad = ALIGN_UP(r->off, align) - r->off;
+ if (r->off + pad > r->cap) {
+ r->err = 1;
+ return -1;
+ }
+ r->off += pad;
+ return 0;
+}
+
+static uint32_t rd_u32(const uint8_t *p)
+{
+ return (uint32_t)p[0]
+ | ((uint32_t)p[1] << 8)
+ | ((uint32_t)p[2] << 16)
+ | ((uint32_t)p[3] << 24);
+}
+
+int __r_byte(struct link_reader *r, uint8_t *out)
+{
+ if (r_skip_align(r, 1) < 0 || r->off + 1 > r->cap) {
+ r->err = 1;
+ return -1;
+ }
+ *out = r->base[r->off++];
+ return 0;
+}
+
+int __r_u32(struct link_reader *r, uint32_t *out)
+{
+ if (r_skip_align(r, 4) < 0 || r->off + 4 > r->cap) {
+ r->err = 1;
+ return -1;
+ }
+ *out = rd_u32(r->base + r->off);
+ r->off += 4;
+ return 0;
+}
+
+int __r_bool(struct link_reader *r, int *out)
+{
+ uint32_t v;
+
+ if (__r_u32(r, &v) < 0)
+ return -1;
+ *out = v ? 1 : 0;
+ return 0;
+}
+
+static int read_string_like(struct link_reader *r, const char **out)
+{
+ uint32_t len;
+
+ if (__r_u32(r, &len) < 0)
+ return -1;
+ if (r->off + (size_t)len + 1 > r->cap) {
+ r->err = 1;
+ return -1;
+ }
+ /* Spec requires nul terminator at base[off + len]. */
+ if (r->base[r->off + len] != 0) {
+ r->err = 1;
+ return -1;
+ }
+ *out = (const char *)(r->base + r->off);
+ r->off += (size_t)len + 1;
+ return 0;
+}
+
+int __r_string(struct link_reader *r, const char **out) { return read_string_like(r, out); }
+int __r_path (struct link_reader *r, const char **out) { return read_string_like(r, out); }
+
+/* Read a variant "v" expected to contain a string. Fails if the
+ * inner signature is anything other than "s" (returns -1, *out set
+ * to NULL). */
+int __r_variant_string(struct link_reader *r, const char **out)
+{
+ uint8_t sig_len;
+ uint32_t slen;
+
+ *out = NULL;
+
+ /* signature is "g" wire form: 1-byte length, bytes, NUL */
+ if (r_skip_align(r, 1) < 0 || r->off + 1 > r->cap) { r->err = 1; return -1; }
+ sig_len = r->base[r->off++];
+ if (sig_len != 1 || r->off + 2 > r->cap) { r->err = 1; return -1; }
+ if (r->base[r->off] != 's' || r->base[r->off + 1] != 0) { r->err = 1; return -1; }
+ r->off += 2;
+
+ /* now a normal string */
+ if (__r_u32(r, &slen) < 0) return -1;
+ if (r->off + (size_t)slen + 1 > r->cap || r->base[r->off + slen] != 0) {
+ r->err = 1;
+ return -1;
+ }
+ *out = (const char *)(r->base + r->off);
+ r->off += (size_t)slen + 1;
+ return 0;
+}
+
+int __r_done(const struct link_reader *r)
+{
+ return !r->err && r->off == r->cap;
+}
+
+int __r_align(struct link_reader *r, size_t n)
+{
+ return r_skip_align(r, n);
+}
+
+/* Begin reading an "a" array. Reads the u32 byte-length prefix
+ * and sets *out_end to the absolute reader offset at which the array
+ * ends. Caller loops while r->off < *out_end. Returns -1 on a
+ * truncated or oversized array length. */
+int __r_array_begin(struct link_reader *r, size_t *out_end)
+{
+ uint32_t array_bytes;
+ size_t end;
+
+ if (__r_u32(r, &array_bytes) < 0)
+ return -1;
+ end = r->off + (size_t)array_bytes;
+ if (end > r->cap) {
+ r->err = 1;
+ return -1;
+ }
+ *out_end = end;
+ return 0;
+}
diff --git a/libink/marshal.h b/libink/marshal.h
new file mode 100644
index 00000000..313205b2
--- /dev/null
+++ b/libink/marshal.h
@@ -0,0 +1,52 @@
+/* libink — D-Bus body marshalling (writer side).
+ *
+ * Copyright (c) 2026 Joachim Wiberg
+ * SPDX-License-Identifier: MIT
+ */
+#ifndef LIBINK_MARSHAL_H_
+#define LIBINK_MARSHAL_H_
+
+#include
+#include
+
+/* struct link_writer is defined in ink.h (public). Field layout is
+ * "opaque" per the public contract; this file's helpers manipulate
+ * the fields directly. */
+#include "link.h"
+
+void __w_init (struct link_writer *w, uint8_t *buf, size_t cap);
+ssize_t __w_finish(struct link_writer *w);
+
+void __w_byte (struct link_writer *w, uint8_t v);
+void __w_bool (struct link_writer *w, int v);
+void __w_u32 (struct link_writer *w, uint32_t v);
+void __w_string (struct link_writer *w, const char *s); /* "s" */
+void __w_path (struct link_writer *w, const char *s); /* "o" */
+void __w_sig (struct link_writer *w, const char *s); /* "g" */
+void __w_variant_string(struct link_writer *w, const char *s); /* "v" containing "s" */
+
+/* element_sig_first_char drives the alignment padding inserted
+ * between the array length prefix and the first element. */
+void __w_array_begin (struct link_writer *w, char element_sig_first_char);
+void __w_array_end (struct link_writer *w);
+
+void __w_struct_begin(struct link_writer *w);
+void __w_struct_end (struct link_writer *w);
+
+/* ---- reader ----
+ *
+ * Reads from a message body pointer + length, advancing a cursor.
+ * struct link_reader is defined in link.h (public, opaque); the
+ * helpers here manipulate the fields directly. */
+void __r_init (struct link_reader *r, const uint8_t *body, size_t len);
+int __r_byte (struct link_reader *r, uint8_t *out);
+int __r_bool (struct link_reader *r, int *out);
+int __r_u32 (struct link_reader *r, uint32_t *out);
+int __r_string(struct link_reader *r, const char **out); /* "s" */
+int __r_path (struct link_reader *r, const char **out); /* "o" */
+int __r_variant_string(struct link_reader *r, const char **out); /* "v" containing "s" */
+int __r_align (struct link_reader *r, size_t n); /* skip to n-byte boundary */
+int __r_array_begin(struct link_reader *r, size_t *out_end);
+int __r_done (const struct link_reader *r);
+
+#endif /* LIBINK_MARSHAL_H_ */
diff --git a/libink/match.c b/libink/match.c
new file mode 100644
index 00000000..add721b4
--- /dev/null
+++ b/libink/match.c
@@ -0,0 +1,199 @@
+/* libink — D-Bus AddMatch / RemoveMatch rule parsing and matching.
+ *
+ * Subset of the spec: type, interface, member, path. Each entry is
+ * a key='value' pair with single-quoted value, separated by commas.
+ * Backslash escapes inside values (\\ and \') are not interpreted —
+ * a peer needing them will get unexpected literal content. Unknown
+ * keys cause the whole rule to be rejected so a peer learns its
+ * filter didn't take, rather than silently receiving everything.
+ *
+ * Copyright (c) 2026 Joachim Wiberg
+ * SPDX-License-Identifier: MIT
+ */
+
+#include
+#include
+#include
+
+#include "internal.h"
+
+static char *dup_range(const char *p, size_t n)
+{
+ char *s = malloc(n + 1);
+
+ if (!s)
+ return NULL;
+ memcpy(s, p, n);
+ s[n] = '\0';
+ return s;
+}
+
+/* Parse one key='value' entry starting at *p. On success advances
+ * *p past the trailing quote and any comma, returns 0. On malformed
+ * input, returns -1. */
+static int parse_kv(const char **p, char **out_key, char **out_value)
+{
+ const char *q = *p;
+ const char *key_start, *val_start;
+
+ while (*q == ' ' || *q == '\t')
+ q++;
+ key_start = q;
+ while ((*q >= 'a' && *q <= 'z') || (*q >= 'A' && *q <= 'Z') || *q == '_')
+ q++;
+ if (q == key_start || *q != '=')
+ return -1;
+ *out_key = dup_range(key_start, (size_t)(q - key_start));
+ if (!*out_key)
+ return -1;
+ q++;
+
+ if (*q != '\'') {
+ free(*out_key);
+ return -1;
+ }
+ q++;
+ val_start = q;
+ while (*q && *q != '\'')
+ q++;
+ if (*q != '\'') {
+ free(*out_key);
+ return -1;
+ }
+ *out_value = dup_range(val_start, (size_t)(q - val_start));
+ if (!*out_value) {
+ free(*out_key);
+ return -1;
+ }
+ q++;
+
+ while (*q == ' ' || *q == '\t' || *q == ',')
+ q++;
+ *p = q;
+ return 0;
+}
+
+struct link_match *__match_parse(const char *rule)
+{
+ struct link_match *m;
+ const char *p;
+
+ if (!rule || strlen(rule) >= LINK_MATCH_RULE_MAX) {
+ errno = EINVAL;
+ return NULL;
+ }
+
+ m = calloc(1, sizeof(*m));
+ if (!m)
+ return NULL;
+ m->raw = strdup(rule);
+ if (!m->raw) {
+ free(m);
+ return NULL;
+ }
+
+ for (p = rule; *p; ) {
+ char *key = NULL, *value = NULL;
+ char **slot = NULL;
+
+ if (parse_kv(&p, &key, &value) < 0)
+ goto bad;
+
+ if (!strcmp(key, "type")) slot = &m->type;
+ else if (!strcmp(key, "interface")) slot = &m->interface;
+ else if (!strcmp(key, "member")) slot = &m->member;
+ else if (!strcmp(key, "path")) slot = &m->path;
+ else {
+ free(key);
+ free(value);
+ goto bad;
+ }
+
+ if (*slot) {
+ /* Duplicate key. */
+ free(key);
+ free(value);
+ goto bad;
+ }
+ *slot = value;
+ free(key);
+ }
+
+ /* No need to default m->type: when it's NULL the matcher below
+ * treats it as "match any", and the only thing libink emits via
+ * the match table is signals, so the effective filter is
+ * already "signal" without the explicit assignment. */
+ return m;
+
+bad:
+ __match_free(m);
+ errno = EINVAL;
+ return NULL;
+}
+
+void __match_free(struct link_match *m)
+{
+ if (!m)
+ return;
+ free(m->raw);
+ free(m->type);
+ free(m->interface);
+ free(m->member);
+ free(m->path);
+ free(m);
+}
+
+static int field_matches(const char *want, const char *got)
+{
+ if (!want)
+ return 1; /* no filter on this field */
+ if (!got)
+ return 0;
+ return strcmp(want, got) == 0;
+}
+
+int __match_matches(const struct link_match *m,
+ const char *path, const char *iface,
+ const char *member)
+{
+ /* Type filter: only signals get delivered through this path. */
+ if (m->type && strcmp(m->type, "signal") != 0)
+ return 0;
+
+ return field_matches(m->path, path)
+ && field_matches(m->interface, iface)
+ && field_matches(m->member, member);
+}
+
+int __match_add(link_connection_t *conn, const char *rule)
+{
+ struct link_match *m;
+
+ if (conn->matches_count >= LINK_MATCH_PEER_CAP) {
+ errno = ENOSPC;
+ return -1;
+ }
+
+ m = __match_parse(rule);
+ if (!m)
+ return -1;
+
+ conn->matches[conn->matches_count++] = m;
+ return 0;
+}
+
+int __match_remove(link_connection_t *conn, const char *rule)
+{
+ size_t i;
+
+ for (i = 0; i < conn->matches_count; i++) {
+ if (strcmp(conn->matches[i]->raw, rule) == 0) {
+ __match_free(conn->matches[i]);
+ conn->matches[i] = conn->matches[conn->matches_count - 1];
+ conn->matches_count--;
+ return 0;
+ }
+ }
+ errno = ENOENT;
+ return -1;
+}
diff --git a/libink/path.c b/libink/path.c
new file mode 100644
index 00000000..80f97963
--- /dev/null
+++ b/libink/path.c
@@ -0,0 +1,43 @@
+/* libink — D-Bus object-path encoding for arbitrary identifiers.
+ *
+ * Copyright (c) 2026 Joachim Wiberg
+ * SPDX-License-Identifier: MIT
+ */
+
+#include
+
+#include "path.h"
+
+static int is_safe(unsigned char c)
+{
+ return (c >= 'A' && c <= 'Z')
+ || (c >= 'a' && c <= 'z')
+ || (c >= '0' && c <= '9');
+}
+
+int link_path_encode(const char *in, char *out, size_t outsz)
+{
+ static const char hex[] = "0123456789abcdef";
+ size_t off = 0;
+
+ if (!in || !out || outsz == 0)
+ return -1;
+
+ for (; *in; in++) {
+ unsigned char c = (unsigned char)*in;
+
+ if (is_safe(c)) {
+ if (off + 1 >= outsz)
+ return -1;
+ out[off++] = (char)c;
+ } else {
+ if (off + 3 >= outsz)
+ return -1;
+ out[off++] = '_';
+ out[off++] = hex[c >> 4];
+ out[off++] = hex[c & 0xf];
+ }
+ }
+ out[off] = '\0';
+ return (int)off;
+}
diff --git a/libink/path.h b/libink/path.h
new file mode 100644
index 00000000..784c634e
--- /dev/null
+++ b/libink/path.h
@@ -0,0 +1,21 @@
+/* libink — D-Bus object-path encoding for arbitrary identifiers.
+ *
+ * Copyright (c) 2026 Joachim Wiberg
+ * SPDX-License-Identifier: MIT
+ */
+#ifndef LIBINK_PATH_H_
+#define LIBINK_PATH_H_
+
+#include
+
+/* Encode `in` as a D-Bus path segment. Bytes in [A-Za-z0-9] pass
+ * through unchanged; everything else (including '_' itself) becomes
+ * "_HH" with lowercase hex, mirroring systemd's escape_path.
+ *
+ * Returns the encoded length (excluding the terminating nul), or -1
+ * if the output buffer cannot fit the result. `outsz` must
+ * accommodate the encoded bytes plus a trailing nul; the worst-case
+ * size for an N-byte input is 3*N + 1. */
+int link_path_encode(const char *in, char *out, size_t outsz);
+
+#endif /* LIBINK_PATH_H_ */
diff --git a/libink/proto.c b/libink/proto.c
new file mode 100644
index 00000000..d9bf712e
--- /dev/null
+++ b/libink/proto.c
@@ -0,0 +1,387 @@
+/* libink — D-Bus wire protocol: message header parsing and building.
+ *
+ * Implements the binary message header format described in the
+ * D-Bus specification, sections "Message Format" and "Header Fields".
+ * Bodies are deliberately not parsed here — that's the marshaller's
+ * job (marshal.c).
+ *
+ * Native byte order is assumed to be little-endian; messages with the
+ * 'B' endianness flag are rejected for now (every conforming client
+ * on the platforms Finit targets sends 'l').
+ *
+ * Copyright (c) 2026 Joachim Wiberg
+ * SPDX-License-Identifier: MIT
+ */
+
+#include
+#include
+#include
+#include
+
+#include "proto.h"
+
+#define HDR_FIXED_SIZE 16
+#define MAX_MSG_SIZE (128 * 1024) /* sanity limit for PID 1 */
+#define ALIGN_UP(x, n) (((x) + (n) - 1) & ~((size_t)((n) - 1)))
+
+static inline uint32_t rd_u32(const uint8_t *p)
+{
+ return (uint32_t)p[0]
+ | ((uint32_t)p[1] << 8)
+ | ((uint32_t)p[2] << 16)
+ | ((uint32_t)p[3] << 24);
+}
+
+static inline void wr_u32(uint8_t *p, uint32_t v)
+{
+ p[0] = (uint8_t)(v & 0xff);
+ p[1] = (uint8_t)((v >> 8) & 0xff);
+ p[2] = (uint8_t)((v >> 16) & 0xff);
+ p[3] = (uint8_t)((v >> 24) & 0xff);
+}
+
+/* Parse a (length-prefixed, nul-terminated) DBus STRING or PATH from
+ * the header field array. Returns a pointer into buf or NULL on
+ * malformed input. *consumed receives the bytes used including the
+ * nul. */
+static const char *parse_string(const uint8_t *buf, size_t avail, size_t *consumed)
+{
+ uint32_t len;
+
+ if (avail < 4)
+ return NULL;
+ len = rd_u32(buf);
+ if (len >= avail - 4) /* need room for len bytes + nul */
+ return NULL;
+ if (buf[4 + len] != 0)
+ return NULL;
+ *consumed = 4 + len + 1;
+ return (const char *)(buf + 4);
+}
+
+/* Parse a SIGNATURE (1-byte length, nul-terminated). */
+static const char *parse_signature(const uint8_t *buf, size_t avail, size_t *consumed)
+{
+ uint32_t len;
+
+ if (avail < 1)
+ return NULL;
+ len = buf[0];
+ if (len + 2 > avail)
+ return NULL;
+ if (buf[1 + len] != 0)
+ return NULL;
+ *consumed = 1 + len + 1;
+ return (const char *)(buf + 1);
+}
+
+ssize_t __msg_parse(const uint8_t *buf, size_t len, struct link_msg *out)
+{
+ uint32_t fields_len, total_hdr, body_off, total;
+ const uint8_t *fp, *fend;
+
+ memset(out, 0, sizeof(*out));
+
+ if (len < HDR_FIXED_SIZE)
+ return 0;
+
+ if (buf[0] != 'l') {
+ errno = EPROTO;
+ return -1;
+ }
+ if (buf[3] != LINK_PROTOCOL_VERSION) {
+ errno = EPROTONOSUPPORT;
+ return -1;
+ }
+ out->endian = buf[0];
+ out->type = buf[1];
+ out->flags = buf[2];
+ out->body_len = rd_u32(buf + 4);
+ out->serial = rd_u32(buf + 8);
+ fields_len = rd_u32(buf + 12);
+
+ if (fields_len > MAX_MSG_SIZE || out->body_len > MAX_MSG_SIZE) {
+ errno = E2BIG;
+ return -1;
+ }
+
+ total_hdr = HDR_FIXED_SIZE + fields_len;
+ body_off = (uint32_t)ALIGN_UP(total_hdr, 8);
+ total = body_off + out->body_len;
+
+ if (len < total)
+ return 0; /* need more bytes */
+
+ /* Walk the array of (byte field-code, variant). */
+ fp = buf + HDR_FIXED_SIZE;
+ fend = fp + fields_len;
+ while (fp < fend) {
+ uint8_t code;
+ const char *vsig;
+ size_t used;
+
+ fp = buf + ALIGN_UP((size_t)(fp - buf), 8);
+ if (fp >= fend)
+ break;
+
+ code = *fp++;
+ vsig = parse_signature(fp, (size_t)(fend - fp), &used);
+ if (!vsig) {
+ errno = EPROTO;
+ return -1;
+ }
+ fp += used;
+
+ if (vsig[0] == 's' || vsig[0] == 'o') {
+ fp = buf + ALIGN_UP((size_t)(fp - buf), 4);
+ if (fp >= fend) { errno = EPROTO; return -1; }
+ const char *s = parse_string(fp, (size_t)(fend - fp), &used);
+ if (!s) { errno = EPROTO; return -1; }
+ switch (code) {
+ case LINK_HDR_PATH: out->path = s; break;
+ case LINK_HDR_INTERFACE: out->interface = s; break;
+ case LINK_HDR_MEMBER: out->member = s; break;
+ case LINK_HDR_ERROR_NAME: out->error_name = s; break;
+ case LINK_HDR_DESTINATION: out->destination = s; break;
+ case LINK_HDR_SENDER: out->sender = s; break;
+ }
+ fp += used;
+ } else if (vsig[0] == 'g') {
+ const char *s = parse_signature(fp, (size_t)(fend - fp), &used);
+ if (!s) { errno = EPROTO; return -1; }
+ if (code == LINK_HDR_SIGNATURE)
+ out->signature = s;
+ fp += used;
+ } else if (vsig[0] == 'u') {
+ fp = buf + ALIGN_UP((size_t)(fp - buf), 4);
+ if (fp + 4 > fend) { errno = EPROTO; return -1; }
+ uint32_t v = rd_u32(fp);
+ if (code == LINK_HDR_REPLY_SERIAL)
+ out->reply_serial = v;
+ fp += 4;
+ } else {
+ /* Unknown field type — skip whole message. */
+ errno = EPROTO;
+ return -1;
+ }
+ }
+
+ out->body = buf + body_off;
+ out->body_avail = out->body_len;
+ return (ssize_t)total;
+}
+
+/* ---------- builders ---------- */
+
+/* Append a (byte field-code, variant) entry to a header-fields array,
+ * with the entry pre-aligned to 8 bytes. */
+static int put_field_string(uint8_t *buf, size_t cap, size_t *off,
+ uint8_t code, char vsig_char,
+ const char *value)
+{
+ size_t o = *off;
+ size_t pad = ALIGN_UP(o, 8) - o;
+ size_t len = strlen(value);
+
+ /* Padding for struct alignment */
+ while (pad-- > 0) {
+ if (o >= cap) return -1;
+ buf[o++] = 0;
+ }
+
+ /* code, variant signature (1B len + 1B char + 1B nul) */
+ if (o + 4 > cap) return -1;
+ buf[o++] = code;
+ buf[o++] = 1;
+ buf[o++] = (uint8_t)vsig_char;
+ buf[o++] = 0;
+
+ if (vsig_char == 's' || vsig_char == 'o') {
+ /* 4-byte align for u32 length */
+ while (o & 3) {
+ if (o >= cap) return -1;
+ buf[o++] = 0;
+ }
+ if (o + 4 + len + 1 > cap) return -1;
+ wr_u32(buf + o, (uint32_t)len);
+ o += 4;
+ memcpy(buf + o, value, len);
+ o += len;
+ buf[o++] = 0;
+ } else if (vsig_char == 'g') {
+ if (o + 1 + len + 1 > cap) return -1;
+ buf[o++] = (uint8_t)len;
+ memcpy(buf + o, value, len);
+ o += len;
+ buf[o++] = 0;
+ } else {
+ return -1;
+ }
+
+ *off = o;
+ return 0;
+}
+
+static int put_field_u32(uint8_t *buf, size_t cap, size_t *off,
+ uint8_t code, uint32_t value)
+{
+ size_t o = *off;
+ size_t pad = ALIGN_UP(o, 8) - o;
+
+ while (pad-- > 0) {
+ if (o >= cap) return -1;
+ buf[o++] = 0;
+ }
+ if (o + 8 > cap) return -1;
+ buf[o++] = code;
+ buf[o++] = 1;
+ buf[o++] = 'u';
+ buf[o++] = 0;
+ while (o & 3) {
+ if (o >= cap) return -1;
+ buf[o++] = 0;
+ }
+ if (o + 4 > cap) return -1;
+ wr_u32(buf + o, value);
+ o += 4;
+ *off = o;
+ return 0;
+}
+
+static ssize_t finalize_header(uint8_t *buf, size_t cap,
+ uint8_t type, uint8_t flags,
+ uint32_t body_len, uint32_t serial,
+ size_t fields_end)
+{
+ size_t hdr_end = fields_end;
+ size_t pad = ALIGN_UP(hdr_end, 8) - hdr_end;
+
+ buf[0] = 'l';
+ buf[1] = type;
+ buf[2] = flags;
+ buf[3] = LINK_PROTOCOL_VERSION;
+ wr_u32(buf + 4, body_len);
+ wr_u32(buf + 8, serial);
+ wr_u32(buf + 12, (uint32_t)(hdr_end - HDR_FIXED_SIZE));
+
+ while (pad-- > 0) {
+ if (hdr_end >= cap) return -1;
+ buf[hdr_end++] = 0;
+ }
+ return (ssize_t)hdr_end;
+}
+
+ssize_t __msg_build_return(uint8_t *buf, size_t cap,
+ uint32_t serial, uint32_t reply_serial,
+ const char *destination,
+ const char *signature, uint32_t body_len)
+{
+ size_t off = HDR_FIXED_SIZE;
+
+ if (cap < HDR_FIXED_SIZE)
+ return -1;
+
+ if (put_field_u32(buf, cap, &off, LINK_HDR_REPLY_SERIAL, reply_serial) < 0)
+ return -1;
+ if (destination &&
+ put_field_string(buf, cap, &off, LINK_HDR_DESTINATION, 's', destination) < 0)
+ return -1;
+ if (signature && *signature &&
+ put_field_string(buf, cap, &off, LINK_HDR_SIGNATURE, 'g', signature) < 0)
+ return -1;
+
+ return finalize_header(buf, cap, LINK_MSG_METHOD_RETURN,
+ LINK_FLAG_NO_REPLY_EXPECTED,
+ body_len, serial, off);
+}
+
+ssize_t __msg_build_error(uint8_t *buf, size_t cap,
+ uint32_t serial, uint32_t reply_serial,
+ const char *destination,
+ const char *error_name,
+ const char *signature, uint32_t body_len)
+{
+ size_t off = HDR_FIXED_SIZE;
+
+ if (cap < HDR_FIXED_SIZE || !error_name)
+ return -1;
+
+ if (put_field_u32(buf, cap, &off, LINK_HDR_REPLY_SERIAL, reply_serial) < 0)
+ return -1;
+ if (put_field_string(buf, cap, &off, LINK_HDR_ERROR_NAME, 's', error_name) < 0)
+ return -1;
+ if (destination &&
+ put_field_string(buf, cap, &off, LINK_HDR_DESTINATION, 's', destination) < 0)
+ return -1;
+ if (signature && *signature &&
+ put_field_string(buf, cap, &off, LINK_HDR_SIGNATURE, 'g', signature) < 0)
+ return -1;
+
+ return finalize_header(buf, cap, LINK_MSG_ERROR,
+ LINK_FLAG_NO_REPLY_EXPECTED,
+ body_len, serial, off);
+}
+
+ssize_t __msg_build_signal(uint8_t *buf, size_t cap,
+ uint32_t serial,
+ const char *path,
+ const char *interface,
+ const char *member,
+ const char *signature, uint32_t body_len)
+{
+ size_t off = HDR_FIXED_SIZE;
+
+ if (cap < HDR_FIXED_SIZE || !path || !interface || !member)
+ return -1;
+
+ if (put_field_string(buf, cap, &off, LINK_HDR_PATH, 'o', path) < 0)
+ return -1;
+ if (put_field_string(buf, cap, &off, LINK_HDR_INTERFACE, 's', interface) < 0)
+ return -1;
+ if (put_field_string(buf, cap, &off, LINK_HDR_MEMBER, 's', member) < 0)
+ return -1;
+ if (signature && *signature &&
+ put_field_string(buf, cap, &off, LINK_HDR_SIGNATURE, 'g', signature) < 0)
+ return -1;
+
+ return finalize_header(buf, cap, LINK_MSG_SIGNAL,
+ LINK_FLAG_NO_REPLY_EXPECTED,
+ body_len, serial, off);
+}
+
+ssize_t __msg_build_method_call(uint8_t *buf, size_t cap,
+ uint32_t serial,
+ const char *path,
+ const char *interface,
+ const char *member,
+ const char *signature,
+ uint32_t body_len)
+{
+ size_t off = HDR_FIXED_SIZE;
+
+ if (cap < HDR_FIXED_SIZE || !path || !member)
+ return -1;
+
+ if (put_field_string(buf, cap, &off, LINK_HDR_PATH, 'o', path) < 0)
+ return -1;
+ if (interface &&
+ put_field_string(buf, cap, &off, LINK_HDR_INTERFACE, 's', interface) < 0)
+ return -1;
+ if (put_field_string(buf, cap, &off, LINK_HDR_MEMBER, 's', member) < 0)
+ return -1;
+ if (signature && *signature &&
+ put_field_string(buf, cap, &off, LINK_HDR_SIGNATURE, 'g', signature) < 0)
+ return -1;
+
+ return finalize_header(buf, cap, LINK_MSG_METHOD_CALL,
+ /* flags=0: we expect a reply */
+ 0,
+ body_len, serial, off);
+}
+
+size_t __msg_header_size(const struct link_msg *m)
+{
+ (void)m;
+ /* Generous upper bound used by callers to size send buffers. */
+ return 512;
+}
diff --git a/libink/proto.h b/libink/proto.h
new file mode 100644
index 00000000..f7ef0866
--- /dev/null
+++ b/libink/proto.h
@@ -0,0 +1,101 @@
+/* libink — D-Bus wire protocol: message header parsing and building.
+ *
+ * Copyright (c) 2026 Joachim Wiberg
+ * SPDX-License-Identifier: MIT
+ */
+#ifndef LIBINK_PROTO_H_
+#define LIBINK_PROTO_H_
+
+#include
+#include
+#include
+
+#include "link.h" /* LINK_MSG_* type codes */
+
+/* Message flags. */
+#define LINK_FLAG_NO_REPLY_EXPECTED 0x01
+#define LINK_FLAG_NO_AUTO_START 0x02
+#define LINK_FLAG_ALLOW_INTERACTIVE_AUTHORIZATION 0x04
+
+/* Header field codes. */
+#define LINK_HDR_PATH 1
+#define LINK_HDR_INTERFACE 2
+#define LINK_HDR_MEMBER 3
+#define LINK_HDR_ERROR_NAME 4
+#define LINK_HDR_REPLY_SERIAL 5
+#define LINK_HDR_DESTINATION 6
+#define LINK_HDR_SENDER 7
+#define LINK_HDR_SIGNATURE 8
+#define LINK_HDR_UNIX_FDS 9
+
+#define LINK_PROTOCOL_VERSION 1
+
+/* Parsed view of an incoming message. Pointers reference bytes
+ * inside the receiver's own rx buffer; treat as borrowed and short-
+ * lived (until the next read of the same connection). */
+struct link_msg {
+ uint8_t type;
+ uint8_t flags;
+ uint8_t endian; /* 'l' or 'B' */
+ uint32_t body_len;
+ uint32_t serial;
+ uint32_t reply_serial;
+
+ const char *path; /* object path, or NULL */
+ const char *interface; /* may be NULL on method calls */
+ const char *member;
+ const char *error_name;
+ const char *destination;
+ const char *sender;
+ const char *signature; /* may be NULL if body is empty */
+
+ /* Pointer into the rx buffer and length, after header padding. */
+ const uint8_t *body;
+ uint32_t body_avail;
+};
+
+/* Parse a complete D-Bus message from `buf` of size `len`. On
+ * success returns the total number of bytes consumed (header +
+ * padding + body) and fills *out. Returns 0 if more bytes are
+ * needed, -1 on malformed input. */
+ssize_t __msg_parse(const uint8_t *buf, size_t len, struct link_msg *out);
+
+/* Compute the on-wire size of a future message header given the
+ * fields we'd populate. Used to size send buffers. */
+size_t __msg_header_size(const struct link_msg *m);
+
+/* Build a method-return header into `buf` (capacity `cap`).
+ * `reply_serial`/`destination` come from the call being replied to.
+ * `signature` is the body signature ("" if no args). `body_len`
+ * is the length of the body that will follow the header padding.
+ * Returns the number of bytes written, or -1 on overflow. */
+ssize_t __msg_build_return(uint8_t *buf, size_t cap,
+ uint32_t serial, uint32_t reply_serial,
+ const char *destination,
+ const char *signature, uint32_t body_len);
+
+/* Build an error reply header. */
+ssize_t __msg_build_error(uint8_t *buf, size_t cap,
+ uint32_t serial, uint32_t reply_serial,
+ const char *destination,
+ const char *error_name,
+ const char *signature, uint32_t body_len);
+
+/* Build a signal header (no reply expected, no destination). */
+ssize_t __msg_build_signal(uint8_t *buf, size_t cap,
+ uint32_t serial,
+ const char *path,
+ const char *interface,
+ const char *member,
+ const char *signature, uint32_t body_len);
+
+/* Build a method-call header (client side). */
+ssize_t __msg_build_method_call(uint8_t *buf, size_t cap,
+ uint32_t serial,
+ const char *path,
+ const char *interface,
+ const char *member,
+ const char *signature,
+ uint32_t body_len);
+
+#endif /* LIBINK_PROTO_H_ */
diff --git a/libink/server.c b/libink/server.c
new file mode 100644
index 00000000..f5a82431
--- /dev/null
+++ b/libink/server.c
@@ -0,0 +1,204 @@
+/* libink — listening socket, accept, peer-credential capture
+ *
+ * Copyright (c) 2026 Joachim Wiberg
+ * SPDX-License-Identifier: MIT
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "internal.h"
+
+static void close_save_errno(int fd)
+{
+ int saved = errno;
+ close(fd);
+ errno = saved;
+}
+
+int link_server_new(link_server_t **out, const char *path)
+{
+ struct sockaddr_un sun = { .sun_family = AF_UNIX };
+ link_server_t *srv;
+ size_t plen;
+ int fd;
+
+ if (!out || !path || !*path) {
+ errno = EINVAL;
+ return -1;
+ }
+
+ plen = strlen(path);
+ if (plen >= sizeof(sun.sun_path) || plen >= LINK_PATH_MAX) {
+ errno = ENAMETOOLONG;
+ return -1;
+ }
+
+ srv = calloc(1, sizeof(*srv));
+ if (!srv)
+ return -1;
+ TAILQ_INIT(&srv->objects);
+
+ fd = socket(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
+ if (fd < 0)
+ goto err_free;
+
+ memcpy(sun.sun_path, path, plen + 1);
+
+ (void)unlink(path);
+
+ /* fchmod() on a Unix-domain socket fd is a silent no-op on Linux:
+ * the file mode is fixed at bind() time as (0777 & ~umask). Set
+ * umask around the bind() so the socket appears with mode 0666
+ * atomically, no race window. World-accessible by design;
+ * per-method authorization happens later in dispatch via
+ * SO_PEERCRED. */
+ {
+ mode_t oldmask = umask(0111);
+ int rc = bind(fd, (struct sockaddr *)&sun, sizeof(sun));
+ int saved = errno;
+
+ umask(oldmask);
+ if (rc < 0) {
+ errno = saved;
+ goto err_close;
+ }
+ }
+
+ if (listen(fd, 16) < 0)
+ goto err_unlink;
+
+ srv->fd = fd;
+ memcpy(srv->path, path, plen + 1);
+ *out = srv;
+ return 0;
+
+err_unlink:
+ (void)unlink(path);
+err_close:
+ close_save_errno(fd);
+err_free:
+ free(srv);
+ return -1;
+}
+
+void link_server_free(link_server_t *srv)
+{
+ struct link_object *o;
+
+ if (!srv)
+ return;
+
+ o = TAILQ_FIRST(&srv->objects);
+ while (o) {
+ struct link_object *next_o = TAILQ_NEXT(o, link);
+ struct link_vtable_entry *e = TAILQ_FIRST(&o->vtables);
+
+ while (e) {
+ struct link_vtable_entry *next_e = TAILQ_NEXT(e, link);
+
+ free(e);
+ e = next_e;
+ }
+ free(o);
+ o = next_o;
+ }
+
+ if (srv->fd >= 0)
+ close(srv->fd);
+ if (srv->path[0])
+ (void)unlink(srv->path);
+ free(srv);
+}
+
+int link_server_get_fd(const link_server_t *srv)
+{
+ if (!srv)
+ return -1;
+
+ return srv->fd;
+}
+
+int link_server_accept(link_server_t *srv, link_connection_t **out)
+{
+ struct ucred cred = { 0 };
+ socklen_t credlen = sizeof(cred);
+ link_connection_t *conn;
+ int cfd;
+
+ if (!srv || !out) {
+ errno = EINVAL;
+ return -1;
+ }
+
+ cfd = accept4(srv->fd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC);
+ if (cfd < 0)
+ return -1;
+
+ conn = calloc(1, sizeof(*conn));
+ if (!conn) {
+ close_save_errno(cfd);
+ return -1;
+ }
+
+ conn->fd = cfd;
+ conn->auth = LINK_AUTH_NUL;
+ conn->server = srv;
+
+ if (getsockopt(cfd, SOL_SOCKET, SO_PEERCRED, &cred, &credlen) == 0)
+ conn->peer_uid = cred.uid;
+ else
+ conn->peer_uid = (uid_t)-1;
+
+ __auth_generate_guid(conn->guid);
+
+ *out = conn;
+ return 0;
+}
+
+link_connection_t *link_server_attach(link_server_t *srv, int fd, uid_t peer_uid)
+{
+ link_connection_t *conn;
+ int flags;
+
+ /* On entry we always own `fd` -- close it on every failure path
+ * so callers don't have to track whether we touched fcntl state. */
+ if (!srv || fd < 0) {
+ if (fd >= 0)
+ close_save_errno(fd);
+ errno = EINVAL;
+ return NULL;
+ }
+
+ /* Match server_accept's fd setup: CLOEXEC first (so a fork-and-
+ * exec between the two calls cannot leak the fd), then NONBLOCK
+ * so process_binary's read loop can drain without hanging. */
+ flags = fcntl(fd, F_GETFD, 0);
+ if (flags < 0 || fcntl(fd, F_SETFD, flags | FD_CLOEXEC) < 0)
+ goto err_close;
+ flags = fcntl(fd, F_GETFL, 0);
+ if (flags < 0 || fcntl(fd, F_SETFL, flags | O_NONBLOCK) < 0)
+ goto err_close;
+
+ conn = calloc(1, sizeof(*conn));
+ if (!conn)
+ goto err_close;
+
+ conn->fd = fd;
+ conn->auth = LINK_AUTH_DONE; /* caller already handshook */
+ conn->server = srv;
+ conn->peer_uid = peer_uid;
+ __auth_generate_guid(conn->guid);
+
+ return conn;
+
+err_close:
+ close_save_errno(fd);
+ return NULL;
+}
diff --git a/mkdocs.yml b/mkdocs.yml
index 8d02f735..d07fdb9a 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -90,6 +90,7 @@ nav:
- Plugins: plugins.md
- Watchdog: watchdog.md
- keventd: keventd.md
+ - D-Bus Integration: dbus.md
- Service State Machine: state-machine.md
- Distributions: distro.md
- Requirements: requirements.md
diff --git a/src/Makefile.am b/src/Makefile.am
index bd126600..73125001 100644
--- a/src/Makefile.am
+++ b/src/Makefile.am
@@ -80,6 +80,9 @@ finit_SOURCES = api.c cgroup.c cgroup.h \
if LOGROTATE
finit_SOURCES += logrotate.c
endif
+if DBUS
+finit_SOURCES += dbus.c
+endif
pkginclude_HEADERS = cgroup.h cond.h conf.h finit.h helpers.h log.h \
plugin.h svc.h service.h
@@ -93,6 +96,10 @@ finit_LDADD += ../plugins/libplug.la
else
finit_LDADD += -ldl
endif
+if DBUS
+finit_CPPFLAGS += -I$(top_srcdir)/libink
+finit_LDADD += $(top_builddir)/libink/libink.la
+endif
initctl_SOURCES = initctl.c initctl.h cgutil.c cgutil.h \
client.c client.h cond.c cond.h reboot.c \
@@ -100,6 +107,10 @@ initctl_SOURCES = initctl.c initctl.h cgutil.c cgutil.h \
initctl_CFLAGS = -W -Wall -Wextra -Wno-unused-parameter -std=gnu99
initctl_CFLAGS += $(lite_CFLAGS) $(uev_CFLAGS)
initctl_LDADD = $(lite_LIBS) $(uev_LIBS)
+if DBUS
+initctl_CPPFLAGS = $(AM_CPPFLAGS) -I$(top_srcdir)/libink
+initctl_LDADD += $(top_builddir)/libink/libink.la
+endif
INIT_LNKS = init telinit
REBOOT_LNKS = reboot shutdown halt poweroff suspend
diff --git a/src/api.c b/src/api.c
index a0af4542..be14e215 100644
--- a/src/api.c
+++ b/src/api.c
@@ -112,41 +112,8 @@ static int restart(svc_t *svc, void *user_data)
static int reload(svc_t *svc, void *user_data)
{
- char cond[MAX_COND_LEN];
-
(void)user_data;
-
- if (!svc)
- return 1;
-
- if (svc_is_blocked(svc))
- svc_start(svc);
- else
- service_timeout_cancel(svc);
-
- /*
- * Clear conditions before reload to ensure dependent services
- * are properly updated. Only needed when the service does NOT
- * support SIGHUP (noreload), because then it will be stopped
- * and restarted, so conditions genuinely go away. When the
- * service handles SIGHUP, its PID and pidfile persist, so the
- * condition stays valid and dependents should not be disrupted.
- *
- * Note: only clear 'ready' for services where the pidfile
- * inotify handler reasserts it (pid/none). For s6/systemd
- * services readiness relies on their respective notification
- * mechanism which may not re-trigger on SIGHUP.
- */
- if (svc_is_noreload(svc)) {
- cond_clear(mkcond(svc, cond, sizeof(cond)));
- if (svc->notify == SVC_NOTIFY_PID || svc->notify == SVC_NOTIFY_NONE)
- service_ready(svc, 0);
- }
-
- svc_mark_dirty(svc);
- service_step(svc);
-
- return 0;
+ return service_reload(svc);
}
static int do_stop (char *buf, size_t len) { return call(stop, buf, len); }
diff --git a/src/cond-w.c b/src/cond-w.c
index 4ec119d2..d4489494 100644
--- a/src/cond-w.c
+++ b/src/cond-w.c
@@ -37,6 +37,13 @@
#include "service.h"
#include "sm.h"
+#ifdef HAVE_DBUS
+/* Forward-declared locally to keep cond-w.c independent of the
+ * daemon's private.h (which pulls in svc/plugin headers). The full
+ * prototype lives in private.h for callers in finit's main loop. */
+void dbus_notify_condition_change(const char *name, const char *state);
+#endif
+
struct cond_boot {
TAILQ_ENTRY(cond_boot) link;
char *name;
@@ -328,6 +335,9 @@ void cond_set(const char *name)
if (cond_set_noupdate(name))
return;
+#ifdef HAVE_DBUS
+ dbus_notify_condition_change(name, "on");
+#endif
cond_update(name);
}
@@ -358,6 +368,9 @@ void cond_set_oneshot(const char *name)
if (cond_set_oneshot_noupdate(name))
return;
+#ifdef HAVE_DBUS
+ dbus_notify_condition_change(name, "on");
+#endif
cond_update(name);
}
@@ -379,6 +392,9 @@ void cond_clear(const char *name)
if (cond_clear_noupdate(name))
return;
+#ifdef HAVE_DBUS
+ dbus_notify_condition_change(name, "off");
+#endif
cond_update(name);
}
diff --git a/src/dbus.c b/src/dbus.c
new file mode 100644
index 00000000..acea312f
--- /dev/null
+++ b/src/dbus.c
@@ -0,0 +1,1113 @@
+/* Finit-side glue between the event loop and libink.
+ *
+ * Owns the libink server, accepts new peers, drives each peer's
+ * state machine, and registers the Finit-specific D-Bus object
+ * tree (org.finit.Manager1 et al). Nothing in libink/ depends on
+ * finit-internal types: the boundary lives in this file, by design.
+ *
+ * Copyright (c) 2026 Joachim Wiberg
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+#include "config.h"
+
+#ifdef HAVE_DBUS
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+#include "link.h"
+#include "path.h"
+
+#include "finit.h"
+#include "cond.h"
+#include "conf.h"
+#include "log.h"
+#include "private.h"
+#include "service.h"
+#include "sig.h"
+#include "sm.h"
+#include "svc.h"
+#include "util.h"
+
+#define DBUS_MAX_PEERS 64
+
+struct peer {
+ uev_t watcher;
+ link_connection_t *conn;
+ TAILQ_ENTRY(peer) link;
+};
+
+static TAILQ_HEAD(, peer) peers = TAILQ_HEAD_INITIALIZER(peers);
+static link_server_t *server;
+static uev_t accept_watcher;
+static size_t peer_count;
+
+static void peer_drop(struct peer *p)
+{
+ uev_io_stop(&p->watcher);
+ link_connection_close(p->conn);
+ TAILQ_REMOVE(&peers, p, link);
+ peer_count--;
+ free(p);
+}
+
+static void peer_cb(uev_t *w, void *arg, int events)
+{
+ struct peer *p = arg;
+
+ (void)w;
+
+ if (UEV_ERROR == events) {
+ peer_drop(p);
+ return;
+ }
+
+ if (link_connection_process(p->conn) < 0)
+ peer_drop(p);
+}
+
+/* Wrap an authenticated connection in a struct peer, insert into the
+ * peer list, and register an event-loop watcher. Enforces
+ * DBUS_MAX_PEERS. Closes the connection and returns NULL on failure.
+ * Used by both the accept path and the system-bus attach path. */
+static struct peer *peer_register(uev_ctx_t *ctx, link_connection_t *conn)
+{
+ struct peer *p;
+
+ if (peer_count >= DBUS_MAX_PEERS) {
+ logit(LOG_WARNING, "D-Bus peer cap reached (%zu), dropping",
+ peer_count);
+ link_connection_close(conn);
+ return NULL;
+ }
+
+ p = calloc(1, sizeof(*p));
+ if (!p) {
+ link_connection_close(conn);
+ return NULL;
+ }
+
+ p->conn = conn;
+ TAILQ_INSERT_TAIL(&peers, p, link);
+ peer_count++;
+
+ if (uev_io_init(ctx, &p->watcher, peer_cb, p,
+ link_connection_get_fd(conn), UEV_READ)) {
+ peer_drop(p);
+ return NULL;
+ }
+ return p;
+}
+
+static void accept_cb(uev_t *w, void *arg, int events)
+{
+ (void)arg;
+
+ if (UEV_ERROR == events) {
+ err(1, "D-Bus accept watcher error");
+ return;
+ }
+
+ for (;;) {
+ link_connection_t *conn = NULL;
+
+ if (link_server_accept(server, &conn) < 0) {
+ if (errno != EAGAIN && errno != EWOULDBLOCK)
+ err(1, "Failed accepting D-Bus client");
+ break;
+ }
+
+ if (!peer_register(w->ctx, conn))
+ continue; /* logged inside */
+ }
+}
+
+/* ---------- org.finit.Manager1 ---------- */
+
+/* Forward decl + buffer-size constant — both consumed by Manager1
+ * handlers below, defined in the Service1 block further down. */
+#define SERVICE_PATH_PREFIX "/org/finit/service/"
+#define SERVICE_PATH_PREFIX_LEN (sizeof(SERVICE_PATH_PREFIX) - 1)
+#define FINIT_SVC_PATH_MAX 512
+static int service_path_for(svc_t *svc, char *buf, size_t bufsz);
+
+static int manager_list_services(link_call_t *call, void *userdata)
+{
+ link_writer_t *w;
+ svc_t *iter = NULL;
+ svc_t *svc;
+
+ (void)userdata;
+
+ w = link_call_reply(call);
+ if (!w)
+ return -1;
+
+ link_w_array_begin(w, 's');
+ for (svc = svc_iterator(&iter, 1); svc; svc = svc_iterator(&iter, 0)) {
+ char ident[64];
+
+ svc_ident(svc, ident, sizeof(ident));
+ link_w_string(w, ident);
+ }
+ link_w_array_end(w);
+ return 0;
+}
+
+static int manager_get_service(link_call_t *call, void *userdata)
+{
+ const char *ident;
+ svc_t *svc;
+ char path[FINIT_SVC_PATH_MAX];
+ link_writer_t *w;
+
+ (void)userdata;
+
+ if (link_call_read_string(call, &ident) < 0)
+ return link_call_reply_error(call,
+ "org.freedesktop.DBus.Error.InvalidArgs",
+ "expected (s)");
+
+ svc = svc_find_by_str(ident);
+ if (!svc)
+ return link_call_reply_error(call,
+ "org.finit.Error.NoSuchService", ident);
+
+ if (service_path_for(svc, path, sizeof(path)) < 0)
+ return link_call_reply_error(call,
+ "org.finit.Error.Failed",
+ "Path encoding overflow");
+
+ w = link_call_reply(call);
+ if (!w)
+ return -1;
+ link_w_path(w, path);
+ return 0;
+}
+
+/* Service-control helpers used by Start/Stop/Restart/Reload. These
+ * mirror the static helpers in api.c — kept private here so api.c
+ * stays untouched in this increment. */
+
+static int dbus_apply_stop(svc_t *svc, void *user_data)
+{
+ (void)user_data;
+ if (!svc)
+ return 1;
+ service_timeout_cancel(svc);
+ svc_stop(svc);
+ service_step(svc);
+ if (!IS_RESERVED_RUNLEVEL(runlevel))
+ service_step_all(SVC_TYPE_ANY);
+ return 0;
+}
+
+static int dbus_apply_start(svc_t *svc, void *user_data)
+{
+ (void)user_data;
+ if (!svc)
+ return 1;
+ service_timeout_cancel(svc);
+ svc_start(svc);
+ service_step(svc);
+ if (!IS_RESERVED_RUNLEVEL(runlevel))
+ service_step_all(SVC_TYPE_ANY);
+ return 0;
+}
+
+static int dbus_apply_restart(svc_t *svc, void *user_data)
+{
+ if (!svc)
+ return 1;
+ if (!svc_is_running(svc))
+ return dbus_apply_start(svc, user_data);
+ service_timeout_cancel(svc);
+ service_stop(svc);
+ service_step(svc);
+ return 0;
+}
+
+struct dispatch_ctx {
+ int (*action)(svc_t *, void *);
+ void *udata;
+ int matched;
+};
+
+static int dispatch_found(svc_t *svc, void *udata)
+{
+ struct dispatch_ctx *ctx = udata;
+
+ ctx->matched++;
+ return ctx->action(svc, ctx->udata);
+}
+
+static int dispatch_missing(char *job, char *id, void *udata)
+{
+ (void)job; (void)id; (void)udata;
+ return 0; /* don't penalise the return; we'll check ->matched */
+}
+
+/* Apply `action(svc, udata)` to every service matched by `ident`.
+ * Returns 0 if at least one service matched and the action succeeded
+ * on all; -1 if no service matched the identity (caller sends
+ * NoSuchService). */
+static int dispatch_action(const char *ident,
+ int (*action)(svc_t *, void *), void *udata)
+{
+ char buf[128];
+ struct dispatch_ctx ctx = { .action = action, .udata = udata };
+ int rc;
+
+ if (!ident || !*ident || strlen(ident) >= sizeof(buf))
+ return -1;
+ memcpy(buf, ident, strlen(ident) + 1);
+ rc = svc_parse_jobstr(buf, sizeof(buf), &ctx,
+ dispatch_found, dispatch_missing);
+ if (ctx.matched == 0)
+ return -1;
+ return rc;
+}
+
+static int manager_take_string_method(link_call_t *call,
+ int (*action)(svc_t *, void *))
+{
+ const char *ident;
+
+ if (link_call_read_string(call, &ident) < 0)
+ return link_call_reply_error(call,
+ "org.freedesktop.DBus.Error.InvalidArgs",
+ "expected (s)");
+ if (dispatch_action(ident, action, NULL) != 0)
+ return link_call_reply_error(call,
+ "org.finit.Error.NoSuchService", ident);
+
+ (void)link_call_reply(call); /* empty reply */
+ return 0;
+}
+
+static int manager_start (link_call_t *call, void *u) { (void)u; return manager_take_string_method(call, dbus_apply_start); }
+static int manager_stop (link_call_t *call, void *u) { (void)u; return manager_take_string_method(call, dbus_apply_stop); }
+static int manager_restart(link_call_t *call, void *u) { (void)u; return manager_take_string_method(call, dbus_apply_restart); }
+
+static int manager_reload(link_call_t *call, void *userdata)
+{
+ (void)userdata;
+ if (IS_RESERVED_RUNLEVEL(runlevel))
+ return link_call_reply_error(call,
+ "org.finit.Error.WrongRunlevel",
+ "Reload not allowed in runlevel S or 0/6");
+ sm_reload();
+ (void)link_call_reply(call);
+ return 0;
+}
+
+static int manager_set_runlevel(link_call_t *call, void *userdata)
+{
+ uint32_t lvl;
+
+ (void)userdata;
+ if (link_call_read_u32(call, &lvl) < 0)
+ return link_call_reply_error(call,
+ "org.freedesktop.DBus.Error.InvalidArgs",
+ "expected (u)");
+ if (lvl > 9 || lvl == INIT_LEVEL)
+ return link_call_reply_error(call,
+ "org.freedesktop.DBus.Error.InvalidArgs",
+ "runlevel must be 0-9 (excluding internal levels)");
+
+ if (lvl == 0) halt = SHUT_OFF;
+ if (lvl == 6) halt = SHUT_REBOOT;
+ sm_runlevel((int)lvl);
+
+ (void)link_call_reply(call);
+ return 0;
+}
+
+static int dbus_shutdown(link_call_t *call, shutop_t target, int level)
+{
+ if (IS_RESERVED_RUNLEVEL(runlevel))
+ return link_call_reply_error(call,
+ "org.finit.Error.WrongRunlevel",
+ "Already in shutdown");
+ halt = target;
+ sm_runlevel(level);
+ (void)link_call_reply(call);
+ return 0;
+}
+
+static int manager_reboot (link_call_t *c, void *u) { (void)u; return dbus_shutdown(c, SHUT_REBOOT, 6); }
+static int manager_poweroff(link_call_t *c, void *u) { (void)u; return dbus_shutdown(c, SHUT_OFF, 0); }
+static int manager_halt (link_call_t *c, void *u) { (void)u; return dbus_shutdown(c, SHUT_HALT, 0); }
+
+static int manager_set_debug(link_call_t *call, void *u)
+{
+ (void)u;
+ log_debug();
+ (void)link_call_reply(call);
+ return 0;
+}
+
+static int signal_one(svc_t *svc, void *udata)
+{
+ int signo = *(int *)udata;
+
+ /* Silently skip stopped services -- a multi-match ident
+ * (e.g. "sshd:*") should not fail the whole call just because
+ * one of the matches happens to be in a halted state. */
+ if (!svc_is_running(svc))
+ return 0;
+ return !!kill(svc->pid, signo);
+}
+
+static int manager_signal(link_call_t *call, void *u)
+{
+ const char *ident;
+ uint32_t signo;
+ int sig;
+
+ (void)u;
+ if (link_call_read_string(call, &ident) < 0 ||
+ link_call_read_u32 (call, &signo) < 0)
+ return link_call_reply_error(call,
+ "org.freedesktop.DBus.Error.InvalidArgs",
+ "expected (s, u)");
+ /* Match the upper bound `initctl signal` allows (1..31). RT
+ * signals are a future story; keep both sides in lockstep so
+ * users see the same range regardless of transport. */
+ if (signo == 0 || signo > 31)
+ return link_call_reply_error(call,
+ "org.freedesktop.DBus.Error.InvalidArgs",
+ "signal out of range (1..31)");
+
+ sig = (int)signo;
+ if (dispatch_action(ident, signal_one, &sig) != 0)
+ return link_call_reply_error(call,
+ "org.finit.Error.NoSuchService", ident);
+
+ (void)link_call_reply(call);
+ return 0;
+}
+
+static int manager_suspend(link_call_t *call, void *u)
+{
+ (void)u;
+ sync();
+ if (suspend() < 0) {
+ const char *msg = (errno == EINVAL)
+ ? "Kernel does not support suspend to RAM"
+ : strerror(errno);
+ return link_call_reply_error(call,
+ "org.finit.Error.Failed", msg);
+ }
+ (void)link_call_reply(call);
+ return 0;
+}
+
+/* ---------- Manager1 properties ----------
+ *
+ * Read-only string properties exposed via the standard
+ * org.freedesktop.DBus.Properties interface. Getters write a
+ * variant containing a single string. */
+
+/* Two distinct getters because the property table is static const --
+ * we can't bind &runlevel/&prevlevel through userdata. */
+static int prop_runlevel(link_writer_t *w, void *u)
+{
+ char buf[8];
+
+ (void)u;
+ snprintf(buf, sizeof(buf), "%d", runlevel);
+ link_w_variant_string(w, buf);
+ return 0;
+}
+
+static int prop_prevrunlevel(link_writer_t *w, void *u)
+{
+ char buf[8];
+
+ (void)u;
+ snprintf(buf, sizeof(buf), "%d", prevlevel);
+ link_w_variant_string(w, buf);
+ return 0;
+}
+
+static int prop_version(link_writer_t *w, void *u)
+{
+ (void)u;
+ link_w_variant_string(w, PACKAGE_VERSION);
+ return 0;
+}
+
+static const link_property_t manager_properties[] = {
+ { .name = "Runlevel", .sig = "s", .getter = prop_runlevel },
+ { .name = "PrevRunlevel", .sig = "s", .getter = prop_prevrunlevel },
+ { .name = "Version", .sig = "s", .getter = prop_version },
+ { NULL, NULL, NULL }
+};
+
+static const link_method_t manager_methods[] = {
+ { .name = "ListServices", .in_sig = "", .out_sig = "as",
+ .handler = manager_list_services },
+ { .name = "GetService", .in_sig = "s", .out_sig = "o",
+ .handler = manager_get_service },
+ { .name = "Start", .in_sig = "s", .out_sig = "",
+ .flags = LINK_METHOD_PRIVILEGED, .handler = manager_start },
+ { .name = "Stop", .in_sig = "s", .out_sig = "",
+ .flags = LINK_METHOD_PRIVILEGED, .handler = manager_stop },
+ { .name = "Restart", .in_sig = "s", .out_sig = "",
+ .flags = LINK_METHOD_PRIVILEGED, .handler = manager_restart },
+ { .name = "Reload", .in_sig = "", .out_sig = "",
+ .flags = LINK_METHOD_PRIVILEGED, .handler = manager_reload },
+ { .name = "SetRunlevel", .in_sig = "u", .out_sig = "",
+ .flags = LINK_METHOD_PRIVILEGED, .handler = manager_set_runlevel },
+ { .name = "Reboot", .in_sig = "", .out_sig = "",
+ .flags = LINK_METHOD_PRIVILEGED, .handler = manager_reboot },
+ { .name = "Poweroff", .in_sig = "", .out_sig = "",
+ .flags = LINK_METHOD_PRIVILEGED, .handler = manager_poweroff },
+ { .name = "Halt", .in_sig = "", .out_sig = "",
+ .flags = LINK_METHOD_PRIVILEGED, .handler = manager_halt },
+ { .name = "Suspend", .in_sig = "", .out_sig = "",
+ .flags = LINK_METHOD_PRIVILEGED, .handler = manager_suspend },
+ { .name = "SetDebug", .in_sig = "", .out_sig = "",
+ .flags = LINK_METHOD_PRIVILEGED, .handler = manager_set_debug },
+ { .name = "Signal", .in_sig = "su", .out_sig = "",
+ .flags = LINK_METHOD_PRIVILEGED, .handler = manager_signal },
+ { NULL, NULL, NULL, 0, NULL }
+};
+
+static const link_vtable_t manager_vtable = {
+ .interface = "org.finit.Manager1",
+ .methods = manager_methods,
+ .properties = manager_properties,
+};
+
+/* ---------- org.finit.Service1 (one object per service) ----------
+ *
+ * Per-service object at /org/finit/service/.
+ * The vtable's `userdata` is the svc_t * for the specific service.
+ * Registration is driven dynamically from svc_new()/svc_del() via
+ * dbus_register_service() / dbus_unregister_service() below. */
+
+/* SERVICE_PATH_PREFIX / SERVICE_PATH_PREFIX_LEN / FINIT_SVC_PATH_MAX
+ * defined near the top of the file so Manager1.GetService can refer
+ * to them. */
+
+static int service_action_method(link_call_t *call, void *userdata,
+ int (*action)(svc_t *, void *))
+{
+ svc_t *svc = userdata;
+
+ if (!svc)
+ return link_call_reply_error(call,
+ "org.finit.Error.NoSuchService",
+ "Service object no longer valid");
+
+ action(svc, NULL);
+ (void)link_call_reply(call);
+ return 0;
+}
+
+static int service1_start (link_call_t *c, void *u) { return service_action_method(c, u, dbus_apply_start); }
+static int service1_stop (link_call_t *c, void *u) { return service_action_method(c, u, dbus_apply_stop); }
+static int service1_restart(link_call_t *c, void *u) { return service_action_method(c, u, dbus_apply_restart); }
+
+static int service1_reload(link_call_t *call, void *userdata)
+{
+ svc_t *svc = userdata;
+
+ if (!svc)
+ return link_call_reply_error(call,
+ "org.finit.Error.NoSuchService",
+ "Service object no longer valid");
+
+ service_reload(svc);
+ (void)link_call_reply(call);
+ return 0;
+}
+
+static const link_method_t service_methods[] = {
+ { .name = "Start", .in_sig = "", .out_sig = "",
+ .flags = LINK_METHOD_PRIVILEGED, .handler = service1_start },
+ { .name = "Stop", .in_sig = "", .out_sig = "",
+ .flags = LINK_METHOD_PRIVILEGED, .handler = service1_stop },
+ { .name = "Restart", .in_sig = "", .out_sig = "",
+ .flags = LINK_METHOD_PRIVILEGED, .handler = service1_restart },
+ { .name = "Reload", .in_sig = "", .out_sig = "",
+ .flags = LINK_METHOD_PRIVILEGED, .handler = service1_reload },
+ { NULL, NULL, NULL, 0, NULL }
+};
+
+static const link_vtable_t service_vtable = {
+ .interface = "org.finit.Service1",
+ .methods = service_methods,
+};
+
+/* Build the canonical object path for a service. Identity is
+ * "name" for single-instance services, "name:id" otherwise. */
+static int service_path_for(svc_t *svc, char *buf, size_t bufsz)
+{
+ char ident[MAX_IDENT_LEN];
+ size_t plen = SERVICE_PATH_PREFIX_LEN;
+ int enc;
+
+ if (bufsz <= plen)
+ return -1;
+ memcpy(buf, SERVICE_PATH_PREFIX, plen);
+
+ svc_ident(svc, ident, sizeof(ident));
+ enc = link_path_encode(ident, buf + plen, bufsz - plen);
+ if (enc < 0)
+ return -1;
+ return (int)plen + enc;
+}
+
+void dbus_register_service(svc_t *svc)
+{
+ char path[FINIT_SVC_PATH_MAX];
+
+ if (!server || !svc)
+ return;
+ if (service_path_for(svc, path, sizeof(path)) < 0)
+ return;
+
+ if (link_server_add_object(server, path, &service_vtable, svc) < 0)
+ logit(LOG_WARNING, "dbus: failed registering %s", path);
+}
+
+void dbus_unregister_service(svc_t *svc)
+{
+ char path[FINIT_SVC_PATH_MAX];
+
+ if (!server || !svc)
+ return;
+ if (service_path_for(svc, path, sizeof(path)) < 0)
+ return;
+
+ (void)link_server_remove_object(server, path);
+}
+
+/* ---------- signal fan-out helper ----------
+ *
+ * Fan out a pre-marshalled signal body to every connected peer,
+ * letting each connection apply its AddMatch filter. Short-circuits
+ * when no peers are connected so dbus_notify_* callers don't have
+ * to inspect that state themselves. */
+static void dbus_emit_signal(const char *path,
+ const char *interface,
+ const char *member,
+ const char *signature,
+ const uint8_t *body, size_t body_len)
+{
+ struct peer *p;
+
+ if (!server || TAILQ_EMPTY(&peers))
+ return;
+ TAILQ_FOREACH(p, &peers, link)
+ (void)link_connection_emit_signal(p->conn,
+ path, interface, member,
+ signature, body, body_len);
+}
+
+/* ---------- signal emission: ServiceStateChanged ---------- */
+
+/* Coarse svc_state_t -> string. svc_status() in svc.h returns a
+ * richer string that also considers svc->block, but emitting just
+ * the state-machine state is enough for clients to track lifecycle
+ * transitions. Keep the strings stable -- they're a wire-API
+ * commitment once shipped.
+ *
+ * No `default:` on purpose: a new SVC_*_STATE added to svc.h must
+ * also pick a wire name here, and -Wswitch-enum makes the missing
+ * case a build error. */
+static const char *state_name(svc_state_t s)
+{
+ switch (s) {
+ case SVC_HALTED_STATE: return "halted";
+ case SVC_DONE_STATE: return "done";
+ case SVC_DEAD_STATE: return "dead";
+ case SVC_CLEANUP_STATE: return "cleanup";
+ case SVC_TEARDOWN_STATE: return "teardown";
+ case SVC_STOPPING_STATE: return "stopping";
+ case SVC_SETUP_STATE: return "setup";
+ case SVC_PAUSED_STATE: return "paused";
+ case SVC_WAITING_STATE: return "waiting";
+ case SVC_STARTING_STATE: return "starting";
+ case SVC_RUNNING_STATE: return "running";
+ }
+ return "unknown";
+}
+
+void dbus_notify_service_state(svc_t *svc, int old_state, int new_state)
+{
+ uint8_t body[256];
+ link_writer_t w;
+ char ident[MAX_IDENT_LEN];
+ ssize_t blen;
+
+ if (!svc)
+ return;
+ svc_ident(svc, ident, sizeof(ident));
+
+ link_writer_init(&w, body, sizeof(body));
+ link_w_string(&w, ident);
+ link_w_string(&w, state_name((svc_state_t)old_state));
+ link_w_string(&w, state_name((svc_state_t)new_state));
+ blen = link_writer_finish(&w);
+ if (blen < 0)
+ return;
+
+ dbus_emit_signal("/org/finit/manager", "org.finit.Manager1",
+ "ServiceStateChanged", "sss", body, (size_t)blen);
+}
+
+/* ---------- signal emission: RunlevelChanged ----------
+ *
+ * Fired by sm.c right after the runlevel global flips. Body is
+ * (old, new) as one-digit strings, matching the format that
+ * Manager1.Runlevel (the property) returns. */
+void dbus_notify_runlevel_change(int old_level, int new_level)
+{
+ uint8_t body[64];
+ link_writer_t w;
+ char old_s[8], new_s[8];
+ ssize_t blen;
+
+ snprintf(old_s, sizeof(old_s), "%d", old_level);
+ snprintf(new_s, sizeof(new_s), "%d", new_level);
+
+ link_writer_init(&w, body, sizeof(body));
+ link_w_string(&w, old_s);
+ link_w_string(&w, new_s);
+ blen = link_writer_finish(&w);
+ if (blen < 0)
+ return;
+
+ dbus_emit_signal("/org/finit/manager", "org.finit.Manager1",
+ "RunlevelChanged", "ss", body, (size_t)blen);
+}
+
+/* ---------- org.finit.Cond1 ---------- */
+
+#define COND_PATH_OBJECT "/org/finit/cond"
+#define COND_INTERFACE "org.finit.Cond1"
+
+/* Cond1.Set/Clear refuse anything that isn't a usr/ condition --
+ * pid/, sys/, hook/ are owned by Finit's state machine and giving
+ * clients write access there would let them corrupt service state.
+ * Bare names ("foo") are normalised to "usr/foo" the same way
+ * initctl does. The returned pointer is valid for the duration
+ * of the caller's stack frame (`buf` must be at least 128 bytes). */
+static const char *normalise_usr_cond(const char *name, char *buf, size_t bufsz)
+{
+ const char *tail;
+
+ if (!name || !*name)
+ return NULL;
+ if (strchr(name, '.'))
+ return NULL;
+
+ if (strchr(name, '/')) {
+ if (strncmp(name, "usr/", 4) != 0)
+ return NULL;
+ tail = name + 4;
+ /* Match initctl's policy: no further slashes in the tail,
+ * and no empty tail ("usr/" alone). */
+ if (!*tail || strchr(tail, '/'))
+ return NULL;
+ if (strlen(name) >= bufsz)
+ return NULL;
+ memcpy(buf, name, strlen(name) + 1);
+ return buf;
+ }
+
+ if ((size_t)snprintf(buf, bufsz, "usr/%s", name) >= bufsz)
+ return NULL;
+ return buf;
+}
+
+/* Reject names that would escape /run/finit/cond/. cond_get(name)
+ * boils down to fopen(_PATH_COND + name), so without this check any
+ * caller can make PID 1 open arbitrary files -- a path traversal
+ * primitive that also stalls PID 1 if pointed at a FIFO or a slow
+ * device. Legal cond names look like "usr/foo", "pid/sshd",
+ * "service/keventd/ready"; no leading slash, no ".." segment. */
+static int cond_name_valid(const char *name)
+{
+ const char *p;
+
+ if (!name || !*name || *name == '/')
+ return 0;
+ for (p = name; *p; p++) {
+ if (*p == '.' && p[1] == '.' &&
+ (p[2] == '\0' || p[2] == '/'))
+ return 0;
+ }
+ return 1;
+}
+
+static int cond1_get(link_call_t *call, void *userdata)
+{
+ const char *name;
+ link_writer_t *w;
+
+ (void)userdata;
+
+ if (link_call_read_string(call, &name) < 0)
+ return link_call_reply_error(call,
+ "org.freedesktop.DBus.Error.InvalidArgs",
+ "expected (s)");
+ if (!cond_name_valid(name))
+ return link_call_reply_error(call,
+ "org.freedesktop.DBus.Error.InvalidArgs",
+ "invalid condition name");
+
+ w = link_call_reply(call);
+ if (!w)
+ return -1;
+ link_w_string(w, condstr(cond_get(name)));
+ return 0;
+}
+
+static int cond1_set_or_clear(link_call_t *call, int do_set)
+{
+ const char *name;
+ char buf[128];
+ const char *full;
+
+ if (link_call_read_string(call, &name) < 0)
+ return link_call_reply_error(call,
+ "org.freedesktop.DBus.Error.InvalidArgs",
+ "expected (s)");
+
+ full = normalise_usr_cond(name, buf, sizeof(buf));
+ if (!full)
+ return link_call_reply_error(call,
+ "org.freedesktop.DBus.Error.InvalidArgs",
+ "Set/Clear is restricted to usr/* conditions");
+
+ if (do_set)
+ /* cond_set_oneshot, not cond_set: a user-asserted condition
+ * is a symlink to _PATH_RECONF, so it tracks the reconf
+ * generation automatically and stays "on" across reloads
+ * and runlevel switches. cond_set() writes a fixed
+ * generation that goes "flux" on the next reload -- wrong
+ * semantics for user conditions, and what initctl cond set
+ * has done forever via the filesystem path. */
+ cond_set_oneshot(full);
+ else
+ cond_clear(full);
+
+ (void)link_call_reply(call);
+ return 0;
+}
+
+static int cond1_set (link_call_t *c, void *u) { (void)u; return cond1_set_or_clear(c, 1); }
+static int cond1_clear(link_call_t *c, void *u) { (void)u; return cond1_set_or_clear(c, 0); }
+
+/* nftw() can't pass user data so a single static handle ferries the
+ * writer into the callback. Safe because dispatch is single-threaded. */
+static link_writer_t *cond_walk_writer;
+static int cond_walk_dump;
+
+static int cond_walk_cb(const char *fpath, const struct stat *sb,
+ int tflag, struct FTW *ftwbuf)
+{
+ const char *name;
+ const char *state;
+ size_t prefix_len;
+
+ (void)sb;
+ (void)ftwbuf;
+
+ if (tflag != FTW_F)
+ return 0;
+ if (!strcmp(fpath, _PATH_RECONF))
+ return 0;
+
+ prefix_len = strlen(_PATH_COND);
+ if (strlen(fpath) <= prefix_len)
+ return 0;
+ name = fpath + prefix_len;
+
+ if (cond_walk_dump) {
+ state = condstr(cond_get_path(fpath));
+ link_w_struct_begin(cond_walk_writer);
+ link_w_string(cond_walk_writer, name);
+ link_w_string(cond_walk_writer, state);
+ link_w_struct_end(cond_walk_writer);
+ } else {
+ link_w_string(cond_walk_writer, name);
+ }
+ return 0;
+}
+
+static int cond1_list(link_call_t *call, void *userdata)
+{
+ link_writer_t *w;
+
+ (void)userdata;
+
+ w = link_call_reply(call);
+ if (!w)
+ return -1;
+
+ link_w_array_begin(w, 's');
+ cond_walk_writer = w;
+ cond_walk_dump = 0;
+ (void)nftw(_PATH_COND, cond_walk_cb, 20, 0);
+ cond_walk_writer = NULL;
+ link_w_array_end(w);
+ return 0;
+}
+
+static int cond1_dump(link_call_t *call, void *userdata)
+{
+ link_writer_t *w;
+
+ (void)userdata;
+
+ w = link_call_reply(call);
+ if (!w)
+ return -1;
+
+ link_w_array_begin(w, '(');
+ cond_walk_writer = w;
+ cond_walk_dump = 1;
+ (void)nftw(_PATH_COND, cond_walk_cb, 20, 0);
+ cond_walk_writer = NULL;
+ link_w_array_end(w);
+ return 0;
+}
+
+static const link_method_t cond_methods[] = {
+ { .name = "Get", .in_sig = "s", .out_sig = "s",
+ .handler = cond1_get },
+ { .name = "Set", .in_sig = "s", .out_sig = "",
+ .flags = LINK_METHOD_PRIVILEGED, .handler = cond1_set },
+ { .name = "Clear", .in_sig = "s", .out_sig = "",
+ .flags = LINK_METHOD_PRIVILEGED, .handler = cond1_clear },
+ { .name = "List", .in_sig = "", .out_sig = "as",
+ .handler = cond1_list },
+ { .name = "Dump", .in_sig = "", .out_sig = "a(ss)",
+ .handler = cond1_dump },
+ { NULL, NULL, NULL, 0, NULL }
+};
+
+static const link_vtable_t cond_vtable = {
+ .interface = COND_INTERFACE,
+ .methods = cond_methods,
+};
+
+/* ---------- signal emission: ConditionChanged ---------- */
+
+void dbus_notify_condition_change(const char *name, const char *state)
+{
+ uint8_t body[256];
+ link_writer_t w;
+ ssize_t blen;
+
+ if (!name || !state)
+ return;
+
+ link_writer_init(&w, body, sizeof(body));
+ link_w_string(&w, name);
+ link_w_string(&w, state);
+ blen = link_writer_finish(&w);
+ if (blen < 0)
+ return;
+
+ dbus_emit_signal(COND_PATH_OBJECT, COND_INTERFACE,
+ "ConditionChanged", "ss", body, (size_t)blen);
+}
+
+/* ---------- system-bus attach (opportunistic) ----------
+ *
+ * If /var/run/dbus/system_bus_socket is reachable, libink connects to
+ * the system bus as a regular client, claims org.finit as a well-known
+ * name, then promotes the authenticated fd into a server-attached
+ * peer so the same vtables serve incoming method calls and outgoing
+ * signal fan-out reaches the system bus.
+ *
+ * peer_uid is set to (uid_t)-1 so LINK_METHOD_PRIVILEGED methods
+ * reject by default -- per-request sender uid lookup via
+ * GetConnectionUnixUser is a follow-up. Read-only methods
+ * (ListServices, Properties.Get, Introspect, ...) work as expected.
+ *
+ * A bounded SO_SNDTIMEO/SO_RCVTIMEO budget is applied via
+ * link_client_open_timeout so a hung dbus-daemon can't stall boot;
+ * once the connection is attached and flipped to non-blocking, those
+ * timeouts are silently inert. */
+
+#define SYSTEM_BUS_PATH "/var/run/dbus/system_bus_socket"
+#define FINIT_BUS_NAME "org.finit"
+/* Budget for the synchronous AUTH + Hello + RequestName round-trips.
+ * If the system bus is alive but the daemon is wedged we'd rather
+ * give up after a couple of seconds than stall the rest of dbus_init
+ * (and through it, boot). */
+#define SYSTEM_BUS_TIMEOUT_MS 2000
+/* DBUS_NAME_FLAG_DO_NOT_QUEUE: fail fast if the name is taken
+ * (something else owns org.finit -- shouldn't happen and we'd
+ * rather log than silently sit in the queue). */
+#define DBUS_NAME_FLAG_DO_NOT_QUEUE 0x04
+
+static int sysbus_request_name(link_client_t *c)
+{
+ uint32_t result = 0;
+ int rc;
+
+ rc = link_client_call_v(c, "/org/freedesktop/DBus",
+ "org.freedesktop.DBus", "RequestName",
+ "su", FINIT_BUS_NAME,
+ (uint32_t)DBUS_NAME_FLAG_DO_NOT_QUEUE);
+ if (rc != LINK_CALL_OK)
+ return -1;
+ if (link_reply_get_u32(link_client_reply(c), &result) < 0)
+ return -1;
+ /* 1 = primary owner; 2/3/4 mean we didn't get the name */
+ return (result == 1) ? 0 : -1;
+}
+
+static void try_attach_system_bus(uev_ctx_t *ctx)
+{
+ link_client_t *c;
+ link_connection_t *conn;
+ int rc;
+
+ c = link_client_open_timeout(SYSTEM_BUS_PATH, SYSTEM_BUS_TIMEOUT_MS);
+ if (!c) {
+ dbg("System bus unavailable at %s; skipping registration",
+ SYSTEM_BUS_PATH);
+ return;
+ }
+
+ rc = link_client_call_v(c, "/org/freedesktop/DBus",
+ "org.freedesktop.DBus", "Hello", NULL);
+ if (rc != LINK_CALL_OK) {
+ dbg("System-bus Hello failed (rc=%d); skipping", rc);
+ link_client_close(c);
+ return;
+ }
+
+ if (sysbus_request_name(c) < 0) {
+ logit(LOG_WARNING, "Failed to claim %s on system bus", FINIT_BUS_NAME);
+ link_client_close(c);
+ return;
+ }
+
+ /* link_server_attach owns the fd from this point on whether it
+ * succeeds or fails, so the steal-then-attach pair has no leak
+ * window. */
+ conn = link_server_attach(server, link_client_steal_fd(c), (uid_t)-1);
+ if (!conn)
+ return;
+
+ if (!peer_register(ctx, conn)) {
+ logit(LOG_WARNING, "Failed registering system-bus peer");
+ return;
+ }
+
+ logit(LOG_NOTICE, "Registered %s on system bus", FINIT_BUS_NAME);
+}
+
+/* ---------- init / exit ---------- */
+
+int dbus_init(uev_ctx_t *ctx)
+{
+ dbg("Setting up D-Bus listening socket at %s ...", FINIT_BUS_SOCKET);
+
+ if (link_server_new(&server, FINIT_BUS_SOCKET) < 0) {
+ err(1, "Failed binding D-Bus socket %s", FINIT_BUS_SOCKET);
+ return 1;
+ }
+
+ if (link_server_add_object(server, "/org/finit/manager",
+ &manager_vtable, NULL) < 0) {
+ err(1, "Failed registering Manager1 object");
+ link_server_free(server);
+ server = NULL;
+ return 1;
+ }
+
+ if (link_server_add_object(server, COND_PATH_OBJECT,
+ &cond_vtable, NULL) < 0) {
+ err(1, "Failed registering Cond1 object");
+ link_server_free(server);
+ server = NULL;
+ return 1;
+ }
+
+ if (uev_io_init(ctx, &accept_watcher, accept_cb, NULL,
+ link_server_get_fd(server), UEV_READ)) {
+ err(1, "Failed registering D-Bus accept watcher");
+ link_server_free(server);
+ server = NULL;
+ return 1;
+ }
+
+ /* Register Service1 objects for every service already loaded.
+ * Subsequent svc_new()/svc_del() calls into
+ * dbus_register_service()/dbus_unregister_service(). */
+ {
+ svc_t *iter = NULL;
+ svc_t *svc;
+
+ for (svc = svc_iterator(&iter, 1); svc;
+ svc = svc_iterator(&iter, 0))
+ dbus_register_service(svc);
+ }
+
+ try_attach_system_bus(ctx);
+
+ return 0;
+}
+
+int dbus_exit(void)
+{
+ struct peer *p;
+
+ uev_io_stop(&accept_watcher);
+
+ while ((p = TAILQ_FIRST(&peers)))
+ peer_drop(p);
+
+ if (server) {
+ link_server_free(server);
+ server = NULL;
+ }
+
+ return 0;
+}
+
+#endif /* HAVE_DBUS */
+
+/**
+ * Local Variables:
+ * indent-tabs-mode: t
+ * c-file-style: "linux"
+ * End:
+ */
diff --git a/src/finit.c b/src/finit.c
index 63df48a1..24e081c5 100644
--- a/src/finit.c
+++ b/src/finit.c
@@ -761,6 +761,11 @@ int main(int argc, char *argv[])
dbg("Starting initctl API responder ...");
api_init(&loop);
+#ifdef HAVE_DBUS
+ dbg("Starting D-Bus listener ...");
+ dbus_init(&loop);
+#endif
+
dbg("Starting service interval monitor ...");
service_init(&loop);
diff --git a/src/finit.h b/src/finit.h
index 5bef212b..30657537 100644
--- a/src/finit.h
+++ b/src/finit.h
@@ -71,6 +71,7 @@
#define BUF_SIZE 4096
#define INIT_SOCKET _PATH_VARRUN "finit/socket"
+#define FINIT_BUS_SOCKET _PATH_VARRUN "finit/bus"
#define INIT_MAGIC 0x03091969
#define INIT_LEVEL 10
diff --git a/src/initctl.c b/src/initctl.c
index 435e2da9..2febc596 100644
--- a/src/initctl.c
+++ b/src/initctl.c
@@ -41,6 +41,13 @@
#include "service.h"
#include "cgutil.h"
#include "utmp-api.h"
+#ifdef HAVE_DBUS
+#include "link.h"
+#include "path.h"
+#endif
+
+/* Used by both do_cond_act and (with HAVE_DBUS) cond_dbus_call. */
+typedef enum { COND_CLR, COND_SET, COND_GET } condop_t;
struct cmd {
char *cmd;
@@ -145,6 +152,11 @@ static int runlevel_get(int *prevlevel)
return rc;
}
+#ifdef HAVE_DBUS
+static int try_dbus_manager(const char *method, const char *arg_sig,
+ const char *arg);
+#endif
+
static int toggle_debug(char *arg)
{
struct init_request rq = {
@@ -152,6 +164,13 @@ static int toggle_debug(char *arg)
.cmd = INIT_CMD_DEBUG,
};
+ (void)arg;
+#ifdef HAVE_DBUS
+ {
+ int rc = try_dbus_manager("SetDebug", "", NULL);
+ if (rc >= 0) return rc;
+ }
+#endif
return client_send(&rq, sizeof(rq));
}
@@ -193,6 +212,68 @@ static int show_log(char *arg)
return do_log(svc, "");
}
+#ifdef HAVE_DBUS
+/* Fetch all org.finit.Manager1 string properties in one Properties.GetAll
+ * round-trip, then pick out a subset. `wanted` is a NULL-terminated array
+ * of property names; `out` parallel-receives the values (each entry left
+ * untouched if its property wasn't returned). Returns 0 on transport
+ * success (even if some properties weren't present), -1 on transport or
+ * parse failure. */
+static int dbus_get_manager_props(const char *const *wanted, char **out, size_t out_sz)
+{
+ link_client_t *c;
+ const link_reply_t *r;
+ link_reader_t reader;
+ size_t end;
+ int rc;
+
+ c = link_client_open(FINIT_BUS_SOCKET);
+ if (!c)
+ return -1;
+
+ rc = link_client_call_v(c, "/org/finit/manager",
+ "org.freedesktop.DBus.Properties", "GetAll",
+ "s", "org.finit.Manager1");
+ if (rc != LINK_CALL_OK) {
+ link_client_close(c);
+ return -1;
+ }
+
+ r = link_client_reply(c);
+ if (!r || !r->body) {
+ link_client_close(c);
+ return -1;
+ }
+
+ link_reader_init(&reader, r->body, r->body_len);
+ if (link_r_array_begin(&reader, &end) < 0) {
+ link_client_close(c);
+ return -1;
+ }
+
+ while (link_r_pos(&reader) < end) {
+ const char *key = NULL;
+ const char *val = NULL;
+ size_t i;
+
+ if (link_r_align(&reader, 8) < 0) break;
+ if (link_r_string(&reader, &key) < 0) break;
+ if (link_r_variant_string(&reader, &val) < 0) break;
+ if (!key || !val) break;
+
+ for (i = 0; wanted[i]; i++) {
+ if (!strcmp(key, wanted[i])) {
+ strlcpy(out[i], val, out_sz);
+ break;
+ }
+ }
+ }
+
+ link_client_close(c);
+ return 0;
+}
+#endif
+
static int do_runlevel(char *arg)
{
struct init_request rq = {
@@ -205,6 +286,23 @@ static int do_runlevel(char *arg)
int currlevel;
char prev, curr;
+#ifdef HAVE_DBUS
+ char curr_buf[16] = { 0 }, prev_buf[16] = { 0 };
+ const char *const wanted[] = { "Runlevel", "PrevRunlevel", NULL };
+ char *out[] = { curr_buf, prev_buf };
+
+ if (dbus_get_manager_props(wanted, out, sizeof(curr_buf)) == 0 &&
+ curr_buf[0] && prev_buf[0]) {
+ int cl = atoi(curr_buf);
+ int pl = atoi(prev_buf);
+
+ curr = (cl == INIT_LEVEL) ? 'S' : (char)(cl + '0');
+ prev = (pl > 0 && pl <= 9) ? (char)(pl + '0') : 'N';
+ printf("%c %c\n", prev, curr);
+ return 0;
+ }
+#endif
+
currlevel = runlevel_get(&prevlevel);
switch (currlevel) {
case 255:
@@ -268,19 +366,271 @@ static int do_startstop(int cmd, char *arg)
return do_svc(cmd, arg);
}
-static int do_start (char *arg) { return do_startstop(INIT_CMD_START_SVC, arg); }
-static int do_stop (char *arg) { return do_startstop(INIT_CMD_STOP_SVC, arg); }
+#ifdef HAVE_DBUS
+
+/* Map a LINK_CALL_ERROR reply on `c` to the appropriate ERRX exit:
+ * org.finit.Error.NoSuchService -> exit 69 (legacy "no such svc")
+ * org.freedesktop.DBus.Error.AccessDenied -> exit 1 (permission denied)
+ * anything else -> exit 1 (method: err)
+ * `c` is closed before exit either way. Use exact-match on the
+ * fully-qualified error name; a substring match would misfire on a
+ * future name that contained one of these as a prefix. */
+static void map_dbus_err(link_client_t *c, const char *method, const char *ident)
+{
+ const link_reply_t *r = link_client_reply(c);
+ char err[128];
+
+ /* The reply view points into c->rxbuf; copy the error name out
+ * before link_client_close() frees the client. Otherwise the
+ * strcmps below read freed memory. */
+ if (r && r->error_name)
+ strlcpy(err, r->error_name, sizeof(err));
+ else
+ err[0] = '\0';
+ link_client_close(c);
+
+ if (!strcmp(err, "org.finit.Error.NoSuchService"))
+ ERRX(noerr ? 0 : 69, "no such task or service(s): %s",
+ ident ? ident : "");
+ if (!strcmp(err, "org.freedesktop.DBus.Error.AccessDenied"))
+ ERRX(1, "permission denied: %s requires root", method);
+ ERRX(1, "%s: %s", method, *err ? err : "D-Bus error");
+}
+
+/* Try the D-Bus path for a Manager1 method. Returns:
+ * 0 succeeded via D-Bus
+ * -1 D-Bus not reachable -- callers should fall back to the
+ * legacy INIT_SOCKET transport
+ * LINK_CALL_ERROR is handled internally via map_dbus_err (does not
+ * return). */
+static int try_dbus_manager(const char *method, const char *arg_sig,
+ const char *arg)
+{
+ link_client_t *c;
+ int rc;
+
+ c = link_client_open(FINIT_BUS_SOCKET);
+ if (!c)
+ return -1;
+
+ /* "s" methods take the service identity (arg may be NULL ->
+ * empty string); void methods pass no body. */
+ if (arg_sig && !strcmp(arg_sig, "s"))
+ rc = link_client_call_v(c, "/org/finit/manager",
+ "org.finit.Manager1", method,
+ "s", arg ? arg : "");
+ else if (!arg_sig || !*arg_sig)
+ rc = link_client_call_v(c, "/org/finit/manager",
+ "org.finit.Manager1", method, NULL);
+ else
+ rc = LINK_CALL_FAIL;
+
+ if (rc == LINK_CALL_ERROR)
+ map_dbus_err(c, method, arg); /* exits */
+ link_client_close(c);
+ return (rc == LINK_CALL_OK) ? 0 : -1;
+}
+
+/* Call a void-arg method on Service1 at /org/finit/service/.
+ * Same return convention as try_dbus_manager. */
+static int try_dbus_service(const char *method, const char *ident)
+{
+ char path[256];
+ const char *prefix = "/org/finit/service/";
+ size_t plen = strlen(prefix);
+ link_client_t *c;
+ int rc;
+
+ if (!ident || !*ident)
+ return -1;
+ memcpy(path, prefix, plen);
+ if (link_path_encode(ident, path + plen, sizeof(path) - plen) < 0)
+ return -1;
+
+ c = link_client_open(FINIT_BUS_SOCKET);
+ if (!c)
+ return -1;
+
+ rc = link_client_call_v(c, path, "org.finit.Service1", method, NULL);
+ if (rc == LINK_CALL_ERROR)
+ map_dbus_err(c, method, ident); /* exits */
+ link_client_close(c);
+ return (rc == LINK_CALL_OK) ? 0 : -1;
+}
+
+/* Try one Cond1.{Get,Set,Clear} call. On COND_GET success the helper
+ * fills *out_exit with the exit code (0 = on, 1 = off, 255 = flux).
+ * Outcomes:
+ * 1 call succeeded; for GET the result is in *out_exit, for
+ * SET/CLR the caller loops to the next arg
+ * 0 bus not reachable, or LINK_CALL_FAIL -- *bus is closed/NULLed
+ * and the caller should drop to the legacy filesystem path
+ * (LINK_CALL_ERROR exits via ERRX inside the helper)
+ *
+ * `*bus` is borrowed; the helper closes it (and sets NULL) on every
+ * exit path that leaves the bus unusable. */
+static int cond_dbus_call(link_client_t **bus, condop_t op,
+ const char *arg, int *out_exit)
+{
+ const char *method = (op == COND_GET) ? "Get"
+ : (op == COND_SET) ? "Set" : "Clear";
+ int rc;
+
+ if (!*bus)
+ return 0;
+
+ rc = link_client_call_v(*bus, "/org/finit/cond",
+ "org.finit.Cond1", method,
+ "s", arg);
+ if (rc == LINK_CALL_OK) {
+ if (op == COND_GET) {
+ const char *state = NULL;
+
+ link_reply_get_string(link_client_reply(*bus), &state);
+ if (verbose && state)
+ puts(state);
+ *out_exit = (state && !strcmp(state, "on")) ? 0
+ : (state && !strcmp(state, "off")) ? 1 : 255;
+ }
+ return 1;
+ }
+ if (rc == LINK_CALL_ERROR) {
+ const link_reply_t *r = link_client_reply(*bus);
+ const char *err = (r && r->error_name) ? r->error_name : "";
+
+ link_client_close(*bus);
+ *bus = NULL;
+ if (!strcmp(err, "org.freedesktop.DBus.Error.AccessDenied"))
+ ERRX(1, "permission denied: cond %s requires root", method);
+ ERRX(73, "Failed %s condition <%s>: %s",
+ op == COND_SET ? "asserting" : "deasserting",
+ arg, *err ? err : "D-Bus error");
+ }
+ /* LINK_CALL_FAIL */
+ link_client_close(*bus);
+ *bus = NULL;
+ return 0;
+}
+
+/* Subscribe to every signal on the bus and print one line per
+ * incoming message:
+ * HH:MM:SS interface.member(arg1, arg2, ...)
+ *
+ * Only string-typed leading args are decoded (matches what our two
+ * current signals -- ServiceStateChanged (sss) and ConditionChanged
+ * (ss) -- emit). Non-string args are silently skipped. Loops until
+ * the connection drops or the user hits ^C. */
+static int do_monitor(char *arg)
+{
+ link_client_t *c;
+ int rc;
+
+ (void)arg;
+
+ c = link_client_open(FINIT_BUS_SOCKET);
+ if (!c)
+ ERRX(1, "monitor requires the D-Bus socket at %s", FINIT_BUS_SOCKET);
+
+ rc = link_client_call_v(c, "/org/freedesktop/DBus",
+ "org.freedesktop.DBus", "AddMatch",
+ "s", "type='signal'");
+ if (rc != LINK_CALL_OK) {
+ link_client_close(c);
+ ERRX(1, "AddMatch failed (rc=%d)", rc);
+ }
+
+ for (;;) {
+ const link_reply_t *r;
+ link_reader_t reader;
+ char ts[16];
+ time_t now;
+ struct tm tm;
+
+ rc = link_client_wait(c, -1);
+ if (rc < 0) {
+ link_client_close(c);
+ ERRX(1, "bus connection lost");
+ }
+ if (rc > 0) /* impossible with timeout=-1, but harmless */
+ continue;
+ r = link_client_reply(c);
+ if (!r || r->type != LINK_MSG_SIGNAL)
+ continue;
+
+ now = time(NULL);
+ localtime_r(&now, &tm);
+ strftime(ts, sizeof(ts), "%H:%M:%S", &tm);
+ printf("%s %s.%s(", ts,
+ r->interface ? r->interface : "?",
+ r->member ? r->member : "?");
+
+ link_reader_init(&reader, r->body, r->body_len);
+ if (r->signature) {
+ const char *p;
+ int first = 1;
+
+ for (p = r->signature; *p == 's'; p++) {
+ const char *s;
+
+ if (link_r_string(&reader, &s) < 0)
+ break;
+ printf("%s%s", first ? "" : ", ", s);
+ first = 0;
+ }
+ }
+ printf(")\n");
+ fflush(stdout);
+ }
+
+ link_client_close(c);
+ return 0;
+}
+#endif /* HAVE_DBUS */
+
+static int do_start (char *arg)
+{
+#ifdef HAVE_DBUS
+ int rc = try_dbus_manager("Start", "s", arg);
+ if (rc >= 0) return rc;
+#endif
+ return do_startstop(INIT_CMD_START_SVC, arg);
+}
+
+static int do_stop (char *arg)
+{
+#ifdef HAVE_DBUS
+ int rc = try_dbus_manager("Stop", "s", arg);
+ if (rc >= 0) return rc;
+#endif
+ return do_startstop(INIT_CMD_STOP_SVC, arg);
+}
static int do_reload (char *arg)
{
- if (!arg || !arg[0])
+ if (!arg || !arg[0]) {
+#ifdef HAVE_DBUS
+ int rc = try_dbus_manager("Reload", "", NULL);
+ if (rc >= 0) return rc;
+#endif
return do_svc(INIT_CMD_RELOAD, NULL);
+ }
+#ifdef HAVE_DBUS
+ {
+ int rc = try_dbus_service("Reload", arg);
+ if (rc >= 0) return rc;
+ }
+#endif
return do_startstop(INIT_CMD_RELOAD_SVC, arg);
}
static int do_restart(char *arg)
{
+#ifdef HAVE_DBUS
+ int rc = try_dbus_manager("Restart", "s", arg);
+ if (rc == 0) return 0;
+ if (rc == 1) ERRX(noerr ? 0 : 7, "failed restarting %s", arg);
+#endif
if (do_startstop(INIT_CMD_RESTART_SVC, arg))
ERRX(noerr ? 0 : 7, "failed restarting %s", arg);
@@ -308,10 +658,6 @@ int do_signal(int argc, char *argv[])
if (argc != 2)
ERRX(2, "invalid number of arguments to signal");
- strlcpy(rq.data, argv[0], sizeof(rq.data));
- if (client_send(&rq, sizeof(rq)))
- ERRX(noerr ? 0 : 69, "no such task or service(s): %s", argv[0]);
-
signo = str2sig(argv[1]);
if (signo == -1) {
const char *errstr = NULL;
@@ -321,6 +667,29 @@ int do_signal(int argc, char *argv[])
ERRX(65, "%s signal: %s", errstr, argv[1]);
}
+#ifdef HAVE_DBUS
+ {
+ link_client_t *c = link_client_open(FINIT_BUS_SOCKET);
+
+ if (c) {
+ int rc = link_client_call_v(c, "/org/finit/manager",
+ "org.finit.Manager1", "Signal",
+ "su", argv[0], (uint32_t)signo);
+
+ if (rc == LINK_CALL_ERROR)
+ map_dbus_err(c, "Signal", argv[0]); /* exits */
+ link_client_close(c);
+ if (rc == LINK_CALL_OK)
+ return 0;
+ /* LINK_CALL_FAIL: drop to legacy */
+ }
+ }
+#endif
+
+ strlcpy(rq.data, argv[0], sizeof(rq.data));
+ if (client_send(&rq, sizeof(rq)))
+ ERRX(noerr ? 0 : 69, "no such task or service(s): %s", argv[0]);
+
/* Reuse runlevel for signal number. */
rq.magic = INIT_MAGIC;
rq.cmd = INIT_CMD_SIGNAL;
@@ -408,8 +777,6 @@ static int do_cond_dump(char *arg)
return 0;
}
-typedef enum { COND_CLR, COND_SET, COND_GET } condop_t;
-
static cond_state_t cond_read(char *path)
{
int now, gen;
@@ -439,6 +806,9 @@ static int do_cond_act(char *args, condop_t op)
cond_state_t cstate;
char path[256];
char *arg;
+#ifdef HAVE_DBUS
+ link_client_t *bus = link_client_open(FINIT_BUS_SOCKET);
+#endif
if (!args || !args[0])
ERRX(2, "Invalid condition (empty)");
@@ -458,6 +828,22 @@ static int do_cond_act(char *args, condop_t op)
ERRX(2, "Invalid condition (periods)");
}
+#ifdef HAVE_DBUS
+ {
+ int exit_code;
+
+ if (cond_dbus_call(&bus, op, arg, &exit_code)) {
+ if (op == COND_GET) {
+ link_client_close(bus);
+ return exit_code;
+ }
+ arg = strtok(NULL, " \t");
+ continue;
+ }
+ /* bus is NULL now -- drop through to legacy */
+ }
+#endif
+
if (strchr(arg, '/'))
snprintf(path, sizeof(path), _PATH_COND "%s", arg);
else
@@ -494,6 +880,10 @@ static int do_cond_act(char *args, condop_t op)
arg = strtok(NULL, " \t");
}
+#ifdef HAVE_DBUS
+ if (bus)
+ link_client_close(bus);
+#endif
return 0;
}
@@ -641,10 +1031,57 @@ static int do_cmd(int cmd)
return 0;
}
-int do_reboot (char *arg) { return do_cmd(INIT_CMD_REBOOT); }
-int do_halt (char *arg) { return do_cmd(INIT_CMD_HALT); }
-int do_poweroff(char *arg) { return do_cmd(INIT_CMD_POWEROFF); }
-int do_suspend (char *arg) { return do_cmd(INIT_CMD_SUSPEND); }
+#ifdef HAVE_DBUS
+static int do_reboot_dbus(const char *method)
+{
+ int rc = try_dbus_manager(method, "", NULL);
+
+ if (rc == 0) {
+ sleep(5); /* match legacy: wait for finit to shut down */
+ return 0;
+ }
+ return rc; /* 1 = error, -1 = fall back */
+}
+#endif
+
+int do_reboot (char *arg)
+{
+#ifdef HAVE_DBUS
+ int rc = do_reboot_dbus("Reboot");
+ if (rc >= 0) return rc;
+#endif
+ return do_cmd(INIT_CMD_REBOOT);
+}
+
+int do_halt (char *arg)
+{
+#ifdef HAVE_DBUS
+ int rc = do_reboot_dbus("Halt");
+ if (rc >= 0) return rc;
+#endif
+ return do_cmd(INIT_CMD_HALT);
+}
+
+int do_poweroff(char *arg)
+{
+#ifdef HAVE_DBUS
+ int rc = do_reboot_dbus("Poweroff");
+ if (rc >= 0) return rc;
+#endif
+ return do_cmd(INIT_CMD_POWEROFF);
+}
+
+int do_suspend(char *arg)
+{
+ (void)arg;
+#ifdef HAVE_DBUS
+ {
+ int rc = try_dbus_manager("Suspend", "", NULL);
+ if (rc >= 0) return rc;
+ }
+#endif
+ return do_cmd(INIT_CMD_SUSPEND);
+}
/**
* do_switch_root - Switch to a new root filesystem (initramfs only)
@@ -1570,6 +2007,9 @@ static int usage(int rc)
" Note: Finit .conf file(s) are *not* reloaded!\n"
" restart [:ID] Restart (stop/start) service by name\n"
" kill [:ID] Send signal S to service by name, with optional ID\n"
+#ifdef HAVE_DBUS
+ " monitor Stream D-Bus signals (service state, conditions) until ^C\n"
+#endif
" ident [NAME] Show matching identities for NAME, or all\n"
" status [:ID] Show service status, by name\n"
" status Show status of services, default command\n");
@@ -1754,6 +2194,9 @@ int main(int argc, char *argv[])
{ "start", NULL, do_start, NULL, NULL },
{ "stop", NULL, do_stop, NULL, NULL },
{ "restart", NULL, do_restart, NULL, NULL },
+#ifdef HAVE_DBUS
+ { "monitor", NULL, do_monitor, NULL, NULL },
+#endif
{ "signal", NULL, NULL, NULL, do_signal },
{ "kill", NULL, NULL, NULL, do_signal }, /* alias */
diff --git a/src/private.h b/src/private.h
index 1612803b..ae501161 100644
--- a/src/private.h
+++ b/src/private.h
@@ -45,6 +45,16 @@ extern uev_ctx_t *ctx;
int api_init (uev_ctx_t *ctx);
int api_exit (void);
+
+#ifdef HAVE_DBUS
+int dbus_init (uev_ctx_t *ctx);
+int dbus_exit (void);
+void dbus_register_service (svc_t *svc);
+void dbus_unregister_service (svc_t *svc);
+void dbus_notify_service_state (svc_t *svc, int old_state, int new_state);
+void dbus_notify_condition_change(const char *name, const char *state);
+void dbus_notify_runlevel_change(int old_level, int new_level);
+#endif
void conf_flush_events(void);
void service_monitor (pid_t lost, int status);
diff --git a/src/service.c b/src/service.c
index 127e0099..ecabbc77 100644
--- a/src/service.c
+++ b/src/service.c
@@ -1328,17 +1328,67 @@ int service_stop(svc_t *svc)
}
/**
- * service_reload - Reload a service
+ * service_reload - Request reload of a service, driven by the state machine
* @svc: Service to reload
*
- * This function does some basic checks of the runtime state of Finit
- * and a sanity check of the @svc before sending %SIGHUP or calling
- * the reload:script command.
+ * Mark a service dirty so the state machine will (re-)apply its
+ * configuration, then advance the state machine. For services that
+ * don't handle SIGHUP this also clears the readiness condition and
+ * the pidfile/none-notify readiness flag so dependents are properly
+ * notified once the service comes back.
+ *
+ * Returns:
+ * POSIX OK(0) on success, non-zero if @svc is NULL.
+ */
+int service_reload(svc_t *svc)
+{
+ char cond[MAX_COND_LEN];
+
+ if (!svc)
+ return 1;
+
+ if (svc_is_blocked(svc))
+ svc_start(svc);
+ else
+ service_timeout_cancel(svc);
+
+ /*
+ * Clear conditions before reload to ensure dependent services
+ * are properly updated. Only needed when the service does NOT
+ * support SIGHUP (noreload), because then it will be stopped
+ * and restarted, so conditions genuinely go away. When the
+ * service handles SIGHUP, its PID and pidfile persist, so the
+ * condition stays valid and dependents should not be disrupted.
+ *
+ * Note: only clear 'ready' for services where the pidfile
+ * inotify handler reasserts it (pid/none). For s6/systemd
+ * services readiness relies on their respective notification
+ * mechanism which may not re-trigger on SIGHUP.
+ */
+ if (svc_is_noreload(svc)) {
+ cond_clear(mkcond(svc, cond, sizeof(cond)));
+ if (svc->notify == SVC_NOTIFY_PID || svc->notify == SVC_NOTIFY_NONE)
+ service_ready(svc, 0);
+ }
+
+ svc_mark_dirty(svc);
+ service_step(svc);
+
+ return 0;
+}
+
+/**
+ * service_reload_apply - Perform the actual reload of a running service
+ * @svc: Service to reload
+ *
+ * Low-level reload step: sends %SIGHUP or runs the reload:script
+ * command. Called by the state machine when a service marked
+ * reload-pending by service_reload() reaches the right state.
*
* Returns:
* POSIX OK(0) or non-zero on error.
*/
-static int service_reload(svc_t *svc)
+static int service_reload_apply(svc_t *svc)
{
const char *id = svc_ident(svc, NULL, 0);
int do_progress = 1;
@@ -2864,6 +2914,10 @@ static void svc_set_state(svc_t *svc, svc_state_t new_state)
return;
*state = new_state;
+#ifdef HAVE_DBUS
+ dbus_notify_service_state(svc, old_state, new_state);
+#endif
+
if (svc_is_runtask(svc)) {
char success[MAX_COND_LEN], failure[MAX_COND_LEN];
@@ -3225,7 +3279,7 @@ int service_step(svc_t *svc)
if (sm_in_reload())
break;
- service_reload(svc);
+ service_reload_apply(svc);
}
svc_mark_clean(svc);
@@ -3263,7 +3317,7 @@ int service_step(svc_t *svc)
if (svc_is_noreload(svc))
service_stop(svc);
else
- service_reload(svc);
+ service_reload_apply(svc);
break;
}
diff --git a/src/service.h b/src/service.h
index 5efc2354..9c50de9e 100644
--- a/src/service.h
+++ b/src/service.h
@@ -43,6 +43,7 @@ void service_forked (svc_t *svc);
void service_ready (svc_t *svc, int ready);
int service_stop (svc_t *svc);
+int service_reload (svc_t *svc);
int service_step (svc_t *svc);
void service_step_all (int types);
void service_worker (void *unused);
diff --git a/src/sm.c b/src/sm.c
index ab3abdb9..44d0972d 100644
--- a/src/sm.c
+++ b/src/sm.c
@@ -388,6 +388,9 @@ void sm_step(void)
prevlevel = runlevel;
runlevel = sm.newlevel;
sm.newlevel = -1;
+#ifdef HAVE_DBUS
+ dbus_notify_runlevel_change(prevlevel, runlevel);
+#endif
/* Restore terse mode and run hooks before shutdown */
if (runlevel == 0 || runlevel == 6) {
diff --git a/src/svc.c b/src/svc.c
index 272c48f1..c756697d 100644
--- a/src/svc.c
+++ b/src/svc.c
@@ -38,6 +38,7 @@
#include "finit.h"
#include "conf.h"
+#include "private.h"
#include "svc.h"
#include "helpers.h"
#include "pid.h"
@@ -156,6 +157,10 @@ svc_t *svc_new(char *cmd, char *name, char *id, int type)
TAILQ_INSERT_TAIL(&svc_list, svc, link);
+#ifdef HAVE_DBUS
+ dbus_register_service(svc);
+#endif
+
return svc;
}
@@ -173,6 +178,10 @@ static struct wq work = {
*/
int svc_del(svc_t *svc)
{
+#ifdef HAVE_DBUS
+ dbus_unregister_service(svc);
+#endif
+
TAILQ_REMOVE(&svc_list, svc, link);
TAILQ_INSERT_TAIL(&gc_list, svc, link);
diff --git a/test/Makefile.am b/test/Makefile.am
index da6eb905..be9b0947 100644
--- a/test/Makefile.am
+++ b/test/Makefile.am
@@ -62,6 +62,12 @@ EXTRA_DIST += start-stop-serv.sh
EXTRA_DIST += signal-service.sh
EXTRA_DIST += testserv.sh
EXTRA_DIST += unexpected-restart.sh
+EXTRA_DIST += dbus-auth.sh
+EXTRA_DIST += dbus-bus.sh
+EXTRA_DIST += dbus-manager.sh
+EXTRA_DIST += dbus-service.sh
+EXTRA_DIST += dbus-cond.sh
+EXTRA_DIST += dbus-initctl.sh
AM_TESTS_ENVIRONMENT = SYSROOT='$(abs_builddir)/sysroot/';
AM_TESTS_ENVIRONMENT += export SYSROOT;
@@ -108,6 +114,14 @@ if TESTSERV
TESTS += testserv.sh
endif
TESTS += unexpected-restart.sh
+if DBUS
+TESTS += dbus-auth.sh
+TESTS += dbus-bus.sh
+TESTS += dbus-manager.sh
+TESTS += dbus-service.sh
+TESTS += dbus-cond.sh
+TESTS += dbus-initctl.sh
+endif
check-recursive: setup-chroot
diff --git a/test/check.sh b/test/check.sh
index 3c847f56..c12a2614 100755
--- a/test/check.sh
+++ b/test/check.sh
@@ -25,8 +25,8 @@ if [ "$run_make" -eq 1 ]; then
fi
./configure --prefix=/usr --exec-prefix= --sysconfdir=/etc --localstatedir=/var \
- --enable-x11-common-plugin --enable-testserv-plugin --with-watchdog \
- --with-keventd --with-libsystemd \
+ --enable-dbus --enable-x11-common-plugin --enable-testserv-plugin \
+ --with-watchdog --with-keventd --with-libsystemd \
CFLAGS='-fsanitize=address -ggdb'
if [ "$run_make" -eq 1 ]; then
diff --git a/test/dbus-auth.sh b/test/dbus-auth.sh
new file mode 100755
index 00000000..1e9535d3
--- /dev/null
+++ b/test/dbus-auth.sh
@@ -0,0 +1,43 @@
+#!/bin/sh
+# libink: D-Bus AUTH EXTERNAL handshake.
+#
+# Verifies the SASL handshake itself in isolation -- everything else
+# the bus does (built-in DBus interface, vtables, signals, initctl
+# routing) lives in the other dbus-*.sh tests.
+
+set -eu
+
+TEST_DIR=$(dirname "$0")
+
+# shellcheck source=/dev/null
+. "$TEST_DIR/lib/setup.sh"
+# shellcheck source=/dev/null
+. "$TEST_DIR/lib/dbus-setup.sh"
+
+say "Socket mode is 0666"
+mode=$(texec stat -c %a "$BUS")
+assert "Socket mode is 666 (got $mode)" "$mode" = "666"
+
+say "AUTH EXTERNAL: claim correct UID (root = 0)"
+reply=$(texec "$CLIENT" auth "$BUS" 0)
+assert "Reply starts with OK (got: $reply)" "${reply%% *}" = "OK"
+
+guid=${reply#OK }
+assert "GUID is 32 hex chars (got: $guid)" \
+ "$(printf '%s' "$guid" | tr -d '0-9a-f' | wc -c)" -eq 0
+assert "GUID length is 32 (got: ${#guid})" "${#guid}" -eq 32
+
+say "AUTH EXTERNAL: wrong UID is rejected"
+set +e
+wrong_reply=$(texec "$CLIENT" auth "$BUS" 1)
+wrong_rc=$?
+set -e
+assert "Wrong UID rejected (rc=$wrong_rc, reply: $wrong_reply)" \
+ "$wrong_rc" -eq 1
+
+say "Two sequential AUTH connections get different GUIDs"
+r1=$(texec "$CLIENT" auth "$BUS" 0)
+r2=$(texec "$CLIENT" auth "$BUS" 0)
+g1=${r1#OK }
+g2=${r2#OK }
+assert "Per-connection GUIDs differ ($g1 vs $g2)" "$g1" != "$g2"
diff --git a/test/dbus-bus.sh b/test/dbus-bus.sh
new file mode 100755
index 00000000..7bd53704
--- /dev/null
+++ b/test/dbus-bus.sh
@@ -0,0 +1,62 @@
+#!/bin/sh
+# libink: org.freedesktop.DBus built-in interface.
+#
+# Covers the stock D-Bus interface every conforming bus implements:
+# Hello (peer name allocation), Introspect (XML), and AddMatch's
+# error-path rule parser.
+
+set -eu
+
+TEST_DIR=$(dirname "$0")
+
+# shellcheck source=/dev/null
+. "$TEST_DIR/lib/setup.sh"
+# shellcheck source=/dev/null
+. "$TEST_DIR/lib/dbus-setup.sh"
+
+say "Hello() returns a unique name beginning with ':1.'"
+name=$(texec "$CLIENT" hello "$BUS")
+case "$name" in
+ :1.*) assert "Hello returned a :1.N name (got $name)" 0 -eq 0 ;;
+ *) fail "Hello returned unexpected name: $name" ;;
+esac
+
+say "Two Hello() calls produce different unique names"
+n1=$(texec "$CLIENT" hello "$BUS")
+n2=$(texec "$CLIENT" hello "$BUS")
+assert "Unique names increment ($n1 vs $n2)" "$n1" != "$n2"
+
+say "Introspect on root path returns valid XML"
+xml=$(texec "$CLIENT" introspect "$BUS" /)
+case "$xml" in
+ *' root (good)" 0 -eq 0 ;;
+ *) fail "Root introspect missing : $xml" ;;
+esac
+
+say "Introspect on /org/finit/manager exposes Manager1.ListServices"
+xml=$(texec "$CLIENT" introspect "$BUS" /org/finit/manager)
+case "$xml" in
+ *'org.finit.Manager1'*'ListServices'*)
+ assert "Manager1 and ListServices visible in XML" 0 -eq 0 ;;
+ *)
+ fail "Manager1 XML missing; got: $xml" ;;
+esac
+
+say "AddMatch with a bogus key is rejected"
+set +e
+texec "$CLIENT" call-s "$BUS" /org/freedesktop/DBus \
+ org.freedesktop.DBus AddMatch "bogus='whatever'" >/tmp/dbus-match.out 2>&1
+am_rc=$?
+set -e
+assert "Bad rule rejected (rc=$am_rc)" "$am_rc" -eq 1
+case "$(cat /tmp/dbus-match.out)" in
+ *MatchRuleInvalid*) assert "Error is MatchRuleInvalid" 0 -eq 0 ;;
+ *) fail "Unexpected reply: $(cat /tmp/dbus-match.out)" ;;
+esac
+
+say "Unknown method on a Finit interface gets an org.freedesktop.DBus.Error.* reply"
+set +e
+texec "$CLIENT" unknown "$BUS"
+unknown_rc=$?
+set -e
+assert "Unknown method returned an error (rc=$unknown_rc)" "$unknown_rc" -eq 0
diff --git a/test/dbus-cond.sh b/test/dbus-cond.sh
new file mode 100755
index 00000000..f0e55da1
--- /dev/null
+++ b/test/dbus-cond.sh
@@ -0,0 +1,69 @@
+#!/bin/sh
+# libink: org.finit.Cond1 vtable + ConditionChanged signal.
+#
+# Covers user-condition manipulation: Get, Set (with signal fan-out),
+# the usr/* policy guard (non-usr conditions are rejected), and the
+# non-root authorization gate.
+
+set -eu
+
+TEST_DIR=$(dirname "$0")
+
+# shellcheck source=/dev/null
+. "$TEST_DIR/lib/setup.sh"
+# shellcheck source=/dev/null
+. "$TEST_DIR/lib/dbus-setup.sh"
+
+say "Cond1.Get returns 'off' for an unset condition"
+result=$(texec "$CLIENT" call-s "$BUS" /org/finit/cond \
+ org.finit.Cond1 Get "no-such-cond")
+case "$result" in
+ OK*) : ;; # ok, the cond reports a state, fall through
+ *) fail "Cond1.Get failed: $result" ;;
+esac
+
+say "Cond1.Set fires Cond1.ConditionChanged and Get reflects the change"
+rm -f /tmp/dbus-cond.out
+( texec "$CLIENT" monitor-signal "$BUS" \
+ "type='signal',interface='org.finit.Cond1',member='ConditionChanged'" \
+ 5000 > /tmp/dbus-cond.out 2>&1 ) &
+cond_mon_pid=$!
+sleep 0.5
+texec "$CLIENT" call-s "$BUS" /org/finit/cond \
+ org.finit.Cond1 Set "dbus-test-cond" >/dev/null \
+ || fail "Cond1.Set returned non-zero"
+set +e
+wait "$cond_mon_pid"
+cond_mon_rc=$?
+set -e
+assert "Cond1 monitor saw a signal (rc=$cond_mon_rc)" "$cond_mon_rc" -eq 0
+case "$(cat /tmp/dbus-cond.out)" in
+ *"SIGNAL org.finit.Cond1 ConditionChanged"*"usr/dbus-test-cond"*on*)
+ assert "ConditionChanged carries usr/dbus-test-cond and 'on'" 0 -eq 0 ;;
+ *)
+ fail "Unexpected Cond1 signal: $(cat /tmp/dbus-cond.out)" ;;
+esac
+
+say "Cond1.Set/Clear on non-usr/* is rejected"
+set +e
+texec "$CLIENT" call-s "$BUS" /org/finit/cond \
+ org.finit.Cond1 Set "pid/sshd" >/tmp/dbus-condrej.out 2>&1
+condrej_rc=$?
+set -e
+assert "pid/* rejected (rc=$condrej_rc)" "$condrej_rc" -eq 1
+case "$(cat /tmp/dbus-condrej.out)" in
+ *InvalidArgs*) assert "Error is InvalidArgs" 0 -eq 0 ;;
+ *) fail "Unexpected reply: $(cat /tmp/dbus-condrej.out)" ;;
+esac
+
+say "Cond1.Set from non-root is rejected with AccessDenied"
+set +e
+texec "$CLIENT" call-s-as-uid 1 "$BUS" /org/finit/cond \
+ org.finit.Cond1 Set "would-be-cond" >/tmp/dbus-condauthz.out 2>&1
+ca_rc=$?
+set -e
+assert "Non-root Cond1.Set rejected (rc=$ca_rc)" "$ca_rc" -eq 1
+case "$(cat /tmp/dbus-condauthz.out)" in
+ *AccessDenied*) assert "Cond1 authz fires" 0 -eq 0 ;;
+ *) fail "Unexpected reply: $(cat /tmp/dbus-condauthz.out)" ;;
+esac
diff --git a/test/dbus-initctl.sh b/test/dbus-initctl.sh
new file mode 100755
index 00000000..2a1b47c1
--- /dev/null
+++ b/test/dbus-initctl.sh
@@ -0,0 +1,96 @@
+#!/bin/sh
+# initctl: confirms the legacy CLI now routes through D-Bus.
+#
+# Subscribes to ServiceStateChanged on a background monitor and then
+# runs initctl -- if D-Bus is in use, the signal fires. If the legacy
+# socket were still in use, the dbus subscriber would see nothing.
+
+set -eu
+
+TEST_DIR=$(dirname "$0")
+
+# shellcheck source=/dev/null
+. "$TEST_DIR/lib/setup.sh"
+# shellcheck source=/dev/null
+. "$TEST_DIR/lib/dbus-setup.sh"
+
+say "initctl restart drives D-Bus (signal observed via dbus-auth-client)"
+rm -f /tmp/dbus-initctl-sig.out
+( texec "$CLIENT" monitor-signal "$BUS" \
+ "type='signal',interface='org.finit.Manager1',member='ServiceStateChanged'" \
+ 5000 > /tmp/dbus-initctl-sig.out 2>&1 ) &
+ic_pid=$!
+sleep 0.5
+texec initctl restart keventd >/dev/null \
+ || fail "initctl restart returned non-zero"
+set +e
+wait "$ic_pid"
+ic_rc=$?
+set -e
+assert "ServiceStateChanged fired from initctl restart (rc=$ic_rc)" \
+ "$ic_rc" -eq 0
+case "$(cat /tmp/dbus-initctl-sig.out)" in
+ *"SIGNAL org.finit.Manager1 ServiceStateChanged"*keventd*)
+ assert "initctl restart routed through D-Bus" 0 -eq 0 ;;
+ *)
+ fail "initctl restart didn't produce expected signal: $(cat /tmp/dbus-initctl-sig.out)" ;;
+esac
+
+say "initctl reload (no args) routes through Manager1.Reload"
+texec initctl reload >/dev/null \
+ || fail "initctl reload returned non-zero"
+assert "initctl reload ok" 0 -eq 0
+
+# A ConditionChanged signal can only originate from Cond1.Set going
+# through finit (the legacy filesystem path doesn't emit signals).
+# So if the monitor sees one, we know initctl cond set was routed
+# via D-Bus.
+say "initctl cond set drives Cond1.Set via D-Bus"
+rm -f /tmp/dbus-initctl-cond.out
+( texec "$CLIENT" monitor-signal "$BUS" \
+ "type='signal',interface='org.finit.Cond1',member='ConditionChanged'" \
+ 5000 > /tmp/dbus-initctl-cond.out 2>&1 ) &
+ic_cond_pid=$!
+sleep 0.5
+texec initctl cond set "via-initctl" >/dev/null \
+ || fail "initctl cond set returned non-zero"
+set +e
+wait "$ic_cond_pid"
+ic_cond_rc=$?
+set -e
+assert "ConditionChanged fired from initctl cond set (rc=$ic_cond_rc)" \
+ "$ic_cond_rc" -eq 0
+case "$(cat /tmp/dbus-initctl-cond.out)" in
+ *"SIGNAL org.finit.Cond1 ConditionChanged"*"usr/via-initctl"*on*)
+ assert "initctl cond set routed through D-Bus" 0 -eq 0 ;;
+ *)
+ fail "initctl cond set didn't produce expected signal: $(cat /tmp/dbus-initctl-cond.out)" ;;
+esac
+
+# ---------- arc B: signal + reload routing ----------
+
+# initctl signal exits 0 iff the bus call succeeded. We send SIGCONT
+# (no observable side-effect on a healthy daemon) so the test stays
+# benign regardless of how keventd handles it.
+say "initctl signal routes through Manager1.Signal"
+texec initctl signal keventd CONT >/dev/null \
+ || fail "initctl signal returned non-zero"
+assert "initctl signal ok" 0 -eq 0
+
+say "initctl signal on a bogus identity reports NoSuchService"
+set +e
+texec initctl signal no-such-svc-anywhere CONT >/tmp/dbus-sig-bad.out 2>&1
+sigbad_rc=$?
+set -e
+assert "Bogus signal target rejected (rc=$sigbad_rc)" "$sigbad_rc" -ne 0
+case "$(cat /tmp/dbus-sig-bad.out)" in
+ *"no such task or service"*)
+ assert "Error message mentions missing service" 0 -eq 0 ;;
+ *)
+ fail "Unexpected initctl signal output: $(cat /tmp/dbus-sig-bad.out)" ;;
+esac
+
+say "initctl reload routes through Service1.Reload"
+texec initctl reload keventd >/dev/null \
+ || fail "initctl reload keventd returned non-zero"
+assert "Per-service reload ok" 0 -eq 0
diff --git a/test/dbus-manager.sh b/test/dbus-manager.sh
new file mode 100755
index 00000000..c3bdce33
--- /dev/null
+++ b/test/dbus-manager.sh
@@ -0,0 +1,135 @@
+#!/bin/sh
+# libink: org.finit.Manager1 vtable.
+#
+# Covers the Manager1 method surface: ListServices, Reload, Stop with
+# bogus service, plus per-method authorization (Restart from non-root
+# is rejected, ListServices remains reachable as non-root).
+
+set -eu
+
+TEST_DIR=$(dirname "$0")
+
+# shellcheck source=/dev/null
+. "$TEST_DIR/lib/setup.sh"
+# shellcheck source=/dev/null
+. "$TEST_DIR/lib/dbus-setup.sh"
+
+say "Manager1.ListServices returns the running services"
+list=$(texec "$CLIENT" liststrings "$BUS" /org/finit/manager \
+ org.finit.Manager1 ListServices)
+assert "ListServices returned at least one service" \
+ "$(printf '%s' "$list" | wc -l | tr -d ' ')" -ge 1
+echo "$list"
+
+say "Manager1.Reload (void) succeeds"
+texec "$CLIENT" call-void "$BUS" /org/finit/manager \
+ org.finit.Manager1 Reload >/dev/null \
+ || fail "Reload returned non-zero"
+assert "Reload void method ok" 0 -eq 0
+
+say "Manager1.Stop with bogus identity returns NoSuchService error"
+set +e
+texec "$CLIENT" call-s "$BUS" /org/finit/manager \
+ org.finit.Manager1 Stop "no-such-service-here" >/tmp/dbus-stop.out 2>&1
+stop_rc=$?
+set -e
+assert "Bogus service rejected (rc=$stop_rc)" "$stop_rc" -eq 1
+case "$(cat /tmp/dbus-stop.out)" in
+ *NoSuchService*) assert "Error is NoSuchService" 0 -eq 0 ;;
+ *) fail "Unexpected error reply: $(cat /tmp/dbus-stop.out)" ;;
+esac
+
+say "Manager1.Restart from non-root is rejected with AccessDenied"
+set +e
+texec "$CLIENT" call-s-as-uid 1 "$BUS" /org/finit/manager \
+ org.finit.Manager1 Restart "testserv" >/tmp/dbus-authz.out 2>&1
+authz_rc=$?
+set -e
+assert "Non-root Restart rejected (rc=$authz_rc)" "$authz_rc" -eq 1
+case "$(cat /tmp/dbus-authz.out)" in
+ *AccessDenied*) assert "Error is AccessDenied" 0 -eq 0 ;;
+ *) fail "Unexpected error: $(cat /tmp/dbus-authz.out)" ;;
+esac
+
+# Send call-s-as-uid an "s" body where the server expects "" -- the
+# server must reply with org.freedesktop.DBus.Error.InvalidArgs.
+# Asserting that *positive* marker (not just "no AccessDenied")
+# ensures we don't silently pass if setuid() failed or the client
+# never reached the server (a transport error would print neither
+# AccessDenied nor InvalidArgs).
+say "Manager1.ListServices is reachable as non-root (not blocked by authz)"
+set +e
+result=$(texec "$CLIENT" call-s-as-uid 1 "$BUS" /org/finit/manager \
+ org.finit.Manager1 ListServices "" 2>&1)
+set -e
+case "$result" in
+ *AccessDenied*) fail "Non-root ListServices rejected by authz: $result" ;;
+ *InvalidArgs*) assert "Non-root reached signature check (InvalidArgs, not AccessDenied)" 0 -eq 0 ;;
+ *) fail "Unexpected reply from non-root ListServices: $result" ;;
+esac
+
+# ---------- Properties ----------
+
+say "Introspect on /org/finit/manager advertises org.freedesktop.DBus.Properties"
+xml=$(texec "$CLIENT" introspect "$BUS" /org/finit/manager)
+case "$xml" in
+ *'org.freedesktop.DBus.Properties'*) assert "Properties interface in XML" 0 -eq 0 ;;
+ *) fail "Properties interface missing from XML" ;;
+esac
+
+say "Manager1 declares Runlevel + Version as in introspection XML"
+case "$xml" in
+ *'/dev/null \
+ || fail "SetDebug returned non-zero"
+# Toggle back so this test leaves debug in the same state we found it
+texec "$CLIENT" call-void "$BUS" /org/finit/manager \
+ org.finit.Manager1 SetDebug >/dev/null || true
+assert "SetDebug round-trip ok" 0 -eq 0
+
+say "Manager1.SetDebug from non-root is rejected with AccessDenied"
+set +e
+texec "$CLIENT" call-void-as-uid 1 "$BUS" /org/finit/manager \
+ org.finit.Manager1 SetDebug >/tmp/dbus-setdbg.out 2>&1
+sdbg_rc=$?
+set -e
+assert "Non-root SetDebug rejected (rc=$sdbg_rc)" "$sdbg_rc" -eq 1
+case "$(cat /tmp/dbus-setdbg.out)" in
+ *AccessDenied*) assert "SetDebug authz fires" 0 -eq 0 ;;
+ *) fail "Unexpected reply: $(cat /tmp/dbus-setdbg.out)" ;;
+esac
+
+# Suspend would actually suspend the test sysroot if it succeeded -- so
+# we only test the non-root rejection path, which fails before suspend()
+# is called.
+say "Manager1.Suspend from non-root is rejected with AccessDenied"
+set +e
+texec "$CLIENT" call-void-as-uid 1 "$BUS" /org/finit/manager \
+ org.finit.Manager1 Suspend >/tmp/dbus-susp.out 2>&1
+susp_rc=$?
+set -e
+assert "Non-root Suspend rejected (rc=$susp_rc)" "$susp_rc" -eq 1
+case "$(cat /tmp/dbus-susp.out)" in
+ *AccessDenied*) assert "Suspend authz fires" 0 -eq 0 ;;
+ *) fail "Unexpected reply: $(cat /tmp/dbus-susp.out)" ;;
+esac
+
+say "initctl runlevel reads via Properties.Get when D-Bus available"
+# runlevel output format is " ", e.g. "N 2". Just check
+# we get a sensible two-token line.
+rl=$(texec initctl runlevel)
+case "$rl" in
+ [N0-9S]\ [0-9S])
+ assert "initctl runlevel returned '$rl'" 0 -eq 0 ;;
+ *)
+ fail "Unexpected initctl runlevel output: $rl" ;;
+esac
diff --git a/test/dbus-service.sh b/test/dbus-service.sh
new file mode 100755
index 00000000..9099a637
--- /dev/null
+++ b/test/dbus-service.sh
@@ -0,0 +1,71 @@
+#!/bin/sh
+# libink: org.finit.Service1 vtable + ServiceStateChanged signal.
+#
+# Covers per-service objects exposed at /org/finit/service/:
+# GetService lookup, Introspect on a service object, Service1.Restart,
+# authorization (non-root rejected), and the Manager1.ServiceStateChanged
+# signal that Service1.Restart triggers.
+
+set -eu
+
+TEST_DIR=$(dirname "$0")
+
+# shellcheck source=/dev/null
+. "$TEST_DIR/lib/setup.sh"
+# shellcheck source=/dev/null
+. "$TEST_DIR/lib/dbus-setup.sh"
+
+say "Manager1.GetService(keventd) returns the encoded object path"
+path=$(texec "$CLIENT" get-service "$BUS" keventd)
+expected="/org/finit/service/keventd"
+assert "GetService returned expected path (got: $path)" "$path" = "$expected"
+
+say "Introspect on the service object exposes Service1 methods"
+xml=$(texec "$CLIENT" introspect "$BUS" /org/finit/service/keventd)
+case "$xml" in
+ *'org.finit.Service1'*'Restart'*)
+ assert "Service1.Restart visible in service-object XML" 0 -eq 0 ;;
+ *)
+ fail "Service1 not visible on /org/finit/service/keventd: $xml" ;;
+esac
+
+say "Service1.Restart on /org/finit/service/keventd succeeds"
+texec "$CLIENT" call-void "$BUS" /org/finit/service/keventd \
+ org.finit.Service1 Restart >/dev/null \
+ || fail "Service1.Restart returned non-zero"
+assert "Per-service Restart ok" 0 -eq 0
+
+say "Service1.Restart from non-root is rejected with AccessDenied"
+set +e
+texec "$CLIENT" call-void-as-uid 1 "$BUS" /org/finit/service/keventd \
+ org.finit.Service1 Restart >/tmp/dbus-svcauthz.out 2>&1
+svc_authz_rc=$?
+set -e
+assert "Non-root Service1.Restart rejected (rc=$svc_authz_rc)" \
+ "$svc_authz_rc" -eq 1
+case "$(cat /tmp/dbus-svcauthz.out)" in
+ *AccessDenied*) assert "Service1 authz fires" 0 -eq 0 ;;
+ *) fail "Expected AccessDenied, got: $(cat /tmp/dbus-svcauthz.out)" ;;
+esac
+
+say "Service1.Restart fires Manager1.ServiceStateChanged"
+rm -f /tmp/dbus-sig.out
+( texec "$CLIENT" monitor-signal "$BUS" \
+ "type='signal',interface='org.finit.Manager1',member='ServiceStateChanged'" \
+ 5000 > /tmp/dbus-sig.out 2>&1 ) &
+mon_pid=$!
+sleep 0.5
+texec "$CLIENT" call-void "$BUS" /org/finit/service/keventd \
+ org.finit.Service1 Restart >/dev/null \
+ || fail "Restart trigger returned non-zero"
+set +e
+wait "$mon_pid"
+mon_rc=$?
+set -e
+assert "monitor saw a signal (rc=$mon_rc)" "$mon_rc" -eq 0
+case "$(cat /tmp/dbus-sig.out)" in
+ *"SIGNAL org.finit.Manager1 ServiceStateChanged"*keventd*)
+ assert "Signal payload contains the keventd identity" 0 -eq 0 ;;
+ *)
+ fail "Unexpected signal output: $(cat /tmp/dbus-sig.out)" ;;
+esac
diff --git a/test/lib/Makefile.am b/test/lib/Makefile.am
index a281a920..ab85f6a4 100644
--- a/test/lib/Makefile.am
+++ b/test/lib/Makefile.am
@@ -1 +1 @@
-EXTRA_DIST = exec.sh setup.sh start.sh sysroot.mk
+EXTRA_DIST = exec.sh setup.sh start.sh sysroot.mk dbus-setup.sh
diff --git a/test/lib/dbus-setup.sh b/test/lib/dbus-setup.sh
new file mode 100644
index 00000000..5b969da7
--- /dev/null
+++ b/test/lib/dbus-setup.sh
@@ -0,0 +1,20 @@
+# shellcheck shell=sh
+# Shared preamble for the D-Bus smoke tests. Expects test/lib/setup.sh
+# to have been sourced already (so texec, skip, retry, say, assert are
+# available). Skips the test when the libink-driven client was not
+# built, otherwise blocks until the bus socket appears.
+#
+# Exports: CLIENT, BUS.
+
+command -v texec >/dev/null \
+ || { echo "dbus-setup.sh: source test/lib/setup.sh first" >&2; exit 99; }
+
+CLIENT=/sbin/dbus-auth-client
+BUS=/run/finit/bus
+
+if ! texec test -x "$CLIENT"; then
+ skip "dbus-auth-client not built (configured with --disable-dbus?)"
+fi
+
+say "Wait for $BUS to appear"
+retry "texec test -S $BUS"
diff --git a/test/setup-sysroot.sh b/test/setup-sysroot.sh
index d6787f4d..f986125e 100755
--- a/test/setup-sysroot.sh
+++ b/test/setup-sysroot.sh
@@ -15,9 +15,28 @@ make -C "$top_builddir" DESTDIR="$SYSROOT" install
mkdir -p "$SYSROOT/sbin/"
cp "$top_builddir/test/src/serv" "$SYSROOT/sbin/"
+# Prefer the real ELF in .libs/ over the libtool wrapper script --
+# since the test client links libink.la, libtool wraps the top-level
+# dbus-auth-client as a shell script that re-execs the real binary
+# via its own RPATH, which falls apart inside the test namespace.
+if [ -x "$top_builddir/test/src/.libs/dbus-auth-client" ]; then
+ cp "$top_builddir/test/src/.libs/dbus-auth-client" "$SYSROOT/sbin/"
+elif [ -x "$top_builddir/test/src/dbus-auth-client" ]; then
+ cp "$top_builddir/test/src/dbus-auth-client" "$SYSROOT/sbin/"
+fi
# shellcheck disable=SC2154
-FINITBIN="$(pwd)/$top_builddir/src/finit" DEST="$SYSROOT" make -f "$srcdir/lib/sysroot.mk"
+# Prefer the real ELF in .libs/ over the libtool wrapper script at
+# $top_builddir/src/finit. Libtool generates a shell wrapper when
+# the binary depends on an in-tree convenience library (e.g. libink),
+# and `ldd ` returns "not a dynamic executable", which
+# silently makes sysroot.mk copy zero host libs into the sysroot.
+if [ -f "$top_builddir/src/.libs/finit" ]; then
+ finitbin_for_ldd="$(pwd)/$top_builddir/src/.libs/finit"
+else
+ finitbin_for_ldd="$(pwd)/$top_builddir/src/finit"
+fi
+FINITBIN="$finitbin_for_ldd" DEST="$SYSROOT" make -f "$srcdir/lib/sysroot.mk"
# Drop plugins we don't need in test, only causes confusing FAIL in logs.
for plugin in tty.so urandom.so rtc.so modprobe.so; do
diff --git a/test/src/.gitignore b/test/src/.gitignore
index fdb16e7f..14824ddc 100644
--- a/test/src/.gitignore
+++ b/test/src/.gitignore
@@ -3,3 +3,4 @@
/.libs/
/.deps/
/serv
+/dbus-auth-client
diff --git a/test/src/Makefile.am b/test/src/Makefile.am
index 38b26490..87d6e362 100644
--- a/test/src/Makefile.am
+++ b/test/src/Makefile.am
@@ -7,3 +7,10 @@ serv_CPPFLAGS += -I$(top_srcdir)/libsystemd $(lite_CFLAGS)
serv_SOURCES += $(top_srcdir)/libsystemd/sd-daemon.c
serv_LDADD = $(lite_LIBS)
endif
+
+if DBUS
+noinst_PROGRAMS += dbus-auth-client
+dbus_auth_client_SOURCES = dbus-auth-client.c
+dbus_auth_client_CPPFLAGS = -D_GNU_SOURCE -I$(top_srcdir)/libink
+dbus_auth_client_LDADD = $(top_builddir)/libink/libink.la
+endif
diff --git a/test/src/dbus-auth-client.c b/test/src/dbus-auth-client.c
new file mode 100644
index 00000000..fd0e3bb2
--- /dev/null
+++ b/test/src/dbus-auth-client.c
@@ -0,0 +1,448 @@
+/* Minimal D-Bus client used by the libink smoke tests.
+ *
+ * Modes:
+ * dbus-auth-client auth
+ * Send the SASL handshake claiming ; print server reply line.
+ * Exit 0 if reply begins "OK ", 1 if "REJECTED ", 2 otherwise.
+ * (Manual SASL: this mode exists *to* test AUTH itself.)
+ *
+ * dbus-auth-client hello
+ * Call org.freedesktop.DBus.Hello, print the assigned unique name.
+ *
+ * dbus-auth-client introspect
+ * Call org.freedesktop.DBus.Introspectable.Introspect, print XML.
+ *
+ * dbus-auth-client liststrings
+ * Call method expecting reply signature "as", print one per line.
+ *
+ * dbus-auth-client call-s
+ * dbus-auth-client call-void
+ * Issue method call with the given (or no) argument; "OK" or
+ * "ERROR: " on stderr.
+ *
+ * dbus-auth-client call-{s,void}-as-uid ...
+ * As above, but setuid() first so AUTH EXTERNAL claims .
+ *
+ * dbus-auth-client get-service
+ * Manager1.GetService(identity) -> print the encoded path.
+ *
+ * dbus-auth-client monitor-signal
+ * AddMatch + wait for one SIGNAL. Print "SIGNAL "
+ * then any string-typed body args. Exit 0 on signal, 1 on timeout.
+ *
+ * dbus-auth-client unknown
+ * Call a bogus method, exit 0 iff the server replies with an
+ * org.freedesktop.DBus.Error.* error.
+ *
+ * Exit codes for the non-auth modes: 0 on success, 1 on server-side
+ * error reply, 2 on transport / parse / arg error.
+ *
+ * Copyright (c) 2026 Joachim Wiberg
+ * SPDX-License-Identifier: MIT
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "link.h"
+
+/* ---------- manual SASL: only mode_auth uses this ---------- */
+
+static const char hex[] = "0123456789abcdef";
+
+static int write_all_fd(int fd, const void *buf, size_t len)
+{
+ const char *p = buf;
+
+ while (len > 0) {
+ ssize_t n = write(fd, p, len);
+ if (n < 0) {
+ if (errno == EINTR) continue;
+ return -1;
+ }
+ p += n;
+ len -= (size_t)n;
+ }
+ return 0;
+}
+
+static ssize_t read_line_fd(int fd, char *buf, size_t bufsz)
+{
+ size_t off = 0;
+
+ while (off + 1 < bufsz) {
+ ssize_t n = read(fd, buf + off, 1);
+ if (n == 0) return -1;
+ if (n < 0) {
+ if (errno == EINTR) continue;
+ return -1;
+ }
+ if (buf[off] == '\n') {
+ buf[off] = '\0';
+ if (off > 0 && buf[off - 1] == '\r')
+ buf[--off] = '\0';
+ return (ssize_t)off;
+ }
+ off++;
+ }
+ return -1;
+}
+
+static int mode_auth(int argc, char *argv[])
+{
+ struct sockaddr_un sun = { .sun_family = AF_UNIX };
+ char hexuid[32], line[64], reply[256];
+ const char *path, *claimed;
+ size_t i, claimed_len, plen;
+ int fd, rc;
+
+ if (argc != 4) return 2;
+ path = argv[2];
+ claimed = argv[3];
+
+ plen = strlen(path);
+ if (plen >= sizeof(sun.sun_path)) return 2;
+ claimed_len = strlen(claimed);
+ if (claimed_len * 2 >= sizeof(hexuid)) return 2;
+ for (i = 0; i < claimed_len; i++) {
+ unsigned c = (unsigned char)claimed[i];
+
+ hexuid[i * 2] = hex[c >> 4];
+ hexuid[i * 2 + 1] = hex[c & 0xf];
+ }
+ hexuid[claimed_len * 2] = '\0';
+
+ fd = socket(AF_UNIX, SOCK_STREAM, 0);
+ if (fd < 0) { perror("socket"); return 2; }
+ memcpy(sun.sun_path, path, plen + 1);
+ if (connect(fd, (struct sockaddr *)&sun, sizeof(sun)) < 0) {
+ perror("connect"); close(fd); return 2;
+ }
+ if (write_all_fd(fd, "\0", 1) < 0) { close(fd); return 2; }
+ rc = snprintf(line, sizeof(line), "AUTH EXTERNAL %s\r\n", hexuid);
+ if (rc < 0 || (size_t)rc >= sizeof(line)) { close(fd); return 2; }
+ if (write_all_fd(fd, line, (size_t)rc) < 0) { close(fd); return 2; }
+ if (read_line_fd(fd, reply, sizeof(reply)) < 0) { close(fd); return 2; }
+ printf("%s\n", reply);
+ close(fd);
+ if (strncmp(reply, "OK ", 3) == 0) return 0;
+ if (strncmp(reply, "REJECTED ", 9) == 0) return 1;
+ return 2;
+}
+
+/* ---------- libink-driven modes ---------- */
+
+/* Convert link_client_call rc to the test client's 0/1/2 convention,
+ * printing the error name on stderr for ERROR replies. */
+static int report_rc(link_client_t *c, int rc)
+{
+ if (rc == LINK_CALL_OK)
+ return 0;
+ if (rc == LINK_CALL_ERROR) {
+ const link_reply_t *r = link_client_reply(c);
+ fprintf(stderr, "ERROR: %s\n",
+ (r && r->error_name) ? r->error_name : "");
+ return 1;
+ }
+ return 2;
+}
+
+/* Drop effective uid to argv-supplied value (decimal). */
+static int drop_uid(const char *uid_arg, const char *progname)
+{
+ char *ep = NULL;
+ long v;
+
+ errno = 0;
+ v = strtol(uid_arg, &ep, 10);
+ if (errno || !ep || *ep != '\0' || v < 0 || v > 65535) {
+ fprintf(stderr, "%s: bad uid: %s\n", progname, uid_arg);
+ return 2;
+ }
+ if (setuid((uid_t)v) < 0) {
+ perror("setuid");
+ return 2;
+ }
+ return 0;
+}
+
+static int mode_hello(int argc, char *argv[])
+{
+ link_client_t *c;
+ const char *name;
+ int rc;
+
+ if (argc != 3) return 2;
+ c = link_client_open(argv[2]);
+ if (!c) return 2;
+
+ rc = link_client_call_v(c, "/org/freedesktop/DBus",
+ "org.freedesktop.DBus", "Hello", NULL);
+ rc = report_rc(c, rc);
+ if (rc == 0) {
+ if (link_reply_get_string(link_client_reply(c), &name) == 0)
+ printf("%s\n", name);
+ else
+ rc = 2;
+ }
+ link_client_close(c);
+ return rc;
+}
+
+static int mode_introspect(int argc, char *argv[])
+{
+ link_client_t *c;
+ const char *xml;
+ int rc;
+
+ if (argc != 4) return 2;
+ c = link_client_open(argv[2]);
+ if (!c) return 2;
+
+ rc = link_client_call_v(c, argv[3],
+ "org.freedesktop.DBus.Introspectable",
+ "Introspect", NULL);
+ rc = report_rc(c, rc);
+ if (rc == 0) {
+ if (link_reply_get_string(link_client_reply(c), &xml) == 0)
+ printf("%s\n", xml);
+ else
+ rc = 2;
+ }
+ link_client_close(c);
+ return rc;
+}
+
+/* Decode body with signature "as", print one string per line. */
+static int print_string_array(const link_reply_t *r)
+{
+ link_reader_t reader;
+ size_t end;
+
+ if (!r || !r->signature || strcmp(r->signature, "as") != 0)
+ return -1;
+
+ link_reader_init(&reader, r->body, r->body_len);
+ if (link_r_array_begin(&reader, &end) < 0)
+ return -1;
+
+ while (link_r_pos(&reader) < end) {
+ const char *s;
+
+ if (link_r_string(&reader, &s) < 0)
+ return -1;
+ printf("%s\n", s);
+ }
+ return 0;
+}
+
+static int mode_liststrings(int argc, char *argv[])
+{
+ link_client_t *c;
+ int rc;
+
+ if (argc != 6) return 2;
+ c = link_client_open(argv[2]);
+ if (!c) return 2;
+
+ rc = link_client_call_v(c, argv[3], argv[4], argv[5], NULL);
+ rc = report_rc(c, rc);
+ if (rc == 0 && print_string_array(link_client_reply(c)) < 0)
+ rc = 2;
+ link_client_close(c);
+ return rc;
+}
+
+/* Shared call helpers used by both the plain and the -as-uid modes.
+ * `arg` may be NULL (void method); when non-NULL the call signature
+ * is "s" with `arg` as the single string argument. */
+static int do_call(const char *sock, const char *obj, const char *iface,
+ const char *method, const char *arg)
+{
+ link_client_t *c;
+ int rc;
+
+ c = link_client_open(sock);
+ if (!c) return 2;
+
+ rc = arg
+ ? link_client_call_v(c, obj, iface, method, "s", arg)
+ : link_client_call_v(c, obj, iface, method, NULL);
+ rc = report_rc(c, rc);
+ if (rc == 0)
+ printf("OK\n");
+ link_client_close(c);
+ return rc;
+}
+
+static int mode_call_s(int argc, char *argv[])
+{
+ if (argc != 7) return 2;
+ return do_call(argv[2], argv[3], argv[4], argv[5], argv[6]);
+}
+
+static int mode_call_void(int argc, char *argv[])
+{
+ if (argc != 6) return 2;
+ return do_call(argv[2], argv[3], argv[4], argv[5], NULL);
+}
+
+static int mode_call_s_as_uid(int argc, char *argv[])
+{
+ int rc;
+
+ if (argc != 8) return 2;
+ if ((rc = drop_uid(argv[2], argv[0])) != 0)
+ return rc;
+ return do_call(argv[3], argv[4], argv[5], argv[6], argv[7]);
+}
+
+static int mode_call_void_as_uid(int argc, char *argv[])
+{
+ int rc;
+
+ if (argc != 7) return 2;
+ if ((rc = drop_uid(argv[2], argv[0])) != 0)
+ return rc;
+ return do_call(argv[3], argv[4], argv[5], argv[6], NULL);
+}
+
+static int mode_get_service(int argc, char *argv[])
+{
+ link_client_t *c;
+ const char *path;
+ int rc;
+
+ if (argc != 4) return 2;
+ c = link_client_open(argv[2]);
+ if (!c) return 2;
+
+ rc = link_client_call_v(c, "/org/finit/manager",
+ "org.finit.Manager1", "GetService",
+ "s", argv[3]);
+ rc = report_rc(c, rc);
+ if (rc == 0) {
+ /* Reply sig is "o", same wire form as "s". */
+ if (link_reply_get_string(link_client_reply(c), &path) == 0)
+ printf("%s\n", path);
+ else
+ rc = 2;
+ }
+ link_client_close(c);
+ return rc;
+}
+
+static int mode_monitor_signal(int argc, char *argv[])
+{
+ link_client_t *c;
+ const link_reply_t *r;
+ link_reader_t reader;
+ char *ep = NULL;
+ long v;
+ int timeout_ms;
+ int rc;
+
+ if (argc != 5) return 2;
+
+ errno = 0;
+ v = strtol(argv[4], &ep, 10);
+ if (errno || !ep || *ep != '\0' || v <= 0 || v > 600000) {
+ fprintf(stderr, "%s: bad timeout: %s\n", argv[0], argv[4]);
+ return 2;
+ }
+ timeout_ms = (int)v;
+
+ c = link_client_open(argv[2]);
+ if (!c) return 2;
+
+ rc = link_client_call_v(c, "/org/freedesktop/DBus",
+ "org.freedesktop.DBus", "AddMatch",
+ "s", argv[3]);
+ if (rc != LINK_CALL_OK) {
+ rc = report_rc(c, rc);
+ link_client_close(c);
+ return rc;
+ }
+
+ for (;;) {
+ rc = link_client_wait(c, timeout_ms);
+ if (rc != 0) {
+ link_client_close(c);
+ return 1; /* timeout or transport */
+ }
+ r = link_client_reply(c);
+ if (!r || r->type != LINK_MSG_SIGNAL)
+ continue;
+
+ printf("SIGNAL %s %s\n",
+ r->interface ? r->interface : "",
+ r->member ? r->member : "");
+ /* Print any leading "s" args (other types are silently
+ * skipped -- callers test for the strings only). */
+ link_reader_init(&reader, r->body, r->body_len);
+ if (r->signature) {
+ const char *s;
+ const char *p;
+
+ for (p = r->signature; *p == 's'; p++) {
+ if (link_r_string(&reader, &s) < 0)
+ break;
+ printf("%s\n", s);
+ }
+ }
+ link_client_close(c);
+ return 0;
+ }
+}
+
+static int mode_unknown(int argc, char *argv[])
+{
+ link_client_t *c;
+ const link_reply_t *r;
+ int rc;
+
+ if (argc != 3) return 2;
+ c = link_client_open(argv[2]);
+ if (!c) return 2;
+
+ rc = link_client_call_v(c, "/org/finit/manager",
+ "org.finit.Manager1", "NotARealMethod", NULL);
+ if (rc != LINK_CALL_ERROR) {
+ link_client_close(c);
+ return rc == LINK_CALL_OK ? 1 : 2;
+ }
+
+ r = link_client_reply(c);
+ {
+ static const char prefix[] = "org.freedesktop.DBus.Error.";
+ rc = (r && r->error_name &&
+ strncmp(r->error_name, prefix, sizeof(prefix) - 1) == 0)
+ ? 0 : 1;
+ }
+ link_client_close(c);
+ return rc;
+}
+
+int main(int argc, char *argv[])
+{
+ if (argc < 2) return 2;
+ if (!strcmp(argv[1], "auth")) return mode_auth (argc, argv);
+ if (!strcmp(argv[1], "hello")) return mode_hello (argc, argv);
+ if (!strcmp(argv[1], "introspect")) return mode_introspect (argc, argv);
+ if (!strcmp(argv[1], "liststrings")) return mode_liststrings (argc, argv);
+ if (!strcmp(argv[1], "call-s")) return mode_call_s (argc, argv);
+ if (!strcmp(argv[1], "call-void")) return mode_call_void (argc, argv);
+ if (!strcmp(argv[1], "call-s-as-uid")) return mode_call_s_as_uid (argc, argv);
+ if (!strcmp(argv[1], "call-void-as-uid")) return mode_call_void_as_uid(argc, argv);
+ if (!strcmp(argv[1], "get-service")) return mode_get_service (argc, argv);
+ if (!strcmp(argv[1], "monitor-signal")) return mode_monitor_signal (argc, argv);
+ if (!strcmp(argv[1], "unknown")) return mode_unknown (argc, argv);
+ fprintf(stderr, "%s: unknown mode '%s'\n", argv[0], argv[1]);
+ return 2;
+}