diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index cec7aeab..e3a148d4 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -18,6 +18,71 @@ concurrency:
cancel-in-progress: true
jobs:
+ fuzz:
+ name: fuzz
+ runs-on: ubuntu-latest
+ if: github.event_name != 'push' || github.ref == 'refs/heads/master'
+ steps:
+ - name: Install dependencies
+ run: |
+ sudo apt-get -y update
+ sudo apt-get -y install pkg-config libconfuse-dev clang
+ # clang picks the newest gcc tree it finds and needs the
+ # matching libstdc++ headers to link the fuzzer runtime
+ sudo apt-get -y install libstdc++-14-dev || true
+ wget https://github.com/troglobit/libuev/releases/download/v2.4.1/libuev-2.4.1.tar.xz
+ wget https://github.com/troglobit/libite/releases/download/v2.6.2/libite-2.6.2.tar.gz
+ tar xf libuev-2.4.1.tar.xz
+ tar xf libite-2.6.2.tar.gz
+ (cd libuev-2.4.1 && ./configure && make -j9 && sudo make install-strip)
+ (cd libite-2.6.2 && ./configure && make -j9 && sudo make install-strip)
+ sudo ldconfig
+ - uses: actions/checkout@v7
+ - name: Configure
+ run: |
+ ./autogen.sh
+ ./configure --prefix=/usr --exec-prefix= --sysconfdir=/etc --localstatedir=/var
+ - name: Build fuzz target
+ run: |
+ clang -fsanitize=fuzzer,address -DLINK_FUZZ_LIBFUZZER -D_GNU_SOURCE \
+ -I libink -I . -o fuzz-msg-parse \
+ test/src/fuzz-msg-parse.c libink/*.c
+ # Restores the newest corpus and saves a fresh one, since a cache
+ # entry is immutable once written. Caches made on a branch are
+ # private to it, so the corpus that accumulates on master is what
+ # pull requests start from, rather than nothing.
+ - name: Restore corpus
+ uses: actions/cache@v6
+ with:
+ path: .fuzz-corpus
+ key: fuzz-corpus-${{ github.run_id }}
+ restore-keys: fuzz-corpus-
+ - name: Fuzz
+ run: |
+ mkdir -p .fuzz-corpus
+ ./fuzz-msg-parse .fuzz-corpus -max_total_time=120 -max_len=4096 \
+ -print_final_stats=1
+ # Without this the corpus only ever grows, and most of what it
+ # accumulates reaches code some earlier input already reached.
+ - name: Minimise corpus
+ if: always()
+ run: |
+ mkdir -p .fuzz-corpus-min
+ ./fuzz-msg-parse -merge=1 .fuzz-corpus-min .fuzz-corpus
+ rm -rf .fuzz-corpus
+ mv .fuzz-corpus-min .fuzz-corpus
+ echo "corpus: $(ls .fuzz-corpus | wc -l) inputs"
+ - name: Upload crashers
+ if: failure()
+ uses: actions/upload-artifact@v7
+ with:
+ name: fuzz-crashers
+ path: |
+ crash-*
+ leak-*
+ timeout-*
+ if-no-files-found: ignore
+
build:
# Verify we can build on latest Ubuntu with both gcc and clang
name: ${{ matrix.compiler }}
@@ -42,7 +107,7 @@ jobs:
(cd libuev-2.4.1 && ./configure && make -j9 && sudo make install-strip)
(cd libite-2.6.2 && ./configure && make -j9 && sudo make install-strip)
sudo ldconfig
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- name: Static Finit
run: |
./autogen.sh
@@ -52,7 +117,7 @@ jobs:
run: |
./configure --prefix=/usr --exec-prefix= --sysconfdir=/etc --localstatedir=/var \
--enable-x11-common-plugin --enable-testserv-plugin --with-watchdog \
- --with-keventd --with-libsystemd \
+ --with-keventd \
CFLAGS="-fsanitize=address -ggdb"
make -j9 clean
make -j9 V=1
@@ -80,7 +145,7 @@ jobs:
make -j1 check || (cat test/test-suite.log; false)
- name: Upload Test Results
if: always()
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: finit-test-${{ matrix.compiler }}
path: test/*.log
diff --git a/.github/workflows/coverity.yml b/.github/workflows/coverity.yml
index f9b51781..d3090c99 100644
--- a/.github/workflows/coverity.yml
+++ b/.github/workflows/coverity.yml
@@ -19,7 +19,7 @@ jobs:
coverity:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- name: Fetch latest Coverity Scan MD5
id: var
env:
@@ -29,7 +29,7 @@ jobs:
--post-data "token=$TOKEN&project=${COVERITY_PROJ}&md5=1" \
-O coverity-latest.tar.gz.md5
echo "md5=$(cat coverity-latest.tar.gz.md5)" | tee -a $GITHUB_OUTPUT
- - uses: actions/cache@v4
+ - uses: actions/cache@v6
id: cache
with:
path: coverity-latest.tar.gz
@@ -86,7 +86,7 @@ jobs:
--form description="${PROJECT_NAME} $(git rev-parse HEAD)" \
https://scan.coverity.com/builds?project=${COVERITY_PROJ}
- name: Upload build.log
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: coverity-build.log
path: cov-int/build-log.txt
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 28e98055..ee3cb619 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -30,7 +30,7 @@ jobs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v7
with:
fetch-depth: 0 # Needed for git-revision-date-localized plugin
@@ -42,7 +42,7 @@ jobs:
echo "version=$version" >> $GITHUB_OUTPUT
- name: Setup Python
- uses: actions/setup-python@v4
+ uses: actions/setup-python@v7
with:
python-version: '3.x'
@@ -58,7 +58,7 @@ jobs:
run: mkdocs build --clean
- name: Upload site artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: site
path: site/
@@ -72,13 +72,13 @@ jobs:
steps:
- name: Download site artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: site
path: site/
- name: Checkout pages repo
- uses: actions/checkout@v4
+ uses: actions/checkout@v7
with:
repository: finit-project/finit-project.github.io
path: pages
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index d0bfcbd4..eb22a67e 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -14,7 +14,7 @@ jobs:
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- name: Installing dependencies ...
run: |
sudo apt-get -y update
@@ -64,7 +64,7 @@ jobs:
mv ../*.tar.* artifacts/
- name: Upload Test Results
if: always()
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: finit-release-test-logs
path: finit-*/_build/sub/test/*.log
diff --git a/.github/workflows/weekly.yml b/.github/workflows/weekly.yml
index d2d8cbe4..b8b31da6 100644
--- a/.github/workflows/weekly.yml
+++ b/.github/workflows/weekly.yml
@@ -16,7 +16,7 @@ jobs:
outputs:
run: ${{ steps.schedule.outputs.run }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- name: Check for any commits since last week
id: schedule
run: |
@@ -42,7 +42,7 @@ jobs:
(cd libuev-2.4.1 && ./configure && make -j9 && sudo make install-strip)
(cd libite-2.6.2 && ./configure && make -j9 && sudo make install-strip)
sudo ldconfig
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- name: Base run
run: |
./autogen.sh
@@ -61,7 +61,7 @@ jobs:
make -j1 distcheck || (cat $dir/test/test-suite.log; false)
- name: Upload Test Results
if: always()
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: finit-weekly-test-logs
path: finit-*/_build/sub/test/*.log
diff --git a/.gitignore b/.gitignore
index d30aefef..5b943e2e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -52,3 +52,8 @@ GTAGS
# VS Code user settings
.vscode
+
+# Fuzzing: the target is built by hand with clang, see doc/build.md,
+# and the corpus it grows is a local artefact, not something to ship
+/fuzz-msg-parse
+/.fuzz-corpus/
diff --git a/Makefile.am b/Makefile.am
index a5333be6..f5e82762 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
@@ -24,6 +35,13 @@ endif
install-dev:
@make -C src install-pkgincludeHEADERS
+# The fuzz target is built by hand with clang (doc/build.md), so it is
+# not in any _PROGRAMS and nothing else would clear it or the corpus
+# it grows.
+distclean-local:
+ -rm -f $(top_builddir)/fuzz-msg-parse
+ -rm -rf $(top_builddir)/.fuzz-corpus
+
# Target to run when building a release
release: distcheck
@for file in $(DIST_ARCHIVES); do \
diff --git a/configure.ac b/configure.ac
index 0b213b53..3a0a971d 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
libsystemd/Makefile libsystemd/libsystemd.pc
man/Makefile
plugins/Makefile
@@ -94,6 +96,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])
@@ -111,7 +117,7 @@ AC_ARG_ENABLE(all_plugins,
AS_HELP_STRING([--enable-all-plugins], [Enable all plugins, default: auto]),
enable_all_plugins=$enableval, enable_all_plugins=auto)
AC_PLUGIN([alsa-utils], [no], [Save and restore ALSA sound settings using alsactl])
-AC_PLUGIN([dbus], [no], [Setup and start system message bus, D-Bus])
+AC_PLUGIN([dbus], [yes], [Setup and start system message bus, D-Bus])
AC_PLUGIN([modules-load], [no], [Scans /etc/modules-load.d for modules to load])
AC_PLUGIN([modprobe], [no], [Coldplug modules using modalias magic])
AC_PLUGIN([resolvconf], [no], [Setup necessary files for resolvconf])
@@ -155,7 +161,7 @@ AC_ARG_WITH(sysconfig,
[sysconfig=$withval], [sysconfig=default])
AC_ARG_WITH(group,
- AS_HELP_STRING([--with-group=NAME], [Group for /run/finit/socket (initctl), default: root]),
+ AS_HELP_STRING([--with-group=NAME], [Group for /run/finit/socket and /run/finit/bus, default: root]),
[group=$withval], [group=root])
AC_ARG_WITH(hostname,
@@ -243,6 +249,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],
@@ -288,7 +298,7 @@ AS_IF([test "x$with_heading" != "xno"], [
AS_IF([test "x$with_group" != "xno"], [
AS_IF([test "x$group" = "xyes"], [
group=root])])
-AC_DEFINE_UNQUOTED(DEFGROUP, "$group", [For /run/finit/socket])
+AC_DEFINE_UNQUOTED(DEFGROUP, "$group", [For /run/finit/socket and /run/finit/bus])
AS_IF([test "x$with_hostname" != "xno"], [
AS_IF([test "x$hostname" = "xyes"], [
@@ -438,6 +448,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..5eb40fc2
--- /dev/null
+++ b/dbus-1/org.finit.conf
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md
index 5b25a99b..9a3082cc 100644
--- a/doc/ChangeLog.md
+++ b/doc/ChangeLog.md
@@ -8,6 +8,14 @@ All relevant changes are documented in this file.
### Changes
+- The `dbus.so` plugin, which starts an external `dbus-daemon`, is now
+ enabled by default. It does nothing on systems without a
+ `dbus-daemon` installed, and `--disable-dbus-plugin` opts out. The
+ daemon it used to register from C now lives in `20-dbus.conf`, and
+ the directories it needs in `tmpfiles.d/dbus.conf`, so both can be
+ overridden from `/etc` like any other system file. Those
+ directories are no longer chowned to `messagebus`, matching how
+ Finit ships directories for other daemons
- The `tty` block takes `passenv`, which the line-based format has had
since v4.4 (issue #286) and the block format was missing
- New `provides` setting for run/task/service/sysv blocks, naming
@@ -20,6 +28,55 @@ All relevant changes are documented in this file.
same service, `command = { "/lib/systemd/systemd-udevd", "-udevd" }`,
and Finit starts the first one it finds. The line-based format could
only express this by repeating the whole stanza per candidate
+- 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/build.md b/doc/build.md
index 65bf5365..c018ff47 100644
--- a/doc/build.md
+++ b/doc/build.md
@@ -44,15 +44,25 @@ Below are a few of the main switches to configure:
* `--enable-static`: Build Finit statically. The plugins will be
built-ins (.o files) and all external libraries, except the C library
- will be linked statically.
+ will be linked statically. Privileged D-Bus methods then accept only
+ `root`, see [Authorization](dbus.md#authorization)
* `--enable-kernel-cmdline`: Enable Finit pre-4.1 parsing of init args from
`/proc/cmdline`, this is *not recommended* since Finit may be running as the
init for container apps that can see the host's `/proc` filesystem
+* `--disable-dbus`: Opt out of Finit's built-in D-Bus support, enabled by
+ default. See [D-Bus Integration](dbus.md) for what it provides. Not to
+ be confused with `--disable-dbus-plugin` below, which only governs
+ starting an external `dbus-daemon`
+
* `--enable-alsa-utils-plugin`: Enable the optional `alsa-utils.so` sound plugin.
-* `--enable-dbus-plugin`: Enable the optional D-Bus `dbus.so` plugin.
+* `--disable-dbus-plugin`: Drop the `dbus.so` plugin, which launches
+ `dbus-daemon` at boot. Enabled by default; the plugin does nothing on
+ a system that has no `dbus-daemon` installed, so opting out is only
+ needed to keep init from starting a bus on a system that has one.
+ Unrelated to the built-in bus, see `--disable-dbus` above.
* `--enable-resolvconf-plugin`: Enable the `resolvconf.so` optional plugin.
@@ -116,6 +126,60 @@ Linux config to:
> `/etc/fstab`.
+Testing
+-------
+
+`make check` runs the test suite in a private namespace, so it is safe
+on a running system. It needs `unshare` and, on Ubuntu, unprivileged
+user namespaces enabled:
+
+```shell
+sudo sysctl kernel.apparmor_restrict_unprivileged_userns=0
+make check
+```
+
+The D-Bus message parser is the only place in Finit where bytes off a
+socket become pointers, so it also has a fuzz target. `make check`
+runs it as a fixed sweep, which takes milliseconds and needs nothing
+beyond the normal build. To fuzz it properly, build it with clang and
+libFuzzer:
+
+```shell
+clang -fsanitize=fuzzer,address -DLINK_FUZZ_LIBFUZZER -D_GNU_SOURCE \
+ -I libink -I . -o fuzz-msg-parse \
+ test/src/fuzz-msg-parse.c libink/*.c
+mkdir -p .fuzz-corpus
+./fuzz-msg-parse .fuzz-corpus -max_total_time=300
+```
+
+Give it a corpus directory as above and it saves what it learns there,
+so the next run picks up where this one left off instead of starting
+cold. Nothing writes to it unless you name it: without the argument
+libFuzzer keeps everything in memory and the run leaves only crashes
+behind. `make distclean` clears the corpus and the target.
+
+Once a corpus has grown, most of it reaches code some earlier input
+already reached. Minimise it:
+
+```shell
+./fuzz-msg-parse -merge=1 .fuzz-corpus-min .fuzz-corpus
+rm -rf .fuzz-corpus && mv .fuzz-corpus-min .fuzz-corpus
+```
+
+Run `./configure` first, the target needs the generated `config.h`.
+If the link fails with `cannot find -lstdc++`, install the `libstdc++`
+headers matching the newest GCC on the system, not the default one:
+clang picks the newest tree it finds, and that is the one that needs
+them. CI runs this target on every pull request, carrying its
+corpus between runs so it reaches deeper over time.
+
+Feed a file back to the target to reproduce a find:
+
+```shell
+./fuzz-msg-parse crash-3f2a...
+```
+
+
Running
-------
diff --git a/doc/dbus.md b/doc/dbus.md
new file mode 100644
index 00000000..e57898a2
--- /dev/null
+++ b/doc/dbus.md
@@ -0,0 +1,338 @@
+D-Bus Integration
+=================
+
+Finit ships with a built-in, brokerless [D-Bus][] implementation, **libink**
+(`-link`), 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 by default, opt out at build time with
+> `--disable-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` |
+
+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 socket is `0660`, owned by `root` and the group given to
+`--with-group` at build time, the same gate as `/run/finit/socket` that
+`initctl` falls back on. The bus reaches every operation `initctl`
+does, so restricting one and not the other would leave the door open.
+Members of that group may use it, see [Authorization](#authorization).
+
+The **system** bus is best-effort: Finit probes for a running `dbus-daemon`
+and, when reachable, claims the well-known name `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` |
+| `org.freedesktop.DBus.Peer` | `Ping`, `GetMachineId` |
+| `org.freedesktop.DBus.Introspectable` | `Introspect()` — XML description |
+| `org.freedesktop.DBus.Properties` | `Get`, `GetAll`; nothing is writable |
+
+Note: `Hello`, `AddMatch`, and `RemoveMatch` are answered on the canonical
+`/org/freedesktop/DBus` object only, as per the D-Bus specification.
+
+`org.finit.Manager1`
+--------------------
+
+Lives at **`/org/finit/manager`**. Owns the global init operations
+and the service registry.
+
+### Methods
+
+| Method | In sig | Out sig | Priv. | Notes |
+|------------------------------|--------|---------|-------|-----------------------------------------------------------|
+| `ListServices` | — | `as` | no | Returns identities (`name`, `name:id`) of loaded services |
+| `GetService` | `s` | `o` | no | Resolves an 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 |
+| `SetRunlevel` | `u` | — | yes | Transition to runlevel `u` (0–6) |
+| `SetDebug` | — | — | yes | Toggle Finit's runtime debug flag |
+| `Signal` | `su` | — | yes | Send signal `u` (1–31) to services matching identity `s` |
+| `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, `"N"` when none |
+| `Version` | `s` | Finit's version string (`PACKAGE_VERSION`) |
+
+### Signals
+
+| Signal | Body | Fires when |
+|-----------------------|----------------------------------------|------------------------|
+| `ServiceStateChanged` | `sss` — identity, old state, new state | Service transitions |
+| `RunlevelChanged` | `ss` — old level, new level | System runlevel change |
+
+State names emitted by `ServiceStateChanged` are stable wire strings:
+`halted`, `done`, `dead`, `cleanup`, `teardown`, `stopping`, `setup`,
+`paused`, `waiting`, `starting`, `running`. `RunlevelChanged` levels
+use the same encoding as the `Runlevel` property: digits, `"S"`, `"N"`.
+
+`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 | Priv. | Notes |
+|-----------|--------|---------|-------|-------------------------------------------------------------|
+| `Start` | — | — | yes | Equivalent to `Manager1.Start()` for this service |
+| `Stop` | — | — | yes | … |
+| `Restart` | — | — | yes | … |
+| `Reload` | — | — | yes | Reload (SIGHUP if supported, else restart) |
+
+### Properties
+
+All read-only; observable via `Properties.Get` and `Properties.GetAll`.
+
+| Property | Type | Returns |
+|-----------------|------|----------------------------------------------------------------|
+| `Identity` | `s` | Service identity, `name` or `name:id` |
+| `Name` | `s` | Program name (basename of the command) |
+| `State` | `s` | Current status, same vocabulary as `initctl status` |
+| `Pid` | `u` | Current PID, 0 when not running |
+| `RestartCount` | `u` | Restarts since the last stable run |
+| `Runlevels` | `u` | Allowed runlevels as a bitmask, bit N = runlevel N, bit 10 = S |
+| `Description` | `s` | The service's `description` string |
+| `Command` | `s` | Full command line, arguments included |
+| `Conditions` | `s` | Declared conditions, raw `.conf` form |
+| `Type` | `s` | Unit type: `service`, `task`, `run`, `sysv`, `tty`, `free` |
+| `Origin` | `s` | Source `.conf` file, empty for built-ins |
+| `Environment` | `s` | The service's `env` setting, raw |
+| `PidFile` | `s` | Declared PID file, raw (`!` prefix included) |
+| `User` | `s` | User the service runs as |
+| `Group` | `s` | Group the service runs as |
+| `Uptime` | `u` | Seconds since start, 0 when not running |
+| `ExitStatus` | `u` | Raw `waitpid(2)` status from the last exit |
+| `RestartsTotal` | `u` | Restarts over the service's lifetime |
+| `RestartMax` | `u` | Restart limit before the service is blocked |
+| `Starts` | `u` | Times started, for `manual-start` units |
+| `ManualStart` | `b` | `manual-start` set in the `.conf` |
+| `Forking` | `b` | Daemon forks to background |
+| `Started` | `b` | Run/task completed successfully |
+
+On every state transition the object also emits the standard
+`org.freedesktop.DBus.Properties.PropertiesChanged` signal: `State`
+in the changed dictionary, `Pid` and `RestartCount` invalidated (call
+`Get` for fresh values).
+
+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 | Priv. | Notes |
+|---------|--------|---------|-------|--------------------------------------------------------------|
+| `Get` | `s` | `s` | no | Returns `"on"`, `"off"`, or `"flux"` for the named condition |
+| `Set` | `s` | — | yes | Assert a `usr/` condition |
+| `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 |
+
+Note: non-`usr/*` paths are rejected with `InvalidArgs` -- system
+conditions belong to Finit's state machine.
+
+### Signals
+
+| Signal | Body | Fires when |
+|--------------------|------------------------|---------------------------------------|
+| `ConditionChanged` | `ss` — name, new state | A condition is asserted or deasserted |
+
+Authorization
+-------------
+
+Privileged methods accept `root`, and any caller belonging to the group given
+to `--with-group` at build time. That is the same set the socket mode already
+admits, so the two gates agree instead of the socket letting the group in and
+every method turning it away.
+
+A build configured with `--enable-static` accepts only `root`. Group
+membership comes from NSS, which the C library loads with `dlopen()`, and a
+build meant to link statically cannot count on that, so the lookup is compiled
+out rather than left to fail open. The socket mode is unchanged, so the group
+can still connect, it just cannot invoke a privileged method.
+
+On the **local** bus the kernel decides this at `connect()`, supplementary
+groups included, and `SO_PEERCRED` tells Finit exactly who is calling, so
+privilege escalation through the bus is impossible.
+
+On the **system** bus one connection carries every caller, so `SO_PEERCRED`
+describes `dbus-daemon` rather than whoever asked. For a privileged method
+Finit asks the bus driver `GetConnectionUnixUser` about the message sender and
+holds the call until the answer arrives. Nothing blocks: the reply comes back
+through the same event loop as everything else, and the held call is then
+dispatched or refused on its merits.
+
+Answers are cached per sender. A bus never reuses a unique name while it
+runs, so an answer holds for as long as that bus does; Finit empties the cache
+when the broker goes away, since a new one numbers its clients from scratch.
+A caller Finit cannot identify is refused, so the failure mode is a denial
+rather than an escalation.
+
+When a privileged method is rejected the error name is exactly
+`org.freedesktop.DBus.Error.AccessDenied`, and the body carries a short reason
+string.
+
+`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/clear` | `Cond1.{Set,Get,Clear}`, `clr` is an alias |
+
+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` 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. The wire protocol is the compatibility
+surface: Finit's own **libink** is an internal implementation detail, external
+clients should use any standard D-Bus library.
+
+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
+[D-Bus]: https://dbus.freedesktop.org/doc/dbus-specification.html
diff --git a/doc/plugins.md b/doc/plugins.md
index 36b6ddde..458544a7 100644
--- a/doc/plugins.md
+++ b/doc/plugins.md
@@ -35,7 +35,10 @@ For your convenience a set of *optional* plugins are available:
> the `/etc/fstab` file and in the Linux kernel config.
* *dbus.so*: Setup and start system message bus, D-Bus, at boot.
- _Optional plugin._
+ Enabled by default, and a no-op on systems without a `dbus-daemon`.
+ The daemon is declared in `20-dbus.conf` and its directories in
+ `tmpfiles.d/dbus.conf`, both overridable from `/etc`; the plugin
+ itself only handles what has to inspect the running system.
* *hook-scripts.so*: Trigger the execution of scripts from plugin hook
points (see [Hooks](#hooks)). _Optional plugin._
diff --git a/libink/.gitignore b/libink/.gitignore
new file mode 100644
index 00000000..ffdcdbf7
--- /dev/null
+++ b/libink/.gitignore
@@ -0,0 +1,7 @@
+.deps/*
+.libs/*
+.dirstamp
+*.lo
+libink.*
+Makefile
+Makefile.in
diff --git a/libink/Makefile.am b/libink/Makefile.am
new file mode 100644
index 00000000..e81d921f
--- /dev/null
+++ b/libink/Makefile.am
@@ -0,0 +1,18 @@
+# libink — brokerless D-Bus library (server + client), born inside Finit
+#
+# Internal convenience library: linked statically into finit and
+# initctl, nothing installed. Public install (and the ABI promise
+# that comes with it) is deferred until libink is extracted into its
+# own project.
+noinst_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 log.c \
+ link.h path.h internal.h
+
+libink_la_CPPFLAGS = -D_GNU_SOURCE -D_DEFAULT_SOURCE -D_BSD_SOURCE
+libink_la_CFLAGS = -W -Wall -Wextra -Wno-unused-parameter -std=gnu99
diff --git a/libink/README.md b/libink/README.md
new file mode 100644
index 00000000..fbbf64c1
--- /dev/null
+++ b/libink/README.md
@@ -0,0 +1,64 @@
+libink — brokerless D-Bus for Finit
+===================================
+
+libink is a small C library implementing the [D-Bus wire protocol][spec],
+both the server and the client side, without a broker and without any
+dependency on `libdbus`, `sd-bus`, or GIO. It was born inside Finit to
+let PID 1 be a bus of its own: clients connect straight to the listening
+socket, authenticate with the standard SASL EXTERNAL handshake, and get
+kernel-authenticated credentials via `SO_PEERCRED`.
+
+For what the bus exposes and how to talk to it, see the User Guide,
+[D-Bus Integration](../doc/dbus.md). This file covers the library
+itself.
+
+Status
+------
+
+libink is an internal implementation detail of Finit: built as a libtool
+convenience library, linked statically into `finit` and `initctl`,
+nothing installed. There is deliberately no ABI promise yet — that
+comes if/when libink is extracted into a project of its own. External
+D-Bus clients need none of this; the wire protocol is the compatibility
+surface, any standard D-Bus library works.
+
+Layout
+------
+
+| File | Contents |
+|-----------------|-------------------------------------------------------|
+| `server.c` | Listening socket, accept, peer credential capture |
+| `auth.c` | SASL EXTERNAL handshake, uid verification |
+| `connection.c` | Per-peer state machine, message framing |
+| `proto.c` | Wire header parse/build |
+| `marshal.c` | Body (de)marshalling: basic types, arrays, variants |
+| `dispatch.c` | Object tree, vtable registration, method dispatch |
+| `builtin.c` | `org.freedesktop.DBus.*` stock interfaces |
+| `match.c` | AddMatch/RemoveMatch rule parsing and signal filter |
+| `path.c` | systemd-style `_HH` object path encoding |
+| `client.c` | Outgoing connections, method calls, reply/signal wait |
+| `io.c` | Shared EINTR-resilient read/write loops |
+
+Public API symbols carry the `link_*` prefix (`link.h`), internal ones
+`__*` (`internal.h`). Method handlers are registered as vtables of
+`link_method_t`/`link_property_t`; the framework emits variant
+signatures from the property table so the declared type is the single
+source of truth.
+
+The boundary to Finit is deliberate: nothing under `libink/` includes a
+Finit header. All glue lives in `src/dbus.c` — object registration,
+signal emission from the service/condition/runlevel hook points, and
+the uev event loop bridge. `initctl` uses the client half of the same
+library, so one wire-format implementation serves both ends. If libink
+is ever spun out, that file is the cut line.
+
+Testing
+-------
+
+The `test/dbus-*.sh` suite exercises the library end to end against a
+live Finit in a namespace, driven by `test/src/dbus-auth-client.c`.
+Wire-format conformance against third-party tools (`dbus-send`,
+`dbus-monitor`) and fuzzing of the parsers are tracked as pre-merge
+work — this is PID 1's attack surface.
+
+[spec]: https://dbus.freedesktop.org/doc/dbus-specification.html
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..fa511c1a
--- /dev/null
+++ b/libink/builtin.c
@@ -0,0 +1,505 @@
+/* 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;
+}
+
+/*
+ * Advance past one single complete type in a D-Bus signature:
+ * a basic type code, 'a' + element type, or a bracketed group.
+ * Signatures come from our own vtables, so trust them; an
+ * unterminated group just stops at NUL.
+ */
+static const char *sig_next(const char *p)
+{
+ while (*p == 'a') /* array prefixes, then element type */
+ p++;
+ if (*p == '(' || *p == '{') {
+ char close = *p == '(' ? ')' : '}';
+
+ for (p++; *p && *p != close; p = sig_next(p))
+ ;
+ }
+ return *p ? p + 1 : p; /* NUL: unterminated group, stop here */
+}
+
+static void emit_args(struct xbuf *x, const char *sig, const char *dir)
+{
+ const char *p, *e;
+
+ for (p = sig; p && *p; p = e) {
+ e = sig_next(p);
+ xprintf(x, " \n",
+ (int)(e - p), p, dir);
+ }
+}
+
+/* Emit a single stanza for one method definition. */
+static void emit_method(struct xbuf *x, const link_method_t *m)
+{
+ xprintf(x, " \n", m->name);
+ emit_args(x, m->in_sig, "in");
+ emit_args(x, m->out_sig, "out");
+ 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");
+}
+
+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));
+ __w_sig(&w, p->sig ? p->sig : "s");
+ 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);
+ __w_sig(&w, p->sig ? p->sig : "s");
+ 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)
+{
+ /* Hello and AddMatch/RemoveMatch are per-connection state, and on
+ * a broker link the connection is shared by every caller: one
+ * sender could exhaust the match cap or drop another's rule.
+ * Naming and subscription belong to the broker for its own
+ * clients, so we do not answer these there. */
+ if (conn->broker && m->member &&
+ (!strcmp(m->member, "Hello") ||
+ !strcmp(m->member, "AddMatch") ||
+ !strcmp(m->member, "RemoveMatch"))) {
+ __dbg("%s is the broker's to answer, not ours", m->member);
+ return __send_error(conn, m,
+ "org.freedesktop.DBus.Error.AccessDenied",
+ "Handled by the message bus, not by this peer");
+ }
+
+ 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..6a0c60b4
--- /dev/null
+++ b/libink/client.c
@@ -0,0 +1,348 @@
+/* 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;
+ const char *destination; /* not owned; NULL when brokerless */
+ 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_set_destination(link_client_t *c, const char *destination)
+{
+ if (c)
+ c->destination = destination;
+}
+
+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)
+{
+ __msg_to_reply(&c->reply, m);
+ 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;
+}
+
+/* A broker interleaves traffic of its own with our replies: claiming a
+ * name makes it emit NameAcquired, and it arrives before the reply to
+ * the call that caused it. Read past anything that is not the reply
+ * we are waiting for. On a brokerless link nothing is interleaved and
+ * the first message read is always the one we want.
+ *
+ * Bounded so a chatty or hostile broker cannot stall PID 1 here; each
+ * read is bounded in turn by SO_RCVTIMEO when the caller asked for a
+ * timeout at open. */
+#define LINK_CALL_MAX_SKIP 16
+
+static int read_reply(link_client_t *c, uint32_t serial)
+{
+ int i;
+
+ for (i = 0; i < LINK_CALL_MAX_SKIP; i++) {
+ struct link_msg msg;
+
+ if (read_one(c, &msg) < 0)
+ return -1;
+
+ /* Not a reply at all, or a reply to something else. */
+ if (msg.type != LINK_MSG_METHOD_RETURN && msg.type != LINK_MSG_ERROR)
+ continue;
+ if (msg.reply_serial != serial)
+ continue;
+
+ publish_reply(c, &msg);
+ return 0;
+ }
+
+ errno = EPROTO;
+ return -1;
+}
+
+/* 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)
+{
+ uint8_t hdr[LINK_CALL_HDR_MAX];
+ 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,
+ c->destination,
+ 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_reply(c, serial) < 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);
+}
+
+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[LINK_CALL_BODY_MAX];
+ 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..d5cff196
--- /dev/null
+++ b/libink/connection.c
@@ -0,0 +1,251 @@
+/* libink — per-connection lifecycle and dispatch entry point
+ *
+ * Copyright (c) 2026 Joachim Wiberg
+ * SPDX-License-Identifier: MIT
+ */
+
+#include
+#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;
+}
+
+/* Issue a method call and remember the serial so the reply can be
+ * handed back to `cb` when the read loop picks it up. Nothing here
+ * waits: this is the counterpart of link_client_call() for a
+ * connection already owned by the event loop. */
+int link_connection_call(link_connection_t *conn, const char *destination,
+ const char *path, const char *interface, const char *member,
+ link_reply_cb_t cb, void *userdata,
+ const char *signature, ...)
+{
+ uint8_t body[LINK_CALL_BODY_MAX];
+ uint8_t hdr[LINK_CALL_HDR_MAX];
+ ssize_t blen = 0;
+ ssize_t hlen;
+ struct link_bus *bus;
+ uint32_t serial;
+ int i;
+
+ if (!conn || conn->fd < 0 || !path || !member) {
+ errno = EINVAL;
+ return -1;
+ }
+
+ bus = __bus_get(conn);
+ if (!bus)
+ return -1;
+
+ for (i = 0; i < LINK_PENDING_CAP; i++) {
+ if (!bus->pending[i].used)
+ break;
+ }
+ if (i == LINK_PENDING_CAP) {
+ errno = EBUSY;
+ return -1;
+ }
+
+ if (signature && *signature) {
+ va_list ap;
+
+ va_start(ap, signature);
+ blen = __marshal_va(body, sizeof(body), signature, ap);
+ va_end(ap);
+ if (blen < 0) {
+ errno = EMSGSIZE;
+ return -1;
+ }
+ }
+
+ serial = ++conn->next_serial;
+ hlen = __msg_build_method_call(hdr, sizeof(hdr), serial, path, interface,
+ member, destination, signature, (uint32_t)blen);
+ if (hlen < 0) {
+ errno = EMSGSIZE;
+ return -1;
+ }
+
+ if (__io_write_all(conn->fd, hdr, (size_t)hlen) < 0)
+ return -1;
+ if (blen > 0 && __io_write_all(conn->fd, body, (size_t)blen) < 0)
+ return -1;
+
+ __dbg("calling %s.%s on %s, serial %u", interface ? interface : "-",
+ member, destination ? destination : "peer", serial);
+
+ bus->pending[i].used = 1;
+ bus->pending[i].serial = serial;
+ bus->pending[i].stamp = __now_ms();
+ bus->pending[i].cb = cb;
+ bus->pending[i].userdata = userdata;
+
+ return 0;
+}
+
+struct link_bus *__bus_get(link_connection_t *conn)
+{
+ if (!conn->bus)
+ conn->bus = calloc(1, sizeof(*conn->bus));
+
+ return conn->bus;
+}
+
+void __bus_free(link_connection_t *conn)
+{
+ free(conn->bus);
+ conn->bus = NULL;
+}
+
+/* Neither half can time itself out, libink has no event loop, so the
+ * embedder sweeps. Calls go first: one timing out usually resolves
+ * the park it was made for, and being told AccessDenied says more to
+ * that caller than a bare timeout. */
+int link_connection_expire(link_connection_t *conn, unsigned int age_ms)
+{
+ struct link_bus *bus;
+ uint64_t now;
+ int i, live = 0;
+
+ if (!conn || !conn->bus)
+ return 0;
+
+ bus = conn->bus;
+ now = __now_ms();
+ for (i = 0; i < LINK_PENDING_CAP; i++) {
+ link_reply_cb_t cb;
+ void *userdata;
+
+ if (!bus->pending[i].used)
+ continue;
+
+ if (now - bus->pending[i].stamp < age_ms) {
+ live++;
+ continue;
+ }
+
+ cb = bus->pending[i].cb;
+ userdata = bus->pending[i].userdata;
+ bus->pending[i].used = 0;
+
+ __dbg("timed out call serial %u, no reply", bus->pending[i].serial);
+ if (cb)
+ cb(conn, NULL, userdata);
+ }
+
+ return live + __dispatch_expire_parked(conn, age_ms);
+}
+
+void link_connection_close(link_connection_t *conn)
+{
+ size_t i;
+
+ if (!conn)
+ return;
+
+ __dbg("closing peer on fd %d", conn->fd);
+
+ /* Anything waiting on this connection has to be told, or a parked
+ * call sits forever and its caller never hears back. */
+ __dispatch_forget_conn(conn);
+
+ 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, (size_t)consumed) < 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..5597407d
--- /dev/null
+++ b/libink/dispatch.c
@@ -0,0 +1,712 @@
+/* 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 ---------- */
+
+/*
+ * Peer fds are non-blocking, so send_all may fail mid-frame (e.g.
+ * EAGAIN from a peer that stopped draining its socket). Any failure
+ * poisons the peer's stream: never retry on the same connection,
+ * drop the peer.
+ */
+#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 */
+
+ /* A broker routes to whoever subscribed with it, so it wants
+ * every signal and never sends us AddMatch of its own. */
+ matched = conn->broker;
+
+ for (i = 0; !matched && i < conn->matches_count; i++) {
+ if (__match_matches(conn->matches[i], path,
+ interface, member))
+ matched = 1;
+ }
+ 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->uid : LINK_UID_UNKNOWN; }
+
+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_begin (link_reader_t *r, char *type) { return __r_variant_begin(r, type); }
+int link_r_skip_basic (link_reader_t *r, char type) { return __r_skip_basic(r, type); }
+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 ---------- */
+
+/* ---------- replies to our own outbound calls ---------- */
+
+/* Hand a reply to whoever issued the matching link_connection_call().
+ * Unmatched replies are dropped: a broker is free to send us things we
+ * never asked for, and that is not a reason to drop the connection. */
+static void deliver_reply(link_connection_t *conn, const struct link_msg *m)
+{
+ link_reply_cb_t cb;
+ link_reply_t r;
+ void *userdata;
+ int i;
+
+ if (!conn->bus) {
+ __dbg("unsolicited reply, serial %u", m->reply_serial);
+ return;
+ }
+
+ for (i = 0; i < LINK_PENDING_CAP; i++) {
+ if (conn->bus->pending[i].used &&
+ conn->bus->pending[i].serial == m->reply_serial)
+ break;
+ }
+ if (i == LINK_PENDING_CAP) {
+ __dbg("unsolicited reply, serial %u", m->reply_serial);
+ return;
+ }
+
+ cb = conn->bus->pending[i].cb;
+ userdata = conn->bus->pending[i].userdata;
+ conn->bus->pending[i].used = 0;
+
+ if (!cb)
+ return;
+
+ __msg_to_reply(&r, m);
+ cb(conn, &r, userdata);
+}
+
+/* ---------- calls parked while their caller is identified ---------- */
+
+/* Every park gets a token that is never issued twice, so a resolver
+ * answering late, twice, or after its connection went away resumes
+ * nothing rather than whatever call has since taken the slot. */
+static struct link_parked *park(link_connection_t *conn, const uint8_t *frame,
+ size_t len, link_authz_t *tok)
+{
+ struct link_bus *bus;
+ int i;
+
+ if (!frame || !len || len > LINK_PARKED_MSG_MAX)
+ return NULL;
+
+ bus = __bus_get(conn);
+ if (!bus)
+ return NULL;
+
+ for (i = 0; i < LINK_PARKED_CAP; i++) {
+ if (!bus->parked[i].tok)
+ break;
+ }
+ if (i == LINK_PARKED_CAP)
+ return NULL;
+
+ bus->parked[i].tok = ++bus->next_tok;
+ bus->parked[i].conn = conn;
+ bus->parked[i].stamp = __now_ms();
+ bus->parked[i].len = len;
+ memcpy(bus->parked[i].buf, frame, len);
+ *tok = bus->parked[i].tok;
+
+ return &bus->parked[i];
+}
+
+static void unpark(struct link_parked *p)
+{
+ p->tok = 0;
+ p->conn = NULL;
+}
+
+/* A resolver that answers late, or never, would otherwise hold both
+ * the slot and the caller forever. Returns how many are still parked;
+ * link_connection_expire() is what calls this. */
+int __dispatch_expire_parked(link_connection_t *conn, unsigned int age_ms)
+{
+ uint64_t now;
+ int i, live = 0;
+
+ if (!conn->bus)
+ return 0;
+
+ now = __now_ms();
+ for (i = 0; i < LINK_PARKED_CAP; i++) {
+ struct link_parked *p = &conn->bus->parked[i];
+ uint8_t buf[LINK_PARKED_MSG_MAX];
+ struct link_msg msg;
+ size_t len;
+
+ if (!p->tok)
+ continue;
+
+ if (now - p->stamp < age_ms) {
+ live++;
+ continue;
+ }
+
+ /* Free the slot before replying, as link_uid_resolved()
+ * does: the send path must not find it still parked. */
+ len = p->len;
+ memcpy(buf, p->buf, len);
+ unpark(p);
+
+ if (__msg_parse(buf, len, &msg) <= 0)
+ continue;
+
+ __dbg("timed out %s, nobody said who the caller was",
+ msg.member ? msg.member : "call");
+ (void)__send_error(conn, &msg,
+ "org.freedesktop.DBus.Error.TimedOut",
+ "Timed out identifying the caller");
+ }
+
+ return live;
+}
+
+void __dispatch_forget_conn(link_connection_t *conn)
+{
+ struct link_bus *bus = conn->bus;
+ int i;
+
+ if (!bus)
+ return;
+
+ /* Drop parked calls first. A pending callback below may try to
+ * resolve one, and resuming a dispatch on a connection that is
+ * being torn down is no use to anyone; an invalidated slot makes
+ * that resolve a no-op instead. */
+ for (i = 0; i < LINK_PARKED_CAP; i++)
+ unpark(&bus->parked[i]);
+
+ for (i = 0; i < LINK_PENDING_CAP; i++) {
+ if (bus->pending[i].used && bus->pending[i].cb)
+ bus->pending[i].cb(conn, NULL, bus->pending[i].userdata);
+ bus->pending[i].used = 0;
+ }
+
+ __bus_free(conn);
+}
+
+static int dispatch_call(link_connection_t *conn, const struct link_msg *m,
+ const uint8_t *frame, size_t framelen,
+ const uid_t *known_uid);
+
+void link_uid_resolved(link_connection_t *conn, link_authz_t tok, uid_t uid)
+{
+ uint8_t buf[LINK_PARKED_MSG_MAX];
+ struct link_parked *p = NULL;
+ struct link_msg msg;
+ size_t len;
+ int i;
+
+ if (!conn || !conn->bus || !tok)
+ return;
+
+ for (i = 0; i < LINK_PARKED_CAP; i++) {
+ if (conn->bus->parked[i].tok == tok) {
+ p = &conn->bus->parked[i];
+ break;
+ }
+ }
+ if (!p)
+ return; /* stale handle, already answered */
+
+ /* Copy the message out and free the slot before dispatching:
+ * the handler may park a call of its own. */
+ len = p->len;
+ memcpy(buf, p->buf, len);
+ unpark(p);
+
+ if (__msg_parse(buf, len, &msg) <= 0)
+ return;
+
+ __dbg("resumed %s, caller uid %d", msg.member ? msg.member : "call", (int)uid);
+ (void)dispatch_call(conn, &msg, NULL, 0, &uid);
+}
+
+int __dispatch_message(link_connection_t *conn, const struct link_msg *m, size_t framelen)
+{
+ if (m->type == LINK_MSG_METHOD_RETURN || m->type == LINK_MSG_ERROR) {
+ deliver_reply(conn, m);
+ return 0;
+ }
+
+ if (m->type != LINK_MSG_METHOD_CALL) {
+ /* Signals from a client to PID 1 are nonsense; drop. */
+ return 0;
+ }
+
+ return dispatch_call(conn, m, conn->rxbuf, framelen, NULL);
+}
+
+/* Who may invoke a privileged method. Without an authorizer, only
+ * root, which is what libink can decide on its own. */
+static int caller_may(link_server_t *srv, uid_t uid)
+{
+ if (uid == LINK_UID_UNKNOWN)
+ return 0;
+ if (srv && srv->authorizer)
+ return srv->authorizer(uid, srv->authz_userdata);
+
+ return uid == 0;
+}
+
+/* Ask who is calling on a broker connection, where the message is the
+ * only evidence. Returns 0 with *uid set, 1 when the call was parked
+ * and will be dispatched again once the resolver answers, -1 when the
+ * caller cannot be identified, and -2 when we have no room to ask. */
+#define CALLER_UID_BUSY (-2)
+
+static int resolve_caller(link_connection_t *conn, const struct link_msg *m,
+ const uint8_t *frame, size_t framelen, uid_t *uid)
+{
+ link_server_t *srv = conn->server;
+ struct link_parked *p;
+ link_authz_t tok;
+ int rc;
+
+ if (!srv || !srv->uid_resolver || !m->sender)
+ return -1;
+
+ /* Enforce the rule link.h states, rather than trusting every
+ * resolver to remember it: a truncated sender key would let two
+ * callers share one identity. */
+ if (strlen(m->sender) >= LINK_SENDER_MAX) {
+ __dbg("sender name too long, refusing to identify it");
+ return -1;
+ }
+
+ /* Park first so the resolver has somewhere to answer, then let
+ * it release the slot immediately if it already knew. */
+ p = park(conn, frame, framelen, &tok);
+ if (!p)
+ return CALLER_UID_BUSY;
+
+ rc = srv->uid_resolver(conn, m->sender, tok, uid, srv->uid_userdata);
+ if (rc != 1)
+ unpark(p);
+ else
+ __dbg("parked %s from %s, awaiting caller uid", m->member, m->sender);
+
+ return rc;
+}
+
+static int dispatch_call(link_connection_t *conn, const struct link_msg *m,
+ const uint8_t *frame, size_t framelen,
+ const uid_t *known_uid)
+{
+ struct link_object *o;
+ struct link_vtable_entry *e = NULL;
+ const link_method_t *meth;
+ struct link_call call;
+ ssize_t blen;
+ uid_t call_uid;
+ int rc;
+
+ /* What a handler sees via link_call_uid(). Unresolved on a broker
+ * connection until a privileged method forces the question. */
+ call_uid = known_uid ? *known_uid : conn->peer_uid;
+
+ if (!m->path || !m->member) {
+ __dbg("malformed call, no path or member");
+ return __send_error(conn, m,
+ "org.freedesktop.DBus.Error.InvalidArgs",
+ "Method call without path or member");
+ }
+
+ __dbg("call %s %s.%s from %s", m->path,
+ m->interface ? m->interface : "-", m->member,
+ m->sender ? m->sender : "peer");
+
+ /* 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) {
+ __dbg("no such object %s", m->path);
+ return __send_error(conn, m,
+ "org.freedesktop.DBus.Error.UnknownObject",
+ "No such object");
+ }
+
+ meth = resolve(o, m->interface, m->member, &e);
+ if (!meth) {
+ __dbg("no such method %s on %s", m->member, m->path);
+ 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) {
+ __dbg("%s takes '%s', caller sent '%s'", m->member, want, got);
+ return __send_error(conn, m,
+ "org.freedesktop.DBus.Error.InvalidArgs",
+ "Argument signature mismatch");
+ }
+ }
+
+ /* Per-method authorization. PRIVILEGED methods require uid 0.
+ * On an ordinary connection the peer's uid was captured via
+ * SO_PEERCRED at accept time and verified against the AUTH
+ * EXTERNAL claim, so conn->peer_uid is the answer. A broker
+ * connection carries every caller at once, so who is asking has
+ * to be established per message, which may park the call. */
+ if (meth->flags & LINK_METHOD_PRIVILEGED) {
+ if (conn->broker && !known_uid) {
+ rc = resolve_caller(conn, m, frame, framelen, &call_uid);
+ if (rc == 1)
+ return 0; /* parked, resumed later */
+
+ if (rc == CALLER_UID_BUSY) {
+ /* Not a permission problem: root may well
+ * be asking, we just have no slot to find
+ * out in. Say so, it is retryable. */
+ __dbg("no free slot to identify %s", m->sender);
+ return __send_error(conn, m,
+ "org.freedesktop.DBus.Error.LimitsExceeded",
+ "Too many calls awaiting authorization");
+ }
+ if (rc < 0)
+ call_uid = (uid_t)-1;
+ }
+
+ if (!caller_may(conn->server, call_uid)) {
+ __dbg("denied %s, caller uid %d is not privileged",
+ m->member, (int)call_uid);
+ return __send_error(conn, m,
+ "org.freedesktop.DBus.Error.AccessDenied",
+ "Caller is not privileged for this method");
+ }
+ }
+
+ memset(&call, 0, sizeof(call));
+ call.conn = conn;
+ call.uid = call_uid;
+ 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..e71fcf00
--- /dev/null
+++ b/libink/internal.h
@@ -0,0 +1,220 @@
+/* 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 LINK_SENDER_MAX
+#define LINK_MATCH_RULE_MAX 256 /* per-peer match rule cap */
+#define LINK_MATCH_PEER_CAP 16 /* max active match rules per peer */
+#define LINK_PENDING_CAP 4 /* outbound calls awaiting a reply */
+/* Staging for an outgoing method call. Generous on purpose: headers
+ * for the calls libink makes run to ~150 B, and both the synchronous
+ * and the connection-side path build into these, so one answer rather
+ * than a number per call site. */
+#define LINK_CALL_HDR_MAX 1024
+#define LINK_CALL_BODY_MAX 1024
+#define LINK_PARKED_CAP 4 /* inbound calls awaiting a uid */
+/* A call parked for authorization is a privileged one: an object path
+ * and at most a service name. Finit's per-service paths alone run to
+ * 512 bytes, so leave room for the header around one. Anything that
+ * does not fit is denied rather than held. */
+#define LINK_PARKED_MSG_MAX 1024
+
+/* 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);
+
+/* An inbound method call held while we find out who sent it. The
+ * message is copied because rxbuf is reused as soon as we return to
+ * the read loop. `tok` is the handle the resolver answers with, and
+ * zero when the slot is free. `stamp` is when it was parked, for
+ * link_connection_expire(). */
+struct link_parked {
+ link_authz_t tok;
+ link_connection_t *conn;
+ uint64_t stamp;
+ size_t len;
+ uint8_t buf[LINK_PARKED_MSG_MAX];
+};
+
+/* Calls in flight in either direction: inbound ones held while we ask
+ * who sent them, outbound ones waiting for their reply. Both belong
+ * to a conversation with a broker, so this hangs off the connection
+ * and is allocated on first use. An ordinary peer, which only ever
+ * calls in and is identified by SO_PEERCRED, never gets one.
+ *
+ * Tokens are handed out per bus, which is all link_uid_resolved()
+ * needs: it is told the connection the answer belongs to. */
+struct link_bus {
+ struct link_parked parked[LINK_PARKED_CAP];
+ link_authz_t next_tok;
+
+ /* Outbound calls we made on this connection, awaiting replies. */
+ struct {
+ int used;
+ uint32_t serial;
+ uint64_t stamp; /* for link_connection_expire() */
+ link_reply_cb_t cb;
+ void *userdata;
+ } pending[LINK_PENDING_CAP];
+};
+
+struct link_server {
+ int fd;
+ char path[LINK_PATH_MAX];
+ struct link_object_list objects;
+ uint32_t next_unique_id; /* for ":1.N" names */
+
+ /* Set by link_server_set_uid_resolver(); see link.h. */
+ link_uid_resolver_t uid_resolver;
+ void *uid_userdata;
+
+ /* Set by link_server_set_authorizer(); see link.h. */
+ link_authorizer_t authorizer;
+ void *authz_userdata;
+};
+
+/* 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 because a reply is marshalled and sent
+ * without yielding. Note that parking means several calls can be in
+ * flight on one connection: what is held is the request, and
+ * link_uid_resolved() resumes from a copy, so txbuf is still only
+ * ever used by one reply at a time. An async handler that returned
+ * before writing its reply would break that. */
+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;
+ uid_t uid; /* caller, resolved for a broker peer */
+};
+
+/* 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. A broker never registers any,
+ * it matches for its own clients, so `broker` bypasses them. */
+ struct link_match *matches[LINK_MATCH_PEER_CAP];
+ size_t matches_count;
+ int broker;
+
+ 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;
+
+ /* Allocated on the first park or outbound call, see above. */
+ struct link_bus *bus;
+
+ struct link_server *server; /* back-pointer for dispatch */
+};
+
+/* log.c — tracing, no-op unless the embedder installed a callback. */
+void __log(const char *func, const char *fmt, ...)
+ __attribute__((format(printf, 2, 3)));
+#define __dbg(fmt, ...) __log(__func__, fmt, ##__VA_ARGS__)
+
+/* io.c — shared EINTR-resilient I/O loops, and the clock the expiry
+ * sweeps measure against. */
+int __io_write_all(int fd, const void *buf, size_t len);
+int __io_read_full(int fd, void *buf, size_t len);
+uint64_t __now_ms(void);
+
+/* auth.c */
+int __auth_process(link_connection_t *conn);
+void __auth_generate_guid(char out[33]);
+int __auth_client(int fd, uid_t uid);
+
+/* connection.c — the per-connection bus state, made on demand. */
+struct link_bus *__bus_get (link_connection_t *conn);
+void __bus_free(link_connection_t *conn);
+
+/* dispatch.c */
+int __dispatch_message(link_connection_t *conn, const struct link_msg *m, size_t framelen);
+void __dispatch_forget_conn(link_connection_t *conn);
+int __dispatch_expire_parked(link_connection_t *conn, unsigned int age_ms);
+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..2d96045e
--- /dev/null
+++ b/libink/io.c
@@ -0,0 +1,68 @@
+/* libink — shared 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. On any other error they return -1 with an unknown
+ * number of bytes already transferred.
+ *
+ * Also the clock the expiry sweeps measure against: monotonic, so a
+ * step in wall time cannot make a call look older or younger than it
+ * is.
+ *
+ * Copyright (c) 2026 Joachim Wiberg
+ * SPDX-License-Identifier: MIT
+ */
+
+#include
+#include
+#include
+
+#include "internal.h"
+
+uint64_t __now_ms(void)
+{
+ struct timespec ts;
+
+ if (clock_gettime(CLOCK_MONOTONIC, &ts))
+ return 0;
+
+ return (uint64_t)ts.tv_sec * 1000 + (uint64_t)(ts.tv_nsec / 1000000);
+}
+
+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/link.h b/libink/link.h
new file mode 100644
index 00000000..40f6f4b9
--- /dev/null
+++ b/libink/link.h
@@ -0,0 +1,464 @@
+/* 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;
+
+/* ---------- debug tracing ---------- */
+
+/* Receives one line per connection, method call, and authorization
+ * decision. `func` is the libink function that emitted it, so a host
+ * can format it the way it formats its own traces.
+ *
+ * With no logger installed, the default, a trace point costs one NULL
+ * test. With one installed the message is formatted before the host
+ * sees it, so a host that only wants tracing sometimes should install
+ * and remove the callback rather than discard by level. */
+typedef void (*link_log_cb_t)(void *userdata, const char *func, const char *msg);
+
+void link_set_logger(link_log_cb_t cb, void *userdata);
+
+/* ---------- caller identity on a broker connection ---------- */
+
+/* Handle for a call parked while its caller is identified. Opaque,
+ * and safe to hold: it encodes a slot and a generation, so resolving
+ * a stale handle is a no-op rather than a use-after-free. */
+typedef uint64_t link_authz_t;
+
+/* "Nobody asked yet", distinct from any real uid. link_call_uid()
+ * returns this on a broker connection until something forces the
+ * question, which today only a LINK_METHOD_PRIVILEGED method does. */
+#define LINK_UID_UNKNOWN ((uid_t)-1)
+
+/* A broker sets SENDER to a unique name, ":1.", so this is very
+ * generous. Callers that key anything on a sender must reject longer
+ * names rather than truncate: two senders sharing a truncated key
+ * would share an identity. */
+#define LINK_SENDER_MAX 64
+
+/* Answer "which uid is `sender`?" for a privileged call arriving on a
+ * broker connection, where SO_PEERCRED describes the broker and not
+ * the caller.
+ *
+ * Return 0 with *uid set when the answer is already known, 1 to answer
+ * later by calling link_uid_resolved() with `tok`, or -1 when it
+ * cannot be determined, which fails the call closed. Returning 1
+ * without ever calling link_uid_resolved() leaks the slot and leaves
+ * the caller without a reply, so always answer. */
+typedef int (*link_uid_resolver_t)(link_connection_t *conn, const char *sender,
+ link_authz_t tok, uid_t *uid, void *userdata);
+
+void link_server_set_uid_resolver(link_server_t *server, link_uid_resolver_t cb,
+ void *userdata);
+
+/* May `uid` invoke a LINK_METHOD_PRIVILEGED method? Return non-zero
+ * to allow. Who counts as privileged is the embedder's policy, not
+ * the library's; with no authorizer installed only uid 0 may. */
+typedef int (*link_authorizer_t)(uid_t uid, void *userdata);
+
+void link_server_set_authorizer(link_server_t *server, link_authorizer_t cb,
+ void *userdata);
+
+/* Complete a deferred resolve and resume the parked call. Pass
+ * (uid_t)-1 to say the caller could not be identified, which denies
+ * it. Resolving a handle twice, or one whose connection has since
+ * closed, does nothing. The connection is the one the resolver was
+ * asked about; a reply callback is handed it as its first argument. */
+void link_uid_resolved(link_connection_t *conn, link_authz_t tok, uid_t uid);
+
+/* Called with the reply to an outbound link_connection_call(). `reply`
+ * is NULL if the connection dropped before one arrived. */
+typedef void (*link_reply_cb_t)(link_connection_t *conn, const link_reply_t *reply,
+ void *userdata);
+
+/* Issue a method call on an established connection and invoke `cb`
+ * when the reply lands. Unlike link_client_call() this never blocks:
+ * the reply is picked up by the normal read loop. Argument marshalling
+ * matches link_client_call_v(). */
+int link_connection_call(link_connection_t *conn, const char *destination,
+ const char *path, const char *interface, const char *member,
+ link_reply_cb_t cb, void *userdata,
+ const char *signature, ...);
+
+/* Nothing in libink runs a clock, it has no event loop, so calls that
+ * go unanswered in either direction are the embedder's to time out.
+ * This drops anything held longer than `age_ms` on one connection: a
+ * park whose resolver never answered, which leaves its caller
+ * org.freedesktop.DBus.Error.TimedOut, and a call whose reply never
+ * came, whose callback runs once with a NULL reply exactly as a
+ * dropped connection would.
+ *
+ * Returns how many are still outstanding, so a sweep can stop
+ * rearming once nothing is left. */
+int link_connection_expire(link_connection_t *conn, unsigned int age_ms);
+
+/* ---------- server / connection lifecycle ---------- */
+
+/* Bind a listening socket at `path` with file mode `mode`, e.g. 0660
+ * to keep it to root and one group. The mode is applied at bind(),
+ * so the socket is never briefly more permissive than asked; setting
+ * the owning group afterwards is the caller's job. */
+int link_server_new (link_server_t **server, const char *path, mode_t mode);
+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_ATTACH_BROKER says the peer is a message bus rather than an
+ * ordinary client. A broker subscribes on behalf of its own clients
+ * and never sends us AddMatch, so signals go to it unconditionally
+ * instead of being filtered by this connection's match rules. */
+#define LINK_ATTACH_BROKER 0x01
+
+link_connection_t *link_server_attach(link_server_t *server, int fd, uid_t peer_uid,
+ unsigned int attach_flags);
+
+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 framework
+ * emits the variant signature from `sig`; the getter writes only the
+ * bare value into the provided writer (link_w_string for "s",
+ * link_w_u32 for "u", ...), so the declared type is the single
+ * source of truth. */
+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 with
+ * errno set on failure: EMSGSIZE and EINVAL mean nothing hit the
+ * wire and the connection is still usable; anything else is a
+ * transport failure that may have left a partial frame -- the
+ * caller must drop the peer. */
+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);
+
+/* Address subsequent calls on `c` to a well-known name. Needed when
+ * a broker routes the message, e.g. "org.freedesktop.DBus" to reach
+ * the bus driver itself; a brokerless link has a single peer and
+ * needs no destination, which is the default. `destination` is not
+ * copied, so it must outlive the client. */
+void link_client_set_destination(link_client_t *c, const char *destination);
+
+/* 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_begin (link_reader_t *r, char *type); /* sig header, cursor at value */
+int link_r_skip_basic (link_reader_t *r, char type); /* skip one basic value */
+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/log.c b/libink/log.c
new file mode 100644
index 00000000..226e263c
--- /dev/null
+++ b/libink/log.c
@@ -0,0 +1,40 @@
+/* libink — optional debug tracing, routed to whoever embeds us.
+ *
+ * libink has no logger of its own by design: it must not depend on the
+ * host's logging, and PID 1 already has one. The embedder installs a
+ * callback and gets a line per connection, call, and authorization
+ * decision. Formatting happens before the callback, so an embedder
+ * that wants tracing off should uninstall rather than filter.
+ *
+ * Copyright (c) 2026 Joachim Wiberg
+ * SPDX-License-Identifier: MIT
+ */
+
+#include
+#include
+
+#include "internal.h"
+
+static link_log_cb_t logger;
+static void *logger_userdata;
+
+void link_set_logger(link_log_cb_t cb, void *userdata)
+{
+ logger = cb;
+ logger_userdata = userdata;
+}
+
+void __log(const char *func, const char *fmt, ...)
+{
+ char msg[256];
+ va_list ap;
+
+ if (!logger)
+ return;
+
+ va_start(ap, fmt);
+ vsnprintf(msg, sizeof(msg), fmt, ap);
+ va_end(ap);
+
+ logger(logger_userdata, func, msg);
+}
diff --git a/libink/marshal.c b/libink/marshal.c
new file mode 100644
index 00000000..ecc41166
--- /dev/null
+++ b/libink/marshal.c
@@ -0,0 +1,409 @@
+/* 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); }
+
+/*
+ * Parse a variant's signature header, i.e. "g" wire form: 1-byte
+ * length, bytes, NUL. Only single-character inner signatures are
+ * supported. On success the cursor sits at the value and the type
+ * code is returned in *type.
+ */
+int __r_variant_begin(struct link_reader *r, char *type)
+{
+ uint8_t sig_len;
+
+ if (r_skip_align(r, 1) < 0 || r->off + 1 > r->cap)
+ goto fail;
+ sig_len = r->base[r->off++];
+ if (sig_len != 1 || r->off + 2 > r->cap)
+ goto fail;
+ if (r->base[r->off + 1] != 0)
+ goto fail;
+ *type = (char)r->base[r->off];
+ r->off += 2;
+ return 0;
+fail:
+ r->err = 1;
+ return -1;
+}
+
+/* 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)
+{
+ char type;
+
+ *out = NULL;
+ if (__r_variant_begin(r, &type) < 0)
+ return -1;
+ if (type != 's') {
+ r->err = 1;
+ return -1;
+ }
+ return read_string_like(r, out);
+}
+
+/*
+ * Skip one basic value of the given type code, as returned by
+ * __r_variant_begin(). Lets a{sv} consumers tolerate value types
+ * they don't know. Returns -1 on non-basic types.
+ */
+int __r_skip_basic(struct link_reader *r, char type)
+{
+ const char *s;
+ uint32_t u;
+ uint8_t y;
+
+ switch (type) {
+ case 's':
+ case 'o':
+ return read_string_like(r, &s);
+ case 'b':
+ case 'u':
+ case 'i':
+ return __r_u32(r, &u);
+ case 'y':
+ return __r_byte(r, &y);
+ default:
+ r->err = 1;
+ return -1;
+ }
+}
+
+/* Read a variant "v" expected to contain a uint32. */
+int __r_variant_u32(struct link_reader *r, uint32_t *out)
+{
+ char type;
+
+ if (__r_variant_begin(r, &type) < 0)
+ return -1;
+ if (type != 'u') {
+ r->err = 1;
+ return -1;
+ }
+
+ return __r_u32(r, out);
+}
+
+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;
+}
+
+ssize_t __marshal_va(uint8_t *body, size_t cap, const char *sig, va_list ap)
+{
+ struct link_writer w;
+ const char *s;
+
+ __w_init(&w, body, cap);
+ for (s = sig; *s; s++) {
+ switch (*s) {
+ case 'y':
+ __w_byte(&w, (uint8_t)va_arg(ap, int));
+ break;
+ case 'b':
+ __w_bool(&w, va_arg(ap, int));
+ break;
+ case 'u':
+ __w_u32(&w, va_arg(ap, uint32_t));
+ break;
+ case 's':
+ __w_string(&w, va_arg(ap, const char *));
+ break;
+ case 'o':
+ __w_path(&w, va_arg(ap, const char *));
+ break;
+ default:
+ return -1;
+ }
+ }
+
+ return __w_finish(&w);
+}
diff --git a/libink/marshal.h b/libink/marshal.h
new file mode 100644
index 00000000..0a210989
--- /dev/null
+++ b/libink/marshal.h
@@ -0,0 +1,63 @@
+/* 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
+#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_begin (struct link_reader *r, char *type); /* sig header, cursor at value */
+int __r_skip_basic (struct link_reader *r, char type); /* skip one basic value */
+int __r_variant_string(struct link_reader *r, const char **out); /* "v" containing "s" */
+int __r_variant_u32 (struct link_reader *r, uint32_t *out); /* "v" containing "u" */
+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);
+
+/* Marshal varargs into `body` (capacity `cap`) according to `sig`.
+ * Returns the marshalled length, or -1 on overflow or an unsupported
+ * type code. Shared by the synchronous client and the asynchronous
+ * connection-side call. */
+ssize_t __marshal_va(uint8_t *body, size_t cap, const char *sig, va_list ap);
+
+#endif /* LIBINK_MARSHAL_H_ */
diff --git a/libink/match.c b/libink/match.c
new file mode 100644
index 00000000..eaaa87f5
--- /dev/null
+++ b/libink/match.c
@@ -0,0 +1,218 @@
+/* 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.
+ *
+ * The exceptions are sender, destination, and eavesdrop, which are
+ * accepted and then ignored. Clients send them as a matter of
+ * course, and refusing the rule leaves such a peer with no signals at
+ * all, which serves it far worse than a filter wider than it asked
+ * for. Widening is safe here: Finit is the only sender on this bus,
+ * and everything it emits through the match table is state any peer
+ * that reached the bus may already read.
+ *
+ * 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 if (!strcmp(key, "sender") ||
+ !strcmp(key, "destination") ||
+ !strcmp(key, "eavesdrop")) {
+ /* Understood well enough to accept, see above. */
+ free(key);
+ free(value);
+ continue;
+ } else {
+ /* XXX: argN and argNpath land here, so a rule
+ * using them takes nothing rather than too
+ * much. They narrow on body contents, which
+ * means parsing the body to honour them. */
+ 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..15068f14
--- /dev/null
+++ b/libink/proto.c
@@ -0,0 +1,406 @@
+/* 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;
+
+ /* XXX: a big-endian sender is refused rather than byte-swapped.
+ * Every conforming client on the platforms Finit targets sends
+ * 'l', so the swap paths have not been worth writing. */
+ 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 *destination,
+ 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 (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_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;
+}
+
+void __msg_to_reply(link_reply_t *r, const struct link_msg *m)
+{
+ r->type = m->type;
+ r->signature = m->signature;
+ r->error_name = m->error_name;
+ r->path = m->path;
+ r->interface = m->interface;
+ r->member = m->member;
+ r->body = m->body_avail ? m->body : NULL;
+ r->body_len = m->body_avail;
+}
diff --git a/libink/proto.h b/libink/proto.h
new file mode 100644
index 00000000..0ea345b1
--- /dev/null
+++ b/libink/proto.h
@@ -0,0 +1,108 @@
+/* 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);
+
+/* Project a parsed message onto the public reply view. Shared by the
+ * synchronous client and the connection-side reply routing so the two
+ * cannot drift as link_reply_t grows. */
+void __msg_to_reply(link_reply_t *r, const struct link_msg *m);
+
+/* 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). `destination` is NULL
+ * when no broker routes the message. */
+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 *destination,
+ 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..9c32c5ce
--- /dev/null
+++ b/libink/server.c
@@ -0,0 +1,229 @@
+/* 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, mode_t mode)
+{
+ 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 the mode the
+ * caller asked for atomically, with no window where it is more
+ * permissive. Who may connect is the caller's policy to set;
+ * per-method authorization happens later in dispatch via
+ * SO_PEERCRED. */
+ __dbg("binding %s, mode %04o", path, (unsigned)mode);
+ {
+ mode_t oldmask = umask(0777 & ~mode);
+ 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);
+
+ __dbg("new peer on fd %d, uid %d", cfd, (int)conn->peer_uid);
+
+ *out = conn;
+ return 0;
+}
+
+void link_server_set_uid_resolver(link_server_t *srv, link_uid_resolver_t cb, void *userdata)
+{
+ if (!srv)
+ return;
+ srv->uid_resolver = cb;
+ srv->uid_userdata = userdata;
+}
+
+void link_server_set_authorizer(link_server_t *srv, link_authorizer_t cb, void *userdata)
+{
+ if (!srv)
+ return;
+ srv->authorizer = cb;
+ srv->authz_userdata = userdata;
+}
+
+link_connection_t *link_server_attach(link_server_t *srv, int fd, uid_t peer_uid,
+ unsigned int attach_flags)
+{
+ 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;
+ conn->broker = !!(attach_flags & LINK_ATTACH_BROKER);
+ __auth_generate_guid(conn->guid);
+
+ __dbg("attached %s peer on fd %d, uid %d",
+ conn->broker ? "broker" : "external", fd, (int)peer_uid);
+
+ return conn;
+
+err_close:
+ close_save_errno(fd);
+ return NULL;
+}
diff --git a/mkdocs.yml b/mkdocs.yml
index 2ccce539..674a554a 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -56,6 +56,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/plugins/dbus.c b/plugins/dbus.c
index 80c653e9..39b6ec02 100644
--- a/plugins/dbus.c
+++ b/plugins/dbus.c
@@ -32,22 +32,11 @@
#include "config.h"
#include "helpers.h"
#include "plugin.h"
-#include "service.h"
-#include "util.h"
#include "conf.h"
+#include "util.h"
#include "log.h"
#define DBUS_DAEMON "dbus-daemon"
-#define DBUS_ARGS "--nofork --system --syslog-only"
-#define DBUS_DESC "D-Bus message bus daemon"
-
-#ifndef DBUS_DAEMONUSER
-#define DBUS_DAEMONUSER "messagebus"
-#endif
-
-#ifndef DBUS_DAEMONGROUP
-#define DBUS_DAEMONGROUP "messagebus"
-#endif
/*
* Dumnpster diving for the D-Bus main configuration file
@@ -96,79 +85,35 @@ static char *dbus_pidfn(void)
return NULL;
}
+/*
+ * The directories live in tmpfiles.d/dbus.conf and the service in
+ * system/20-dbus.conf, both of which an administrator can override.
+ * What is left needs to look at the running system, so it stays here.
+ */
static void setup(void *arg)
{
- char *group = DBUS_DAEMONGROUP;
- char *user = DBUS_DAEMONUSER;
- char esccmd[256];
- char pid[300];
char *pidfn;
- mode_t prev;
- char *cmd;
if (rescue) {
dbg("Skipping %s plugin in rescue mode.", "dbus");
return;
}
- cmd = which(DBUS_DAEMON);
- if (!cmd) {
+ if (!whichp(DBUS_DAEMON)) {
dbg("Skipping plugin, %s is not installed.", DBUS_DAEMON);
return;
}
- if (getuser(user, NULL) == -1) {
- if (getuser("dbus", NULL) == -1)
- user = "root"; /* fallback */
- else
- user = "dbus"; /* e.g., Buildroot */
- }
-
- if (getgroup(group) == -1) {
- if (getgroup("dbus") == -1)
- group = "root"; /* fallback */
- else
- group = "dbus"; /* e.g., Buildroot */
- }
-
/* Clean up from any previous pre-bootstrap run */
pidfn = dbus_pidfn();
- if (pidfn)
+ if (pidfn) {
remove(pidfn);
-
- dbg("Creating D-Bus Required Directories ...");
- prev = umask(0);
- mksubsys("/var/run/dbus", 0755, user, group);
- mksubsys("/var/run/lock/subsys", 0755, user, group);
- mksubsys("/var/lib/dbus", 0755, user, group);
- mksubsys("/tmp/dbus", 0755, user, group);
- umask(prev);
+ free(pidfn);
+ }
/* Generate machine id for dbus */
if (whichp("dbus-uuidgen"))
run_interactive("dbus-uuidgen --ensure", "Verifying D-Bus machine UUID");
-
- /*
- * Register service with Finit
- * Note: dbus drops privs after starting up.
- */
- pid[0] = 0;
- if (pidfn) {
- char esc[280];
-
- snprintf(pid, sizeof(pid), "\tpidfile = \"%s\"\n",
- conf_escape(pidfn, esc, sizeof(esc)));
- free(pidfn);
- }
- conf_save_service(SVC_TYPE_SERVICE, "dbus", "dbus.conf",
- "\tdescription = \"" DBUS_DESC "\"\n"
- "\trunlevel = \"S123456789\"\n"
- "\tnotify = \"none\"\n"
- "\tcgroup system {}\n"
- "%s"
- "\tcommand = \"%s " DBUS_ARGS "\"\n",
- pid, conf_escape(cmd, esccmd, sizeof(esccmd)));
- free(cmd);
}
static plugin_t plugin = {
diff --git a/src/Makefile.am b/src/Makefile.am
index df8a8dfd..0c065e6a 100644
--- a/src/Makefile.am
+++ b/src/Makefile.am
@@ -81,6 +81,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
@@ -94,6 +97,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 \
@@ -101,6 +108,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 b60a58d9..be14e215 100644
--- a/src/api.c
+++ b/src/api.c
@@ -113,38 +113,7 @@ static int restart(svc_t *svc, void *user_data)
static int reload(svc_t *svc, void *user_data)
{
(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)) {
- svc_cond_clear(svc);
- 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 b3662d6e..fc3d2150 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;
@@ -353,6 +360,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);
}
@@ -383,6 +393,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);
}
@@ -404,6 +417,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..8f18f1f0
--- /dev/null
+++ b/src/dbus.c
@@ -0,0 +1,1721 @@
+/* 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
+
+#include
+
+#include "link.h"
+#include "path.h"
+
+#include "finit.h"
+#include "cond.h"
+#include "conf.h"
+#include "log.h"
+#include "private.h"
+#include "schedule.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;
+ int dead;
+ TAILQ_ENTRY(peer) link;
+};
+
+static TAILQ_HEAD(, peer) peers = TAILQ_HEAD_INITIALIZER(peers);
+static TAILQ_HEAD(, peer) reapq = TAILQ_HEAD_INITIALIZER(reapq);
+static link_server_t *server;
+static uev_t accept_watcher;
+static size_t peer_count;
+static struct peer *sysbus_peer;
+
+static void sysbus_probe(void);
+static void sender_cache_flush(void);
+
+/* libink has no logger of its own, so give it ours. The func name it
+ * passes takes the place of dbg()'s __func__, which would otherwise
+ * always read "link_log_cb". */
+static void link_log_cb(void *userdata, const char *func, const char *msg)
+{
+ (void)userdata;
+ logit(LOG_DEBUG, "%s():%s", func, msg);
+}
+
+/* Close and free everything dropped since the last time round the
+ * event loop. Safe here because no connection's read loop is on the
+ * stack; see peer_drop(). */
+static void peer_reap(void *arg)
+{
+ struct peer *p;
+
+ (void)arg;
+ while ((p = TAILQ_FIRST(&reapq))) {
+ TAILQ_REMOVE(&reapq, p, link);
+ link_connection_close(p->conn);
+ free(p);
+ }
+}
+
+/* Not zero: a uev timer armed with a zero timeout is a disarmed
+ * timer, so the work would never run and the connections would leak.
+ * Any short delay does, the peer is already unlinked and its watcher
+ * stopped, so nothing touches it in the meantime. */
+static struct wq reap_work = { .cb = peer_reap, .delay = 10 };
+
+/*
+ * Nothing in libink can time itself out, it has no event loop, so the
+ * deadline for a parked call and for a call we made on the broker is
+ * ours to keep. The sweep only runs while something is outstanding:
+ * expire_arm() starts it, and it stops rearming as soon as nothing is
+ * left, so a system that never talks to a broker never wakes up for
+ * this.
+ */
+#define DBUS_CALL_TIMEOUT_MS 5000
+#define DBUS_SWEEP_MS 1000
+
+static void expire_sweep(void *arg);
+static struct wq expire_work = { .cb = expire_sweep, .delay = DBUS_SWEEP_MS };
+static int expire_armed;
+
+/* Idempotent: several parks in one turn of the loop share one sweep. */
+static void expire_arm(void)
+{
+ if (expire_armed)
+ return;
+ if (!schedule_work(&expire_work))
+ expire_armed = 1;
+}
+
+static void expire_sweep(void *arg)
+{
+ struct peer *p, *tmp;
+ int live = 0;
+
+ (void)arg;
+ expire_armed = 0;
+
+ /* _SAFE because expiring a call runs its callback, and a
+ * callback that ends up writing to a peer can drop it, which
+ * unlinks it from this very list. */
+ TAILQ_FOREACH_SAFE(p, &peers, link, tmp) {
+ if (!p->dead)
+ live += link_connection_expire(p->conn, DBUS_CALL_TIMEOUT_MS);
+ }
+
+ if (live)
+ expire_arm();
+}
+
+/*
+ * A peer can be dropped from inside its own read loop: a handler emits
+ * a signal, the write to this very peer fails, and dbus_emit_signal()
+ * lands here while link_connection_process() still holds the
+ * connection and will touch its rx buffer on the way out. Freeing now
+ * would pull that out from under it, so unlink the peer and let the
+ * event loop free it once the stack has unwound.
+ */
+static void peer_drop(struct peer *p)
+{
+ if (p->dead)
+ return; /* already on its way out */
+ p->dead = 1;
+
+ uev_io_stop(&p->watcher);
+ TAILQ_REMOVE(&peers, p, link);
+ peer_count--;
+
+ /* broker gone; the notify paths probe for its return. Its unique
+ * names die with it, so nothing we learned about them is safe to
+ * carry over to whatever takes its place. */
+ if (p == sysbus_peer) {
+ sysbus_peer = NULL;
+ sender_cache_flush();
+ }
+
+ TAILQ_INSERT_TAIL(&reapq, p, link);
+ schedule_work(&reap_work);
+}
+
+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[MAX_IDENT_LEN];
+
+ 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[MAX_IDENT_LEN];
+ 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;
+ int rc;
+
+ if (link_call_read_string(call, &ident) < 0)
+ return link_call_reply_error(call,
+ "org.freedesktop.DBus.Error.InvalidArgs",
+ "expected (s)");
+
+ rc = dispatch_action(ident, action, NULL);
+ if (rc < 0)
+ return link_call_reply_error(call,
+ "org.finit.Error.NoSuchService", ident);
+ if (rc)
+ return link_call_reply_error(call,
+ "org.finit.Error.Failed",
+ "failed on matched service(s)");
+
+ (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;
+ /*
+ * Same semantics as api.c: harmless no-op during bootstrap
+ * and shutdown, the client still sees success.
+ */
+ if (IS_RESERVED_RUNLEVEL(runlevel))
+ warnx("Ignoring reload in runlevel S and 6/0.");
+ else
+ 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, rc;
+
+ (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;
+ rc = dispatch_action(ident, signal_one, &sig);
+ if (rc < 0)
+ return link_call_reply_error(call,
+ "org.finit.Error.NoSuchService", ident);
+ if (rc)
+ return link_call_reply_error(call,
+ "org.finit.Error.Failed",
+ "failed signalling matched service(s)");
+
+ (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. The values
+ * use the same encoding as the runlevel(8) command and `initctl
+ * runlevel`: "S" for single-user, "N" for no previous runlevel --
+ * the internal digit is not a wire format.
+ */
+static const char *runlevel_encode(int level, char *buf, size_t len)
+{
+ if (level == INIT_LEVEL)
+ strlcpy(buf, "S", len);
+ else if (level >= 0 && level <= 9)
+ snprintf(buf, len, "%d", level);
+ else
+ strlcpy(buf, "N", len);
+
+ return buf;
+}
+
+static int prop_runlevel(link_writer_t *w, void *u)
+{
+ char buf[8];
+
+ (void)u;
+ link_w_string(w, runlevel_encode(runlevel, buf, sizeof(buf)));
+ return 0;
+}
+
+static int prop_prevrunlevel(link_writer_t *w, void *u)
+{
+ char buf[8];
+
+ (void)u;
+ if (prevlevel <= 0 || prevlevel > 9)
+ strlcpy(buf, "N", sizeof(buf));
+ else
+ snprintf(buf, sizeof(buf), "%d", prevlevel);
+ link_w_string(w, buf);
+ return 0;
+}
+
+static int prop_version(link_writer_t *w, void *u)
+{
+ (void)u;
+ link_w_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 }
+};
+
+/*
+ * Getters write the bare value; the framework emits the variant
+ * signature from the table below. `State` deliberately uses the
+ * initctl status vocabulary from svc_status(), not the coarser
+ * ServiceStateChanged strings -- a client that only tracks edges
+ * has the signal, a client that asks gets the full story.
+ */
+#define SVC_PROP_STR(fn, field) \
+static int fn(link_writer_t *w, void *arg) \
+{ \
+ link_w_string(w, ((svc_t *)arg)->field); \
+ return 0; \
+}
+
+#define SVC_PROP_U32(fn, field) \
+static int fn(link_writer_t *w, void *arg) \
+{ \
+ link_w_u32(w, (uint32_t)((svc_t *)arg)->field); \
+ return 0; \
+}
+
+#define SVC_PROP_BOOL(fn, field) \
+static int fn(link_writer_t *w, void *arg) \
+{ \
+ link_w_bool(w, ((svc_t *)arg)->field); \
+ return 0; \
+}
+
+static int svc_prop_identity(link_writer_t *w, void *arg)
+{
+ link_w_string(w, svc_ident(arg, NULL, 0));
+ return 0;
+}
+
+static int svc_prop_state(link_writer_t *w, void *arg)
+{
+ link_w_string(w, svc_status(arg));
+ return 0;
+}
+
+static int svc_prop_type(link_writer_t *w, void *arg)
+{
+ link_w_string(w, svc_typestr((svc_t *)arg));
+ return 0;
+}
+
+static int svc_prop_command(link_writer_t *w, void *arg)
+{
+ svc_t *svc = arg;
+ char buf[512];
+
+ compose_cmdline(svc, buf, sizeof(buf));
+ if (svc_is_sysv(svc)) {
+ strlcat(buf, " ", sizeof(buf));
+ strlcat(buf, svc->state == SVC_HALTED_STATE
+ ? "stop" : "start", sizeof(buf));
+ }
+
+ link_w_string(w, buf);
+ return 0;
+}
+
+static int svc_prop_pid(link_writer_t *w, void *arg)
+{
+ svc_t *svc = arg;
+
+ link_w_u32(w, svc->pid > 0 ? (uint32_t)svc->pid : 0);
+ return 0;
+}
+
+static int svc_prop_restarts(link_writer_t *w, void *arg)
+{
+ svc_t *svc = arg;
+
+ link_w_u32(w, svc->restart_cnt > 0 ? (uint32_t)svc->restart_cnt : 0);
+ return 0;
+}
+
+static int svc_prop_uptime(link_writer_t *w, void *arg)
+{
+ svc_t *svc = arg;
+ long up = 0;
+
+ if (svc->pid > 0) {
+ up = jiffies() - svc->start_time;
+ if (up < 0)
+ up = 0;
+ }
+ link_w_u32(w, (uint32_t)up);
+ return 0;
+}
+
+SVC_PROP_STR (svc_prop_name, name)
+SVC_PROP_STR (svc_prop_desc, desc)
+SVC_PROP_STR (svc_prop_conditions, cond)
+SVC_PROP_STR (svc_prop_origin, file)
+SVC_PROP_STR (svc_prop_environ, env)
+SVC_PROP_STR (svc_prop_pidfile, pidfile)
+SVC_PROP_STR (svc_prop_user, username)
+SVC_PROP_STR (svc_prop_group, group)
+SVC_PROP_U32 (svc_prop_runlevels, runlevels)
+SVC_PROP_U32 (svc_prop_exitstatus, status)
+SVC_PROP_U32 (svc_prop_restarts_tot, restart_tot)
+SVC_PROP_U32 (svc_prop_restart_max, restart_max)
+SVC_PROP_U32 (svc_prop_starts, once)
+SVC_PROP_BOOL(svc_prop_manual, manual)
+SVC_PROP_BOOL(svc_prop_forking, forking)
+SVC_PROP_BOOL(svc_prop_started, started)
+
+static const link_property_t service_properties[] = {
+ { .name = "Identity", .sig = "s", .getter = svc_prop_identity },
+ { .name = "Name", .sig = "s", .getter = svc_prop_name },
+ { .name = "State", .sig = "s", .getter = svc_prop_state },
+ { .name = "Pid", .sig = "u", .getter = svc_prop_pid },
+ { .name = "RestartCount", .sig = "u", .getter = svc_prop_restarts },
+ { .name = "Runlevels", .sig = "u", .getter = svc_prop_runlevels },
+ { .name = "Description", .sig = "s", .getter = svc_prop_desc },
+ { .name = "Command", .sig = "s", .getter = svc_prop_command },
+ { .name = "Conditions", .sig = "s", .getter = svc_prop_conditions },
+ { .name = "Type", .sig = "s", .getter = svc_prop_type },
+ { .name = "Origin", .sig = "s", .getter = svc_prop_origin },
+ { .name = "Environment", .sig = "s", .getter = svc_prop_environ },
+ { .name = "PidFile", .sig = "s", .getter = svc_prop_pidfile },
+ { .name = "User", .sig = "s", .getter = svc_prop_user },
+ { .name = "Group", .sig = "s", .getter = svc_prop_group },
+ { .name = "Uptime", .sig = "u", .getter = svc_prop_uptime },
+ { .name = "ExitStatus", .sig = "u", .getter = svc_prop_exitstatus },
+ { .name = "RestartsTotal",.sig = "u", .getter = svc_prop_restarts_tot },
+ { .name = "RestartMax", .sig = "u", .getter = svc_prop_restart_max },
+ { .name = "Starts", .sig = "u", .getter = svc_prop_starts },
+ { .name = "ManualStart", .sig = "b", .getter = svc_prop_manual },
+ { .name = "Forking", .sig = "b", .getter = svc_prop_forking },
+ { .name = "Started", .sig = "b", .getter = svc_prop_started },
+ { NULL, NULL, NULL }
+};
+
+static const link_vtable_t service_vtable = {
+ .interface = "org.finit.Service1",
+ .methods = service_methods,
+ .properties = service_properties,
+};
+
+/* 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. */
+/* A peer that has gone away is routine rather than a fault: initctl
+ * calls and exits, and on the way down every peer goes at once, which
+ * is not something an operator watching the shutdown needs to read
+ * about. Keep the warning for a write that failed for some other
+ * reason, where something really is wrong. */
+static int quiet_drop(int err)
+{
+ if (err == EPIPE || err == ECONNRESET || err == ENOTCONN)
+ return 1;
+
+ return runlevel == 0 || runlevel == 6;
+}
+
+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, *tmp;
+
+ if (!server || TAILQ_EMPTY(&peers))
+ return;
+ TAILQ_FOREACH_SAFE(p, &peers, link, tmp) {
+ int err;
+
+ if (link_connection_emit_signal(p->conn,
+ path, interface, member,
+ signature, body, body_len) >= 0)
+ continue;
+
+ err = errno;
+
+ /* nothing hit the wire, and same for every peer */
+ if (err == EMSGSIZE || err == EINVAL)
+ break;
+
+ logit(quiet_drop(err) ? LOG_DEBUG : LOG_WARNING,
+ "D-Bus peer fd %d write failed: %s, dropping",
+ link_connection_get_fd(p->conn), strerror(err));
+ peer_drop(p);
+ }
+}
+
+/* ---------- 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 -Wall (-Wswitch) flags the
+ * missing case.
+ */
+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];
+ char path[FINIT_SVC_PATH_MAX];
+ ssize_t blen;
+
+ if (!svc)
+ return;
+
+ sysbus_probe();
+
+ 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);
+
+ /*
+ * Dual emission: Properties-aware clients track one object via
+ * the standard PropertiesChanged instead of filtering the
+ * manager-wide signal. Volatile numerics are invalidated, not
+ * marshalled -- interested clients re-Get.
+ */
+ if (service_path_for(svc, path, sizeof(path)) < 0)
+ return;
+
+ link_writer_init(&w, body, sizeof(body));
+ link_w_string(&w, "org.finit.Service1");
+ link_w_array_begin(&w, '{');
+ link_w_struct_begin(&w);
+ link_w_string(&w, "State");
+ link_w_variant_string(&w, svc_status(svc));
+ link_w_struct_end(&w);
+ link_w_array_end(&w);
+ link_w_array_begin(&w, 's');
+ link_w_string(&w, "Pid");
+ link_w_string(&w, "RestartCount");
+ link_w_array_end(&w);
+ blen = link_writer_finish(&w);
+ if (blen < 0)
+ return;
+
+ dbus_emit_signal(path, "org.freedesktop.DBus.Properties",
+ "PropertiesChanged", "sa{sv}as",
+ body, (size_t)blen);
+}
+
+/* ---------- signal emission: RunlevelChanged ----------
+ *
+ * Fired by sm.c right after the runlevel global flips. Body is
+ * (old, new) in the same runlevel(8) encoding as the Manager1
+ * Runlevel property: digits, "S", or "N". */
+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;
+
+ runlevel_encode(old_level, old_s, sizeof(old_s));
+ runlevel_encode(new_level, new_s, sizeof(new_s));
+
+ 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;
+
+ sysbus_probe();
+
+ 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);
+}
+
+/* ---------- who may change things ----------
+ *
+ * Root, or a member of the group the bus socket is owned by, which is
+ * the same set --with-group already lets connect. Both gates then say
+ * the same thing, rather than the socket admitting the wheel group and
+ * every method turning it away.
+ *
+ * On the local bus the kernel already made this decision at connect(),
+ * supplementary groups and all, so the lookup only confirms it. The
+ * system bus has no socket mode to lean on, which is why we ask here
+ * rather than trusting the connection.
+ *
+ * Deliberately uncached: /etc/group changes while Finit runs, and a
+ * privileged call is an operator action, not a hot path.
+ */
+static int caller_is_privileged(uid_t uid, void *userdata)
+{
+ (void)userdata;
+
+ if (uid == 0)
+ return 1;
+
+ /* Group membership comes from NSS, which the C library loads
+ * with dlopen(), so a build meant to link statically cannot
+ * count on it. Compiled out rather than left to fail open:
+ * root only there, see doc/dbus.md. */
+#ifndef ENABLE_STATIC
+ {
+ gid_t groups[NGROUPS_MAX];
+ int ngroups = NGROUPS_MAX;
+ struct passwd *pw;
+ int gid, i;
+
+ gid = getgroup(DEFGROUP);
+ if (gid < 0)
+ return 0;
+
+ pw = getpwuid(uid);
+ if (!pw)
+ return 0;
+
+ if (getgrouplist(pw->pw_name, pw->pw_gid, groups, &ngroups) < 0)
+ return 0;
+
+ for (i = 0; i < ngroups; i++) {
+ if (groups[i] == (gid_t)gid)
+ return 1;
+ }
+ }
+#endif
+
+ return 0;
+}
+
+/* ---------- caller identity on the system bus ----------
+ *
+ * libink parks a privileged call and asks us who sent it; we ask the
+ * bus driver with GetConnectionUnixUser and answer when the reply
+ * lands, through the same event loop as everything else.
+ *
+ * A bus never reuses a unique name, so an answer holds for as long as
+ * that bus runs. It does not survive the bus restarting, though:
+ * a new dbus-daemon numbers from scratch and :1.7 becomes somebody
+ * else, so peer_drop() empties the cache when the broker goes.
+ *
+ * It is a ring: the oldest entry loses on overflow, and losing one
+ * only costs another round trip.
+ */
+#define SENDER_CACHE_LEN 16
+
+struct sender_uid {
+ char name[LINK_SENDER_MAX];
+ uid_t uid;
+};
+
+static struct sender_uid sender_cache[SENDER_CACHE_LEN];
+static unsigned sender_next;
+
+static void sender_cache_flush(void)
+{
+ memset(sender_cache, 0, sizeof(sender_cache));
+ sender_next = 0;
+}
+
+static int sender_cached(const char *sender, uid_t *uid)
+{
+ int i;
+
+ for (i = 0; i < SENDER_CACHE_LEN; i++) {
+ if (sender_cache[i].name[0] && !strcmp(sender_cache[i].name, sender)) {
+ *uid = sender_cache[i].uid;
+ return 1;
+ }
+ }
+
+ return 0;
+}
+
+static void sender_remember(const char *sender, uid_t uid)
+{
+ unsigned i = sender_next++ % SENDER_CACHE_LEN;
+
+ strlcpy(sender_cache[i].name, sender, sizeof(sender_cache[i].name));
+ sender_cache[i].uid = uid;
+}
+
+/* One outstanding GetConnectionUnixUser. Freed by the reply callback,
+ * which libink guarantees to run exactly once, with a NULL reply if
+ * the connection drops first. */
+struct uid_query {
+ link_authz_t tok;
+ char sender[LINK_SENDER_MAX];
+};
+
+static void uid_reply_cb(link_connection_t *conn, const link_reply_t *reply, void *userdata)
+{
+ struct uid_query *q = userdata;
+ uid_t uid = (uid_t)-1;
+ uint32_t val;
+
+ if (!reply) {
+ dbg("connection dropped before %s was identified", q->sender);
+ } else if (reply->type == LINK_MSG_METHOD_RETURN &&
+ link_reply_get_u32(reply, &val) == 0) {
+ uid = (uid_t)val;
+ sender_remember(q->sender, uid);
+ dbg("sender %s is uid %d", q->sender, (int)uid);
+ } else {
+ dbg("GetConnectionUnixUser(%s) failed: %s", q->sender,
+ reply->error_name ? reply->error_name : "unexpected reply");
+ }
+
+ link_uid_resolved(conn, q->tok, uid);
+ free(q);
+}
+
+static int sysbus_uid_resolver(link_connection_t *conn, const char *sender,
+ link_authz_t tok, uid_t *uid, void *userdata)
+{
+ struct uid_query *q;
+
+ (void)userdata;
+
+ /* Never truncate: a shortened key could match a different
+ * sender and hand it someone else's privileges. */
+ if (strlen(sender) >= LINK_SENDER_MAX)
+ return -1;
+
+ if (sender_cached(sender, uid)) {
+ dbg("sender %s is uid %d, from cache", sender, (int)*uid);
+ return 0;
+ }
+
+ dbg("asking the bus driver who %s is ...", sender);
+
+ q = calloc(1, sizeof(*q));
+ if (!q)
+ return -1;
+ q->tok = tok;
+ strlcpy(q->sender, sender, sizeof(q->sender));
+
+ if (link_connection_call(conn, "org.freedesktop.DBus", "/org/freedesktop/DBus",
+ "org.freedesktop.DBus", "GetConnectionUnixUser",
+ uid_reply_cb, q, "s", sender) < 0) {
+ dbg("Failed asking the bus driver about %s: %s", sender, strerror(errno));
+ free(q);
+ return -1;
+ }
+
+ /* Both the call and the park it belongs to now have a deadline
+ * to answer by, so make sure something is watching the clock. */
+ expire_arm();
+
+ return 1; /* parked; uid_reply_cb() answers */
+}
+
+/* ---------- 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 (uid_t)-1 because the connection has no single owner;
+ * who is calling is established per message by sysbus_uid_resolver()
+ * below.
+ *
+ * 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
+
+/* sysbus_probe() re-runs on every service and condition change, so a
+ * broker that keeps refusing would repeat itself for every event, and
+ * before syslog is up each line is an open/write/close on /dev/kmsg.
+ * Say it once, then trace, until an attach succeeds. */
+static int sysbus_warned;
+
+#define sysbus_level() (sysbus_warned ? LOG_DEBUG : LOG_WARNING)
+
+/* What the broker said, for the log. A refused call carries an error
+ * name; anything that failed below that has nothing to quote. */
+static const char *sysbus_errstr(link_client_t *c)
+{
+ const link_reply_t *r = link_client_reply(c);
+
+ return (r && r->error_name) ? r->error_name : "transport or parse failure";
+}
+
+static int sysbus_request_name(link_client_t *c)
+{
+ const char *reason;
+ uint32_t result;
+ 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)
+ reason = sysbus_errstr(c);
+ else if (link_reply_get_u32(link_client_reply(c), &result) < 0)
+ reason = "malformed RequestName reply";
+ else if (result != 1) /* 2/3/4 mean we did not get the name */
+ reason = "name already owned";
+ else
+ return 0;
+
+ logit(sysbus_level(), "Failed to claim %s on system bus: %s",
+ FINIT_BUS_NAME, reason);
+ sysbus_warned = 1;
+
+ return -1;
+}
+
+static int try_attach_system_bus(uev_ctx_t *ctx)
+{
+ link_client_t *c;
+ link_connection_t *conn;
+ struct peer *p;
+ 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 -1;
+ }
+
+ /* Unlike the local bus, a broker routes by destination, and it
+ * drops anything not addressed to the driver before Hello. */
+ link_client_set_destination(c, "org.freedesktop.DBus");
+
+ rc = link_client_call_v(c, "/org/freedesktop/DBus",
+ "org.freedesktop.DBus", "Hello", NULL);
+ if (rc != LINK_CALL_OK) {
+ logit(sysbus_level(), "System-bus Hello failed: %s",
+ sysbus_errstr(c));
+ sysbus_warned = 1;
+ link_client_close(c);
+ return -1;
+ }
+
+ if (sysbus_request_name(c) < 0) {
+ link_client_close(c);
+ return -1;
+ }
+
+ /* 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,
+ LINK_ATTACH_BROKER);
+ if (!conn)
+ return -1;
+
+ p = peer_register(ctx, conn);
+ if (!p) {
+ logit(LOG_WARNING, "Failed registering system-bus peer");
+ return -1;
+ }
+
+ sysbus_peer = p;
+ link_server_set_uid_resolver(server, sysbus_uid_resolver, NULL);
+ sysbus_warned = 0; /* arm the warning for a later broker restart */
+ logit(LOG_NOTICE, "Registered %s on system bus", FINIT_BUS_NAME);
+ return 0;
+}
+
+/*
+ * The broker is usually not up yet when dbus_init() runs -- it is
+ * typically a finit service itself -- and it may restart at any
+ * time. The service and condition notify paths call sysbus_probe()
+ * on every event: when org.finit is unclaimed and the broker's
+ * socket exists, one coalesced attach attempt is scheduled. This
+ * stays daemon-agnostic -- only the socket is probed, never a
+ * service name -- and the broker's own service transitions are what
+ * trigger it.
+ */
+static uev_t sysbus_tmr;
+static int sysbus_tmr_up;
+
+static void sysbus_probe_cb(uev_t *w, void *arg, int events)
+{
+ (void)arg;
+ (void)events;
+
+ if (!sysbus_peer)
+ (void)try_attach_system_bus(w->ctx);
+}
+
+static void sysbus_probe(void)
+{
+ if (sysbus_peer || !server)
+ return;
+ if (access(SYSTEM_BUS_PATH, F_OK))
+ return;
+
+ /* coalesce bursts to a single probe */
+ if (!sysbus_tmr_up)
+ sysbus_tmr_up = !uev_timer_init(ctx, &sysbus_tmr,
+ sysbus_probe_cb, NULL,
+ 200, 0);
+ else
+ uev_timer_set(&sysbus_tmr, 200, 0);
+}
+
+/* ---------- init / exit ---------- */
+
+int dbus_init(uev_ctx_t *ctx)
+{
+ dbg("Setting up D-Bus listening socket at %s ...", FINIT_BUS_SOCKET);
+
+ /* Same access policy as INIT_SOCKET: the bus reaches every
+ * service operation initctl does, so --with-group has to gate
+ * both or it gates neither. */
+ link_set_logger(link_log_cb, NULL);
+
+ if (link_server_new(&server, FINIT_BUS_SOCKET, 0660) < 0) {
+ err(1, "Failed binding D-Bus socket %s", FINIT_BUS_SOCKET);
+ return 1;
+ }
+
+ if (chown(FINIT_BUS_SOCKET, geteuid(), getgroup(DEFGROUP)))
+ err(1, "Failed setting group %s on %s", DEFGROUP, FINIT_BUS_SOCKET);
+
+ link_server_set_authorizer(server, caller_is_privileged, NULL);
+
+ 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);
+ }
+
+ (void)try_attach_system_bus(ctx);
+
+ return 0;
+}
+
+int dbus_exit(void)
+{
+ struct peer *p;
+
+ if (sysbus_tmr_up) {
+ uev_timer_stop(&sysbus_tmr);
+ sysbus_tmr_up = 0;
+ }
+ uev_io_stop(&accept_watcher);
+
+ while ((p = TAILQ_FIRST(&peers)))
+ peer_drop(p);
+
+ /* No read loop is running now, and the timer never will again. */
+ peer_reap(NULL);
+
+ 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 96061dd7..8c0e3dc8 100644
--- a/src/finit.c
+++ b/src/finit.c
@@ -780,6 +780,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 f0dbcf86..e5f0df70 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;
@@ -72,7 +79,6 @@ int iw, pw;
extern int reboot_main(int argc, char *argv[]);
-
/* figure ut width of IDENT and PID columns */
static void col_widths(void)
{
@@ -145,6 +151,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,24 +163,22 @@ 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));
}
-static int do_log(svc_t *svc, char *tail)
+static int do_log_named(const char *nm, pid_t pid, char *tail)
{
const char *logfile = "/var/log/syslog";
- pid_t pid;
- char *nm;
- if (svc) {
- nm = svc_ident(svc, NULL, 0);
- pid = svc->pid;
- if (!pid)
- return 0; /* not running */
- } else {
- nm = "finit";
- pid = 1;
- }
+ if (!pid)
+ return 0; /* not running */
if (!fexist(logfile)) {
logfile = "/var/log/messages";
@@ -180,6 +189,13 @@ static int do_log(svc_t *svc, char *tail)
return systemf("cat %s | grep '\\[%d\\]\\|%s' %s", logfile, pid, nm, tail);
}
+static int do_log(svc_t *svc, char *tail)
+{
+ if (svc)
+ return do_log_named(svc_ident(svc, NULL, 0), svc->pid, tail);
+ return do_log_named("finit", 1, tail);
+}
+
static int show_log(char *arg)
{
svc_t *svc = NULL;
@@ -193,6 +209,86 @@ static int show_log(char *arg)
return do_log(svc, "");
}
+#ifdef HAVE_DBUS
+/*
+ * Advance one a{sv} dict entry: key + variant type; the caller reads
+ * the value with the link_r_* matching `type`.
+ */
+static int dbus_dict_next(link_reader_t *r, const char **key, char *type)
+{
+ if (link_r_align(r, 8) < 0)
+ return -1;
+ if (link_r_string(r, key) < 0)
+ return -1;
+ return link_r_variant_begin(r, type);
+}
+
+/* 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;
+ char type;
+ size_t i;
+
+ if (dbus_dict_next(&reader, &key, &type) < 0)
+ goto fail;
+ if (type != 's' || link_r_string(&reader, &val) < 0)
+ goto fail;
+
+ for (i = 0; wanted[i]; i++) {
+ if (!strcmp(key, wanted[i])) {
+ strlcpy(out[i], val, out_sz);
+ break;
+ }
+ }
+ }
+
+ link_client_close(c);
+ return 0;
+fail:
+ link_client_close(c);
+ return -1;
+}
+
+#endif
+
static int do_runlevel(char *arg)
{
struct init_request rq = {
@@ -205,6 +301,19 @@ 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]) {
+ /* already in runlevel(8) encoding: digits, S, N */
+ printf("%s %s\n", prev_buf, curr_buf);
+ return 0;
+ }
+#endif
+
currlevel = runlevel_get(&prevlevel);
switch (currlevel) {
case 255:
@@ -268,19 +377,279 @@ 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;
+}
+
+/* Build the object path for a service identity, same encoding as the
+ * server side; use instead of a Manager1.GetService round-trip. */
+static int dbus_svc_path(const char *ident, char *path, size_t len)
+{
+ const char *prefix = "/org/finit/service/";
+ size_t plen = strlen(prefix);
+
+ if (!ident || !*ident || plen >= len)
+ return -1;
+ memcpy(path, prefix, plen);
+ return link_path_encode(ident, path + plen, len - plen);
+}
+
+/* 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];
+ link_client_t *c;
+ int rc;
+
+ if (dbus_svc_path(ident, path, sizeof(path)) < 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 +677,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 +686,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;
@@ -410,8 +798,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;
@@ -441,6 +827,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)");
@@ -460,6 +849,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
@@ -496,6 +901,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;
}
@@ -503,16 +912,16 @@ static int do_cond_get(char *arg) { return do_cond_act(arg, COND_GET); }
static int do_cond_set(char *arg) { return do_cond_act(arg, COND_SET); }
static int do_cond_clr(char *arg) { return do_cond_act(arg, COND_CLR); }
-static char *svc_cond(svc_t *svc, char *buf, size_t len, int ansi)
+static char *cond_string(const char *condstr, char *buf, size_t len, int ansi)
{
char *cond, *conds;
buf[0] = 0;
- if (!svc->cond[0])
+ if (!condstr || !condstr[0])
return buf;
- conds = strdupa(svc->cond);
+ conds = strdupa(condstr);
if (!conds)
return buf;
@@ -564,6 +973,11 @@ static char *svc_cond(svc_t *svc, char *buf, size_t len, int ansi)
return buf;
}
+static char *svc_cond(svc_t *svc, char *buf, size_t len, int ansi)
+{
+ return cond_string(svc->cond, buf, len, ansi);
+}
+
static int do_cond_show(char *arg)
{
enum cond_state cond;
@@ -643,10 +1057,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)
@@ -830,7 +1291,6 @@ char *runlevel_string(int currlevel, int levels)
strlcat(lvl, i == INIT_LEVEL ? "S" : l, sizeof(lvl));
-
if (!plain && currlevel == i)
strlcat(lvl, "\e[0m", sizeof(lvl));
} else {
@@ -844,7 +1304,6 @@ char *runlevel_string(int currlevel, int levels)
i++;
} while (i < INIT_LEVEL);
-
strlcat(lvl, "]", sizeof(lvl));
return lvl;
@@ -876,23 +1335,9 @@ char *runlevel_arr(int levels)
return lvl;
}
-static int missing(svc_t *svc)
-{
- if (svc->state == SVC_HALTED_STATE && svc_is_missing(svc))
- return 1;
-
- return 0;
-}
-
-static char *svc_command(svc_t *svc, char *buf, size_t len, int ansi)
+static char *svc_command(svc_t *svc, char *buf, size_t len)
{
- int bold = missing(svc) && ansi;
-
- if (whichp(svc->cmd))
- bold = 0;
-
- strlcpy(buf, bold ? "\e[1m" : "", len);
- strlcat(buf, svc->cmd, len);
+ strlcpy(buf, svc->cmd, len);
for (int i = 1; i < MAX_NUM_SVC_ARGS; i++) {
if (!svc->args[i][0])
@@ -909,40 +1354,24 @@ static char *svc_command(svc_t *svc, char *buf, size_t len, int ansi)
strlcat(buf, cmd, len);
}
- strlcat(buf, bold ? "\e[0m" : "", len);
-
return buf;
}
-static char *svc_environ(svc_t *svc, char *buf, size_t len, int ansi)
-{
- int bold = missing(svc);
-
- if (!ansi || svc_checkenv(svc))
- bold = 0;
-
- strlcpy(buf, bold ? "\e[1m" : "", len);
- strlcat(buf, svc->env, len);
- strlcat(buf, bold ? "\e[0m" : "", len);
-
- return buf;
-}
-
-static char *exit_status(svc_t *svc, char *buf, size_t len)
+static char *exit_status_raw(int status, int manual, char *buf, size_t len)
{
int rc, sig;
char *str;
- rc = WEXITSTATUS(svc->status);
- sig = WTERMSIG(svc->status);
+ rc = WEXITSTATUS(status);
+ sig = WTERMSIG(status);
- if (WIFEXITED(svc->status)) {
+ if (WIFEXITED(status)) {
str = code2str(rc);
snprintf(buf, len, " (code=exited, status=%d%s%s%s)", rc,
str[0] ? "/" : "", str,
- svc->manual ? ", manual=yes" : "");
+ manual ? ", manual=yes" : "");
}
- else if (WIFSIGNALED(svc->status)) {
+ else if (WIFSIGNALED(status)) {
str = sig2str(sig);
snprintf(buf, len, " (code=signal, status=%d%s%s)", sig, str[0] ? "/" : "", str);
}
@@ -950,57 +1379,6 @@ static char *exit_status(svc_t *svc, char *buf, size_t len)
return buf;
}
-static char *status(svc_t *svc, int full)
-{
- static char buf[96];
- const char *color;
- char ok[48] = {0};
- char *s;
-
- s = svc_status(svc);
- switch (svc->state) {
- case SVC_HALTED_STATE:
- exit_status(svc, ok, sizeof(ok));
- color = "\e[1m";
- break;
-
- case SVC_RUNNING_STATE:
- color = "\e[1;32m";
- break;
-
- case SVC_DONE_STATE:
- exit_status(svc, ok, sizeof(ok));
- if (WIFEXITED(svc->status)) {
- if (WEXITSTATUS(svc->status))
- color = "\e[1;31m";
- else
- color = "\e[1;32m";
- } else {
- if (full && WIFSIGNALED(svc->status))
- color = "\e[1;31m";
- else
- color = "\e[1;33m";
- }
- break;
-
- default:
- exit_status(svc, ok, sizeof(ok));
- color = "\e[1;33m";
- break;
- }
-
- if (!full || plain)
- color = NULL;
-
- if (!full)
- snprintf(buf, sizeof(buf), "%-8.8s", s);
- else
- snprintf(buf, sizeof(buf), "%s%s%s%s",
- color ? color : "", s, ok, color ? "\e[0m" : "");
-
- return buf;
-}
-
static void show_cgroup_tree(char *group, char *pfx)
{
char path[256];
@@ -1038,6 +1416,176 @@ static int svc_compare(svc_t *svc, char *arg)
return 0;
}
+/*
+ * All fields the status views need, transport-independent: filled
+ * from a raw svc_t on the legacy socket path or from Service1
+ * properties on the D-Bus path, rendered by the same printers.
+ */
+struct svc_row {
+ char ident[MAX_IDENT_LEN];
+ char state[16];
+ char type[16];
+ char desc[MAX_STR_LEN];
+ char cmdline[512];
+ char env[MAX_CMD_LEN];
+ char cond[MAX_COND_LEN];
+ char pidfile[MAX_CMD_LEN];
+ char user[MAX_USER_LEN];
+ char grp[MAX_USER_LEN];
+ char origin[MAX_ARG_LEN];
+ uint32_t pid;
+ uint32_t runlevels;
+ uint32_t uptime; /* seconds, 0 when not running */
+ uint32_t exitstatus; /* raw waitpid(2) status */
+ uint32_t starts;
+ int32_t restart_tot;
+ int32_t restart_cnt;
+ int32_t restart_max; /* -1 = restart:always */
+ int manual;
+ int forking;
+ int started;
+};
+
+static void fill_row_from_svc(struct svc_row *r, svc_t *svc)
+{
+ long now = jiffies();
+
+ memset(r, 0, sizeof(*r));
+ svc_ident(svc, r->ident, sizeof(r->ident));
+ strlcpy(r->state, svc_status(svc), sizeof(r->state));
+ strlcpy(r->type, svc_typestr(svc), sizeof(r->type));
+ strlcpy(r->desc, svc->desc, sizeof(r->desc));
+ svc_command(svc, r->cmdline, sizeof(r->cmdline));
+ strlcpy(r->env, svc->env, sizeof(r->env));
+ strlcpy(r->cond, svc->cond, sizeof(r->cond));
+ strlcpy(r->pidfile, svc->pidfile, sizeof(r->pidfile));
+ strlcpy(r->user, svc->username, sizeof(r->user));
+ strlcpy(r->grp, svc->group, sizeof(r->grp));
+ strlcpy(r->origin, svc->file, sizeof(r->origin));
+ r->pid = svc->pid > 0 ? (uint32_t)svc->pid : 0;
+ r->runlevels = (uint32_t)svc->runlevels;
+ if (svc->pid && now > svc->start_time)
+ r->uptime = (uint32_t)(now - svc->start_time);
+ r->exitstatus = (uint32_t)svc->status;
+ r->starts = (uint32_t)svc->once;
+ r->restart_tot = (int32_t)svc->restart_tot;
+ r->restart_cnt = (int32_t)svc->restart_cnt;
+ r->restart_max = (int32_t)svc->restart_max;
+ r->manual = svc->manual;
+ r->forking = svc->forking;
+ r->started = svc->started;
+}
+
+static const char *pidfile_str(struct svc_row *r)
+{
+ const char *pidfn = r->pidfile;
+
+ if (pidfn[0] == '!')
+ pidfn++;
+ else if (pidfn[0] == 0)
+ pidfn = "none";
+
+ return pidfn;
+}
+
+/* string-only twin of svc_checkenv() */
+static int checkenv_str(const char *env)
+{
+ if (!env || !env[0] || env[0] == '-')
+ return 1;
+
+ return fexist(env);
+}
+
+/* same matching rules as svc_compare() */
+static int row_compare(struct svc_row *r, char *arg)
+{
+ char ident[MAX_IDENT_LEN];
+ char *ptr;
+
+ strlcpy(ident, r->ident, sizeof(ident));
+ ptr = strchr(ident, ':');
+ if (ptr && !strchr(arg, ':'))
+ *ptr = 0;
+
+ return !strcmp(ident, arg);
+}
+
+static int row_missing(struct svc_row *r)
+{
+ char argv0[512];
+ char *sep;
+
+ if (strcmp(r->state, "missing"))
+ return 0;
+
+ strlcpy(argv0, r->cmdline, sizeof(argv0));
+ sep = strchr(argv0, ' ');
+ if (sep)
+ *sep = 0;
+
+ return !whichp(argv0);
+}
+
+/* the words svc_status() emits for a blocked/halted service */
+static const char *halted_words[] = {
+ "halted", "missing", "crashed", "stopped", "busy",
+ "restart", "conflict", "unknown", NULL
+};
+
+/* transport-independent status(svc, full) */
+static char *row_status(struct svc_row *r, int full)
+{
+ static char buf[96];
+ const char *color;
+ char ok[48] = {0};
+ int st = (int)r->exitstatus;
+ size_t i;
+
+ if (!full) {
+ snprintf(buf, sizeof(buf), "%-8.8s", r->state);
+ return buf;
+ }
+
+ if (!strcmp(r->state, "running")) {
+ color = "\e[1;32m";
+ } else if (!strcmp(r->state, "done") || !strcmp(r->state, "failed")) {
+ exit_status_raw(st, r->manual, ok, sizeof(ok));
+ if (WIFEXITED(st))
+ color = WEXITSTATUS(st) ? "\e[1;31m" : "\e[1;32m";
+ else if (WIFSIGNALED(st))
+ color = "\e[1;31m";
+ else
+ color = "\e[1;33m";
+ } else {
+ exit_status_raw(st, r->manual, ok, sizeof(ok));
+ color = "\e[1;33m";
+ for (i = 0; halted_words[i]; i++) {
+ if (!strcmp(r->state, halted_words[i])) {
+ color = "\e[1m";
+ break;
+ }
+ }
+ }
+
+ if (plain)
+ color = NULL;
+
+ snprintf(buf, sizeof(buf), "%s%s%s%s",
+ color ? color : "", r->state, ok, color ? "\e[0m" : "");
+
+ return buf;
+}
+
+/* scripting mode: exit code only, same rules as the legacy path */
+static int quiet_row(struct svc_row *r)
+{
+ if (!strcmp(r->type, "run") || !strcmp(r->type, "task"))
+ return r->started ? 0 : 1;
+
+ return strcmp(r->state, "running") != 0;
+}
+
/*
* Escape a string for safe JSON output. Handles quotes, backslashes,
* and control characters. Returns pointer to static buffer.
@@ -1094,46 +1642,40 @@ static char *json_escape(const char *str)
return buf;
}
-static int json_status_one(FILE *fp, svc_t *svc, char *indent, int prev)
+static int json_status_one(FILE *fp, struct svc_row *r, char *indent, int prev)
{
- long now = jiffies();
- char *pidfn = NULL;
+ const char *pidfn = pidfile_str(r);
char buf[512];
- pidfn = svc->pidfile;
- if (pidfn[0] == '!')
- pidfn++;
- else if (pidfn[0] == 0)
- pidfn = "none";
-
fprintf(fp,
"%s"
"%s{\n"
"%s \"identity\": \"%s\",\n",
prev ? ",\n" : indent, prev ? indent : "",
- indent, svc_ident(svc, NULL, 0));
+ indent, r->ident);
fprintf(fp,
"%s \"description\": \"%s\",\n",
- indent, json_escape(svc->desc));
+ indent, json_escape(r->desc));
fprintf(fp,
"%s \"type\": \"%s\",\n"
"%s \"forking\": %s,\n"
"%s \"status\": \"%s\",\n",
- indent, svc_typestr(svc),
- indent, svc->forking ? "true" : "false",
- indent, svc_status(svc));
+ indent, r->type,
+ indent, r->forking ? "true" : "false",
+ indent, r->state);
- if (svc->state != SVC_RUNNING_STATE) {
+ if (strcmp(r->state, "running")) {
+ int st = (int)r->exitstatus;
int rc, sig;
- rc = WEXITSTATUS(svc->status);
- sig = WTERMSIG(svc->status);
+ rc = WEXITSTATUS(st);
+ sig = WTERMSIG(st);
- if (WIFEXITED(svc->status))
+ if (WIFEXITED(st))
fprintf(fp,
"%s \"exit\": { \"%s\": %d },\n",
indent, "code", rc);
- else if (WIFSIGNALED(svc->status))
+ else if (WIFSIGNALED(st))
fprintf(fp,
"%s \"exit\": { \"%s\": %d },\n",
indent, "signal", sig);
@@ -1141,32 +1683,30 @@ static int json_status_one(FILE *fp, svc_t *svc, char *indent, int prev)
fprintf(fp,
"%s \"origin\": \"%s\",\n",
- indent, svc->file[0] ? svc->file : "built-in");
- svc_command(svc, buf, sizeof(buf), 0);
+ indent, r->origin[0] ? r->origin : "built-in");
fprintf(fp,
"%s \"command\": \"%s\",\n",
- indent, json_escape(buf));
+ indent, json_escape(r->cmdline));
- svc_environ(svc, buf, sizeof(buf), 0);
- if (buf[0])
+ if (r->env[0])
fprintf(fp,
- "%s \"environment\": \"%s\",\n", indent, json_escape(buf));
+ "%s \"environment\": \"%s\",\n", indent, json_escape(r->env));
- svc_cond(svc, buf, sizeof(buf), 0);
+ cond_string(r->cond, buf, sizeof(buf), 0);
if (buf[0])
fprintf(fp,
"%s \"condition\": %s,\n", indent, buf);
- if (svc->manual)
+ if (r->manual)
fprintf(fp,
- "%s \"starts\": %d,\n", indent, svc->once);
+ "%s \"starts\": %u,\n", indent, r->starts);
fprintf(fp,
- "%s \"restarts\": %d,\n", indent, svc->restart_tot); /* XXX: add restart_cnt and restart_max */
+ "%s \"restarts\": %d,\n", indent, r->restart_tot); /* XXX: add restart_cnt and restart_max */
/* Add memory and CPU information if cgroup support is available */
- if (cgrp && svc->pid > 1) {
- char *group = pid_cgroup(svc->pid);
+ if (cgrp && r->pid > 1) {
+ char *group = pid_cgroup(r->pid);
if (group) {
uint64_t throttled_usec = 0;
@@ -1270,37 +1810,475 @@ static int json_status_one(FILE *fp, svc_t *svc, char *indent, int prev)
fprintf(fp,
"%s \"pidfile\": \"%s\",\n"
- "%s \"pid\": %d,\n"
+ "%s \"pid\": %u,\n"
"%s \"user\": \"%s\",\n"
"%s \"group\": \"%s\",\n"
"%s \"uptime\": %ld,\n"
"%s \"runlevels\": %s\n"
"%s}",
indent, pidfn,
- indent, svc->pid,
- indent, svc->username,
- indent, svc->group,
- indent, svc->pid ? now - svc->start_time : 0,
- indent, runlevel_arr(svc->runlevels),
+ indent, r->pid,
+ indent, r->user,
+ indent, r->grp,
+ indent, (long)r->uptime,
+ indent, runlevel_arr((int)r->runlevels),
indent);
return 0;
}
-static int show_status(char *arg)
+static void status_heading(int pidw, int identw)
{
- char ident[MAX_IDENT_LEN];
+ char title[80];
+
+ snprintf(title, sizeof(title), "%-*s %-*s %-8s %-13s ",
+ pidw, "PID", identw, "IDENT", "STATUS", "RUNLEVELS");
+ strlcat(title, !verbose ? "DESCRIPTION" : "COMMAND", sizeof(title));
+ print_header("%s", title);
+}
+
+static void print_runlevels(const char *lvls)
+{
+ /* ANSI escapes for the active level eat into the field width */
+ if (strchr(lvls, '\e'))
+ printf("%-21.21s ", lvls);
+ else
+ printf("%-13.13s ", lvls);
+}
+
+/*
+ * The detail view, shared by the legacy and D-Bus paths; the caller
+ * fills a svc_row from its transport first. Cgroup statistics and
+ * the log tail are always local operations, they only need the PID.
+ */
+static int show_one_row(struct svc_row *r)
+{
+ char uptm[42] = "N/A";
char buf[512];
+ int bold;
+
+ printf(" Status : %s\n", row_status(r, 1));
+ printf(" Identity : %s\n", r->ident);
+ printf("Description : %s\n", r->desc);
+ printf(" Origin : %s\n", r->origin[0] ? r->origin : "built-in");
+
+ if (r->env[0]) {
+ bold = !plain && !strcmp(r->state, "missing") &&
+ !checkenv_str(r->env);
+ printf("Environment : %s%s%s\n", bold ? "\e[1m" : "",
+ r->env, bold ? "\e[0m" : "");
+ }
+
+ cond_string(r->cond, buf, sizeof(buf), !plain);
+ if (buf[0])
+ printf("Condition(s): <%s>\n", buf);
+
+ bold = !plain && row_missing(r);
+ printf(" Command : %s%s%s\n", bold ? "\e[1m" : "",
+ r->cmdline, bold ? "\e[0m" : "");
+ printf(" PID file : %s\n", pidfile_str(r));
+ printf(" PID : %u\n", r->pid);
+ printf(" User : %s\n", r->user);
+ printf(" Group : %s\n", r->grp);
+ printf(" Uptime : %s\n", r->pid
+ ? uptime((long)r->uptime, uptm, sizeof(uptm)) : uptm);
+ if (r->manual)
+ printf(" Starts : %u\n", r->starts);
+ printf(" Restarts : %d (%d/%d)\n",
+ r->restart_tot, r->restart_cnt, r->restart_max);
+ printf(" Runlevels : %s\n",
+ runlevel_string(runlevel, (int)r->runlevels));
+
+ if (cgrp && r->pid > 1) {
+ const struct cg *cg;
+ uint64_t throttled_usec = 0;
+ uint64_t nr_throttled = 0;
+ char path[256];
+ char *group;
+
+ group = pid_cgroup(r->pid);
+ if (!group)
+ goto no_cgroup; /* ... or PID doesn't exist (anymore) */
+
+ snprintf(path, sizeof(path), "%s/%s", FINIT_CGPATH, group);
+ cg = cg_conf(path);
+
+ printf(" Memory : %s\n", memsz(cgroup_memory(group), uptm, sizeof(uptm)));
+
+ if (cgroup_throttle(group, &throttled_usec, &nr_throttled) == 0) {
+ printf("CPU Throttle : %lu usec (%lu times)\n",
+ throttled_usec, nr_throttled);
+ }
+
+ printf(" CGroup : %s cpu %s [%s, %s] mem [%s, %s]\n",
+ group, cg->cg_cpu.set, cg->cg_cpu.weight, cg->cg_cpu.max,
+ cg->cg_mem.min, cg->cg_mem.max);
+ show_cgroup_tree(group, " ");
+
+ free(group);
+ }
+no_cgroup:
+ printf("\n");
+
+ return do_log_named(r->ident, r->pid, "| tail -10");
+}
+
+static void render_table(struct svc_row *rows, int n, char *filter)
+{
+ int i, identw = 0, pidw = 0;
+
+ for (i = 0; i < n; i++) {
+ int w;
+
+ w = (int)strlen(rows[i].ident);
+ if (w > identw)
+ identw = w;
+ w = snprintf(NULL, 0, "%u", rows[i].pid);
+ if (w > pidw)
+ pidw = w;
+ }
+ if (identw < 6)
+ identw = 6;
+ if (pidw < 3)
+ pidw = 3;
+
+ if (heading)
+ status_heading(pidw, identw);
+
+ for (i = 0; i < n; i++) {
+ struct svc_row *r = &rows[i];
+
+ if (filter && !row_compare(r, filter))
+ continue;
+
+ printf("%-*u ", pidw, r->pid);
+ printf("%-*s %s ", identw, r->ident, row_status(r, 0));
+ print_runlevels(runlevel_string(runlevel, (int)r->runlevels));
+
+ if (!verbose)
+ puts(r->desc);
+ else {
+ int bold = !plain && row_missing(r);
+
+ printf("%s%s%s\n", bold ? "\e[1m" : "",
+ r->cmdline, bold ? "\e[0m" : "");
+ }
+ }
+}
+
+#ifdef HAVE_DBUS
+/*
+ * Service1 wire properties -> svc_row fields. Unknown keys and
+ * value types are skipped so newer finit keeps working with older
+ * initctl.
+ */
+#define ROW_STR(k, f) { k, 's', offsetof(struct svc_row, f), \
+ sizeof(((struct svc_row *)0)->f) }
+#define ROW_U32(k, f) { k, 'u', offsetof(struct svc_row, f), 0 }
+#define ROW_BOOL(k, f) { k, 'b', offsetof(struct svc_row, f), 0 }
+
+static const struct row_field {
+ const char *key;
+ char type;
+ size_t off;
+ size_t len;
+} row_fields[] = {
+ ROW_STR ("State", state),
+ ROW_STR ("Type", type),
+ ROW_STR ("Description", desc),
+ ROW_STR ("Command", cmdline),
+ ROW_STR ("Environment", env),
+ ROW_STR ("Conditions", cond),
+ ROW_STR ("PidFile", pidfile),
+ ROW_STR ("User", user),
+ ROW_STR ("Group", grp),
+ ROW_STR ("Origin", origin),
+ ROW_U32 ("Pid", pid),
+ ROW_U32 ("Runlevels", runlevels),
+ ROW_U32 ("Uptime", uptime),
+ ROW_U32 ("ExitStatus", exitstatus),
+ ROW_U32 ("Starts", starts),
+ ROW_U32 ("RestartsTotal", restart_tot),
+ ROW_U32 ("RestartCount", restart_cnt),
+ ROW_U32 ("RestartMax", restart_max),
+ ROW_BOOL("ManualStart", manual),
+ ROW_BOOL("Forking", forking),
+ ROW_BOOL("Started", started),
+ { NULL, 0, 0, 0 }
+};
+
+/*
+ * One service's Properties.GetAll into a row. Returns 0 on success,
+ * 1 when the object vanished since ListServices (bus error reply),
+ * -1 on transport failure.
+ */
+static int dbus_fill_row(link_client_t *c, struct svc_row *row)
+{
+ const link_reply_t *r;
+ link_reader_t reader;
+ char path[256];
+ size_t end;
+ int rc;
+
+ if (dbus_svc_path(row->ident, path, sizeof(path)) < 0)
+ return -1;
+
+ rc = link_client_call_v(c, path,
+ "org.freedesktop.DBus.Properties",
+ "GetAll", "s", "org.finit.Service1");
+ if (rc == LINK_CALL_ERROR)
+ return 1; /* gone since ListServices */
+ if (rc != LINK_CALL_OK)
+ return -1;
+ r = link_client_reply(c);
+ if (!r || !r->body)
+ 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 struct row_field *f;
+ const char *key, *val;
+ uint32_t u;
+ int b;
+ char type;
+
+ if (dbus_dict_next(&reader, &key, &type) < 0)
+ return -1;
+
+ for (f = row_fields; f->key; f++)
+ if (f->type == type && !strcmp(f->key, key))
+ break;
+
+ if (!f->key) {
+ if (link_r_skip_basic(&reader, type) < 0)
+ return -1;
+ continue;
+ }
+
+ switch (type) {
+ case 's':
+ if (link_r_string(&reader, &val) < 0)
+ return -1;
+ strlcpy((char *)row + f->off, val, f->len);
+ break;
+ case 'u':
+ if (link_r_u32(&reader, &u) < 0)
+ return -1;
+ memcpy((char *)row + f->off, &u, sizeof(u));
+ break;
+ case 'b':
+ if (link_r_bool(&reader, &b) < 0)
+ return -1;
+ memcpy((char *)row + f->off, &b, sizeof(b));
+ break;
+ }
+ }
+
+ return 0;
+}
+
+/*
+ * ListServices into a caller-freed ident array. One round trip;
+ * show_ident() needs nothing more. Returns count or -1.
+ */
+static int dbus_list_idents(link_client_t *c, char (**out)[MAX_IDENT_LEN])
+{
+ char (*idents)[MAX_IDENT_LEN] = NULL, (*tmp)[MAX_IDENT_LEN];
+ const link_reply_t *r;
+ link_reader_t reader;
+ size_t n = 0, end;
+ int rc;
+
+ rc = link_client_call_v(c, "/org/finit/manager",
+ "org.finit.Manager1", "ListServices", NULL);
+ if (rc != LINK_CALL_OK)
+ return -1;
+
+ r = link_client_reply(c);
+ if (!r || !r->body)
+ 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 *ident;
+
+ if (link_r_string(&reader, &ident) < 0)
+ goto fail;
+ tmp = realloc(idents, (n + 1) * sizeof(*idents));
+ if (!tmp)
+ goto fail;
+ idents = tmp;
+ /* copied: the next call clobbers the client rx buffer */
+ strlcpy(idents[n], ident, sizeof(idents[n]));
+ n++;
+ }
+
+ *out = idents;
+ return (int)n;
+fail:
+ free(idents);
+ return -1;
+}
+
+/*
+ * Rows for every service matching `arg` (all when NULL), fetched over
+ * D-Bus. Returns the row count (caller frees *out), or -1 to fall
+ * back to the legacy socket.
+ */
+static int dbus_fetch_svc_rows(char *arg, struct svc_row **out)
+{
+ char (*idents)[MAX_IDENT_LEN] = NULL;
+ struct svc_row *rows = NULL;
+ link_client_t *c;
+ int n, i, m = 0;
+
+ c = link_client_open(FINIT_BUS_SOCKET);
+ if (!c)
+ return -1;
+
+ n = dbus_list_idents(c, &idents);
+ if (n < 0)
+ goto fail;
+
+ rows = calloc(n ? n : 1, sizeof(*rows));
+ if (!rows)
+ goto fail;
+
+ for (i = 0; i < n; i++) {
+ struct svc_row *row = &rows[m];
+ int rc;
+
+ strlcpy(row->ident, idents[i], sizeof(row->ident));
+ if (arg && arg[0] && !row_compare(row, arg))
+ continue;
+
+ rc = dbus_fill_row(c, row);
+ if (rc < 0)
+ goto fail;
+ if (rc > 0)
+ continue; /* vanished, skip */
+ m++;
+ }
+
+ free(idents);
+ link_client_close(c);
+ *out = rows;
+ return m;
+fail:
+ free(idents);
+ free(rows);
+ link_client_close(c);
+ return -1;
+}
+
+/* wire encoding is runlevel(8) style: digits, S, N */
+static int runlevel_from_str(const char *s)
+{
+ if (!strcmp(s, "S"))
+ return INIT_LEVEL;
+
+ return atoi(s);
+}
+
+/*
+ * All show_status() views over D-Bus, self-contained (the current
+ * runlevel comes from Manager1, not the legacy socket). Returns 0
+ * with the command exit code in *retval, or -1 to fall back.
+ */
+static int dbus_show_status(char *arg, int *retval)
+{
+ static const char *const wanted[] = { "Runlevel", NULL };
+ char curr[16] = "0";
+ char *outv[] = { curr };
+ struct svc_row *rows = NULL;
+ char *filter;
+ int n, i;
+
+ n = dbus_fetch_svc_rows(arg, &rows);
+ if (n < 0)
+ return -1;
+
+ *retval = 0;
+
+ if (arg && arg[0]) {
+ if (!n) {
+ free(rows);
+ /* exits, exactly like the legacy path */
+ ERRX(noerr ? 0 : 69, "no such task or service(s): %s", arg);
+ }
+ if (n == 1) {
+ if (quiet) {
+ *retval = quiet_row(&rows[0]);
+ free(rows);
+ return 0;
+ }
+ /* runlevel feeds the detail view's Runlevels line */
+ if (dbus_get_manager_props(wanted, outv, sizeof(curr)) < 0)
+ goto fail;
+ runlevel = runlevel_from_str(curr);
+ if (json) {
+ *retval = json_status_one(stdout, &rows[0], "", 0);
+ puts("");
+ } else
+ *retval = show_one_row(&rows[0]);
+ free(rows);
+ return 0;
+ }
+ /* several matches: filtered table below */
+ }
+
+ if (dbus_get_manager_props(wanted, outv, sizeof(curr)) < 0)
+ goto fail;
+ runlevel = runlevel_from_str(curr);
+
+ filter = (arg && arg[0]) ? arg : NULL;
+ if (json) {
+ int prev = 0;
+
+ for (i = 0; i < n; i++) {
+ if (filter && !row_compare(&rows[i], filter))
+ continue;
+ if (!prev)
+ fputs("[\n", stdout);
+ json_status_one(stdout, &rows[i], " ", prev++);
+ }
+ if (prev)
+ fputs("\n]\n", stdout);
+ } else
+ render_table(rows, n, filter);
+
+ free(rows);
+ return 0;
+fail:
+ free(rows);
+ return -1;
+}
+#endif /* HAVE_DBUS */
+
+static int show_status(char *arg)
+{
+ struct svc_row row;
int num = 0;
svc_t *svc;
+#ifdef HAVE_DBUS
+ {
+ int rc;
+
+ if (dbus_show_status(arg, &rc) == 0)
+ return rc;
+ }
+#endif
+
runlevel = runlevel_get(NULL);
while (arg && arg[0]) {
- long now = jiffies();
- char uptm[42] = "N/A";
- char *pidfn = NULL;
-
for (svc = client_svc_iterator(1); svc; svc = client_svc_iterator(0))
num += svc_compare(svc, arg);
@@ -1311,95 +2289,32 @@ static int show_status(char *arg)
if (!svc)
ERRX(noerr ? 0 : 69, "no such task or service(s): %s", arg);
- if (quiet) {
- if (svc_is_runtask(svc)) {
- if (svc->started)
- return 0;
- return 1;
- }
- return svc->state != SVC_RUNNING_STATE;
- }
+ fill_row_from_svc(&row, svc);
+ if (quiet)
+ return quiet_row(&row);
if (json) {
int rc;
- rc = json_status_one(stdout, svc, "", 0);
+ rc = json_status_one(stdout, &row, "", 0);
puts("");
return rc;
}
- pidfn = svc->pidfile;
- if (pidfn[0] == '!')
- pidfn++;
- else if (pidfn[0] == 0)
- pidfn = "none";
-
- printf(" Status : %s\n", status(svc, 1));
- printf(" Identity : %s\n", svc_ident(svc, ident, sizeof(ident)));
- printf("Description : %s\n", svc->desc);
- printf(" Origin : %s\n", svc->file[0] ? svc->file : "built-in");
- svc_environ(svc, buf, sizeof(buf), !plain);
- if (buf[0])
- printf("Environment : %s\n", buf);
- svc_cond(svc, buf, sizeof(buf), !plain);
- if (buf[0])
- printf("Condition(s): <%s>\n", buf);
- printf(" Command : %s\n", svc_command(svc, buf, sizeof(buf), !plain));
- printf(" PID file : %s\n", pidfn);
- printf(" PID : %d\n", svc->pid);
- printf(" User : %s\n", svc->username);
- printf(" Group : %s\n", svc->group);
- printf(" Uptime : %s\n", svc->pid ? uptime(now - svc->start_time, uptm, sizeof(uptm)) : uptm);
- if (svc->manual)
- printf(" Starts : %d\n", svc->once);
- printf(" Restarts : %d (%d/%d)\n", svc->restart_tot, svc->restart_cnt, svc->restart_max);
- printf(" Runlevels : %s\n", runlevel_string(runlevel, svc->runlevels));
-
- if (cgrp && svc->pid > 1) {
- const struct cg *cg;
- uint64_t throttled_usec = 0;
- uint64_t nr_throttled = 0;
- char path[256];
- char *group;
-
- group = pid_cgroup(svc->pid);
- if (!group)
- goto no_cgroup; /* ... or PID doesn't exist (anymore) */
-
- snprintf(path, sizeof(path), "%s/%s", FINIT_CGPATH, group);
- cg = cg_conf(path);
-
- printf(" Memory : %s\n", memsz(cgroup_memory(group), uptm, sizeof(uptm)));
-
- if (cgroup_throttle(group, &throttled_usec, &nr_throttled) == 0) {
- printf("CPU Throttle : %lu usec (%lu times)\n",
- throttled_usec, nr_throttled);
- }
-
- printf(" CGroup : %s cpu %s [%s, %s] mem [%s, %s]\n",
- group, cg->cg_cpu.set, cg->cg_cpu.weight, cg->cg_cpu.max,
- cg->cg_mem.min, cg->cg_mem.max);
- show_cgroup_tree(group, " ");
-
- free(group);
- }
- no_cgroup:
- printf("\n");
-
- return do_log(svc, "| tail -10");
+ return show_one_row(&row);
}
if (json) {
int prev = 0;
for (svc = client_svc_iterator(1); svc; svc = client_svc_iterator(0)) {
- svc_ident(svc, ident, sizeof(ident));
if (num && !svc_compare(svc, arg))
continue;
if (!prev)
fputs("[\n", stdout);
- json_status_one(stdout, svc, " ", prev++);
+ fill_row_from_svc(&row, svc);
+ json_status_one(stdout, &row, " ", prev++);
}
if (prev)
fputs("\n]\n", stdout);
@@ -1407,64 +2322,71 @@ static int show_status(char *arg)
return 0;
}
- col_widths();
- if (heading) {
- char title[80];
+ {
+ struct svc_row *rows = NULL, *tmp;
+ int n = 0;
- snprintf(title, sizeof(title), "%-*s %-*s %-8s %-13s ",
- pw, "PID", iw, "IDENT", "STATUS", "RUNLEVELS");
- if (!verbose)
- strlcat(title, "DESCRIPTION", sizeof(title));
- else
- strlcat(title, "COMMAND", sizeof(title));
-
- print_header("%s", title);
+ for (svc = client_svc_iterator(1); svc; svc = client_svc_iterator(0)) {
+ tmp = realloc(rows, (n + 1) * sizeof(*rows));
+ if (!tmp) {
+ free(rows);
+ return 1;
+ }
+ rows = tmp;
+ fill_row_from_svc(&rows[n++], svc);
+ }
+ render_table(rows, n, num > 1 ? arg : NULL);
+ free(rows);
}
- for (svc = client_svc_iterator(1); svc; svc = client_svc_iterator(0)) {
- char *lvls;
-
- svc_ident(svc, ident, sizeof(ident));
- if (num && !svc_compare(svc, arg))
- continue;
-
- printf("%-*d ", pw, svc->pid);
- printf("%-*s %s ", iw, ident, status(svc, 0));
+ return 0;
+}
- lvls = runlevel_string(runlevel, svc->runlevels);
- if (strchr(lvls, '\e'))
- printf("%-21.21s ", lvls);
- else
- printf("%-13.13s ", lvls);
+static void ident_line(const char *identity, char *arg)
+{
+ size_t len;
+ char *pos;
- if (!verbose)
- puts(svc->desc);
- else
- puts(svc_command(svc, buf, sizeof(buf), !plain));
- }
+ pos = strchr(identity, ':');
+ if (pos)
+ len = (size_t)(pos - identity);
+ else
+ len = strlen(identity);
+ if (arg && arg[0] && strncasecmp(identity, arg, len))
+ return;
- return 0;
+ puts(identity);
}
static int show_ident(char *arg)
{
svc_t *svc;
+#ifdef HAVE_DBUS
+ {
+ char (*idents)[MAX_IDENT_LEN];
+ link_client_t *c;
+ int n = -1, i;
+
+ c = link_client_open(FINIT_BUS_SOCKET);
+ if (c) {
+ n = dbus_list_idents(c, &idents);
+ link_client_close(c);
+ }
+ if (n >= 0) {
+ for (i = 0; i < n; i++)
+ ident_line(idents[i], arg);
+ free(idents);
+ return 0;
+ }
+ }
+#endif
+
for (svc = client_svc_iterator(1); svc; svc = client_svc_iterator(0)) {
char ident[MAX_IDENT_LEN];
- size_t len;
- char *pos;
svc_ident(svc, ident, sizeof(ident));
- pos = strchr(ident, ':');
- if (pos)
- len = pos - ident;
- else
- len = strlen(ident);
- if (arg && arg[0] && strncasecmp(ident, arg, len))
- continue;
-
- puts(ident);
+ ident_line(ident, arg);
}
return 0;
@@ -1572,6 +2494,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");
@@ -1756,6 +2681,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 55f3b858..3c2e05f4 100644
--- a/src/service.c
+++ b/src/service.c
@@ -520,7 +520,7 @@ static int is_norespawn(void)
fexist("/tmp/norespawn");
}
-static void compose_cmdline(svc_t *svc, char *buf, size_t len)
+void compose_cmdline(svc_t *svc, char *buf, size_t len)
{
size_t i;
@@ -1443,17 +1443,65 @@ 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)
+{
+ 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)) {
+ svc_cond_clear(svc);
+ 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;
@@ -3015,6 +3063,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
+
/*
* The unit has stopped: HALTED comes after any post:/cleanup:
* script, DONE is a completed run/task, where remain-after-exit
@@ -3384,7 +3436,7 @@ int service_step(svc_t *svc)
if (sm_in_reload())
break;
- service_reload(svc);
+ service_reload_apply(svc);
}
svc_mark_clean(svc);
@@ -3422,7 +3474,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 f8b94fe5..59699f3f 100644
--- a/src/service.h
+++ b/src/service.h
@@ -54,11 +54,13 @@ void service_ready_script (svc_t *svc); /* XXX: only for pidfile plugin b
int service_timeout_after (svc_t *svc, int timeout, void (*cb)(svc_t *svc));
int service_timeout_cancel (svc_t *svc);
+void compose_cmdline (svc_t *svc, char *buf, size_t len);
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 cbe29cb5..a1fe9c99 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"
@@ -153,6 +154,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;
}
@@ -170,6 +175,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/system/20-dbus.conf b/system/20-dbus.conf
new file mode 100644
index 00000000..f68f23ab
--- /dev/null
+++ b/system/20-dbus.conf
@@ -0,0 +1,19 @@
+# System message bus, D-Bus
+#
+# The dbus.so plugin creates the directories dbus-daemon needs and makes
+# sure the machine has a UUID, but the daemon itself is declared here so
+# you can change it. The leading '-' says it is fine if dbus-daemon is
+# not installed; the service is then skipped without a warning.
+#
+# Finit registers org.finit on this bus once it is up, see docs/dbus.md.
+#
+# Override this file by copying it to /etc/finit.d/, using the same name,
+# then change the contents any way you like, it can even be empty.
+
+service dbus {
+ description = "D-Bus message bus daemon"
+ runlevel = "S123456789"
+ notify = "none"
+ cgroup system {}
+ command = "-dbus-daemon --nofork --system --syslog-only"
+}
diff --git a/system/Makefile.am b/system/Makefile.am
index 5dac235b..284aebf9 100644
--- a/system/Makefile.am
+++ b/system/Makefile.am
@@ -7,6 +7,10 @@ if BUILD_HOTPLUG_PLUGIN
system_DATA += 10-hotplug.conf
endif
+if BUILD_DBUS_PLUGIN
+dist_system_DATA += 20-dbus.conf
+endif
+
if BUILD_TESTSERV_PLUGIN
dist_system_DATA += 90-testserv.conf
endif
diff --git a/test/Makefile.am b/test/Makefile.am
index a12c296b..82f80bb7 100644
--- a/test/Makefile.am
+++ b/test/Makefile.am
@@ -10,6 +10,7 @@ EXTRA_DIST = skel/bin/busybox-x86_64.sha256 skel/sbin/service.sh skel/etc/env
skel/etc/rcS.d/S01abc.sh skel/etc/rcS.d/S02def.sh \
skel/cdrom/.empty skel/dev/shm/.empty skel/dev/pts/.empty \
skel/etc/inittab skel/etc/hostname skel/etc/fstab \
+ skel/usr/share/dbus-1/system.conf skel/etc/machine-id \
skel/etc/passwd skel/etc/group skel/etc/ld.so.conf \
skel/etc/init.d/rcS skel/etc/init.d/rcK skel/tmp/.empty \
skel/etc/finit.d/.empty skel/etc/finit.d/available/.empty \
@@ -71,6 +72,16 @@ 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-authz.sh
+EXTRA_DIST += dbus-broker.sh
+EXTRA_DIST += fuzz-msg-parse.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
+EXTRA_DIST += dbus-introspect.sh
AM_TESTS_ENVIRONMENT = SYSROOT='$(abs_builddir)/sysroot/';
AM_TESTS_ENVIRONMENT += export SYSROOT;
@@ -126,6 +137,21 @@ if TESTSERV
TESTS += testserv.sh
endif
TESTS += unexpected-restart.sh
+if DBUS
+TESTS += dbus-auth.sh
+TESTS += dbus-authz.sh
+TESTS += dbus-bus.sh
+TESTS += dbus-manager.sh
+TESTS += dbus-service.sh
+TESTS += dbus-cond.sh
+TESTS += dbus-initctl.sh
+TESTS += dbus-introspect.sh
+TESTS += fuzz-msg-parse.sh
+# Needs the plugin to bring up the bus, not just the built-in one
+if BUILD_DBUS_PLUGIN
+TESTS += dbus-broker.sh
+endif
+endif
check-recursive: setup-chroot
diff --git a/test/check.sh b/test/check.sh
index 3c847f56..e76d4af7 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 \
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..ce12bd48
--- /dev/null
+++ b/test/dbus-auth.sh
@@ -0,0 +1,49 @@
+#!/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"
+
+# The bus reaches every service operation initctl does, so it has to
+# be gated like INIT_SOCKET: 0660, owned by root and the --with-group
+# group. Only the mode is asserted here, the test namespace does not
+# enforce it -- a setuid() client still connects to a 0660 socket.
+say "Socket is gated like INIT_SOCKET, not world-accessible"
+mode=$(texec stat -c %a "$BUS")
+sock=$(texec stat -c %a /run/finit/socket)
+assert "Socket mode is 660 (got $mode)" "$mode" = "660"
+assert "Bus and INIT_SOCKET agree ($mode vs $sock)" "$mode" = "$sock"
+
+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-authz.sh b/test/dbus-authz.sh
new file mode 100755
index 00000000..66badd54
--- /dev/null
+++ b/test/dbus-authz.sh
@@ -0,0 +1,52 @@
+#!/bin/sh
+# Who may invoke a privileged method. Root always, and anyone in the
+# group the bus socket is owned by, which is DEFGROUP from
+# --with-group, 'root' in a test build. Everyone else is refused.
+#
+# A caller that gets past the check still has to name a service that
+# exists, so NoSuchService is how we tell "allowed, then failed" apart
+# from "not allowed at all".
+
+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"
+
+# uid 1000 is 'wheelie', a member of group root in the test sysroot.
+# uid 2 is 'bin', a member of nothing that matters here.
+say "A member of the group may call a privileged method"
+set +e
+allowed=$(texec "$CLIENT" call-s-as-uid 1000 "$BUS" /org/finit/manager \
+ org.finit.Manager1 Restart nosuchservice 2>&1)
+set -e
+case "$allowed" in
+ *AccessDenied*) fail "Group member was refused: $allowed" ;;
+ *NoSuchService*) assert "Group member passed authorization" 0 -eq 0 ;;
+ *) fail "Unexpected reply for group member: $allowed" ;;
+esac
+
+say "A caller outside the group may not"
+set +e
+denied=$(texec "$CLIENT" call-s-as-uid 2 "$BUS" /org/finit/manager \
+ org.finit.Manager1 Restart nosuchservice 2>&1)
+set -e
+case "$denied" in
+ *AccessDenied*) assert "Non-member refused" 0 -eq 0 ;;
+ *NoSuchService*) fail "Non-member passed authorization: $denied" ;;
+ *) fail "Unexpected reply for non-member: $denied" ;;
+esac
+
+say "Root is still allowed"
+set +e
+asroot=$(texec "$CLIENT" call-s "$BUS" /org/finit/manager \
+ org.finit.Manager1 Restart nosuchservice 2>&1)
+set -e
+case "$asroot" in
+ *AccessDenied*) fail "Root was refused: $asroot" ;;
+ *NoSuchService*) assert "Root passed authorization" 0 -eq 0 ;;
+ *) fail "Unexpected reply for root: $asroot" ;;
+esac
diff --git a/test/dbus-broker.sh b/test/dbus-broker.sh
new file mode 100755
index 00000000..e82897f5
--- /dev/null
+++ b/test/dbus-broker.sh
@@ -0,0 +1,61 @@
+#!/bin/sh
+# Finit against a real message bus.
+#
+# Every other dbus-*.sh test drives libink's own client, so the wire
+# format is only ever checked against the implementation that produced
+# it. Here the dbus plugin brings up a real dbus-daemon, Finit finds
+# it and claims org.finit, and then dbus-send talks to Finit: a client
+# that shares no code with us.
+#
+# It also covers the only path the brokerless bus cannot reach, where
+# one connection carries every caller and Finit has to ask the broker
+# who sent each privileged call.
+#
+# Skipped when the host has no dbus-daemon or dbus-send to stage.
+
+set -eu
+
+TEST_DIR=$(dirname "$0")
+
+# shellcheck source=/dev/null
+. "$TEST_DIR/lib/setup.sh"
+
+# Staged by lib/sysroot.mk from the host, when it has them.
+for prog in dbus-daemon dbus-send; do
+ texec sh -c "command -v $prog >/dev/null" \
+ || skip "no $prog in the test root, need it on the host"
+done
+
+say 'The dbus plugin started a system bus'
+retry 'assert_file_exists /var/run/dbus/system_bus_socket'
+
+say 'Finit claims org.finit on the system bus'
+retry "texec dbus-send --system --print-reply --dest=org.freedesktop.DBus \
+ /org/freedesktop/DBus org.freedesktop.DBus.GetNameOwner string:org.finit" 60 0.5
+
+say 'A read-only method answers through the broker'
+list=$(texec dbus-send --system --print-reply --dest=org.finit \
+ /org/finit/manager org.finit.Manager1.ListServices)
+assert "ListServices returned the dbus service" \
+ "$(printf '%s' "$list" | grep -c '"dbus"')" -ge 1
+
+say 'Properties.Get answers through the broker'
+rl=$(texec dbus-send --system --print-reply --dest=org.finit \
+ /org/finit/manager org.freedesktop.DBus.Properties.Get \
+ string:org.finit.Manager1 string:Runlevel)
+assert "Runlevel property is 2" "$(printf '%s' "$rl" | grep -c '"2"')" -eq 1
+
+# The point of the exercise: a privileged call over the broker means
+# Finit parks it, asks the driver who the sender is, and dispatches on
+# the answer. Root is allowed, so reaching NoSuchService proves the
+# whole round trip worked rather than a blanket denial. The repeat
+# call goes the same way, only answered from the sender cache.
+for pass in first repeat; do
+ say "A privileged method resolves the caller, $pass call"
+ priv=$(texec dbus-send --system --print-reply --dest=org.finit \
+ /org/finit/manager org.finit.Manager1.Restart string:nosuchservice 2>&1 || true)
+ case "$priv" in
+ *NoSuchService*) assert "Caller identified on the $pass call" 0 -eq 0 ;;
+ *) fail "Unexpected reply to the $pass privileged call: $priv" ;;
+ esac
+done
diff --git a/test/dbus-bus.sh b/test/dbus-bus.sh
new file mode 100755
index 00000000..57a21da8
--- /dev/null
+++ b/test/dbus-bus.sh
@@ -0,0 +1,82 @@
+#!/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
+
+# A client that sends these must still get its signals: refusing the
+# rule would leave it with none at all. The sender here is deliberately
+# wrong, so a delivered signal proves the key was ignored and not
+# quietly honoured.
+say "AddMatch accepts, and ignores, sender/destination/eavesdrop"
+rm -f /tmp/dbus-ignored.out
+( texec "$CLIENT" monitor-signal "$BUS" \
+ "type='signal',sender='org.freedesktop.DBus',destination=':1.99',eavesdrop='false',interface='org.finit.Cond1',member='ConditionChanged'" \
+ 5000 > /tmp/dbus-ignored.out 2>&1 ) &
+ign_pid=$!
+sleep 0.5
+texec "$CLIENT" call-s "$BUS" /org/finit/cond \
+ org.finit.Cond1 Set "dbus-ignored-keys" >/dev/null \
+ || fail "Cond1.Set returned non-zero"
+set +e
+wait "$ign_pid"
+ign_rc=$?
+set -e
+assert "Signal still delivered through the wider rule (rc=$ign_rc)" "$ign_rc" -eq 0
+
+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..f903f0cf
--- /dev/null
+++ b/test/dbus-initctl.sh
@@ -0,0 +1,144 @@
+#!/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
+
+say "initctl status views render over D-Bus alone"
+# hide the legacy socket: everything below must come over the bus
+texec mv /run/finit/socket /run/finit/socket.hidden
+
+set +e
+out=$(texec initctl -p status)
+status_rc=$?
+one=$(texec initctl -p status keventd)
+one_rc=$?
+texec initctl -q status keventd
+quiet_rc=$?
+jout=$(texec initctl -j status)
+json_rc=$?
+idout=$(texec initctl ident)
+ident_rc=$?
+set -e
+
+texec mv /run/finit/socket.hidden /run/finit/socket
+
+assert "status table ok without legacy socket (rc=$status_rc)" \
+ "$status_rc" -eq 0
+case "$out" in
+ *keventd*running*) assert "keventd running in table" 0 -eq 0 ;;
+ *) fail "keventd/running missing from status table" ;;
+esac
+
+assert "status detail view ok (rc=$one_rc)" "$one_rc" -eq 0
+case "$one" in
+ *"Status : running"*"Identity : keventd"*"Restarts :"*)
+ assert "detail view fields present" 0 -eq 0 ;;
+ *) fail "detail view incomplete: $one" ;;
+esac
+
+assert "quiet mode exit code (rc=$quiet_rc)" "$quiet_rc" -eq 0
+
+assert "json mode ok (rc=$json_rc)" "$json_rc" -eq 0
+case "$jout" in
+ *'"identity": "keventd"'*'"status": "running"'*)
+ assert "json fields present" 0 -eq 0 ;;
+ *) fail "json output incomplete: $jout" ;;
+esac
+
+assert "ident ok (rc=$ident_rc)" "$ident_rc" -eq 0
+case "$idout" in
+ *keventd*) assert "ident lists keventd" 0 -eq 0 ;;
+ *) fail "ident missing keventd" ;;
+esac
diff --git a/test/dbus-introspect.sh b/test/dbus-introspect.sh
new file mode 100755
index 00000000..08e4927c
--- /dev/null
+++ b/test/dbus-introspect.sh
@@ -0,0 +1,31 @@
+#!/bin/sh
+# libink: introspection XML well-formedness.
+#
+# Covers compound type signatures in generated introspection XML:
+# Cond1.Dump declares out_sig "a(ss)", which must appear as a single
+# , not one per signature character.
+
+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 "Introspect on /org/finit/cond"
+xml=$(texec "$CLIENT" introspect "$BUS" /org/finit/cond)
+
+say "Cond1.Dump advertises a single a(ss) out arg"
+case "$xml" in
+ *''*) assert "a(ss) intact" 0 -eq 0 ;;
+ *) fail "a(ss) not found as a single arg" ;;
+esac
+
+say "No per-character type fragments in the XML"
+case "$xml" in
+ *'type="("'* | *'type=")"'* | *'type="{"'* | *'type="}"'* | *'type="a"'*)
+ fail "per-character fragment leaked" ;;
+ *) assert "no bracket/array fragments" 0 -eq 0 ;;
+esac
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..e812651b
--- /dev/null
+++ b/test/dbus-service.sh
@@ -0,0 +1,93 @@
+#!/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
+# properties via Properties.Get, 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 properties: Identity, State, Pid"
+ident=$(texec "$CLIENT" getprop "$BUS" /org/finit/service/keventd \
+ org.finit.Service1 Identity)
+assert "Identity is keventd (got: $ident)" "$ident" = "keventd"
+
+state=$(texec "$CLIENT" getprop "$BUS" /org/finit/service/keventd \
+ org.finit.Service1 State)
+assert "State is running (got: $state)" "$state" = "running"
+
+pid=$(texec "$CLIENT" getprop "$BUS" /org/finit/service/keventd \
+ org.finit.Service1 Pid)
+assert "Pid is non-zero (got: $pid)" "$pid" -gt 0
+
+say "Service1 properties are advertised in introspection XML"
+case "$xml" in
+ *'/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/depserv.sh b/test/depserv.sh
index 0d0eaa1d..396afae2 100755
--- a/test/depserv.sh
+++ b/test/depserv.sh
@@ -66,7 +66,9 @@ test_one()
run "initctl stop foo"
run "initctl status"
run "initctl status bar"
- assert_status "bar" "waiting"
+ # bar passes through stopped on its way to waiting, so this has to
+ # wait for the end of the transition rather than sample it once
+ retry 'assert_status "bar" "waiting"'
}
run "initctl debug"
diff --git a/test/fuzz-msg-parse.sh b/test/fuzz-msg-parse.sh
new file mode 100755
index 00000000..6e18850e
--- /dev/null
+++ b/test/fuzz-msg-parse.sh
@@ -0,0 +1,20 @@
+#!/bin/sh
+# libink: __msg_parse() against truncation, corruption, and garbage.
+#
+# Runs the fuzz target's fixed sweep, which is the same contract check
+# libFuzzer drives, so the suite covers it on every build without
+# needing clang. Anything it finds aborts, and the sanitizers CI
+# builds with turn a stray read into a failure here rather than a
+# puzzle on a target.
+
+set -eu
+
+TEST_DIR=$(dirname "$0")
+DRIVER="$TEST_DIR/src/fuzz-msg-parse"
+
+[ -x "$DRIVER" ] || {
+ echo "fuzz-msg-parse not built, D-Bus support is off"
+ exit 77
+}
+
+exec "$DRIVER"
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/lib/setup.sh b/test/lib/setup.sh
index 18b90138..41065998 100755
--- a/test/lib/setup.sh
+++ b/test/lib/setup.sh
@@ -330,7 +330,6 @@ export SYSROOT
top_builddir="${top_builddir:-$TEST_DIR/..}"
sysroot_finit="$SYSROOT/sbin/finit"
built_finit="$top_builddir/src/finit"
-[ -x "$top_builddir/src/.libs/finit" ] && built_finit="$top_builddir/src/.libs/finit"
if [ -x "$built_finit" ] && [ -e "$sysroot_finit" ] && ! cmp -s "$built_finit" "$sysroot_finit"; then
fail "Stale $sysroot_finit, run 'make -C test setup-chroot' or use 'make check'"
diff --git a/test/lib/sysroot.mk b/test/lib/sysroot.mk
index ea2d8c7c..ae71e7f2 100644
--- a/test/lib/sysroot.mk
+++ b/test/lib/sysroot.mk
@@ -36,11 +36,23 @@ BBURL ?= $(BBHOME)/$(BBVER)/$(BBBIN)
# glibc dlopen()s NSS modules at runtime, so ldd does not list them, but
# without libnss_files getpwnam() cannot resolve users inside the chroot
-_libs_nss = $(firstword $(wildcard /lib/$(ARCH)-linux-gnu/libnss_files.so.2 \
+_libs_nss := $(firstword $(wildcard /lib/$(ARCH)-linux-gnu/libnss_files.so.2 \
/usr/lib/$(ARCH)-linux-gnu/libnss_files.so.2 \
/lib64/libnss_files.so.2 /lib/libnss_files.so.2))
-_libs_src = $(shell ldd $(FINITBIN) | grep -Eo '/[^ ]+') $(_libs_nss)
-libs = $(foreach path,$(_libs_src),$(abspath $(DEST))$(path))
+# A real broker and a real client, staged when the host has them, so
+# one test can check Finit against dbus-daemon instead of only against
+# libink's own client. Absent is fine, dbus-broker.sh skips.
+dbus_bins := $(foreach b,dbus-daemon dbus-send,$(firstword $(wildcard /usr/bin/$(b) /bin/$(b))))
+# Given several binaries ldd prefixes each with a 'path:' header, and
+# that trailing colon would land in a make target. Excluding it here
+# is enough, no need for one ldd per binary.
+_libs_dbus := $(if $(dbus_bins),$(shell ldd $(dbus_bins) | grep -Eo '/[^ :]+'))
+
+# The binaries stage exactly like the libraries: same host path, same
+# path under DEST, copied by the rule below.
+_libs_src := $(shell ldd $(FINITBIN) | grep -Eo '/[^ ]+') $(_libs_nss) \
+ $(_libs_dbus) $(dbus_bins)
+libs := $(foreach path,$(sort $(_libs_src)),$(abspath $(DEST))$(path))
all: $(libs) $(DEST)/bin/$(BBBIN)
@(cd $(DEST); \
diff --git a/test/setup-sysroot.sh b/test/setup-sysroot.sh
index d6787f4d..59af2272 100755
--- a/test/setup-sysroot.sh
+++ b/test/setup-sysroot.sh
@@ -2,19 +2,14 @@
set -eu
-echo "=== Finit Test Sysroot Setup ==="
-echo "Date: $(date)"
-echo "SYSROOT: $SYSROOT"
-echo "top_builddir: $top_builddir"
-echo "srcdir: $srcdir"
-echo "================================"
-echo
-
# shellcheck disable=SC2154
make -C "$top_builddir" DESTDIR="$SYSROOT" install
mkdir -p "$SYSROOT/sbin/"
cp "$top_builddir/test/src/serv" "$SYSROOT/sbin/"
+if [ -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"
@@ -31,11 +26,4 @@ for conf in 10-hotplug.conf; do
done
# Update dynamic linker cache for /usr/local/lib libraries
-echo "Running ldconfig in sysroot: $SYSROOT"
-echo "Contents of $SYSROOT/etc/ld.so.conf:"
-cat "$SYSROOT/etc/ld.so.conf" || echo "Warning: ld.so.conf not found"
-echo "Libraries in $SYSROOT/usr/local/lib:"
-ls -la "$SYSROOT/usr/local/lib/" 2>/dev/null || echo "Warning: /usr/local/lib not found in sysroot"
-ldconfig -v -r "$SYSROOT" || echo "Warning: ldconfig failed with exit code $?"
-echo "Verifying ldconfig cache was created:"
-ls -la "$SYSROOT/etc/ld.so.cache" || echo "Warning: ld.so.cache not created"
+ldconfig -r "$SYSROOT" || echo "Warning: ldconfig failed with exit code $?"
diff --git a/test/skel/etc/group b/test/skel/etc/group
index 696ac862..197977f1 100644
--- a/test/skel/etc/group
+++ b/test/skel/etc/group
@@ -1,4 +1,4 @@
-root:x:0:
+root:x:0:wheelie
daemon:x:1:
bin:x:2:
sys:x:3:
@@ -7,3 +7,4 @@ tty:x:5:
disk:x:6:
dialout:x:20:
nogroup:x:65534:
+wheelie:x:1000:
diff --git a/test/skel/etc/machine-id b/test/skel/etc/machine-id
new file mode 100644
index 00000000..cc671894
--- /dev/null
+++ b/test/skel/etc/machine-id
@@ -0,0 +1 @@
+0123456789abcdef0123456789abcdef
diff --git a/test/skel/etc/passwd b/test/skel/etc/passwd
index 2933891f..bede8cf6 100644
--- a/test/skel/etc/passwd
+++ b/test/skel/etc/passwd
@@ -3,3 +3,4 @@ daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
+wheelie:x:1000:1000:wheelie:/home/wheelie:/bin/sh
diff --git a/test/skel/usr/share/dbus-1/system.conf b/test/skel/usr/share/dbus-1/system.conf
new file mode 100644
index 00000000..5d256837
--- /dev/null
+++ b/test/skel/usr/share/dbus-1/system.conf
@@ -0,0 +1,36 @@
+
+
+
+
+ system
+
+
+ /etc/dbus-1/system.d
+ unix:path=/var/run/dbus/system_bus_socket
+ EXTERNAL
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..d907ff86 100644
--- a/test/src/Makefile.am
+++ b/test/src/Makefile.am
@@ -2,8 +2,21 @@ noinst_PROGRAMS = serv
serv_SOURCES = serv.c
serv_CPPFLAGS = -D_XOPEN_SOURCE=600 -D_BSD_SOURCE -D_GNU_SOURCE -D_DEFAULT_SOURCE -I$(top_builddir)
-if LIBSYSTEMD
-serv_CPPFLAGS += -I$(top_srcdir)/libsystemd $(lite_CFLAGS)
+# serv is what notify.sh drives to test notify:systemd, so it always
+# needs sd_notify(). The source is in-tree and it links straight in,
+# unrelated to whether --with-libsystemd installs the shared library.
+serv_CPPFLAGS += -DHAVE_LIBSYSTEMD=1 -I$(top_srcdir)/libsystemd $(lite_CFLAGS)
serv_SOURCES += $(top_srcdir)/libsystemd/sd-daemon.c
serv_LDADD = $(lite_LIBS)
+
+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
+
+noinst_PROGRAMS += fuzz-msg-parse
+fuzz_msg_parse_SOURCES = fuzz-msg-parse.c
+fuzz_msg_parse_CPPFLAGS = -D_GNU_SOURCE -I$(top_srcdir)/libink -I$(top_builddir)
+fuzz_msg_parse_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..14796647
--- /dev/null
+++ b/test/src/dbus-auth-client.c
@@ -0,0 +1,490 @@
+/* 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;
+}
+
+/*
+ * getprop BUS PATH IFACE PROP -- Properties.Get, prints the variant
+ * value; dispatches on the wire signature, "s" and "u" supported.
+ */
+static int mode_getprop(int argc, char *argv[])
+{
+ const link_reply_t *r;
+ link_client_t *c;
+ link_reader_t reader;
+ const char *s;
+ uint32_t u;
+ 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],
+ "org.freedesktop.DBus.Properties", "Get",
+ "ss", argv[4], argv[5]);
+ rc = report_rc(c, rc);
+ if (rc == 0) {
+ char type = 0;
+
+ r = link_client_reply(c);
+ link_reader_init(&reader, r->body, r->body_len);
+ if (link_r_variant_begin(&reader, &type) != 0)
+ rc = 2;
+ else if (type == 's' && link_r_string(&reader, &s) == 0)
+ printf("%s\n", s);
+ else if (type == 'u' && link_r_u32(&reader, &u) == 0)
+ printf("%u\n", u);
+ else {
+ fprintf(stderr, "unsupported variant type '%c'\n", type);
+ 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], "getprop")) return mode_getprop (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;
+}
diff --git a/test/src/fuzz-msg-parse.c b/test/src/fuzz-msg-parse.c
new file mode 100644
index 00000000..e9cb02c9
--- /dev/null
+++ b/test/src/fuzz-msg-parse.c
@@ -0,0 +1,232 @@
+/* libink — fuzz target for __msg_parse().
+ *
+ * __msg_parse() is the one place where bytes off a socket become
+ * pointers, before any authentication has vouched for the peer. It
+ * hands back borrowed pointers into the caller's buffer, so "did not
+ * crash" is too weak a pass: a field that points outside the header it
+ * was supposed to come from, or a string with no terminator inside it,
+ * is a bug the caller hits later and somewhere else. Every input is
+ * checked against that contract here.
+ *
+ * Built two ways. With libFuzzer (clang -fsanitize=fuzzer,address
+ * -DLINK_FUZZ_LIBFUZZER) it is a normal fuzz target. Otherwise it
+ * gets the driver below: named files are replayed, which is how a
+ * crash found by the fuzzer is reproduced, and with no arguments it
+ * runs a fixed sweep so the suite exercises the same contract on
+ * every build without needing clang or a corpus in the tree.
+ *
+ * Copyright (c) 2026 Joachim Wiberg
+ * SPDX-License-Identifier: MIT
+ */
+
+#include
+#include
+#include
+#include
+
+#include "internal.h"
+
+#define FRAME_MAX 512
+#define HDR_FIXED_SIZE 16 /* must agree with proto.c */
+
+static void fail(const char *what, size_t size)
+{
+ fprintf(stderr, "fuzz-msg-parse: %s, on a %zu byte input\n", what, size);
+ abort();
+}
+
+/* A header field is read out of the header field array and nowhere
+ * else. Checking it against the whole buffer would be too generous:
+ * a parser that walked off the end of the fields and into the body
+ * would still be pointing at bytes it was handed, and pass. The
+ * bound is derived from the raw header here rather than taken from
+ * the parser, so the two have to agree independently.
+ *
+ * The string must also terminate inside that region, or whoever
+ * borrows it reads past what it was given. */
+static void check_str(const char *s, const uint8_t *base, size_t len,
+ size_t hdr_end, const char *what)
+{
+ const char *first = (const char *)base + HDR_FIXED_SIZE;
+ const char *last = (const char *)base + hdr_end;
+ size_t room;
+
+ if (!s)
+ return;
+ if (s < first || s >= last)
+ fail(what, len);
+
+ room = (size_t)(last - s);
+ if (strnlen(s, room) == room)
+ fail(what, len);
+}
+
+int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size);
+
+int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
+{
+ struct link_msg m;
+ size_t hdr_end, body_off;
+ uint32_t fields_len;
+ ssize_t rc;
+
+ rc = __msg_parse(data, size, &m);
+ if (rc <= 0)
+ return 0; /* need more, or refused: both fine */
+
+ /* Consuming more than it was handed would desynchronise the
+ * read loop and make it skip into the next message. */
+ if ((size_t)rc > size)
+ fail("claimed more bytes than it was given", size);
+
+ /* Only 'l' messages parse, so reading the length this way is
+ * safe, and rc > 0 means the fixed header was all there. */
+ memcpy(&fields_len, data + 12, sizeof(fields_len));
+ hdr_end = HDR_FIXED_SIZE + fields_len;
+ body_off = (hdr_end + 7) & ~(size_t)7;
+ if (hdr_end > size || body_off > size)
+ fail("accepted a header longer than the message", size);
+
+ check_str(m.path, data, size, hdr_end, "path outside the header");
+ check_str(m.interface, data, size, hdr_end, "interface outside the header");
+ check_str(m.member, data, size, hdr_end, "member outside the header");
+ check_str(m.error_name, data, size, hdr_end, "error name outside the header");
+ check_str(m.destination, data, size, hdr_end, "destination outside the header");
+ check_str(m.sender, data, size, hdr_end, "sender outside the header");
+ check_str(m.signature, data, size, hdr_end, "signature outside the header");
+
+ if (m.body_avail) {
+ if (m.body != data + body_off)
+ fail("body does not start where the header ends", size);
+ if (m.body_avail > size - body_off)
+ fail("body runs past the buffer", size);
+ }
+
+ return 0;
+}
+
+#ifndef LINK_FUZZ_LIBFUZZER
+/* Hand the parser a buffer sized to the input and nothing more.
+ * Reading past the end of a roomy array stays inside the allocation
+ * and the sanitizer never sees it; against an exact allocation the
+ * same read is a fault. This is what libFuzzer does, and the reason
+ * it finds things a fixed buffer cannot. */
+static void run(const uint8_t *data, size_t size)
+{
+ uint8_t *exact = malloc(size ? size : 1);
+
+ if (!exact) {
+ fprintf(stderr, "fuzz-msg-parse: out of memory\n");
+ abort();
+ }
+ memcpy(exact, data, size);
+ LLVMFuzzerTestOneInput(exact, size);
+ free(exact);
+}
+
+/* Deterministic, so a failure reproduces from the same build. */
+static uint32_t prng(uint32_t *state)
+{
+ uint32_t x = *state;
+
+ x ^= x << 13;
+ x ^= x >> 17;
+ x ^= x << 5;
+
+ return *state = x;
+}
+
+static int sweep(void)
+{
+ uint8_t frame[FRAME_MAX], copy[FRAME_MAX];
+ static const uint8_t poke[] = { 0x00, 0x01, 0x7f, 0x80, 0xff };
+ uint32_t state = 0x1234abcd;
+ ssize_t len;
+ size_t i, j, n;
+
+ len = __msg_build_method_call(frame, sizeof(frame), 1,
+ "/org/finit/manager", "org.finit.Manager1",
+ "ListServices", NULL, NULL, 0);
+ if (len <= 0) {
+ fprintf(stderr, "fuzz-msg-parse: cannot build a reference frame\n");
+ return 1;
+ }
+
+ /* Every prefix: the read loop hands over whatever arrived, and
+ * a short read must come back "need more", never a parse. */
+ for (i = 0; i <= (size_t)len; i++)
+ run(frame, i);
+
+ /* One byte wrong, everywhere, with the values that flip a
+ * length or an offset furthest. */
+ for (i = 0; i < (size_t)len; i++) {
+ for (j = 0; j < sizeof(poke); j++) {
+ memcpy(copy, frame, (size_t)len);
+ copy[i] = poke[j];
+ run(copy, (size_t)len);
+ }
+ }
+
+ /* fields_len decides where the header walk stops, so it is the
+ * byte that decides whether a field is read from the header or
+ * from somewhere else. Walk it across the whole frame, and
+ * past it, at every truncation the read loop could hand over. */
+ for (i = 0; i <= (size_t)len + 8; i++) {
+ uint32_t fl = (uint32_t)i;
+
+ memcpy(copy, frame, (size_t)len);
+ memcpy(copy + 12, &fl, sizeof(fl));
+ for (j = 0; j <= (size_t)len; j++)
+ run(copy, j);
+ }
+
+ /* And input that was never a message to begin with. */
+ for (n = 0; n < 20000; n++) {
+ size_t sz = prng(&state) % (FRAME_MAX + 1);
+
+ for (i = 0; i < sz; i++)
+ copy[i] = (uint8_t)prng(&state);
+
+ /* Half of them keep a plausible header, so the parser
+ * gets past its first checks and into field walking. */
+ if (sz >= 16 && (n & 1)) {
+ copy[0] = 'l';
+ copy[3] = LINK_PROTOCOL_VERSION;
+ }
+ run(copy, sz);
+ }
+
+ return 0;
+}
+
+static int replay(const char *path)
+{
+ uint8_t buf[64 * 1024];
+ size_t len;
+ FILE *fp;
+
+ fp = fopen(path, "rb");
+ if (!fp) {
+ perror(path);
+ return 1;
+ }
+ len = fread(buf, 1, sizeof(buf), fp);
+ fclose(fp);
+
+ run(buf, len);
+ return 0;
+}
+
+int main(int argc, char *argv[])
+{
+ int i, rc = 0;
+
+ if (argc < 2)
+ return sweep();
+
+ for (i = 1; i < argc; i++)
+ rc |= replay(argv[i]);
+
+ return rc;
+}
+#endif /* !LINK_FUZZ_LIBFUZZER */
diff --git a/tmpfiles.d/Makefile.am b/tmpfiles.d/Makefile.am
index 5155277c..facb9722 100644
--- a/tmpfiles.d/Makefile.am
+++ b/tmpfiles.d/Makefile.am
@@ -6,6 +6,10 @@ dist_tmpfiles_DATA = etc.conf legacy.conf var.conf
dist_tmpfiles_DATA += dnsmasq.conf frr.conf lldpd.conf openswan.conf \
quagga.conf sshd.conf uuidd.conf
+if BUILD_DBUS_PLUGIN
+dist_tmpfiles_DATA += dbus.conf
+endif
+
if BUILD_TESTSERV_PLUGIN
dist_tmpfiles_DATA += testserv.conf
endif
diff --git a/tmpfiles.d/dbus.conf b/tmpfiles.d/dbus.conf
new file mode 100644
index 00000000..f98e0252
--- /dev/null
+++ b/tmpfiles.d/dbus.conf
@@ -0,0 +1,4 @@
+d /var/run/dbus 0755 - - -
+d /var/run/lock/subsys 0755 - - -
+d /var/lib/dbus 0755 - - -
+d /tmp/dbus 0755 - - -