Skip to content

WIP Finit 5.x - #490

Draft
troglobit wants to merge 22 commits into
masterfrom
next
Draft

WIP Finit 5.x#490
troglobit wants to merge 22 commits into
masterfrom
next

Conversation

@troglobit

Copy link
Copy Markdown
Collaborator

Initial work on what could become Finit 5.0 ...

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Verified protocol/behavior mismatches (D-Bus header flags, runlevel signal encoding, and error mapping) can break interoperability and/or cause incorrect runtime/test behavior.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Pull request overview

This PR introduces an in-tree, brokerless D-Bus implementation (“libink”) and wires Finit (PID 1) and initctl onto it, along with an accompanying smoke-test suite and documentation updates.

Changes:

  • Add libink (server+client) and a Finit-side D-Bus object tree (Manager1/Service1/Cond1) with signal emission hooks.
  • Update build system/configure logic to optionally enable D-Bus support and install a system-bus policy file.
  • Add D-Bus-focused smoke tests plus a small client used by the tests.
File summaries
File Description
test/src/Makefile.am Build the dbus-auth-client test helper when DBUS is enabled
test/src/dbus-auth-client.c New minimal client for libink smoke tests (auth/methods/signals)
test/src/.gitignore Ignore dbus-auth-client test binary
test/setup-sysroot.sh Copy dbus-auth-client into sysroot when present; reduce ldconfig verbosity
test/Makefile.am Add dbus-*.sh tests to EXTRA_DIST and TESTS under DBUS
test/lib/setup.sh Adjust sysroot freshness check for finit binary
test/lib/Makefile.am Distribute dbus-setup.sh helper
test/lib/dbus-setup.sh Shared D-Bus test preamble (locate client/bus socket, wait for bus)
test/dbus-service.sh Smoke test for Service1 object surface + ServiceStateChanged signal
test/dbus-manager.sh Smoke test for Manager1 methods/properties and authz behavior
test/dbus-introspect.sh Validate introspection XML handling of compound signatures
test/dbus-initctl.sh Verify initctl routes through D-Bus by observing emitted signals
test/dbus-cond.sh Smoke test for Cond1 methods, policy checks, and ConditionChanged signal
test/dbus-bus.sh Smoke test for org.freedesktop.DBus built-ins (Hello/AddMatch/Introspect)
test/dbus-auth.sh Smoke test for SASL AUTH EXTERNAL handshake behavior
test/check.sh Enable D-Bus in test configure invocation
src/svc.c Register/unregister per-service D-Bus objects on svc create/delete
src/sm.c Emit runlevel-changed notification over D-Bus
src/service.h Export compose_cmdline() and new service_reload() API
src/service.c Refactor reload into public service_reload() and internal apply helper; emit state-change notifications
src/private.h Add internal dbus_* prototypes guarded by HAVE_DBUS
src/Makefile.am Build/link dbus.c and libink into finit/initctl under DBUS
src/finit.h Add FINIT_BUS_SOCKET path constant
src/finit.c Initialize D-Bus listener during startup when enabled
src/dbus.c New: Finit↔libink glue, vtables, signal emission, optional system-bus attach
src/cond-w.c Emit ConditionChanged signal when conditions are set/cleared (guarded)
src/api.c Route legacy API reload through service_reload()
mkdocs.yml Add D-Bus Integration page to docs nav
Makefile.am Add libink + dbus-1 subdirs under DBUS, ensure build order
libink/server.c New: server socket creation/accept, peer uid capture, attach authenticated fds
libink/README.md New: library overview, layout, and testing notes
libink/proto.h New: message header parse/build API and constants
libink/proto.c New: message header parser/builders
libink/path.h New: object-path escaping API
libink/path.c New: systemd-style path segment escaping
libink/match.c New: AddMatch rule parsing and matching
libink/marshal.h New: internal marshal API for writer/reader
libink/marshal.c New: body marshalling/unmarshalling helpers
libink/Makefile.am New: build libink as a convenience libtool library
libink/link.h New: public libink API (server, client, marshal helpers)
libink/io.c New: shared EINTR-resilient read/write helpers
libink/internal.h New: internal types/constants and private APIs
libink/dispatch.c New: object registry + method dispatch + reply/signal send helpers
libink/connection.c New: per-connection state machine (auth→binary dispatch)
libink/client.c New: synchronous client implementation for method calls and signal wait
libink/builtin.c New: built-in org.freedesktop.DBus.* handlers (Hello/Introspect/Properties/AddMatch)
libink/auth.c New: SASL AUTH EXTERNAL implementation for server and client
libink/.gitignore Ignore build artifacts in libink
doc/dbus.md New: D-Bus Integration user guide
doc/ChangeLog.md Document D-Bus/libink feature and initctl routing
doc/build.md Document --disable-dbus and clarify dbus plugin vs built-in support
dbus-1/org.finit.conf New: system-bus policy restricting state-changing methods
dbus-1/Makefile.am Install dbus policy file when DBUS is enabled
dbus-1/.gitignore Ignore generated Makefile artifacts
configure.ac Add --disable-dbus, HAVE_DBUS define, and DBUS automake conditional
Review details

Suppressed comments (3)

src/dbus.c:421

  • manager_signal() also treats any non-zero dispatch_action() return as NoSuchService. If kill(2) fails for a matched/running service, this incorrectly reports a missing service instead of an operational failure.
    libink/proto.c:322
  • Similarly, ERROR replies should not set NO_REPLY_EXPECTED; reply frames should use flags=0 to match the D-Bus spec and maximize interop.
	return finalize_header(buf, cap, LINK_MSG_ERROR,
			       LINK_FLAG_NO_REPLY_EXPECTED,
			       body_len, serial, off);

libink/proto.c:349

  • Signal frames should also use flags=0; NO_REPLY_EXPECTED is a METHOD_CALL flag and setting it on SIGNALs is at best meaningless and at worst trips protocol validators.
	return finalize_header(buf, cap, LINK_MSG_SIGNAL,
			       LINK_FLAG_NO_REPLY_EXPECTED,
			       body_len, serial, off);
  • Files reviewed: 56/56 changed files
  • Comments generated: 4
  • Review effort level: Lite

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Comment thread 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"
Comment thread src/dbus.c Outdated
Comment on lines +904 to +905
snprintf(old_s, sizeof(old_s), "%d", old_level);
snprintf(new_s, sizeof(new_s), "%d", new_level);
Comment thread src/dbus.c
Comment on lines +307 to +316
const char *ident;

if (link_call_read_string(call, &ident) < 0)
return link_call_reply_error(call,
"org.freedesktop.DBus.Error.InvalidArgs",
"expected (s)");
if (dispatch_action(ident, action, NULL) != 0)
return link_call_reply_error(call,
"org.finit.Error.NoSuchService", ident);

Comment thread libink/proto.c
Comment on lines +293 to +295
return finalize_header(buf, cap, LINK_MSG_METHOD_RETURN,
LINK_FLAG_NO_REPLY_EXPECTED,
body_len, serial, off);
@troglobit
troglobit force-pushed the next branch 3 times, most recently from a20c97b to 240ada0 Compare August 13, 2026 07:02
Finit had no way to answer the question every service manager gets
asked: what is running, and change it.  D-Bus is how the rest of
userspace asks, but linking libdbus, sd-bus or GIO into PID 1 buys a
dependency, an allocator and a main loop we do not control.

So libink: the wire format, an object tree, and a bus of Finit's own
at /run/finit/bus, gated like INIT_SOCKET.  It speaks the standard
org.freedesktop.DBus, .Peer, .Introspectable interfaces, and Finit's
own Manager1, Service1 and Cond1 on top.  Methods that change
something are marked privileged and answered only for a caller the
kernel vouched for, via SO_PEERCRED.

Server and client both, since initctl is the first thing that needs
to talk to it, and its Start/Stop/Restart/Reload now go over the bus
rather than the legacy socket.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
The bus can already answer questions and change services, so give
initctl the two things it still did another way: watching signals as
they happen, and getting or setting user conditions.

The dbus tests move with it, split by area rather than one file that
grew every time the library did.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
Runlevel and version are state, not actions, so they belong behind
org.freedesktop.DBus.Properties rather than another method each.

Finit also claims org.finit on the system bus when it finds one, so
ordinary D-Bus clients can reach it without knowing about
/run/finit/bus.  Opportunistic on purpose: no dbus-daemon is a normal
state for the systems Finit runs on, not an error to report.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
The summary table, the per-service detail, JSON and the quiet and
ident forms all read state Finit already publishes, so they read it
from the bus like everything else rather than through a second path
that has to be kept in step.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
libink was written against the only bus it had, its own, where the
peer on the other end is the client.  A broker is not: it routes for
senders it names itself, expects a DESTINATION on anything addressed
through it, and answers on its own schedule rather than next.

Runlevels go on the wire as S and N rather than the digits Finit
keeps internally, since that is what a caller outside Finit means by
one.

The library stays a convenience library, linked into finit and
initctl and installed nowhere: the ABI promise waits until libink is
its own project.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
The attach is best effort and its diagnostics were tuned for the case
where no broker exists, so a broker that answers but refuses us was
reported as a bare rc=1 at debug level.  Chasing that meant reading
the header builder to find out what the number meant.

Failures now quote the error name the broker sent, and the one for a
name we could not claim says which of the three ways it went wrong.

Repeats stay quiet.  The probe runs on every service and condition
change, and before syslog is up each line is an open, write and close
on /dev/kmsg, so a broker that keeps refusing would otherwise flood
the console during boot.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
The last assertion sampled bar's state one step after asking Finit to
stop foo, but bar passes through stopped on its way to waiting, so the
test failed roughly one run in eight.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
libink was written peer to peer, where one connection is one client
and one principal.  Attaching to a message bus breaks both halves of
that, and two things followed from it.

Signals never reached the system bus.  Fan-out is gated on the peer
having sent AddMatch, but a broker subscribes for its own clients and
never sends us one, so every ServiceStateChanged was dropped on the
floor.  A connection attached with LINK_ATTACH_BROKER gets them all.

Hello, AddMatch and RemoveMatch write per-connection state.  Shared by
every caller behind a broker, that lets one sender exhaust the match
cap or drop another's rule, so we leave all three to the bus, whose
job they are.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
The D-Bus socket was bound world read/write, on the reasoning that
SO_PEERCRED authorizes each method anyway.  That leaves the read-only
surface open to every local user, and it quietly ignores --with-group:
a system that restricts initctl to the wheel group still handed the
same service state to anyone who asked over the bus.

Bind it 0660 and chown it to the configured group, the same gate the
fallback socket has always had.  libink takes the mode as an argument
rather than assuming one, since who may connect is the embedder's
policy, not the library's.

The mode is applied at bind(), so there is no window where the socket
is more permissive than intended.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
On the local bus SO_PEERCRED says who is calling and the kernel is the
one saying it.  Behind a broker one connection carries every caller,
so that credential describes dbus-daemon and nothing else, and every
privileged method was refused there, root included.

Ask the bus driver instead.  libink parks the call and hands us the
sender; we ask GetConnectionUnixUser and answer when the reply lands,
through the same event loop as everything else.  Nothing blocks:
blocking in PID 1 is why libuEv exists.  That needs calls libink can
make on a connection it already has, so it gained those too.

Answers are cached, since a bus never reuses a unique name while it
runs.  Not across a restart though: a new dbus-daemon numbers from
scratch and :1.7 becomes somebody else, so the cache goes when the
broker does.  A sender name too long to key on is refused rather than
truncated, two callers sharing a truncated key would share an
identity.

Privilege is no longer uid 0 alone.  The socket is already owned by
the --with-group group, so refusing its members every method that
changes anything left a wheel user able to open the bus and unable to
reboot.  Both gates now say the same thing.

Group membership needs NSS, which the C library loads with dlopen(),
so the lookup is compiled out where Finit is built to link statically.
That leaves such a build root-only, which is worth saying out loud
rather than leaving to be discovered.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
The legacy socket logs a line per command under initctl debug; the bus
logged nothing, so the transport that now carries most of initctl was
the one you could not watch.

libink gets a logger hook rather than a dependency on Finit's: it
passes the emitting function and a formatted message, and dbus.c hands
both to logit() so the two sources read alike.  Trace points cover the
connection lifecycle, every inbound call, and why a call was refused.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
A peer can be dropped from inside its own read loop: a handler emits a
signal, the write to that very peer fails, and the drop lands while
link_connection_process() still holds the connection and will touch
its rx buffer on the way out.  Freeing there pulls the ground out from
under it.  Unlink the peer and let the event loop free it once the
stack has unwound.

The work has to be scheduled with a non-zero delay.  A uev timer armed
with zero is a disarmed timer, so the queue would never run and the
connections would leak instead.

Losing a peer is also not a warning.  It is what shutdown looks like
from here, and every reboot said so on the console.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
Finit speaks D-Bus itself now and claims org.finit on the system bus
when it finds one, but nothing in a default build ever brings that bus
up.  The plugin that does was opt-in, so the built-in support sat idle
unless the integrator knew to ask for both halves.

Defaulting it on is only reasonable if the result stays the admin's to
change, and a service registered from C through conf_save_service() is
not: it lands in the run path where it cannot be overridden or emptied
out.  So the daemon moves to 20-dbus.conf and its directories to
tmpfiles.d/dbus.conf, the same way hotplug and every other daemon we
ship them for.  The plugin keeps only what has to look at the running
system, the stale pidfile and the machine UUID.

Those directories are no longer chowned to messagebus.  tmpfiles.d
skips a line whose user does not exist rather than falling back, so
the plugin's messagebus/dbus/root ladder has no equivalent there, and
dbus-daemon binds its socket before dropping privileges anyway.

The plugin already bows out where there is no dbus-daemon installed,
so systems that never wanted a bus are unaffected, and
--disable-dbus-plugin is there for those that have one and would still
rather init left it alone.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
The other dbus-*.sh tests drive libink's own client, so the wire format
was only ever checked against the implementation that wrote it, and the
broker path had no coverage at all.  Every bug found in it so far was
found by hand on a target.

Let the dbus plugin bring up a real dbus-daemon, wait for Finit to
claim org.finit, then talk to Finit with dbus-send, which shares no
code with us.  The privileged call is the interesting one: it can only
be answered by parking the call and asking the broker who sent it.
The bus reads the policy Finit installs, so a malformed org.finit.conf
fails here rather than on a target.

Tests no longer build --with-libsystemd.  Our replacement carries the
real soname but only the sd_notify() symbols, so in the test root it
shadowed the libsystemd the host's libdbus-1 wants and dbus-daemon
died on a missing sd_is_socket.  Nothing under test needs the shared
library: serv is the only consumer and it compiles sd-daemon.c
straight in, which it now does regardless of the flag so notify.sh
keeps testing notify:systemd either way.

Staged from the host by lib/sysroot.mk like any other binary, and
skipped when the host has neither program.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
It still described treating every system-bus caller as unprivileged as
the state of things, which stopped being true when Finit learned to ask
the broker who sent a call.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
Parked calls and outbound calls awaiting a reply only ever happen on a
connection talking to a broker, but the parked array sat on the server
and the pending array on every connection.  A server with no broker
carried 4 KiB of slots it could never fill, and both were reachable
from code paths that have no business in them.

Move both behind one struct, allocated on the first park or call and
freed with the connection.  link_server_t goes from 4400 to 168 bytes;
link_connection_t barely moves, its buffers dominate, but an ordinary
peer no longer carries reply-tracking it never uses.

Tokens are now per bus rather than per server, so link_uid_resolved()
takes the connection the answer is about.  Every resolver already has
it: it is the first argument to both the resolver and the reply
callback.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
A call is parked until the resolver says who sent it, and an outbound
call sits in a pending slot until its reply lands.  Neither had a way
to give up.  A broker that answers GetConnectionUnixUser slowly, or
not at all, leaves the caller waiting forever and keeps the slot; four
of those and every later privileged call is refused with
LimitsExceeded until Finit restarts.

libink cannot time itself out, it has no event loop, so the deadline
is the embedder's to keep.  One sweep per connection covers both, and
the ordering between them stays in the library rather than in each
embedder: calls first, because one timing out usually resolves the
park it was made for, and AccessDenied tells that caller more than a
bare timeout.

The sweep is armed when a resolve is deferred and stops rearming as
soon as nothing is outstanding, so a system that never meets a broker
never wakes up for it.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
Only the file header said we refuse 'B' messages, and nobody reads a
header comment when they are looking at why a parse failed.  Put it at
the check, with an XXX so it turns up in a grep for known gaps.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
"Set not yet implemented" reads as a promise.  Finit exposes no
writable property and has no use for one: everything a caller might
want to change is a Manager1 method, where the authorization lives.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
A rule carrying sender, destination, or eavesdrop was refused whole,
and a peer whose AddMatch fails gets no signals at all.  That is a
poor trade for keys clients attach as a matter of course: better a
filter wider than asked for than a subscription that never happened.

They are accepted and ignored rather than honoured.  Widening costs
nothing here since Finit is the only sender on this bus, and what it
emits through the match table is state any peer that got this far may
already read.

argN and argNpath still take the whole rule down.  Honouring them
means parsing message bodies, and nothing asks for them yet.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
__msg_parse() turns bytes off a socket into pointers, before anything
has vouched for the peer, and it is the only place in libink that
does.  It had no test of its own beyond whatever the other tests
happened to send it, all of it well-formed.

The target checks the parser's contract, not merely that it survived.
A header field must point into the header field array, and terminate
inside it, and the parse must never claim more bytes than it was
handed.  Crash-only would pass a parser that walked into the body and
returned fields from there, since those bytes were handed over too.
The expected bounds are derived from the raw header rather than from
the parser, so the two have to agree independently.

Every input is copied into an allocation sized to it first.  Reading
past the end of a roomy buffer stays inside the allocation and the
sanitizer never sees it; against an exact one the same read is a
fault, which is where the sharpest findings come from.

Under libFuzzer it is an ordinary fuzz target and named files replay,
which is how a find gets reproduced.  With no arguments it runs a
fixed sweep -- every truncation, every single-byte corruption, every
value of the length that decides where the header ends, and seeded
garbage -- so the suite covers the same contract on every build,
without clang or a corpus in the tree.  It takes 40 ms.

CI fuzzes it properly on every pull request, keeps the crashers, and
carries the corpus between runs so it reaches deeper over time than
any single run can.  Note that clang links the fuzzer runtime against
the newest GCC tree it finds, so the libstdc++ headers have to match
that one and not the default compiler, which is worth saying since
installing the obvious package leaves you exactly where you started.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
Signed-off-by: Joachim Wiberg <troglobit@gmail.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 78 out of 79 changed files in this pull request and generated no new comments.

Suppressed comments (9)

test/lib/setup.sh:332

  • When finit is linked with libtool libraries, $top_builddir/src/finit is typically a libtool wrapper script while the real binary is in src/.libs/finit. Comparing/copying the wrapper makes the stale-sysroot check fail even when the sysroot is up to date.
    test/dbus-initctl.sh:116
  • Restore path should match the moved socket path (see earlier mv). Using /run/finit/socket will fail on glibc-based sysroots without a /var/run -> /run symlink.
    libink/proto.c:298
  • NO_REPLY_EXPECTED is a MethodCall flag; setting it on METHOD_RETURN frames is non-standard and can confuse strict D-Bus tooling. Replies should use flags=0 here.
    libink/proto.c:352
  • Signals do not expect replies by definition; setting LINK_FLAG_NO_REPLY_EXPECTED is redundant and non-standard. Using flags=0 improves interoperability with strict parsers.
    test/dbus-auth.sh:25
  • This test hard-codes INIT_SOCKET as /run/finit/socket, but the build-time path uses _PATH_VARRUN ("/var/run/" on glibc). In the test sysroot, /var/run is not a symlink to /run, so stat() will fail or check the wrong socket.
    test/lib/dbus-setup.sh:14
  • BUS is set to /run/finit/bus, but FINIT_BUS_SOCKET is built from _PATH_VARRUN ("/var/run/" on glibc). The test sysroot skeleton does not create /var/run -> /run, so the socket will appear under /var/run/finit/bus and these tests will wait forever.
    test/src/.gitignore:6
  • test/src/Makefile.am builds an additional noinst_PROGRAMS binary (fuzz-msg-parse) under test/src/, but it is not ignored. This will leave an untracked file after builds/tests.
    libink/proto.c:325
  • Same issue as METHOD_RETURN: ERROR replies should not set LINK_FLAG_NO_REPLY_EXPECTED. Use flags=0 for reply message types.

This issue also appears on line 350 of the same file.
test/dbus-initctl.sh:101

  • The legacy socket path is hard-coded as /run/finit/socket, but INIT_SOCKET is built from _PATH_VARRUN ("/var/run/" on glibc). In the test sysroot, /var/run is not a symlink to /run, so hiding /run/finit/socket may not actually hide the socket initctl uses.

This issue also appears on line 113 of the same file.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants