Page MenuHomeFreeBSD

D56835.diff
No OneTemporary

D56835.diff

This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/sbin/rcctl/Makefile b/sbin/rcctl/Makefile
new file mode 100644
--- /dev/null
+++ b/sbin/rcctl/Makefile
@@ -0,0 +1,17 @@
+.include <src.opts.mk>
+
+CFLAGS+= -I${SRCTOP}/contrib/libucl/include
+CFLAGS+= -I${SRCTOP}/sbin/rcd
+
+PACKAGE= rc
+
+PROG= rcctl
+SRCS= rcctl.c
+
+LIBADD= ucl
+
+MAN= rcctl.8
+
+WARNS?= 6
+
+.include <bsd.prog.mk>
diff --git a/sbin/rcctl/rcctl.8 b/sbin/rcctl/rcctl.8
new file mode 100644
--- /dev/null
+++ b/sbin/rcctl/rcctl.8
@@ -0,0 +1,197 @@
+.\" Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+.\"
+.\" SPDX-License-Identifier: BSD-2-Clause
+.\"
+.Dd April 30, 2026
+.Dt RCCTL 8
+.Os
+.Sh NAME
+.Nm rcctl
+.Nd control utility for rcd
+.Sh SYNOPSIS
+.Nm
+.Cm status
+.Op Ar service
+.Nm
+.Cm start | stop | restart | reload
+.Ar service ...
+.Nm
+.Cm enable | disable
+.Ar service ...
+.Nm
+.Cm delete
+.Ar service ...
+.Nm
+.Cm rcvar
+.Ar service
+.Nm
+.Cm show
+.Ar service
+.Nm
+.Cm resources
+.Ar service
+.Nm
+.Cm suspend | resume
+.Nm
+.Cm list
+.Nm
+.Ar command
+.Ar service
+.Sh DESCRIPTION
+.Nm
+communicates with
+.Xr rcd 8
+via its UNIX domain control socket to manage services.
+.Pp
+The following commands are available:
+.Bl -tag -width "resources"
+.It Cm status Op Ar service
+Show the state of all services, or a specific service.
+.Pp
+The status display distinguishes between different reasons a service
+is not running:
+.Bl -tag -width "disabled" -compact
+.It Cm nojail
+Service is filtered by jail context
+.Pq not applicable in the current jail environment .
+.It Cm disabled
+Service is disabled by the administrator
+.Pq via Va rcvar No or Cm rcctl disable .
+.El
+.It Cm start Ar service ...
+Start one or more services.
+For template instances, use the
+.Ar template Ns @ Ns Ar instance
+syntax.
+.It Cm stop Ar service ...
+Stop one or more services.
+.It Cm restart Ar service ...
+Stop then start one or more services.
+.It Cm reload Ar service ...
+Send the reload signal (default SIGHUP) to running services.
+.It Cm enable Ar service ...
+Mark services to start on boot.
+Persisted in
+.Pa /etc/rcd.conf.d/ .
+.It Cm disable Ar service ...
+Mark services to not start on boot.
+.It Cm delete Ar service ...
+Remove the override file for one or more services, resetting
+them to unit file defaults.
+The service is re-enabled since the default is
+.Cm enable = true .
+.It Cm rcvar Ar service
+Show the enabled state, unit type, unit file path, and
+override file path (if present) for a service.
+Useful for debugging service configuration.
+.It Cm show Ar service
+Display the full effective configuration of a service in UCL format,
+including unit file settings, overrides, and runtime state.
+.It Cm resources Ar service
+Show resource usage via
+.Xr rctl 8
+for a running service.
+.It Cm suspend
+Stop all services with the
+.Cm resume
+keyword (for power suspend).
+.It Cm resume
+Restart all services with the
+.Cm resume
+keyword (after power resume).
+.It Cm list
+List all known services.
+.It Ar command Ar service
+Run a custom command defined in the service's
+.Cm commands
+block.
+.El
+.Sh TEMPLATE INSTANCES
+Services defined as templates support per-instance operation:
+.Bd -literal -offset indent
+rcctl start dhclient@em0
+rcctl stop bluetooth@ubt0
+rcctl restart geli@ada0p4 geli@ada1p4
+.Ed
+.Pp
+Template instances are configured in
+.Pa /etc/rcd.conf.d/<template> :
+.Bd -literal -offset indent
+instances {
+ em0 {}
+ wlan0 {}
+}
+.Ed
+.Pp
+Or as a simple array when no per-instance config is needed:
+.Bd -literal -offset indent
+instances ["em0", "wlan0"];
+.Ed
+.Ss Instance Grouping
+When multiple instances of the same template appear consecutively on
+the command line,
+.Nm
+groups them into a single request.
+For legacy
+.Xr rc.d 8
+scripts, this results in a single invocation with all instance names
+as arguments, matching the traditional calling convention:
+.Bd -literal -offset indent
+rcctl restart netif@em0 netif@em1
+.Ed
+.Pp
+This is equivalent to:
+.Bd -literal -offset indent
+/bin/sh /etc/rc.d/netif restart em0 em1
+.Ed
+.Pp
+Instances of different templates, or non-template services interspersed
+between instances, break the grouping:
+.Bd -literal -offset indent
+# Two separate calls (different templates):
+rcctl restart netif@em0 jail@web
+
+# Three calls (sshd breaks the group):
+rcctl restart netif@em0 sshd netif@em1
+.Ed
+.Sh EXAMPLES
+.Bd -literal
+# Show all services
+rcctl status
+
+# Start and enable sshd
+rcctl start sshd
+rcctl enable sshd
+
+# Show effective config
+rcctl show sshd
+
+# Show enabled state and unit file location
+rcctl rcvar sshd
+
+# Restart multiple services
+rcctl restart nginx sshd
+
+# Start a template instance
+rcctl start dhclient@em0
+
+# Restart multiple interfaces (grouped into one script call)
+rcctl restart netif@em0 netif@em1
+
+# Run a custom command
+rcctl configtest sshd
+rcctl rotate_log accounting
+
+# Remove override and reset to defaults
+rcctl delete nginx
+
+# Suspend/resume for power management
+rcctl suspend
+rcctl resume
+.Ed
+.Sh SEE ALSO
+.Xr rcd 8 ,
+.Xr rcd.conf 5 ,
+.Xr rcd.d 5
+.Sh AUTHORS
+.An Baptiste Daroussin Aq Mt bapt@FreeBSD.org
diff --git a/sbin/rcctl/rcctl.c b/sbin/rcctl/rcctl.c
new file mode 100644
--- /dev/null
+++ b/sbin/rcctl/rcctl.c
@@ -0,0 +1,431 @@
+/*
+ * Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+/*
+ * rcctl — Control utility for rcd(8).
+ *
+ * Communicates with the rcd daemon via a UNIX domain socket using
+ * UCL-encoded messages. Provides a CLI for managing services.
+ *
+ * Usage:
+ * rcctl status [service]
+ * rcctl start <service>
+ * rcctl stop <service>
+ * rcctl restart <service>
+ * rcctl reload <service>
+ * rcctl enable <service>
+ * rcctl disable <service>
+ * rcctl resources <service>
+ * rcctl deps <service>
+ * rcctl list
+ */
+
+#include <sys/param.h>
+#include <sys/socket.h>
+#include <sys/un.h>
+
+#include <netinet/in.h>
+
+#include <err.h>
+#include <errno.h>
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sysexits.h>
+#include <unistd.h>
+
+#include <ucl.h>
+
+#include "xio.h"
+
+#define RCD_CONTROL_SOCK "/var/run/rcd.sock"
+
+/* Command name constants — shared vocabulary with rcd(8). */
+static const char cmd_status[] = "status";
+static const char cmd_list[] = "list";
+static const char cmd_suspend[] = "suspend";
+static const char cmd_resume[] = "resume";
+static const char cmd_show[] = "show";
+static const char cmd_resources[] = "resources";
+
+static const char *control_socket = RCD_CONTROL_SOCK;
+
+/*
+ * Connect to the rcd control socket.
+ */
+static int
+ctl_connect(void)
+{
+ struct sockaddr_un sun;
+ int fd;
+
+ fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
+ if (fd < 0)
+ err(1, "socket");
+
+ memset(&sun, 0, sizeof(sun));
+ sun.sun_family = AF_UNIX;
+ strlcpy(sun.sun_path, control_socket, sizeof(sun.sun_path));
+
+ /*
+ * Retry connect(2) on EINTR. For a blocking UNIX socket, EINTR
+ * means the system call was interrupted before the connection
+ * completed — simply retry.
+ */
+ while (connect(fd, (struct sockaddr *)&sun, SUN_LEN(&sun)) != 0) {
+ if (errno == EINTR)
+ continue;
+ err(1, "connect to %s", control_socket);
+ }
+
+ return (fd);
+}
+
+/*
+ * Send a UCL request and read the response.
+ */
+static ucl_object_t *
+ctl_request(int fd, const ucl_object_t *req)
+{
+ unsigned char *buf;
+ size_t len;
+ uint32_t nlen;
+ char *rbuf;
+ ssize_t n;
+ struct ucl_parser *parser;
+ ucl_object_t *resp;
+
+ /* Send request */
+ buf = ucl_object_emit(req, UCL_EMIT_JSON_COMPACT);
+ if (buf == NULL)
+ return (NULL);
+
+ len = strlen((char *)buf);
+ nlen = htonl((uint32_t)len);
+
+ if (xwrite(fd, &nlen, sizeof(nlen)) < 0 ||
+ xwrite(fd, buf, len) < 0) {
+ warn("write to control socket");
+ free(buf);
+ return (NULL);
+ }
+ free(buf);
+
+ /* Read response */
+ n = xread(fd, &nlen, sizeof(nlen));
+ if (n != (ssize_t)sizeof(nlen)) {
+ if (n < 0)
+ warn("read from control socket");
+ else
+ warnx("short read from control socket");
+ return (NULL);
+ }
+
+ nlen = ntohl(nlen);
+ if (nlen > 1024 * 1024) /* Cap at 1MB */
+ return (NULL);
+ rbuf = malloc(nlen + 1);
+ if (rbuf == NULL)
+ return (NULL);
+
+ n = xread(fd, rbuf, nlen);
+ if (n != (ssize_t)nlen) {
+ if (n < 0)
+ warn("read from control socket");
+ else
+ warnx("short read from control socket (%zd/%u)",
+ n, nlen);
+ free(rbuf);
+ return (NULL);
+ }
+ rbuf[nlen] = '\0';
+
+ parser = ucl_parser_new(UCL_PARSER_DEFAULT);
+ ucl_parser_add_string(parser, rbuf, nlen);
+ resp = ucl_parser_get_object(parser);
+ ucl_parser_free(parser);
+ free(rbuf);
+
+ return (resp);
+}
+
+/*
+ * Print status table.
+ */
+static void
+print_status(const ucl_object_t *resp)
+{
+ const ucl_object_t *services, *svc;
+ ucl_object_iter_t it;
+
+ printf("%-20s %-12s %8s %s\n",
+ "SERVICE", "STATE", "PID", "TYPE");
+ printf("%-20s %-12s %8s %s\n",
+ "-------", "-----", "---", "----");
+
+ services = ucl_object_lookup(resp, "services");
+ if (services == NULL)
+ return;
+
+ it = ucl_object_iterate_new(services);
+ while ((svc = ucl_object_iterate_safe(it, true)) != NULL) {
+ const ucl_object_t *name, *state, *pid, *type;
+
+ name = ucl_object_lookup(svc, "name");
+ state = ucl_object_lookup(svc, "state");
+ pid = ucl_object_lookup(svc, "pid");
+ type = ucl_object_lookup(svc, "type");
+
+ {
+ const ucl_object_t *en;
+ const char *ststr;
+ long long p = pid ? (long long)ucl_object_toint(pid) : -1;
+ char pbuf[16];
+
+ en = ucl_object_lookup(svc, "enabled");
+ ststr = state ? ucl_object_tostring(state) : "?";
+
+ /* Show reason for services that are off */
+ if (en != NULL && !ucl_object_toboolean(en)) {
+ const ucl_object_t *nj, *ns;
+
+ nj = ucl_object_lookup(svc, "nojail");
+ ns = ucl_object_lookup(svc, "nostart");
+ if (nj != NULL && ucl_object_toboolean(nj))
+ ststr = "nojail";
+ else if (ns != NULL && ucl_object_toboolean(ns))
+ ststr = "nostart";
+ else
+ ststr = "disabled";
+ }
+
+ if (p <= 0)
+ strlcpy(pbuf, "-", sizeof(pbuf));
+ else
+ snprintf(pbuf, sizeof(pbuf), "%lld", p);
+ printf("%-20s %-12s %8s %s\n",
+ name ? ucl_object_tostring(name) : "?",
+ ststr, pbuf,
+ type ? ucl_object_tostring(type) : "?");
+ }
+ }
+ ucl_object_iterate_free(it);
+}
+
+static void
+usage(void)
+{
+
+ fprintf(stderr,
+ "usage: rcctl [-s socket] status [service]\n"
+ " rcctl [-s socket] start|stop|restart|reload <service>\n"
+ " rcctl [-s socket] enable|disable <service>\n"
+ " rcctl [-s socket] show <service>\n"
+ " rcctl [-s socket] resources <service>\n"
+ " rcctl [-s socket] suspend|resume\n"
+ " rcctl [-s socket] list\n");
+ exit(EX_USAGE);
+}
+
+int
+main(int argc, char *argv[])
+{
+ ucl_object_t *req, *resp;
+ const ucl_object_t *status_obj, *msg_obj;
+ int fd, ch;
+
+ while ((ch = getopt(argc, argv, "s:")) != -1) {
+ switch (ch) {
+ case 's':
+ control_socket = optarg;
+ break;
+ default:
+ usage();
+ }
+ }
+ argc -= optind;
+ argv += optind;
+
+ if (argc < 1)
+ usage();
+
+ /*
+ * For commands that take multiple services (start, stop, restart,
+ * reload, enable, disable, show), loop over argv[1..].
+ * For global commands (status, list, suspend, resume), single request.
+ */
+ if (argc < 2 ||
+ strcmp(argv[0], cmd_status) == 0 ||
+ strcmp(argv[0], cmd_list) == 0 ||
+ strcmp(argv[0], cmd_suspend) == 0 ||
+ strcmp(argv[0], cmd_resume) == 0 ||
+ strcmp(argv[0], cmd_show) == 0 ||
+ strcmp(argv[0], cmd_resources) == 0) {
+ /* Single request, optionally with one service */
+ fd = ctl_connect();
+ req = ucl_object_typed_new(UCL_OBJECT);
+ ucl_object_insert_key(req,
+ ucl_object_fromstring(argv[0]), "command", 0, false);
+ if (argc >= 2)
+ ucl_object_insert_key(req,
+ ucl_object_fromstring(argv[1]),
+ "service", 0, false);
+ resp = ctl_request(fd, req);
+ ucl_object_unref(req);
+ close(fd);
+ } else {
+ /*
+ * Multi-service: group template@instance args by
+ * template name so legacy scripts get all instances
+ * in one call (e.g., /bin/sh netif restart em0 em1).
+ * Non-template services are sent individually.
+ */
+ int si;
+
+ resp = NULL;
+ for (si = 1; si < argc; ) {
+ const char *arg, *at;
+ char tmpl[256];
+
+ arg = argv[si];
+ at = strchr(arg, '@');
+
+ if (at != NULL) {
+ /*
+ * Template instance: collect all
+ * instances of the same template.
+ */
+ size_t tlen;
+ ucl_object_t *insts;
+ int sj;
+
+ tlen = (size_t)(at - arg);
+ if (tlen >= sizeof(tmpl))
+ tlen = sizeof(tmpl) - 1;
+ memcpy(tmpl, arg, tlen);
+ tmpl[tlen] = '\0';
+
+ insts = ucl_object_typed_new(UCL_ARRAY);
+ for (sj = si; sj < argc; sj++) {
+ const char *a2 = argv[sj];
+ const char *at2 = strchr(a2, '@');
+
+ if (at2 == NULL)
+ break;
+ if ((size_t)(at2 - a2) != tlen ||
+ memcmp(a2, tmpl, tlen) != 0)
+ break;
+ ucl_array_append(insts,
+ ucl_object_fromstring(at2 + 1));
+ }
+
+ fd = ctl_connect();
+ req = ucl_object_typed_new(UCL_OBJECT);
+ ucl_object_insert_key(req,
+ ucl_object_fromstring(argv[0]),
+ "command", 0, false);
+ ucl_object_insert_key(req,
+ ucl_object_fromstring(arg),
+ "service", 0, false);
+ ucl_object_insert_key(req, insts,
+ "instances", 0, false);
+ resp = ctl_request(fd, req);
+ ucl_object_unref(req);
+ close(fd);
+
+ si = sj; /* skip grouped args */
+ } else {
+ /* Regular service */
+ fd = ctl_connect();
+ req = ucl_object_typed_new(UCL_OBJECT);
+ ucl_object_insert_key(req,
+ ucl_object_fromstring(argv[0]),
+ "command", 0, false);
+ ucl_object_insert_key(req,
+ ucl_object_fromstring(arg),
+ "service", 0, false);
+ resp = ctl_request(fd, req);
+ ucl_object_unref(req);
+ close(fd);
+
+ si++;
+ }
+
+ if (resp == NULL) {
+ warnx("%s: no response", arg);
+ continue;
+ }
+
+ status_obj = ucl_object_lookup(resp, "status");
+ if (status_obj != NULL &&
+ strcmp(ucl_object_tostring(status_obj),
+ "error") == 0) {
+ msg_obj = ucl_object_lookup(resp, "message");
+ warnx("%s: %s", arg,
+ msg_obj ?
+ ucl_object_tostring(msg_obj) :
+ "unknown error");
+ }
+ ucl_object_unref(resp);
+ }
+ return (0);
+ }
+
+ if (resp == NULL)
+ errx(1, "no response from rcd");
+
+ /* Check for errors */
+ status_obj = ucl_object_lookup(resp, "status");
+ if (status_obj != NULL &&
+ strcmp(ucl_object_tostring(status_obj), "error") == 0) {
+ msg_obj = ucl_object_lookup(resp, "message");
+ errx(1, "%s",
+ msg_obj ? ucl_object_tostring(msg_obj) : "unknown error");
+ }
+
+ /* Display output based on command */
+ if (strcmp(argv[0], cmd_status) == 0 ||
+ strcmp(argv[0], cmd_list) == 0) {
+ print_status(resp);
+ } else if (strcmp(argv[0], cmd_resources) == 0) {
+ const ucl_object_t *res;
+ res = ucl_object_lookup(resp, "resources");
+ if (res != NULL)
+ printf("%s\n", ucl_object_tostring(res));
+ } else if (strcmp(argv[0], cmd_suspend) == 0) {
+ const ucl_object_t *n;
+ n = ucl_object_lookup(resp, "stopped");
+ if (n != NULL)
+ printf("suspended %lld services\n",
+ (long long)ucl_object_toint(n));
+ } else if (strcmp(argv[0], cmd_resume) == 0) {
+ const ucl_object_t *n;
+ n = ucl_object_lookup(resp, "started");
+ if (n != NULL)
+ printf("resumed %lld services\n",
+ (long long)ucl_object_toint(n));
+ } else if (strcmp(argv[0], cmd_show) == 0) {
+ const ucl_object_t *cfg;
+ cfg = ucl_object_lookup(resp, "config");
+ if (cfg != NULL) {
+ unsigned char *out;
+ out = ucl_object_emit(cfg, UCL_EMIT_CONFIG);
+ if (out != NULL) {
+ printf("%s", out);
+ free(out);
+ }
+ }
+ } else {
+ /* Simple OK/error response */
+ msg_obj = ucl_object_lookup(resp, "message");
+ if (msg_obj != NULL)
+ printf("%s\n", ucl_object_tostring(msg_obj));
+ }
+
+ ucl_object_unref(resp);
+ return (0);
+}
diff --git a/sbin/rcd/Makefile b/sbin/rcd/Makefile
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/Makefile
@@ -0,0 +1,62 @@
+.include <src.opts.mk>
+
+LUASRC= ${SRCTOP}/contrib/lua/src
+
+CFLAGS+= -I${SRCTOP}/contrib/libucl/include
+CFLAGS+= -I${SRCTOP}/lib/liblua
+CFLAGS+= -I${LUASRC}
+CFLAGS+= -I${SRCTOP}/libexec/flua/modules
+
+PACKAGE= rc
+
+PROGS= rcd rcd-exec
+
+# --- rcd: main daemon ---
+SRCS.rcd= rcd.c unit.c depgraph.c process.c sockact.c \
+ compat.c control.c rctl_mgr.c jail_svc.c log.c enable.c \
+ luaexec.c hash.c
+
+# Built-in Lua modules from flua
+.PATH: ${SRCTOP}/libexec/flua/modules
+SRCS.rcd+= lposix.c
+
+# lua_ucl.c — UCL bindings for Lua (built-in, no /usr needed)
+UCLSRC= ${SRCTOP}/contrib/libucl
+.PATH: ${UCLSRC}/lua
+SRCS.rcd+= lua_ucl.c
+CFLAGS+= -I${UCLSRC}/src -I${UCLSRC}/uthash -I${SRCTOP}/libexec/flua
+CWARNFLAGS.lua_ucl.c= -Wno-cast-align -Wno-cast-qual
+CWARNFLAGS.hash.c= -Wno-cast-align -Wno-cast-qual
+
+LIBADD.rcd= ucl jail lua
+
+MAN.rcd= rcd.8 rcd.conf.5 rcd.d.5 rcd-lua.3
+
+# Embed JSON schemas into the binary as C byte arrays.
+CFLAGS+= -I${.OBJDIR}
+
+conf_schema.h: ${.CURDIR}/schema/rcd.conf.schema.json
+ file2c -sx 'static const char conf_schema_json[] = {' ', 0x00};' \
+ < ${.ALLSRC} > ${.TARGET}
+
+unit_schema.h: ${.CURDIR}/schema/unit.schema.json
+ file2c -sx 'static const char unit_schema_json[] = {' ', 0x00};' \
+ < ${.ALLSRC} > ${.TARGET}
+
+rcd.pieo: conf_schema.h unit_schema.h
+
+CLEANFILES+= conf_schema.h unit_schema.h
+
+# --- rcd-exec: pre-exec setup helper ---
+SRCS.rcd-exec= rcd-exec.c
+
+LIBADD.rcd-exec= jail util
+
+MAN.rcd-exec= rcd-exec.8
+
+WARNS?= 6
+
+HAS_TESTS=
+SUBDIR.${MK_TESTS}+= tests
+
+.include <bsd.progs.mk>
diff --git a/sbin/rcd/compat.c b/sbin/rcd/compat.c
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/compat.c
@@ -0,0 +1,436 @@
+/*
+ * Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+/*
+ * Legacy rc.d script compatibility layer.
+ *
+ * Scans directories for rc.d scripts, parses their PROVIDE/REQUIRE/BEFORE/
+ * KEYWORD headers (same format as rcorder(8)), checks rc.conf to determine
+ * if the service is enabled, and wraps each script as a virtual unit of
+ * type UNIT_LEGACY in the dependency graph.
+ */
+
+#include <sys/param.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+
+#include <ctype.h>
+#include <dirent.h>
+#include <errno.h>
+#include <paths.h>
+#include <spawn.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "rcd.h"
+
+/*
+ * Parse a single header line of the form:
+ * # PROVIDE: name1 name2 ...
+ * # REQUIRE: name1 name2 ...
+ * # BEFORE: name1 name2 ...
+ * # KEYWORD: kw1 kw2 ...
+ *
+ * Also accepts REQUIRES/KEYWORDS (plural) for compatibility with rcorder.
+ */
+static int
+parse_header_line(const char *line, const char *tag, charv_t *outp)
+{
+ const char *p;
+ char *buf, *tok;
+ size_t taglen, prev_len;
+
+ taglen = strlen(tag);
+
+ /* Skip leading whitespace and '#' */
+ p = line;
+ while (*p == ' ' || *p == '\t')
+ p++;
+ if (*p != '#')
+ return (0);
+ p++;
+ while (*p == ' ' || *p == '\t')
+ p++;
+
+ /* Match tag (case insensitive) */
+ if (strncasecmp(p, tag, taglen) != 0)
+ return (0);
+ p += taglen;
+
+ /* Accept optional trailing 'S' (REQUIRES, KEYWORDS) */
+ if (*p == 's' || *p == 'S')
+ p++;
+
+ /* Must be followed by ':' */
+ if (*p != ':')
+ return (0);
+ p++;
+
+ /* Parse space-separated names */
+ buf = xstrdup(p);
+
+ prev_len = outp->len;
+ for (tok = strtok(buf, " \t\r\n"); tok != NULL;
+ tok = strtok(NULL, " \t\r\n"))
+ vec_push(outp, xstrdup(tok));
+ free(buf);
+
+ return (outp->len > prev_len ? 1 : 0);
+}
+
+static bool
+compat_has_keyword(const struct unit *u, const char *kw)
+{
+
+ vec_foreach(u->u_keyword, i) {
+ if (strcasecmp(u->u_keyword.d[i], kw) == 0)
+ return (true);
+ }
+ return (false);
+}
+
+/*
+ * Parse PROVIDE/REQUIRE/BEFORE/KEYWORD headers from an rc.d script.
+ */
+int
+compat_parse_headers(const char *path, struct unit *u)
+{
+ FILE *fp;
+ char *line;
+ size_t linecap;
+ ssize_t linelen;
+ int found_any;
+ bool has_command, has_pidfile, has_start_cmd, has_rcvar, has_code;
+
+ fp = fopen(path, "re");
+ if (fp == NULL) {
+ log_warn("open %s: %s", path, strerror(errno));
+ return (-1);
+ }
+
+ line = NULL;
+ linecap = 0;
+ found_any = 0;
+ has_command = false;
+ has_pidfile = false;
+ has_start_cmd = false;
+ has_rcvar = false;
+ has_code = false;
+
+ while ((linelen = getline(&line, &linecap, fp)) > 0) {
+ const char *p = line;
+
+ while (*p == ' ' || *p == '\t')
+ p++;
+
+ /* Comment lines: parse PROVIDE/REQUIRE/BEFORE/KEYWORD */
+ if (*p == '#') {
+ parse_header_line(line, "PROVIDE",
+ &u->u_provide);
+ parse_header_line(line, "REQUIRE",
+ &u->u_require);
+ parse_header_line(line, "BEFORE",
+ &u->u_before);
+ if (parse_header_line(line, "KEYWORD",
+ &u->u_keyword) > 0)
+ found_any++;
+ if (u->u_provide.len > 0 || u->u_require.len > 0 ||
+ u->u_before.len > 0)
+ found_any = 1;
+ continue;
+ }
+
+ /* Blank lines */
+ if (*p == '\n' || *p == '\0')
+ continue;
+
+ /* Non-comment, non-blank: executable code */
+ has_code = true;
+
+ /* Detect type-determining variables */
+ if (strncmp(p, "pidfile=", 8) == 0)
+ has_pidfile = true;
+ else if (strncmp(p, "command=", 8) == 0)
+ has_command = true;
+ else if (strncmp(p, "start_cmd=", 10) == 0)
+ has_start_cmd = true;
+ else if (strncmp(p, "rcvar=", 6) == 0)
+ has_rcvar = true;
+ }
+
+ free(line);
+ fclose(fp);
+
+ /*
+ * Classify the script type:
+ * pidfile= or command= (without start_cmd=) → daemon
+ * only comments/blank lines (no code) → barrier
+ * otherwise → oneshot
+ */
+ if (has_pidfile || (has_command && !has_start_cmd))
+ u->u_type = UNIT_LEGACY_FORKING;
+ else if (!has_code)
+ u->u_type = UNIT_BARRIER;
+ u->u_has_rcvar = has_rcvar;
+
+ return (found_any > 0 ? 0 : -1);
+}
+
+/*
+ * Load all rc.conf variables by sourcing rc.subr in a shell.
+ *
+ * This is the safe approach: we let /bin/sh handle all the
+ * complexity of rc.conf parsing (variable expansion, conditionals,
+ * includes, rc.conf.d/ directories, etc.) and just read the result.
+ *
+ * Runs once at startup; the result is cached in cfg->cfg_rcvars.
+ */
+int
+compat_load_rcvars(struct rcd_config *cfg)
+{
+ FILE *fp;
+ char *line;
+ size_t linecap;
+ ssize_t linelen;
+ int pipefd[2];
+ pid_t pid;
+ int status, error;
+ char *argv[4];
+ posix_spawn_file_actions_t fa;
+
+ cfg->cfg_rcvars = hash_new();
+
+ if (pipe(pipefd) != 0)
+ return (-1);
+
+ posix_spawn_file_actions_init(&fa);
+ posix_spawn_file_actions_adddup2(&fa, pipefd[1], STDOUT_FILENO);
+ posix_spawn_file_actions_addclose(&fa, pipefd[0]);
+ posix_spawn_file_actions_addclose(&fa, pipefd[1]);
+
+ argv[0] = __DECONST(char *, _PATH_BSHELL);
+ argv[1] = __DECONST(char *, "-c");
+ argv[2] = __DECONST(char *,
+ ". /etc/rc.subr;"
+ "load_rc_config;"
+ "set");
+ argv[3] = NULL;
+
+ error = posix_spawn(&pid, _PATH_BSHELL, &fa, NULL, argv, NULL);
+ posix_spawn_file_actions_destroy(&fa);
+ close(pipefd[1]);
+
+ if (error != 0) {
+ close(pipefd[0]);
+ return (-1);
+ }
+
+ fp = fdopen(pipefd[0], "r");
+ if (fp == NULL) {
+ close(pipefd[0]);
+ xwaitpid(pid, &status, 0);
+ return (-1);
+ }
+
+ line = NULL;
+ linecap = 0;
+ while ((linelen = getline(&line, &linecap, fp)) > 0) {
+ char *eq, *val, *end;
+
+ /* Only keep *_enable variables */
+ eq = strchr(line, '=');
+ if (eq == NULL)
+ continue;
+ *eq = '\0';
+
+ if (strlen(line) < 8 ||
+ strcmp(line + strlen(line) - 7, "_enable") != 0)
+ continue;
+
+ val = eq + 1;
+ /* Strip quotes and trailing whitespace */
+ if (val[0] == '$' && val[1] == '\'')
+ val += 2;
+ else
+ while (*val == '\'' || *val == '"')
+ val++;
+ end = val + strlen(val) - 1;
+ while (end > val && (*end == '\n' || *end == '\r' ||
+ *end == '\'' || *end == '"'))
+ *end-- = '\0';
+
+ hash_add(cfg->cfg_rcvars, line, xstrdup(val), free);
+ }
+
+ free(line);
+ fclose(fp);
+
+ if (xwaitpid(pid, &status, 0) < 0) {
+ log_warn("waitpid rc.subr: %s", strerror(errno));
+ return (-1);
+ }
+ if (WIFSIGNALED(status)) {
+ log_warn("rc.subr shell killed by signal %d",
+ WTERMSIG(status));
+ return (-1);
+ }
+ if (WIFEXITED(status) && WEXITSTATUS(status) != 0) {
+ log_warn("rc.subr shell exited with status %d",
+ WEXITSTATUS(status));
+ }
+
+ return (0);
+}
+
+/*
+ * Check if a legacy service is enabled by looking up ${name}_enable
+ * in the cached rc.conf variables.
+ */
+bool
+compat_is_enabled(const char *name, struct rcd_config *cfg)
+{
+ char *pattern;
+ const char *val;
+
+ xasprintf(&pattern, "%s_enable", name);
+
+ val = (const char *)hash_get_value(cfg->cfg_rcvars, pattern);
+ free(pattern);
+ if (val != NULL) {
+ if (strcasecmp(val, "YES") == 0 ||
+ strcasecmp(val, "TRUE") == 0 ||
+ strcasecmp(val, "ON") == 0 ||
+ strcmp(val, "1") == 0)
+ return (true);
+ return (false);
+ }
+ return (false); /* Default: not enabled */
+}
+
+/*
+ * Scan a directory for rc.d scripts and create legacy unit wrappers.
+ */
+int
+compat_scan(struct rcd_ctx *ctx, const char *dirpath)
+{
+ DIR *dp;
+ struct dirent *de;
+ struct unit *u;
+ char path[PATH_MAX];
+
+ dp = opendir(dirpath);
+ if (dp == NULL) {
+ if (errno == ENOENT)
+ return (0);
+ log_warn("opendir %s: %s", dirpath, strerror(errno));
+ return (-1);
+ }
+
+ while ((de = readdir(dp)) != NULL) {
+ struct stat sb;
+
+ /* Skip hidden files and common non-script files */
+ if (de->d_name[0] == '.')
+ continue;
+
+ if (snprintf(path, sizeof(path), "%s/%s",
+ dirpath, de->d_name) >= (int)sizeof(path))
+ continue;
+
+ /* Skip non-regular files (directories, symlinks, etc.) */
+ if (stat(path, &sb) != 0 || !S_ISREG(sb.st_mode))
+ continue;
+
+ /*
+ * Check if this provision is already satisfied by a
+ * native unit. If so, the native unit takes precedence.
+ */
+
+ u = unit_alloc();
+ if (u == NULL)
+ continue;
+
+ u->u_type = UNIT_LEGACY;
+ u->u_path = xstrdup(path);
+
+ /*
+ * For legacy scripts, set stop_command to call the
+ * script with "stop" argument. This ensures proper
+ * teardown instead of just sending SIGTERM.
+ */
+ xasprintf(&u->u_stop_command, "%s %s stop", _PATH_BSHELL, path);
+
+ /* Parse headers from the script */
+ if (compat_parse_headers(path, u) != 0) {
+ unit_free(u);
+ continue;
+ }
+
+ /* KEYWORD: NORCD — script explicitly opts out of rcd */
+ if (compat_has_keyword(u, "NORCD")) {
+ log_debug("skipping %s: NORCD keyword", de->d_name);
+ unit_free(u);
+ continue;
+ }
+
+ /* Use the first provision as the service name */
+ if (u->u_provide.len > 0)
+ u->u_name = xstrdup(u->u_provide.d[0]);
+ else
+ u->u_name = xstrdup(de->d_name);
+
+ /* Check if already provided by a native unit */
+ if (depgraph_find(&ctx->ctx_graph, u->u_name) != NULL) {
+ log_debug("skipping legacy %s: overridden by native unit",
+ u->u_name);
+ unit_free(u);
+ continue;
+ }
+
+ /*
+ * Check rc.conf for enabled state.
+ * Scripts without rcvar= are always enabled (like rc.subr).
+ * Barriers are also always enabled.
+ */
+ if (u->u_type == UNIT_BARRIER)
+ u->u_enabled = true;
+ else if (u->u_has_rcvar)
+ u->u_enabled = compat_is_enabled(u->u_name,
+ &ctx->ctx_config);
+ else
+ u->u_enabled = true;
+
+ /* Check keywords for filtering */
+ vec_foreach(u->u_keyword, i) {
+ if (strcmp(u->u_keyword.d[i],
+ "nostart") == 0)
+ u->u_nostart = true;
+ if (strcmp(u->u_keyword.d[i],
+ "firstboot") == 0)
+ u->u_boot_only = true;
+ if (strcmp(u->u_keyword.d[i],
+ "nojail") == 0)
+ u->u_nojail = true;
+ if (strcmp(u->u_keyword.d[i],
+ "nojailvnet") == 0)
+ u->u_nojailvnet = true;
+ if (strcmp(u->u_keyword.d[i],
+ "resume") == 0)
+ u->u_resume = true;
+ }
+
+ depgraph_add(&ctx->ctx_graph, u);
+ log_debug("loaded legacy unit: %s (%s)", u->u_name, path);
+ }
+
+ closedir(dp);
+ return (0);
+}
+
+
diff --git a/sbin/rcd/control.c b/sbin/rcd/control.c
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/control.c
@@ -0,0 +1,1179 @@
+/*
+ * Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+/*
+ * Control socket. Listens on a UNIX domain socket and handles commands
+ * from rcctl(8). Client credentials are verified via LOCAL_PEERCRED.
+ * Messages are UCL-encoded with a 4-byte length prefix.
+ */
+
+#include <sys/param.h>
+#include <sys/event.h>
+#include <sys/socket.h>
+#include <sys/stat.h>
+#include <sys/ucred.h>
+#include <sys/un.h>
+
+#include <arpa/inet.h>
+
+#include <errno.h>
+#include <fcntl.h>
+#include <grp.h>
+#include <libgen.h>
+#include <paths.h>
+#include <pwd.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <syslog.h>
+#include <unistd.h>
+
+#include <ucl.h>
+
+#include "rcd.h"
+
+#define CTL_MAX_MSG (64 * 1024)
+
+static const char *state_names[] = {
+ [STATE_INACTIVE] = "inactive",
+ [STATE_STARTING] = "starting",
+ [STATE_RUNNING] = "running",
+ [STATE_STOPPING] = "stopping",
+ [STATE_FAILED] = "failed",
+ [STATE_DONE] = "done",
+ [STATE_WAITING] = "waiting",
+};
+
+static const char *
+unit_type_str(enum unit_type t)
+{
+
+ switch (t) {
+ case UNIT_SIMPLE: return ("simple");
+ case UNIT_FORKING: return ("forking");
+ case UNIT_ONESHOT: return ("oneshot");
+ case UNIT_BARRIER: return ("barrier");
+ case UNIT_LEGACY: return ("legacy");
+ case UNIT_LEGACY_FORKING: return ("legacy-forking");
+ }
+ return ("unknown");
+}
+
+/*
+ * Verify that the connecting peer is authorized (root or in control group).
+ */
+static bool
+check_credentials(int fd, const char *groupname)
+{
+ struct xucred cred;
+ socklen_t len;
+ struct group *grp;
+ int i;
+
+ len = sizeof(cred);
+ if (getsockopt(fd, SOL_LOCAL, LOCAL_PEERCRED, &cred, &len) != 0)
+ return (false);
+
+ /* Verify credential structure version */
+ if (cred.cr_version != XUCRED_VERSION)
+ return (false);
+
+ /* Root is always allowed */
+ if (cred.cr_uid == 0)
+ return (true);
+
+ /* Check group membership */
+ if (groupname == NULL) {
+ log_warn("control: no control_group configured, "
+ "non-root access denied");
+ return (false);
+ }
+ grp = getgrnam(groupname);
+ if (grp == NULL) {
+ log_warn("control: group '%s' does not exist",
+ groupname);
+ return (false);
+ }
+
+ for (i = 0; i < cred.cr_ngroups; i++) {
+ if (cred.cr_groups[i] == grp->gr_gid)
+ return (true);
+ }
+
+ return (false);
+}
+
+/*
+ * Check if a credential matches a principal list.
+ * Principals are: "username" for user match, "@groupname" for group match.
+ * Returns true if any principal matches the credential.
+ */
+static bool
+match_principals(const charv_t *principals, const struct xucred *cred)
+{
+ struct passwd *pw;
+ struct group *gr;
+
+ vec_foreach(*principals, i) {
+ const char *p = principals->d[i];
+
+ if (p[0] == '@') {
+ /* Group match */
+ gr = getgrnam(p + 1);
+ if (gr == NULL)
+ continue;
+ for (int j = 0; j < cred->cr_ngroups; j++) {
+ if (cred->cr_groups[j] == gr->gr_gid)
+ return (true);
+ }
+ } else {
+ /* User match */
+ pw = getpwnam(p);
+ if (pw != NULL && pw->pw_uid == cred->cr_uid)
+ return (true);
+ }
+ }
+ return (false);
+}
+
+/*
+ * Check if a credential is authorized to perform a command on a unit.
+ * Root is always authorized. The global control_group bypasses ACLs.
+ * Per-service ACLs are checked from the unit's access block.
+ *
+ * cmd is one of: "start", "stop", "restart", "reload", "status",
+ * "describe", "show", "resources".
+ * Returns true if authorized.
+ */
+bool
+access_check(const struct unit *u, const char *cmd,
+ const struct xucred *cred)
+{
+ const charv_t *acl;
+
+ /* Root always passes */
+ if (cred->cr_uid == 0)
+ return (true);
+
+ /* Pick the ACL for the requested command */
+ if (strcmp(cmd, "start") == 0)
+ acl = &u->u_access.ua_start;
+ else if (strcmp(cmd, "stop") == 0)
+ acl = &u->u_access.ua_stop;
+ else if (strcmp(cmd, "restart") == 0)
+ acl = &u->u_access.ua_restart;
+ else if (strcmp(cmd, "reload") == 0)
+ acl = &u->u_access.ua_reload;
+ else if (strcmp(cmd, "status") == 0 || strcmp(cmd, "describe") == 0 ||
+ strcmp(cmd, "show") == 0 || strcmp(cmd, "resources") == 0) {
+ /*
+ * Read-only commands: implicitly allow all control-group
+ * members when no per-service ACL is defined.
+ */
+ acl = &u->u_access.ua_status;
+ if (acl->len == 0)
+ return (true);
+ return (match_principals(acl, cred));
+ } else
+ return (false); /* Unknown command — deny */
+
+ /* Mutating commands (start/stop/restart/reload) -- deny if no ACL */
+ if (acl->len == 0)
+ return (false);
+
+ return (match_principals(acl, cred));
+}
+
+/*
+ * Send a UCL response on a connected socket.
+ */
+static int
+send_response(int fd, const ucl_object_t *obj)
+{
+ unsigned char *buf;
+ size_t len;
+ uint32_t nlen;
+
+ buf = ucl_object_emit(obj, UCL_EMIT_JSON_COMPACT);
+ if (buf == NULL)
+ return (-1);
+
+ len = strlen((char *)buf);
+ nlen = htonl((uint32_t)len);
+
+ if (xwrite(fd, &nlen, sizeof(nlen)) != sizeof(nlen) ||
+ xwrite(fd, buf, len) != (ssize_t)len) {
+ free(buf);
+ return (-1);
+ }
+
+ free(buf);
+ return (0);
+}
+
+/*
+ * Shortcut: send an error response (status: "error", message: msg).
+ */
+static void
+send_error(int fd, const char *msg)
+{
+ ucl_object_t *resp;
+
+ resp = ucl_object_typed_new(UCL_OBJECT);
+ ucl_object_insert_key(resp,
+ ucl_object_fromstring("error"), "status", 0, false);
+ ucl_object_insert_key(resp,
+ ucl_object_fromstring(msg), "message", 0, false);
+ send_response(fd, resp);
+ ucl_object_unref(resp);
+}
+
+/*
+ * Shortcut: send an OK response (status: "ok"), optionally with a message.
+ */
+static void
+send_ok(int fd, const char *msg)
+{
+ ucl_object_t *resp;
+
+ resp = ucl_object_typed_new(UCL_OBJECT);
+ ucl_object_insert_key(resp,
+ ucl_object_fromstring("ok"), "status", 0, false);
+ if (msg != NULL)
+ ucl_object_insert_key(resp,
+ ucl_object_fromstring(msg), "message", 0, false);
+ send_response(fd, resp);
+ ucl_object_unref(resp);
+}
+
+/*
+ * Build status response for one or all services.
+ */
+static ucl_object_t *
+build_status_response(struct rcd_ctx *ctx, const char *svcname)
+{
+ ucl_object_t *top, *arr, *sobj;
+ struct unit *u;
+
+ top = ucl_object_typed_new(UCL_OBJECT);
+ ucl_object_insert_key(top,
+ ucl_object_fromstring("ok"), "status", 0, false);
+
+ arr = ucl_object_typed_new(UCL_ARRAY);
+
+ TAILQ_FOREACH(u, &ctx->ctx_graph.dg_units, u_entries) {
+ if (svcname != NULL && strcmp(u->u_name, svcname) != 0)
+ continue;
+
+ sobj = ucl_object_typed_new(UCL_OBJECT);
+ ucl_object_insert_key(sobj,
+ ucl_object_fromstring(u->u_name), "name", 0, false);
+ ucl_object_insert_key(sobj,
+ ucl_object_frombool(u->u_enabled), "enabled", 0, false);
+ if (ctx->ctx_jailed && u->u_nojail)
+ ucl_object_insert_key(sobj,
+ ucl_object_frombool(true), "nojail", 0, false);
+ if (u->u_nostart)
+ ucl_object_insert_key(sobj,
+ ucl_object_frombool(true), "nostart", 0, false);
+ ucl_object_insert_key(sobj,
+ ucl_object_fromstring(state_names[u->u_state]),
+ "state", 0, false);
+ ucl_object_insert_key(sobj,
+ ucl_object_fromint(u->u_pid), "pid", 0, false);
+ ucl_object_insert_key(sobj,
+ ucl_object_fromstring(unit_type_str(u->u_type)),
+ "type", 0, false);
+
+ ucl_array_append(arr, sobj);
+ }
+
+ ucl_object_insert_key(top, arr, "services", 0, false);
+ return (top);
+}
+
+/*
+ * Process a single control command.
+ */
+static void
+process_command(struct rcd_ctx *ctx, int clientfd,
+ const ucl_object_t *req, const struct xucred *cred)
+{
+ const ucl_object_t *cmd_obj, *svc_obj, *flag_obj;
+ const char *cmd, *svc, *instance;
+ ucl_object_t *resp;
+ struct unit *u;
+ bool force_flag, one_flag;
+ char *hook_output;
+
+ cmd_obj = ucl_object_lookup(req, "command");
+ if (cmd_obj == NULL)
+ return;
+ cmd = ucl_object_tostring(cmd_obj);
+
+ svc_obj = ucl_object_lookup(req, "service");
+ svc = (svc_obj != NULL) ? ucl_object_tostring(svc_obj) : NULL;
+
+ /*
+ * Command modifier flags (like rc's fast/force/one/quiet prefixes).
+ * force: bypass enable check, always run
+ * one: temporarily treat as enabled for this invocation
+ */
+ force_flag = false;
+ one_flag = false;
+ flag_obj = ucl_object_lookup(req, "force");
+ if (flag_obj != NULL)
+ force_flag = ucl_object_toboolean(flag_obj);
+ flag_obj = ucl_object_lookup(req, "one");
+ if (flag_obj != NULL)
+ one_flag = ucl_object_toboolean(flag_obj);
+
+ if (strcmp(cmd, "status") == 0) {
+ resp = build_status_response(ctx, svc);
+ send_response(clientfd, resp);
+ ucl_object_unref(resp);
+ return;
+ }
+
+ /*
+ * Power management: suspend stops all services with the
+ * "resume" keyword, resume restarts them. These are global
+ * commands that don't take a service name.
+ */
+ if (strcmp(cmd, "suspend") == 0) {
+ struct unit *su;
+ int count;
+
+ log_info("suspend: stopping resume-flagged services");
+ count = 0;
+ TAILQ_FOREACH(su, &ctx->ctx_graph.dg_units, u_entries) {
+ if (!su->u_resume)
+ continue;
+ if (su->u_state != STATE_RUNNING)
+ continue;
+ log_info("suspend: stopping %s", su->u_name);
+ proc_stop_sync(ctx, su);
+ count++;
+ }
+ resp = ucl_object_typed_new(UCL_OBJECT);
+ ucl_object_insert_key(resp,
+ ucl_object_fromstring("ok"), "status", 0, false);
+ ucl_object_insert_key(resp,
+ ucl_object_fromint(count), "stopped", 0, false);
+ send_response(clientfd, resp);
+ ucl_object_unref(resp);
+ return;
+ }
+
+ if (strcmp(cmd, "resume") == 0) {
+ struct unit *su;
+ int count;
+
+ log_info("resume: restarting resume-flagged services");
+ count = 0;
+ TAILQ_FOREACH(su, &ctx->ctx_graph.dg_units, u_entries) {
+ if (!su->u_resume)
+ continue;
+ if (su->u_state == STATE_RUNNING)
+ continue;
+ if (!su->u_enabled)
+ continue;
+ log_info("resume: starting %s", su->u_name);
+ su->u_state = STATE_STARTING;
+ su->u_retry_count = 0;
+ proc_spawn(ctx, su);
+ count++;
+ }
+ resp = ucl_object_typed_new(UCL_OBJECT);
+ ucl_object_insert_key(resp,
+ ucl_object_fromstring("ok"), "status", 0, false);
+ ucl_object_insert_key(resp,
+ ucl_object_fromint(count), "started", 0, false);
+ send_response(clientfd, resp);
+ ucl_object_unref(resp);
+ return;
+ }
+
+ /* Commands that require a service name */
+ if (svc == NULL) {
+ send_error(clientfd, "service name required");
+ return;
+ }
+
+ instance = NULL;
+ u = depgraph_find(&ctx->ctx_graph, svc);
+
+ /*
+ * Handle "service@instance" names.
+ * If the full name isn't found, split on '@', look up the
+ * base unit, and either instantiate a template or pass through
+ * to a legacy script with the instance as an argument.
+ */
+ if (u == NULL) {
+ const char *at = strchr(svc, '@');
+
+ if (at != NULL) {
+ char tmpl_name[256];
+ struct unit *tmpl;
+ size_t tlen;
+
+ tlen = (size_t)(at - svc);
+ if (tlen >= sizeof(tmpl_name))
+ tlen = sizeof(tmpl_name) - 1;
+ memcpy(tmpl_name, svc, tlen);
+ tmpl_name[tlen] = '\0';
+
+ tmpl = depgraph_find(&ctx->ctx_graph, tmpl_name);
+ if (tmpl != NULL && tmpl->u_template) {
+ u = unit_instantiate(tmpl, at + 1);
+ if (u != NULL)
+ depgraph_add(&ctx->ctx_graph, u);
+ } else if (tmpl != NULL &&
+ (tmpl->u_type == UNIT_LEGACY ||
+ tmpl->u_type == UNIT_LEGACY_FORKING)) {
+ /* Validate instance name: only safe chars */
+ if (!valid_service_name(at + 1)) {
+ log_warn("control: invalid "
+ "instance name '%s'", at + 1);
+ send_error(clientfd,
+ "invalid instance name");
+ return;
+ }
+ u = tmpl;
+ instance = at + 1;
+ }
+ }
+ }
+
+ if (u == NULL) {
+ send_error(clientfd, "service not found");
+ return;
+ }
+
+ /*
+ * Per-service access control.
+ * check_credentials already verified the client is root or in
+ * the control_group. For non-root clients that passed via
+ * control_group, also check per-service ACLs if defined.
+ */
+ if (cred->cr_uid != 0 && !access_check(u, cmd, cred)) {
+ log_warn("control: uid %d denied %s on %s",
+ cred->cr_uid, cmd, u->u_name);
+ send_error(clientfd, "permission denied");
+ return;
+ }
+
+ /*
+ * Legacy rc.d scripts: pass all commands directly to the
+ * script. The script knows how to handle start, stop,
+ * restart, reload, status, and any extra_commands.
+ * Only enable/disable/show/describe are handled by rcd.
+ */
+ if ((u->u_type == UNIT_LEGACY || u->u_type == UNIT_LEGACY_FORKING) &&
+ strcmp(cmd, "enable") != 0 &&
+ strcmp(cmd, "disable") != 0 &&
+ strcmp(cmd, "delete") != 0 &&
+ strcmp(cmd, "show") != 0 &&
+ strcmp(cmd, "describe") != 0 &&
+ strcmp(cmd, "rcvar") != 0 &&
+ strcmp(cmd, "resources") != 0 &&
+ strcmp(cmd, "start") != 0 &&
+ strcmp(cmd, "stop") != 0 &&
+ strcmp(cmd, "restart") != 0 &&
+ strcmp(cmd, "reload") != 0) {
+ char hook[PATH_MAX];
+ const char *script_path;
+ int rc, off;
+
+ script_path = (u->u_template_ref != NULL) ?
+ u->u_template_ref->u_path : u->u_path;
+
+ off = snprintf(hook, sizeof(hook), "%s %s %s",
+ _PATH_BSHELL, script_path, cmd);
+ if (off >= (int)sizeof(hook))
+ off = (int)sizeof(hook) - 1;
+
+ /*
+ * Append instance names. If the request has an
+ * "instances" array, pass them all as arguments
+ * so the script gets: sh script cmd inst1 inst2
+ * Otherwise, if this is a single instance, pass it.
+ */
+ {
+ const ucl_object_t *insts, *ival;
+ ucl_object_iter_t iit;
+ int n;
+
+ insts = ucl_object_lookup(req, "instances");
+ if (insts != NULL &&
+ ucl_object_type(insts) == UCL_ARRAY) {
+ iit = ucl_object_iterate_new(insts);
+ while ((ival = ucl_object_iterate_safe(iit,
+ true)) != NULL &&
+ off < (int)sizeof(hook) - 1) {
+ n = snprintf(hook + off,
+ sizeof(hook) - off, " %s",
+ ucl_object_tostring(ival));
+ if (n >= (int)(sizeof(hook) - off))
+ break;
+ off += n;
+ }
+ ucl_object_iterate_free(iit);
+ } else if (u->u_instance != NULL &&
+ off < (int)sizeof(hook) - 1) {
+ n = snprintf(hook + off,
+ sizeof(hook) - off, " %s",
+ u->u_instance);
+ if (n < (int)(sizeof(hook) - off))
+ off += n;
+ } else if (instance != NULL &&
+ off < (int)sizeof(hook) - 1) {
+ n = snprintf(hook + off,
+ sizeof(hook) - off, " %s",
+ instance);
+ if (n < (int)(sizeof(hook) - off))
+ off += n;
+ }
+ }
+
+ log_info("%s: legacy %s", u->u_name, cmd);
+ hook_output = NULL;
+ rc = proc_run_hook_capture(hook, &hook_output);
+
+ /*
+ * Update state based on command and result.
+ */
+ if (strcmp(cmd, "start") == 0)
+ u->u_state = (rc == 0) ? STATE_DONE : STATE_FAILED;
+ else if (strcmp(cmd, "stop") == 0 && rc == 0)
+ u->u_state = STATE_INACTIVE;
+ else if (strcmp(cmd, "restart") == 0)
+ u->u_state = (rc == 0) ? STATE_DONE : STATE_FAILED;
+ else if (strcmp(cmd, "reload") == 0 && rc == 0)
+ u->u_state = STATE_DONE;
+
+ resp = ucl_object_typed_new(UCL_OBJECT);
+ ucl_object_insert_key(resp,
+ ucl_object_fromstring(rc == 0 ? "ok" : "error"),
+ "status", 0, false);
+ if (rc != 0) {
+ if (hook_output != NULL && hook_output[0] != '\0')
+ ucl_object_insert_key(resp,
+ ucl_object_fromstring(hook_output),
+ "message", 0, false);
+ else
+ ucl_object_insert_key(resp,
+ ucl_object_fromstring("command failed"),
+ "message", 0, false);
+ }
+ free(hook_output);
+ send_response(clientfd, resp);
+ ucl_object_unref(resp);
+ return;
+ }
+
+ if (strcmp(cmd, "start") == 0) {
+ /*
+ * force/one: bypass disabled check. "force" also
+ * skips precondition checks (like rc's forcestart).
+ * "one" just enables temporarily (like onestart).
+ */
+ if (!u->u_enabled && !force_flag && !one_flag) {
+ send_error(clientfd, "service is disabled");
+ return;
+ }
+ if (u->u_state == STATE_RUNNING) {
+ send_ok(clientfd, "already running");
+ return;
+ }
+ u->u_state = STATE_STARTING;
+ proc_spawn(ctx, u);
+ } else if (strcmp(cmd, "stop") == 0) {
+ proc_stop(ctx, u);
+ } else if (strcmp(cmd, "restart") == 0) {
+ /*
+ * Synchronous restart with timeout via proc_stop_sync,
+ * then immediate re-spawn. proc_stop_sync uses a
+ * temporary kqueue and does not block indefinitely.
+ */
+ proc_stop_sync(ctx, u);
+ u->u_state = STATE_STARTING;
+ u->u_retry_count = 0;
+ proc_spawn(ctx, u);
+ } else if (strcmp(cmd, "reload") == 0) {
+ proc_reload(ctx, u);
+ } else if (strcmp(cmd, "enable") == 0) {
+ enable_service(u->u_name, NULL);
+ u->u_enabled = true;
+ } else if (strcmp(cmd, "disable") == 0) {
+ disable_service(u->u_name, NULL);
+ u->u_enabled = false;
+ } else if (strcmp(cmd, "delete") == 0) {
+ delete_override(u->u_name);
+ /* Reset to unit file defaults */
+ u->u_enabled = true;
+ free(u->u_override_conf);
+ u->u_override_conf = NULL;
+ } else if (strcmp(cmd, "rcvar") == 0) {
+ char path[PATH_MAX];
+
+ resp = ucl_object_typed_new(UCL_OBJECT);
+ ucl_object_insert_key(resp,
+ ucl_object_fromstring("ok"), "status", 0, false);
+ ucl_object_insert_key(resp,
+ ucl_object_fromstring(u->u_name), "name", 0, false);
+ ucl_object_insert_key(resp,
+ ucl_object_frombool(u->u_enabled), "enabled", 0, false);
+ ucl_object_insert_key(resp,
+ ucl_object_fromstring(unit_type_str(u->u_type)),
+ "type", 0, false);
+ if (u->u_path != NULL)
+ ucl_object_insert_key(resp,
+ ucl_object_fromstring(u->u_path),
+ "unit_file", 0, false);
+ snprintf(path, sizeof(path), "/etc/rcd.conf.d/%s", u->u_name);
+ if (access(path, F_OK) == 0)
+ ucl_object_insert_key(resp,
+ ucl_object_fromstring(path),
+ "override_file", 0, false);
+ send_response(clientfd, resp);
+ ucl_object_unref(resp);
+ return;
+ } else if (strcmp(cmd, "describe") == 0) {
+ resp = ucl_object_typed_new(UCL_OBJECT);
+ ucl_object_insert_key(resp,
+ ucl_object_fromstring("ok"), "status", 0, false);
+ ucl_object_insert_key(resp,
+ ucl_object_fromstring(
+ u->u_description ? u->u_description : u->u_name),
+ "description", 0, false);
+ ucl_object_insert_key(resp,
+ ucl_object_fromstring(unit_type_str(u->u_type)),
+ "type", 0, false);
+ ucl_object_insert_key(resp,
+ ucl_object_frombool(u->u_enabled),
+ "enabled", 0, false);
+ if (u->u_command != NULL)
+ ucl_object_insert_key(resp,
+ ucl_object_fromstring(u->u_command),
+ "command", 0, false);
+ /* Include custom commands */
+ if (!STAILQ_EMPTY(&u->u_commands)) {
+ ucl_object_t *earr;
+ struct kv *cmd_kv;
+
+ earr = ucl_object_typed_new(UCL_ARRAY);
+ STAILQ_FOREACH(cmd_kv, &u->u_commands, kv_entries)
+ ucl_array_append(earr,
+ ucl_object_fromstring(cmd_kv->kv_key));
+ ucl_object_insert_key(resp, earr,
+ "commands", 0, false);
+ }
+ send_response(clientfd, resp);
+ ucl_object_unref(resp);
+ return;
+ } else if (strcmp(cmd, "show") == 0) {
+ /*
+ * Dump the full effective unit configuration as UCL.
+ * This reflects the unit file + any overrides applied.
+ */
+ ucl_object_t *cfg;
+ struct kv *kv_p;
+
+ resp = ucl_object_typed_new(UCL_OBJECT);
+ ucl_object_insert_key(resp,
+ ucl_object_fromstring("ok"), "status", 0, false);
+
+ cfg = ucl_object_typed_new(UCL_OBJECT);
+
+ ucl_object_insert_key(cfg,
+ ucl_object_fromstring(u->u_name), "name", 0, false);
+ if (u->u_description != NULL)
+ ucl_object_insert_key(cfg,
+ ucl_object_fromstring(u->u_description),
+ "description", 0, false);
+
+ ucl_object_insert_key(cfg,
+ ucl_object_fromstring(unit_type_str(u->u_type)),
+ "type", 0, false);
+
+ if (u->u_command != NULL)
+ ucl_object_insert_key(cfg,
+ ucl_object_fromstring(u->u_command),
+ "command", 0, false);
+ if (u->u_command_args != NULL)
+ ucl_object_insert_key(cfg,
+ ucl_object_fromstring(u->u_command_args),
+ "command_args", 0, false);
+ if (u->u_exec != NULL)
+ ucl_object_insert_key(cfg,
+ ucl_object_fromstring(u->u_exec),
+ "exec", 0, false);
+
+ ucl_object_insert_key(cfg,
+ ucl_object_frombool(u->u_enabled), "enabled", 0, false);
+
+ if (u->u_path != NULL)
+ ucl_object_insert_key(cfg,
+ ucl_object_fromstring(u->u_path),
+ "path", 0, false);
+
+ /* Provide/require/before */
+ if (u->u_provide.len > 0) {
+ ucl_object_t *arr = ucl_object_typed_new(UCL_ARRAY);
+ vec_foreach(u->u_provide, pi)
+ ucl_array_append(arr,
+ ucl_object_fromstring(u->u_provide.d[pi]));
+ ucl_object_insert_key(cfg, arr, "provides", 0, false);
+ }
+ if (u->u_require.len > 0) {
+ ucl_object_t *arr = ucl_object_typed_new(UCL_ARRAY);
+ vec_foreach(u->u_require, ri)
+ ucl_array_append(arr,
+ ucl_object_fromstring(u->u_require.d[ri]));
+ ucl_object_insert_key(cfg, arr, "requires", 0, false);
+ }
+ if (u->u_before.len > 0) {
+ ucl_object_t *arr = ucl_object_typed_new(UCL_ARRAY);
+ vec_foreach(u->u_before, bi)
+ ucl_array_append(arr,
+ ucl_object_fromstring(u->u_before.d[bi]));
+ ucl_object_insert_key(cfg, arr, "before", 0, false);
+ }
+
+ /* Process config */
+ if (u->u_proc.pc_user != NULL || u->u_proc.pc_group != NULL ||
+ u->u_proc.pc_nice != 0 || u->u_proc.pc_oom_protect) {
+ ucl_object_t *proc = ucl_object_typed_new(UCL_OBJECT);
+ if (u->u_proc.pc_user != NULL)
+ ucl_object_insert_key(proc,
+ ucl_object_fromstring(u->u_proc.pc_user),
+ "user", 0, false);
+ if (u->u_proc.pc_group != NULL)
+ ucl_object_insert_key(proc,
+ ucl_object_fromstring(u->u_proc.pc_group),
+ "group", 0, false);
+ if (u->u_proc.pc_nice != 0)
+ ucl_object_insert_key(proc,
+ ucl_object_fromint(u->u_proc.pc_nice),
+ "nice", 0, false);
+ if (u->u_proc.pc_cpuset != NULL)
+ ucl_object_insert_key(proc,
+ ucl_object_fromstring(u->u_proc.pc_cpuset),
+ "cpuset", 0, false);
+ if (u->u_proc.pc_fib != 0)
+ ucl_object_insert_key(proc,
+ ucl_object_fromint(u->u_proc.pc_fib),
+ "fib", 0, false);
+ ucl_object_insert_key(proc,
+ ucl_object_frombool(u->u_proc.pc_oom_protect),
+ "oom_protect", 0, false);
+ ucl_object_insert_key(cfg, proc, "process", 0, false);
+ }
+
+ /* Restart config */
+ if (u->u_restart.rc_policy != RESTART_NEVER) {
+ ucl_object_t *rst = ucl_object_typed_new(UCL_OBJECT);
+ const char *pol = u->u_restart.rc_policy == RESTART_ALWAYS ?
+ "always" : "on-failure";
+ ucl_object_insert_key(rst,
+ ucl_object_fromstring(pol), "policy", 0, false);
+ ucl_object_insert_key(rst,
+ ucl_object_fromint(u->u_restart.rc_max_retries),
+ "max_retries", 0, false);
+ ucl_object_insert_key(rst,
+ ucl_object_fromint(u->u_restart.rc_delay_ms),
+ "delay", 0, false);
+ ucl_object_insert_key(cfg, rst, "restart", 0, false);
+ }
+
+ /* Signals */
+ if (u->u_sig_stop != SIGTERM)
+ ucl_object_insert_key(cfg,
+ ucl_object_fromint(u->u_sig_stop),
+ "sig_stop", 0, false);
+ if (u->u_sig_reload != SIGHUP)
+ ucl_object_insert_key(cfg,
+ ucl_object_fromint(u->u_sig_reload),
+ "sig_reload", 0, false);
+
+ /* Start delay */
+ if (u->u_start_delay_ms > 0)
+ ucl_object_insert_key(cfg,
+ ucl_object_fromint(u->u_start_delay_ms),
+ "start_delay", 0, false);
+
+ /* Hooks */
+ if (u->u_start_precmd != NULL)
+ ucl_object_insert_key(cfg,
+ ucl_object_fromstring(u->u_start_precmd),
+ "start_precmd", 0, false);
+ if (u->u_stop_command != NULL)
+ ucl_object_insert_key(cfg,
+ ucl_object_fromstring(u->u_stop_command),
+ "stop_command", 0, false);
+
+ /* Custom commands */
+ if (!STAILQ_EMPTY(&u->u_commands)) {
+ ucl_object_t *cmds = ucl_object_typed_new(UCL_OBJECT);
+ STAILQ_FOREACH(kv_p, &u->u_commands, kv_entries)
+ ucl_object_insert_key(cmds,
+ ucl_object_fromstring(kv_p->kv_val),
+ kv_p->kv_key, 0, false);
+ ucl_object_insert_key(cfg, cmds,
+ "commands", 0, false);
+ }
+
+ /* Runtime state */
+ {
+ ucl_object_t *state = ucl_object_typed_new(UCL_OBJECT);
+
+ ucl_object_insert_key(state,
+ ucl_object_fromstring(state_names[u->u_state]),
+ "state", 0, false);
+ ucl_object_insert_key(state,
+ ucl_object_fromint(u->u_pid),
+ "pid", 0, false);
+ ucl_object_insert_key(state,
+ ucl_object_fromint(u->u_retry_count),
+ "retry_count", 0, false);
+ ucl_object_insert_key(cfg, state,
+ "runtime", 0, false);
+ }
+
+ ucl_object_insert_key(resp, cfg, "config", 0, false);
+ send_response(clientfd, resp);
+ ucl_object_unref(resp);
+ return;
+ } else if (strcmp(cmd, "resources") == 0) {
+ char usage[4096];
+ if (rctl_get_usage(u, usage, sizeof(usage)) == 0) {
+ resp = ucl_object_typed_new(UCL_OBJECT);
+ ucl_object_insert_key(resp,
+ ucl_object_fromstring("ok"), "status", 0, false);
+ ucl_object_insert_key(resp,
+ ucl_object_fromstring(usage),
+ "resources", 0, false);
+ send_response(clientfd, resp);
+ ucl_object_unref(resp);
+ return;
+ }
+ /*
+ * rctl_get_usage writes a detailed reason into usage
+ * on failure (e.g., RACCT not in kernel, service not
+ * running). Send it back to the client.
+ */
+ send_error(clientfd, usage);
+ return;
+ } else {
+ /*
+ * Not a built-in command — look it up in the unit's
+ * custom commands map.
+ */
+ struct kv *cmd_kv;
+
+ STAILQ_FOREACH(cmd_kv, &u->u_commands, kv_entries) {
+ if (strcmp(cmd_kv->kv_key, cmd) == 0) {
+ int rc;
+
+ log_info("%s: running command %s",
+ u->u_name, cmd);
+ hook_output = NULL;
+ rc = proc_run_hook_capture(cmd_kv->kv_val,
+ &hook_output);
+ resp = ucl_object_typed_new(UCL_OBJECT);
+ ucl_object_insert_key(resp,
+ ucl_object_fromstring(
+ rc == 0 ? "ok" : "error"),
+ "status", 0, false);
+ if (rc != 0) {
+ if (hook_output != NULL &&
+ hook_output[0] != '\0')
+ ucl_object_insert_key(resp,
+ ucl_object_fromstring(
+ hook_output),
+ "message", 0, false);
+ else
+ ucl_object_insert_key(resp,
+ ucl_object_fromstring(
+ "command failed"),
+ "message", 0, false);
+ }
+ free(hook_output);
+ send_response(clientfd, resp);
+ ucl_object_unref(resp);
+ return;
+ }
+ }
+
+ /* Unknown command */
+ send_error(clientfd, "unknown command");
+ return;
+ }
+
+ /* Generic OK response */
+ send_ok(clientfd, NULL);
+}
+
+/*
+ * Initialize the control socket.
+ */
+int
+control_init(struct rcd_ctx *ctx)
+{
+ struct sockaddr_un sun;
+ struct kevent kev;
+ int fd, dirfd, sockfd;
+ mode_t old_umask;
+ char *dir, *base, *pathcopy, *p;
+
+ fd = socket(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
+ if (fd < 0) {
+ log_warn("control socket: %s", strerror(errno));
+ return (-1);
+ }
+
+ memset(&sun, 0, sizeof(sun));
+ sun.sun_family = AF_UNIX;
+ strlcpy(sun.sun_path, ctx->ctx_config.cfg_control_socket,
+ sizeof(sun.sun_path));
+
+ /*
+ * Use dirfd-relative operations (funlinkat, bindat, fchmodat)
+ * to avoid TOCTOU races between lstat/unlink/bind/chmod.
+ */
+
+ /*
+ * Ensure the parent directory exists.
+ * During early boot /var/run (tmpfs) may not exist yet.
+ * Walk the path and mkdir each component (mkdir -p
+ * semantics) so the socket can be created regardless.
+ */
+ pathcopy = xstrdup(sun.sun_path);
+ dir = dirname(pathcopy);
+ for (p = dir; *p != '\0'; p++) {
+ if (*p != '/')
+ continue;
+ *p = '\0';
+ (void)mkdir(dir, 0755);
+ *p = '/';
+ }
+ (void)mkdir(dir, 0755);
+ free(pathcopy);
+
+ pathcopy = xstrdup(sun.sun_path);
+ dir = dirname(pathcopy);
+ dirfd = open(dir, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
+ free(pathcopy);
+ if (dirfd < 0) {
+ log_warn("control: open %s: %s", dir,
+ strerror(errno));
+ close(fd);
+ return (-1);
+ }
+
+ pathcopy = xstrdup(sun.sun_path);
+ base = basename(pathcopy);
+
+ /* Remove stale socket if present */
+ sockfd = openat(dirfd, base, O_PATH | O_CLOEXEC);
+ if (sockfd >= 0) {
+ funlinkat(dirfd, base, sockfd, 0);
+ close(sockfd);
+ }
+
+ old_umask = umask(0177);
+ if (bindat(dirfd, fd, (struct sockaddr *)&sun,
+ SUN_LEN(&sun)) != 0) {
+ log_warn("control bindat: %s", strerror(errno));
+ umask(old_umask);
+ free(pathcopy);
+ close(dirfd);
+ close(fd);
+ return (-1);
+ }
+ umask(old_umask);
+
+ if (fchmodat(dirfd, base,
+ ctx->ctx_config.cfg_control_perms, 0) != 0)
+ log_warn("control fchmodat: %s", strerror(errno));
+
+ ctx->ctx_ctlsock_pathfd = openat(dirfd, base,
+ O_RDONLY | O_CLOEXEC);
+ if (ctx->ctx_ctlsock_pathfd < 0)
+ log_warn("control openat: %s", strerror(errno));
+
+ free(pathcopy);
+ close(dirfd);
+
+ if (listen(fd, 16) != 0) {
+ log_warn("control listen: %s", strerror(errno));
+ close(fd);
+ return (-1);
+ }
+
+ ctx->ctx_ctlsock = fd;
+
+ /* Register in kqueue for incoming connections */
+ EV_SET(&kev, fd, EVFILT_READ, EV_ADD, 0, 0, NULL);
+ if (kevent(ctx->ctx_kq, &kev, 1, NULL, 0, NULL) != 0) {
+ log_warn("control kevent: %s", strerror(errno));
+ close(fd);
+ ctx->ctx_ctlsock = -1;
+ return (-1);
+ }
+
+ /*
+ * Watch the socket file for deletion (e.g., cleanvar).
+ * EVFILT_VNODE with NOTE_DELETE fires when the socket is unlinked.
+ * We use EV_CLEAR (not EV_ONESHOT) so the watch remains active
+ * after each event — control_reinit() will close the old fd and
+ * register a new one on the recreated socket.
+ *
+ * The pathfd was opened immediately after bindat() above, while
+ * we still held the parent directory fd, eliminating the race
+ * where cleanvar deletes the socket between bindat() and openat().
+ */
+ if (ctx->ctx_ctlsock_pathfd >= 0) {
+ EV_SET(&kev, ctx->ctx_ctlsock_pathfd, EVFILT_VNODE,
+ EV_ADD | EV_CLEAR, NOTE_DELETE, 0, NULL);
+ if (kevent(ctx->ctx_kq, &kev, 1, NULL, 0, NULL) != 0) {
+ log_warn("control vnode kevent: %s",
+ strerror(errno));
+ close(ctx->ctx_ctlsock_pathfd);
+ ctx->ctx_ctlsock_pathfd = -1;
+ }
+ }
+
+ log_info("control socket: %s", sun.sun_path);
+ return (0);
+}
+
+/*
+ * Recreate the control socket after it was deleted (e.g., by cleanvar).
+ */
+void
+control_reinit(struct rcd_ctx *ctx)
+{
+
+ log_info("control socket deleted, recreating");
+ if (ctx->ctx_ctlsock_pathfd >= 0) {
+ close(ctx->ctx_ctlsock_pathfd);
+ ctx->ctx_ctlsock_pathfd = -1;
+ }
+ control_close(ctx);
+ control_init(ctx);
+}
+
+/*
+ * Handle activity on the control socket (accept + read + process).
+ */
+void
+control_handle(struct rcd_ctx *ctx, int listenfd)
+{
+ struct ucl_parser *parser;
+ ucl_object_t *req;
+ struct xucred cred;
+ struct timeval tv;
+ socklen_t credlen;
+ int clientfd;
+ char buf[CTL_MAX_MSG];
+ uint32_t msglen;
+ ssize_t n;
+
+ clientfd = accept4(listenfd, NULL, NULL,
+ SOCK_CLOEXEC | SOCK_NONBLOCK);
+ if (clientfd < 0) {
+ if (errno != EINTR && errno != ECONNABORTED)
+ log_warn("accept: %s", strerror(errno));
+ return;
+ }
+
+ /*
+ * Set a receive timeout to prevent a malicious client from
+ * blocking the event loop by connecting and never sending data.
+ * Switch to blocking mode with a 5-second timeout.
+ */
+ {
+ int flags = fcntl(clientfd, F_GETFL, 0);
+ if (flags < 0 ||
+ fcntl(clientfd, F_SETFL, flags & ~O_NONBLOCK) < 0) {
+ log_warn("control: fcntl: %s", strerror(errno));
+ close(clientfd);
+ return;
+ }
+ }
+ tv.tv_sec = 5;
+ tv.tv_usec = 0;
+ if (setsockopt(clientfd, SOL_SOCKET, SO_RCVTIMEO,
+ &tv, sizeof(tv)) != 0)
+ log_warn("control: SO_RCVTIMEO: %s", strerror(errno));
+
+ /*
+ * Get peer credentials. We need these both for the global
+ * authorization check and for per-service ACLs.
+ */
+ credlen = sizeof(cred);
+ if (getsockopt(clientfd, SOL_LOCAL, LOCAL_PEERCRED,
+ &cred, &credlen) != 0 || cred.cr_version != XUCRED_VERSION) {
+ close(clientfd);
+ return;
+ }
+
+ if (!check_credentials(clientfd, ctx->ctx_config.cfg_control_group)) {
+ log_warn("control: unauthorized client (uid %d)",
+ cred.cr_uid);
+ close(clientfd);
+ return;
+ }
+
+ /* Read length-prefixed message (handles EINTR and partial reads) */
+ n = xread(clientfd, &msglen, sizeof(msglen));
+ if (n != (ssize_t)sizeof(msglen)) {
+ close(clientfd);
+ return;
+ }
+ msglen = ntohl(msglen);
+ if (msglen == 0 || msglen >= CTL_MAX_MSG) {
+ close(clientfd);
+ return;
+ }
+
+ n = xread(clientfd, buf, msglen);
+ if (n != (ssize_t)msglen) {
+ close(clientfd);
+ return;
+ }
+ buf[msglen] = '\0';
+
+ /* Parse UCL request */
+ parser = ucl_parser_new(UCL_PARSER_DEFAULT);
+ if (!ucl_parser_add_string(parser, buf, msglen)) {
+ ucl_parser_free(parser);
+ close(clientfd);
+ return;
+ }
+ req = ucl_parser_get_object(parser);
+ ucl_parser_free(parser);
+
+ if (req != NULL) {
+ process_command(ctx, clientfd, req, &cred);
+ ucl_object_unref(req);
+ }
+
+ close(clientfd);
+}
+
+void
+control_close(struct rcd_ctx *ctx)
+{
+
+ if (ctx->ctx_ctlsock >= 0) {
+ close(ctx->ctx_ctlsock);
+ unlink(ctx->ctx_config.cfg_control_socket);
+ ctx->ctx_ctlsock = -1;
+ }
+}
diff --git a/sbin/rcd/depgraph.c b/sbin/rcd/depgraph.c
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/depgraph.c
@@ -0,0 +1,319 @@
+/*
+ * Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+/*
+ * Dependency graph — DAG construction, topological sort, and parallel
+ * scheduling. Units declare provide/require/before relationships;
+ * this module resolves them into a graph and provides the ready set
+ * for parallel startup.
+ */
+
+#include <sys/param.h>
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "rcd.h"
+
+int
+depgraph_init(struct depgraph *dg)
+{
+
+ TAILQ_INIT(&dg->dg_units);
+ dg->dg_nunits = 0;
+ dg->dg_provisions = hash_new();
+ return (0);
+}
+
+int
+depgraph_add(struct depgraph *dg, struct unit *u)
+{
+
+ TAILQ_INSERT_TAIL(&dg->dg_units, u, u_entries);
+ dg->dg_nunits++;
+
+ /* Register all provisions */
+ vec_foreach(u->u_provide, i)
+ hash_add(dg->dg_provisions, u->u_provide.d[i], u, NULL);
+
+ return (0);
+}
+
+/*
+ * Find a unit by provision name.
+ */
+struct unit *
+depgraph_find(struct depgraph *dg, const char *name)
+{
+
+ return ((struct unit *)hash_get_value(dg->dg_provisions, name));
+}
+
+/*
+ * Resolve all require/before references into dep_link pointers.
+ * Returns 0 on success, -1 if there are missing dependencies (non-fatal).
+ */
+int
+depgraph_resolve(struct depgraph *dg)
+{
+ struct unit *u, *dep;
+ struct dep_link *dl, *dl_tmp;
+ int errors;
+
+ errors = 0;
+
+ /* Clear existing links to allow re-resolution on reload */
+ TAILQ_FOREACH(u, &dg->dg_units, u_entries) {
+ STAILQ_FOREACH_SAFE(dl, &u->u_deps, dl_entries, dl_tmp)
+ free(dl);
+ STAILQ_INIT(&u->u_deps);
+ STAILQ_FOREACH_SAFE(dl, &u->u_rdeps, dl_entries, dl_tmp)
+ free(dl);
+ STAILQ_INIT(&u->u_rdeps);
+ u->u_unmet = 0;
+ }
+
+ TAILQ_FOREACH(u, &dg->dg_units, u_entries) {
+ /* Resolve REQUIRE: u depends on dep */
+ vec_foreach(u->u_require, i) {
+ dep = (struct unit *)hash_get_value(
+ dg->dg_provisions, u->u_require.d[i]);
+ if (dep == NULL) {
+ log_warn(
+ "%s: unresolved dependency: %s",
+ u->u_name,
+ u->u_require.d[i]);
+ errors++;
+ continue;
+ }
+
+ /*
+ * Disabled dependencies are considered
+ * already satisfied — they will never start,
+ * so waiting on them would block forever.
+ * This matches rcorder behavior where
+ * filtered scripts are simply absent.
+ */
+ /* u depends on dep (dep must start before u) */
+ dl = xcalloc(1, sizeof(*dl));
+ dl->dl_unit = dep;
+ STAILQ_INSERT_TAIL(&u->u_deps, dl,
+ dl_entries);
+ u->u_unmet++;
+
+ /* dep is depended on by u */
+ dl = xcalloc(1, sizeof(*dl));
+ dl->dl_unit = u;
+ STAILQ_INSERT_TAIL(&dep->u_rdeps, dl,
+ dl_entries);
+ }
+
+ /*
+ * Resolve BEFORE: u must start before dep → dep depends on u.
+ * Skip if u is disabled — it won't run, so adding
+ * "dep depends on u" would block dep forever.
+ */
+ if (!u->u_enabled)
+ continue; /* u won't run, skip its BEFORE edges */
+ vec_foreach(u->u_before, i) {
+ dep = (struct unit *)hash_get_value(
+ dg->dg_provisions, u->u_before.d[i]);
+ if (dep == NULL)
+ continue;
+ if (!dep->u_enabled)
+ continue;
+
+ /* dep depends on u */
+ dl = xcalloc(1, sizeof(*dl));
+ dl->dl_unit = u;
+ STAILQ_INSERT_TAIL(&dep->u_deps, dl,
+ dl_entries);
+ dep->u_unmet++;
+
+ /* u is depended on by dep */
+ dl = xcalloc(1, sizeof(*dl));
+ dl->dl_unit = dep;
+ STAILQ_INSERT_TAIL(&u->u_rdeps, dl,
+ dl_entries);
+ }
+ }
+
+ return (errors > 0 ? -1 : 0);
+}
+
+/*
+ * DFS visit states for cycle detection.
+ */
+enum dfs_state {
+ DFS_UNVISITED,
+ DFS_IN_PROGRESS,
+ DFS_DONE
+};
+
+static int
+dfs_visit(struct unit *u, enum dfs_state *states, struct unit **ulist,
+ int nunits)
+{
+ struct dep_link *dl;
+ int cycles, idx_dep;
+
+ /* Find index of u in ulist */
+ int idx;
+ for (idx = 0; idx < nunits; idx++) {
+ if (ulist[idx] == u)
+ break;
+ }
+ if (idx >= nunits)
+ return (0);
+
+ states[idx] = DFS_IN_PROGRESS;
+ cycles = 0;
+
+ STAILQ_FOREACH(dl, &u->u_deps, dl_entries) {
+ for (idx_dep = 0; idx_dep < nunits; idx_dep++) {
+ if (ulist[idx_dep] == dl->dl_unit)
+ break;
+ }
+ if (idx_dep >= nunits)
+ continue;
+
+ if (states[idx_dep] == DFS_IN_PROGRESS) {
+ log_warn("dependency cycle: %s -> %s",
+ u->u_name, dl->dl_unit->u_name);
+ dl->dl_unit->u_state = STATE_FAILED;
+ cycles++;
+ } else if (states[idx_dep] == DFS_UNVISITED) {
+ cycles += dfs_visit(dl->dl_unit, states, ulist,
+ nunits);
+ }
+ }
+
+ states[idx] = DFS_DONE;
+ return (cycles);
+}
+
+/*
+ * Detect cycles using DFS. Returns 0 if no cycles, -1 if cycles found.
+ */
+int
+depgraph_check_cycles(struct depgraph *dg)
+{
+ struct unit **ulist;
+ enum dfs_state *states;
+ struct unit *u;
+ int i, n, cycles;
+
+ n = dg->dg_nunits;
+ if (n == 0)
+ return (0);
+
+ ulist = xcalloc(n, sizeof(*ulist));
+ states = xcalloc(n, sizeof(*states));
+
+ i = 0;
+ TAILQ_FOREACH(u, &dg->dg_units, u_entries)
+ ulist[i++] = u;
+
+ cycles = 0;
+ for (i = 0; i < n; i++) {
+ if (states[i] == DFS_UNVISITED)
+ cycles += dfs_visit(ulist[i], states, ulist, n);
+ }
+
+ free(ulist);
+ free(states);
+ return (cycles > 0 ? -1 : 0);
+}
+
+/*
+ * Get the set of units whose dependencies are all satisfied (unmet == 0)
+ * and that are in INACTIVE state (not yet started).
+ */
+void
+depgraph_ready_set(struct depgraph *dg, struct unit **out, int *nout)
+{
+ struct unit *u;
+ int n, cap;
+
+ cap = (int)dg->dg_nunits;
+ n = 0;
+ TAILQ_FOREACH(u, &dg->dg_units, u_entries) {
+ if (!u->u_enabled)
+ continue;
+ if (u->u_template)
+ continue;
+ if (u->u_state != STATE_INACTIVE)
+ continue;
+ if (u->u_unmet <= 0) {
+ if (n >= cap)
+ break;
+ out[n++] = u;
+ }
+ }
+ *nout = n;
+}
+
+/*
+ * Mark a unit as done (started or failed) and decrement the unmet count
+ * of all units that depend on it.
+ */
+void
+depgraph_mark_done(struct depgraph *dg __unused, struct unit *u)
+{
+ struct dep_link *dl;
+
+ STAILQ_FOREACH(dl, &u->u_rdeps, dl_entries) {
+ if (dl->dl_unit->u_unmet > 0)
+ dl->dl_unit->u_unmet--;
+ }
+}
+
+/*
+ * Compute shutdown order (reverse dependency order).
+ * Returns units in the order they should be stopped.
+ * 'cap' is the capacity of the 'out' array (must be >= dg_nunits).
+ */
+void
+depgraph_shutdown_order(struct depgraph *dg, struct unit **out, int *nout)
+{
+ struct unit *u;
+ int n;
+
+ /*
+ * Simple approach: collect all running units, then reverse.
+ * A proper implementation would do reverse topological sort.
+ */
+ n = 0;
+ TAILQ_FOREACH(u, &dg->dg_units, u_entries) {
+ if (!u->u_enabled || u->u_template)
+ continue;
+ if (u->u_state != STATE_RUNNING)
+ continue;
+ out[n++] = u;
+ }
+
+ /* Reverse the array for shutdown order */
+ for (int i = 0; i < n / 2; i++) {
+ struct unit *tmp = out[i];
+ out[i] = out[n - 1 - i];
+ out[n - 1 - i] = tmp;
+ }
+
+ *nout = n;
+}
+
+void
+depgraph_free(struct depgraph *dg)
+{
+ struct unit *u, *u_tmp;
+
+ TAILQ_FOREACH_SAFE(u, &dg->dg_units, u_entries, u_tmp) {
+ TAILQ_REMOVE(&dg->dg_units, u, u_entries);
+ unit_free(u);
+ }
+ hash_destroy(dg->dg_provisions);
+}
diff --git a/sbin/rcd/enable.c b/sbin/rcd/enable.c
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/enable.c
@@ -0,0 +1,182 @@
+/*
+ * Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+/*
+ * Persistent enable/disable of services. Writes UCL fragments to
+ * /etc/rcd.conf.d/<service> to persist the enabled state across reboots.
+ */
+
+#include <sys/param.h>
+#include <sys/stat.h>
+
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <ucl.h>
+
+#include "rcd.h"
+
+#define RCD_CONFD_DIR "/etc/rcd.conf.d"
+
+/*
+ * Ensure the override directory exists.
+ */
+static int
+ensure_confdir(void)
+{
+
+ if (mkdir(RCD_CONFD_DIR, 0755) != 0 && errno != EEXIST) {
+ log_warn("mkdir %s: %s", RCD_CONFD_DIR, strerror(errno));
+ return (-1);
+ }
+ return (0);
+}
+
+/*
+ * Write an enable/disable override for a service.
+ * Creates or updates /etc/rcd.conf.d/<service> with:
+ * enable = true; (or false)
+ *
+ * Atomic write: uses mkstemp() for safe temp file creation
+ * (prevents symlink races) then rename() for atomic replacement.
+ */
+static int
+write_enable_state(const char *name, bool enable)
+{
+ struct ucl_parser *parser;
+ ucl_object_t *top;
+ char path[PATH_MAX];
+ unsigned char *buf;
+ int fd;
+
+ /* Validate service name to prevent path traversal */
+ if (!valid_service_name(name)) {
+ log_warn("invalid service name: '%s'", name);
+ return (-1);
+ }
+
+ if (ensure_confdir() != 0)
+ return (-1);
+
+ if ((size_t)snprintf(path, sizeof(path), "%s/%s",
+ RCD_CONFD_DIR, name) >= sizeof(path)) {
+ log_warn("%s: service name too long", name);
+ return (-1);
+ }
+
+ /* Load existing file if present, to preserve other overrides */
+ top = NULL;
+ parser = ucl_parser_new(UCL_PARSER_DEFAULT);
+ if (ucl_parser_add_file(parser, path))
+ top = ucl_parser_get_object(parser);
+ ucl_parser_free(parser);
+
+ if (top == NULL)
+ top = ucl_object_typed_new(UCL_OBJECT);
+
+ /* Set or replace the enable key */
+ ucl_object_replace_key(top,
+ ucl_object_frombool(enable), "enable", 0, false);
+
+ buf = ucl_object_emit(top, UCL_EMIT_CONFIG);
+ ucl_object_unref(top);
+
+ if (buf == NULL)
+ return (-1);
+
+ /*
+ * Atomic write using mkstemp() + rename().
+ * mkstemp() creates the file with O_EXCL | O_CREAT,
+ * preventing symlink races. The file is created with
+ * mode 0600 and umask restrictions apply.
+ */
+ {
+ char tmpl[PATH_MAX];
+
+ if ((size_t)snprintf(tmpl, sizeof(tmpl),
+ "%s.XXXXXXXX", path) >= sizeof(tmpl)) {
+ free(buf);
+ return (-1);
+ }
+ fd = mkstemp(tmpl);
+ if (fd < 0) {
+ log_warn("mkstemp %s: %s", tmpl, strerror(errno));
+ free(buf);
+ return (-1);
+ }
+
+ /* Write the content, handling partial writes and EINTR */
+ if (xwrite(fd, buf, strlen((char *)buf)) < 0) {
+ log_warn("write %s: %s", tmpl,
+ strerror(errno));
+ close(fd);
+ unlink(tmpl);
+ free(buf);
+ return (-1);
+ }
+
+ if (close(fd) != 0) {
+ log_warn("close %s: %s", tmpl, strerror(errno));
+ unlink(tmpl);
+ free(buf);
+ return (-1);
+ }
+
+ if (rename(tmpl, path) != 0) {
+ log_warn("rename %s -> %s: %s",
+ tmpl, path, strerror(errno));
+ unlink(tmpl);
+ free(buf);
+ return (-1);
+ }
+ }
+
+ free(buf);
+
+ log_info("%s: %s", name, enable ? "enabled" : "disabled");
+ return (0);
+}
+
+int
+enable_service(const char *name, const char *confdir __unused)
+{
+
+ return (write_enable_state(name, true));
+}
+
+int
+disable_service(const char *name, const char *confdir __unused)
+{
+
+ return (write_enable_state(name, false));
+}
+
+int
+delete_override(const char *name)
+{
+ char path[PATH_MAX];
+
+ /* Validate service name to prevent path traversal */
+ if (!valid_service_name(name)) {
+ log_warn("invalid service name: '%s'", name);
+ return (-1);
+ }
+
+ if ((size_t)snprintf(path, sizeof(path), "%s/%s",
+ RCD_CONFD_DIR, name) >= sizeof(path))
+ return (-1);
+ if (unlink(path) != 0 && errno != ENOENT) {
+ log_warn("unlink %s: %s", path, strerror(errno));
+ return (-1);
+ }
+ log_info("%s: override deleted", name);
+ return (0);
+}
+
+
diff --git a/sbin/rcd/hash.h b/sbin/rcd/hash.h
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/hash.h
@@ -0,0 +1,60 @@
+/*
+ * Copyright (c) 2021 Baptiste Daroussin <bapt@FreeBSD.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#ifndef _HASH_H
+#define _HASH_H
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdlib.h>
+
+/*
+ * Allocation error handling for hash table operations.
+ * Define HASH_ALLOC_ERROR before including this header to override
+ * the default abort() behaviour (e.g., longjmp to a recovery point).
+ */
+#ifndef HASH_ALLOC_ERROR
+#define HASH_ALLOC_ERROR abort()
+#endif
+
+typedef struct hash hash_t;
+
+hash_t *hash_new(void);
+void hash_destroy(hash_t *table);
+bool hash_add(hash_t *table, const char *key, void *value,
+ void (*free_func)(void *));
+size_t hash_count(hash_t *table);
+
+typedef struct {
+ char *key;
+ void *value;
+ hash_t *_table;
+ size_t _index;
+} hash_it;
+
+typedef struct {
+ char *key;
+ void *value;
+ void (*free_func)(void *);
+ bool tombstone; /* Deleted entry; probe chain continues */
+} hash_entry;
+
+hash_entry *hash_get(hash_t *table, const char *key);
+void *hash_get_value(hash_t *table, const char *key);
+hash_it hash_iterator(hash_t *table);
+bool hash_next(hash_it *it);
+bool hash_del(hash_t *h, const char *key);
+void *hash_delete(hash_t *h, const char *key);
+
+#define hash_safe_add(_t, _k, _v, _free_func) do { \
+ if ((_t) == NULL) \
+ (_t) = hash_new(); \
+ else if (hash_get((_t), (_k)) != NULL) \
+ break; \
+ hash_add((_t), (_k), (_v), (_free_func)); \
+} while (0)
+
+#endif /* !_HASH_H */
diff --git a/sbin/rcd/hash.c b/sbin/rcd/hash.c
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/hash.c
@@ -0,0 +1,252 @@
+/*
+ * Copyright (c) 2021 Baptiste Daroussin <bapt@FreeBSD.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+/*
+ * Open-addressing hash table with automatic growth.
+ * Adapted from pkg's pkghash for general use.
+ */
+
+#include "hash.h"
+
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "mum.h"
+
+#define STREQ(s1, s2) (strcmp((s1), (s2)) == 0)
+
+/*
+ * Allocation helpers using HASH_ALLOC_ERROR (defined in hash.h).
+ * Defaults to abort() unless overridden by the includer.
+ */
+#define HASH_MALLOC(ptr, size) do { \
+ ptr = malloc(size); \
+ if (ptr == NULL) \
+ HASH_ALLOC_ERROR; \
+} while (0)
+
+#define HASH_CALLOC(ptr, n, size) do { \
+ ptr = calloc(n, size); \
+ if (ptr == NULL) \
+ HASH_ALLOC_ERROR; \
+} while (0)
+
+struct hash {
+ hash_entry *entries;
+ size_t capacity;
+ size_t count;
+};
+
+hash_t *
+hash_new(void)
+{
+ hash_t *table;
+
+ HASH_MALLOC(table, sizeof(*table));
+ table->count = 0;
+ table->capacity = 128;
+ HASH_CALLOC(table->entries, table->capacity, sizeof(hash_entry));
+ return (table);
+}
+
+void
+hash_destroy(hash_t *table)
+{
+ size_t i;
+
+ if (table == NULL)
+ return;
+
+ for (i = 0; i < table->capacity; i++) {
+ if (table->entries[i].key != NULL)
+ free(table->entries[i].key);
+ if (table->entries[i].free_func != NULL)
+ table->entries[i].free_func(table->entries[i].value);
+ }
+ free(table->entries);
+ free(table);
+}
+
+hash_entry *
+hash_get(hash_t *table, const char *key)
+{
+ uint64_t h;
+ size_t index;
+
+ if (table == NULL)
+ return (NULL);
+ h = mum_hash(key, strlen(key), 0);
+ index = (size_t)(h & (uint64_t)(table->capacity - 1));
+
+ while (table->entries[index].key != NULL ||
+ table->entries[index].tombstone) {
+ if (table->entries[index].key != NULL &&
+ STREQ(key, table->entries[index].key))
+ return (&table->entries[index]);
+ index++;
+ if (index >= table->capacity)
+ index = 0;
+ }
+ return (NULL);
+}
+
+void *
+hash_get_value(hash_t *table, const char *key)
+{
+ hash_entry *e;
+
+ e = hash_get(table, key);
+ return (e != NULL ? e->value : NULL);
+}
+
+static bool
+hash_set_entry(hash_entry *entries, size_t capacity,
+ const char *key, void *value, size_t *pcount,
+ void (*free_func)(void *))
+{
+ uint64_t h;
+ size_t index;
+
+ h = mum_hash(key, strlen(key), 0);
+ index = (size_t)(h & (uint64_t)(capacity - 1));
+
+ while (entries[index].key != NULL ||
+ entries[index].tombstone) {
+ if (entries[index].key != NULL &&
+ STREQ(key, entries[index].key))
+ return (false);
+ index++;
+ if (index >= capacity)
+ index = 0;
+ }
+
+ if (pcount != NULL) {
+ char *kdup = strdup(key);
+
+ if (kdup == NULL)
+ HASH_ALLOC_ERROR;
+ key = kdup;
+ (*pcount)++;
+ }
+ entries[index].key = (char *)key;
+ entries[index].value = value;
+ entries[index].free_func = free_func;
+ entries[index].tombstone = false;
+ return (true);
+}
+
+static bool
+hash_expand(hash_t *table)
+{
+ size_t new_capacity;
+ hash_entry *new_entries;
+ size_t i;
+
+ new_capacity = table->capacity * 2;
+ if (new_capacity < table->capacity)
+ return (false);
+ HASH_CALLOC(new_entries, new_capacity, sizeof(hash_entry));
+
+ for (i = 0; i < table->capacity; i++) {
+ hash_entry entry = table->entries[i];
+ if (entry.key != NULL)
+ hash_set_entry(new_entries, new_capacity,
+ entry.key, entry.value, NULL,
+ entry.free_func);
+ }
+
+ free(table->entries);
+ table->entries = new_entries;
+ table->capacity = new_capacity;
+ return (true);
+}
+
+bool
+hash_add(hash_t *table, const char *key, void *value,
+ void (*free_func)(void *))
+{
+
+ if (table->count * 2 >= table->capacity &&
+ !hash_expand(table))
+ return (false);
+
+ return (hash_set_entry(table->entries, table->capacity,
+ key, value, &table->count, free_func));
+}
+
+size_t
+hash_count(hash_t *table)
+{
+
+ if (table == NULL)
+ return (0);
+ return (table->count);
+}
+
+hash_it
+hash_iterator(hash_t *table)
+{
+ hash_it it = { 0 };
+
+ it._table = table;
+ return (it);
+}
+
+bool
+hash_next(hash_it *it)
+{
+ hash_t *table;
+
+ table = it->_table;
+ if (table == NULL || table->count == 0)
+ return (false);
+ while (it->_index < table->capacity) {
+ size_t i = it->_index;
+
+ it->_index++;
+ if (table->entries[i].key != NULL) {
+ it->key = table->entries[i].key;
+ it->value = table->entries[i].value;
+ return (true);
+ }
+ }
+ return (false);
+}
+
+bool
+hash_del(hash_t *table, const char *key)
+{
+ hash_entry *e;
+
+ e = hash_get(table, key);
+ if (e == NULL)
+ return (false);
+ free(e->key);
+ e->key = NULL;
+ if (e->free_func != NULL)
+ e->free_func(e->value);
+ e->tombstone = true;
+ table->count--;
+ return (true);
+}
+
+void *
+hash_delete(hash_t *table, const char *key)
+{
+ hash_entry *e;
+ void *value;
+
+ e = hash_get(table, key);
+ if (e == NULL)
+ return (NULL);
+ free(e->key);
+ e->key = NULL;
+ e->tombstone = true;
+ value = e->value;
+ table->count--;
+ return (value);
+}
diff --git a/sbin/rcd/jail_svc.c b/sbin/rcd/jail_svc.c
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/jail_svc.c
@@ -0,0 +1,174 @@
+/*
+ * Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+/*
+ * Service jail management. Creates lightweight jails for service
+ * isolation using jail(2) directly.
+ */
+
+#include <sys/param.h>
+#include <sys/jail.h>
+
+#include <jail.h>
+
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "rcd.h"
+
+/*
+ * Map option names to jail parameter strings.
+ */
+static const struct {
+ const char *name;
+ const char *param;
+ const char *value;
+} jail_option_map[] = {
+ { "netv4", "ip4", "new" },
+ { "netv6", "ip6", "new" },
+ { "mlock", "allow.mlock", "true" },
+ { "sysvipc", "allow.sysvipc", "true" },
+ { "allow.routing", "allow.socket_af", "true" },
+ { "vmm", "allow.vmm", "true" },
+ { NULL, NULL, NULL }
+};
+
+/*
+ * Create a jail for the service.
+ */
+int
+jail_svc_create(struct unit *u)
+{
+ struct jailparam *params;
+ int nparams, maxparams;
+ char namebuf[64];
+ int jid, i, j;
+
+ if (!u->u_jail.jc_enable)
+ return (0);
+
+ /* Generate jail name if not specified */
+ if (u->u_jail.jc_name == NULL) {
+ snprintf(namebuf, sizeof(namebuf), "svcj-%s", u->u_name);
+ u->u_jail.jc_name = xstrdup(namebuf);
+ }
+
+ /* Allocate params array sized for all possible entries */
+ maxparams = 5 +
+ (int)u->u_jail.jc_options.len +
+ (int)u->u_jail.jc_ip4addr.len +
+ (int)u->u_jail.jc_ip6addr.len;
+ params = xcalloc(maxparams, sizeof(*params));
+ nparams = 0;
+
+ /* Jail name */
+ jailparam_init(&params[nparams], "name");
+ jailparam_import(&params[nparams], u->u_jail.jc_name);
+ nparams++;
+
+ /* Path (default: /) */
+ jailparam_init(&params[nparams], "path");
+ jailparam_import(&params[nparams],
+ u->u_jail.jc_path != NULL ? u->u_jail.jc_path : "/");
+ nparams++;
+
+ /* Inherit host */
+ jailparam_init(&params[nparams], "host");
+ jailparam_import(&params[nparams], "inherit");
+ nparams++;
+
+ /* No devfs by default */
+ if (!u->u_jail.jc_devfs) {
+ jailparam_init(&params[nparams], "mount.nodevfs");
+ jailparam_import(&params[nparams], "true");
+ nparams++;
+ }
+
+ /* persist so the jail survives between operations */
+ jailparam_init(&params[nparams], "persist");
+ jailparam_import(&params[nparams], "true");
+ nparams++;
+
+ /* Apply option map */
+ for (i = 0; i < (int)u->u_jail.jc_options.len; i++) {
+ for (j = 0; jail_option_map[j].name != NULL; j++) {
+ if (strcmp(
+ u->u_jail.jc_options.d[i],
+ jail_option_map[j].name) == 0) {
+ jailparam_init(&params[nparams],
+ jail_option_map[j].param);
+ jailparam_import(&params[nparams],
+ jail_option_map[j].value);
+ nparams++;
+ break;
+ }
+ }
+ }
+
+ /* IPv4 addresses */
+ for (i = 0; i < (int)u->u_jail.jc_ip4addr.len; i++) {
+ jailparam_init(&params[nparams], "ip4.addr");
+ jailparam_import(&params[nparams],
+ u->u_jail.jc_ip4addr.d[i]);
+ nparams++;
+ }
+
+ /* IPv6 addresses */
+ for (i = 0; i < (int)u->u_jail.jc_ip6addr.len; i++) {
+ jailparam_init(&params[nparams], "ip6.addr");
+ jailparam_import(&params[nparams],
+ u->u_jail.jc_ip6addr.d[i]);
+ nparams++;
+ }
+
+ /* Create the jail */
+ jid = jailparam_set(params, nparams, JAIL_CREATE);
+
+ jailparam_free(params, nparams);
+ free(params);
+
+ if (jid < 0) {
+ log_warn("%s: jail creation failed: %s",
+ u->u_name, strerror(errno));
+ return (-1);
+ }
+
+ u->u_jail.jc_jid = jid;
+ log_info("%s: created jail %s (jid %d)",
+ u->u_name, u->u_jail.jc_name, jid);
+ return (0);
+}
+
+/*
+ * Destroy a service jail.
+ */
+int
+jail_svc_destroy(struct unit *u)
+{
+ struct jailparam params[1];
+ int jid;
+
+ if (u->u_jail.jc_jid <= 0)
+ return (0);
+
+ jailparam_init(&params[0], "name");
+ jailparam_import(&params[0], u->u_jail.jc_name);
+
+ jid = jailparam_set(params, 1, JAIL_DYING);
+ jailparam_free(params, 1);
+
+ if (jid < 0 && errno != ENOENT) {
+ log_warn("%s: jail destroy failed: %s",
+ u->u_name, strerror(errno));
+ return (-1);
+ }
+
+ log_info("%s: destroyed jail %s", u->u_name, u->u_jail.jc_name);
+ u->u_jail.jc_jid = 0;
+ return (0);
+}
diff --git a/sbin/rcd/log.c b/sbin/rcd/log.c
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/log.c
@@ -0,0 +1,512 @@
+/*
+ * Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+/*
+ * Logging subsystem. Outputs to syslog and optionally stderr.
+ * Also sets up stdout/stderr redirects for service processes via
+ * posix_spawn file actions.
+ */
+
+#include <sys/param.h>
+#include <sys/event.h>
+#include <sys/sysctl.h>
+
+#include <errno.h>
+#include <fcntl.h>
+#include <spawn.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <syslog.h>
+#include <unistd.h>
+
+#include "rcd.h"
+
+static int log_level = LOG_INFO;
+static bool log_verbose;
+static bool log_console_enabled = true;
+static int log_console_fd = -1;
+
+void
+log_init(int level)
+{
+
+ log_level = level;
+ openlog("rcd", LOG_PID | LOG_NDELAY, LOG_DAEMON);
+}
+
+void
+log_set_verbose(bool v)
+{
+
+ log_verbose = v;
+}
+
+/*
+ * Duplicate stderr to a private fd for console output.
+ * Must be called before daemonization, while stderr is still
+ * connected to the console by init(8).
+ */
+void
+log_console_open(void)
+{
+
+ if (log_console_fd >= 0)
+ close(log_console_fd);
+ log_console_fd = dup(STDERR_FILENO);
+}
+
+/*
+ * Close the console fd. Called when boot is complete.
+ */
+void
+log_console_close(void)
+{
+
+ if (log_console_fd >= 0) {
+ close(log_console_fd);
+ log_console_fd = -1;
+ }
+}
+
+/*
+ * Write a message to the console for boot-time progress feedback.
+ * Uses the fd saved by log_console_open(), which survives
+ * daemonization (fork + setsid).
+ *
+ * The message is written only if console output is enabled
+ * (controlled by quiet_boot in rcd.conf).
+ */
+void
+log_console(const char *fmt, ...)
+{
+ va_list ap;
+ char buf[256];
+ ssize_t len;
+
+ if (!log_console_enabled || log_console_fd < 0)
+ return;
+
+ va_start(ap, fmt);
+ len = vsnprintf(buf, sizeof(buf), fmt, ap);
+ va_end(ap);
+
+ if (len > 0) {
+ if ((size_t)len >= sizeof(buf))
+ len = sizeof(buf) - 1;
+ buf[len] = '\n';
+ xwrite(log_console_fd, buf, (size_t)(len + 1));
+ }
+}
+
+void
+log_console_set_enabled(bool enabled)
+{
+
+ log_console_enabled = enabled;
+}
+
+void
+log_info(const char *fmt, ...)
+{
+ va_list ap;
+
+ if (log_level < LOG_INFO)
+ return;
+ va_start(ap, fmt);
+ vsyslog(LOG_INFO, fmt, ap);
+ va_end(ap);
+ if (log_verbose) {
+ va_start(ap, fmt);
+ vfprintf(stderr, fmt, ap);
+ fputc('\n', stderr);
+ va_end(ap);
+ }
+}
+
+void
+log_warn(const char *fmt, ...)
+{
+ va_list ap;
+
+ va_start(ap, fmt);
+ vsyslog(LOG_WARNING, fmt, ap);
+ va_end(ap);
+ if (log_verbose) {
+ va_start(ap, fmt);
+ fprintf(stderr, "WARNING: ");
+ vfprintf(stderr, fmt, ap);
+ fputc('\n', stderr);
+ va_end(ap);
+ }
+}
+
+void
+log_err(int eval, const char *fmt, ...)
+{
+ va_list ap;
+
+ va_start(ap, fmt);
+ vsyslog(LOG_ERR, fmt, ap);
+ va_end(ap);
+ if (log_verbose) {
+ va_start(ap, fmt);
+ fprintf(stderr, "ERROR: ");
+ vfprintf(stderr, fmt, ap);
+ fputc('\n', stderr);
+ va_end(ap);
+ }
+ exit(eval);
+}
+
+void
+log_debug(const char *fmt, ...)
+{
+ va_list ap;
+
+ if (log_level < LOG_DEBUG)
+ return;
+ va_start(ap, fmt);
+ vsyslog(LOG_DEBUG, fmt, ap);
+ va_end(ap);
+ if (log_verbose) {
+ va_start(ap, fmt);
+ fprintf(stderr, "DEBUG: ");
+ vfprintf(stderr, fmt, ap);
+ fputc('\n', stderr);
+ va_end(ap);
+ }
+}
+
+/*
+ * Write a boottrace message to kern.boottrace.log.
+ * This allows measuring boot performance with boottrace(4).
+ */
+void
+boottrace(const char *fmt, ...)
+{
+ va_list ap;
+ char msg[256];
+
+ va_start(ap, fmt);
+ vsnprintf(msg, sizeof(msg), fmt, ap);
+ va_end(ap);
+
+ sysctlbyname("kern.boottrace.log", NULL, NULL, msg,
+ strlen(msg) + 1);
+}
+
+/*
+ * Parse a "syslog:facility.level" spec into syslog priority.
+ */
+static int
+parse_syslog_priority(const char *spec)
+{
+ const char *dot;
+ int facility, level;
+
+ /* Skip "syslog:" prefix */
+ if (strncmp(spec, "syslog:", 7) == 0)
+ spec += 7;
+
+ facility = LOG_DAEMON;
+ level = LOG_INFO;
+
+ dot = strchr(spec, '.');
+ if (dot != NULL) {
+ char fac[32];
+ size_t len = (size_t)(dot - spec);
+
+ if (len >= sizeof(fac))
+ len = sizeof(fac) - 1;
+ memcpy(fac, spec, len);
+ fac[len] = '\0';
+
+ if (strcmp(fac, "daemon") == 0)
+ facility = LOG_DAEMON;
+ else if (strcmp(fac, "local0") == 0)
+ facility = LOG_LOCAL0;
+ else if (strcmp(fac, "local1") == 0)
+ facility = LOG_LOCAL1;
+ else if (strcmp(fac, "user") == 0)
+ facility = LOG_USER;
+
+ spec = dot + 1;
+ }
+
+ if (strcmp(spec, "err") == 0 || strcmp(spec, "error") == 0)
+ level = LOG_ERR;
+ else if (strcmp(spec, "warning") == 0 || strcmp(spec, "warn") == 0)
+ level = LOG_WARNING;
+ else if (strcmp(spec, "debug") == 0)
+ level = LOG_DEBUG;
+ else if (strcmp(spec, "notice") == 0)
+ level = LOG_NOTICE;
+
+ return (facility | level);
+}
+
+/*
+ * Create a pipe for syslog-forwarded output. The write end is dup2'd
+ * into the child's stdout or stderr via posix_spawn file actions.
+ * Returns the read end fd. The write end fd is returned via *wfdp
+ * and MUST be closed by the caller AFTER posix_spawn (the child needs
+ * it in its fd table at fork time).
+ */
+static int
+create_syslog_pipe(posix_spawn_file_actions_t *fa, int target_fd,
+ int *wfdp)
+{
+ int pipefd[2];
+
+ if (pipe(pipefd) != 0)
+ return (-1);
+
+ /* In the child: dup2 write end to target, close both originals */
+ posix_spawn_file_actions_adddup2(fa, pipefd[1], target_fd);
+ posix_spawn_file_actions_addclose(fa, pipefd[1]);
+ posix_spawn_file_actions_addclose(fa, pipefd[0]);
+
+ /*
+ * Return read end to caller; write end must stay open until
+ * after posix_spawn so the child inherits it.
+ */
+ *wfdp = pipefd[1];
+ return (pipefd[0]);
+}
+
+/*
+ * Set up posix_spawn file actions to redirect the child's stdout and stderr
+ * based on the unit's logging configuration.
+ *
+ * Supported targets:
+ * "syslog:facility.level" - pipe to syslog via rcd's kqueue
+ * "file:/path" - redirect to file
+ * "null" - redirect to /dev/null
+ *
+ * For syslog targets, we create a pipe. The write end is dup2'd into the
+ * child's fd. The read end stays open in rcd and should be registered in
+ * kqueue for line-buffered forwarding to syslog. The pipe fd is stored
+ * in the unit's log_conf for the caller to retrieve.
+ */
+int
+log_setup_fds(struct unit *u, posix_spawn_file_actions_t *fa)
+{
+ const char *out, *err;
+
+ out = u->u_log.lc_stdout;
+ err = u->u_log.lc_stderr;
+
+ /* Default: redirect to /dev/null if no logging configured */
+ if (out == NULL && err == NULL) {
+ posix_spawn_file_actions_addopen(fa, STDOUT_FILENO,
+ "/dev/null", O_WRONLY, 0);
+ posix_spawn_file_actions_addopen(fa, STDERR_FILENO,
+ "/dev/null", O_WRONLY, 0);
+ return (0);
+ }
+
+ /* stdout */
+ if (out != NULL) {
+ if (strncmp(out, "file:", 5) == 0) {
+ posix_spawn_file_actions_addopen(fa, STDOUT_FILENO,
+ out + 5, O_WRONLY | O_CREAT | O_APPEND | O_NOFOLLOW, 0644);
+ } else if (strcmp(out, "null") == 0) {
+ posix_spawn_file_actions_addopen(fa, STDOUT_FILENO,
+ "/dev/null", O_WRONLY, 0);
+ } else if (strncmp(out, "syslog:", 7) == 0) {
+ int wfd;
+ int rfd = create_syslog_pipe(fa, STDOUT_FILENO,
+ &wfd);
+ if (rfd >= 0) {
+ u->u_log.lc_stdout_pipefd = rfd;
+ u->u_log.lc_stdout_wfd = wfd;
+ u->u_log.lc_stdout_priority =
+ parse_syslog_priority(out);
+ }
+ }
+ }
+
+ /* stderr */
+ if (err != NULL) {
+ if (strncmp(err, "file:", 5) == 0) {
+ posix_spawn_file_actions_addopen(fa, STDERR_FILENO,
+ err + 5, O_WRONLY | O_CREAT | O_APPEND | O_NOFOLLOW, 0644);
+ } else if (strcmp(err, "null") == 0) {
+ posix_spawn_file_actions_addopen(fa, STDERR_FILENO,
+ "/dev/null", O_WRONLY, 0);
+ } else if (strncmp(err, "syslog:", 7) == 0) {
+ int wfd;
+ int rfd = create_syslog_pipe(fa, STDERR_FILENO,
+ &wfd);
+ if (rfd >= 0) {
+ u->u_log.lc_stderr_pipefd = rfd;
+ u->u_log.lc_stderr_wfd = wfd;
+ u->u_log.lc_stderr_priority =
+ parse_syslog_priority(err);
+ }
+ }
+ } else {
+ /* Default: stderr follows stdout */
+ posix_spawn_file_actions_adddup2(fa, STDOUT_FILENO,
+ STDERR_FILENO);
+ }
+
+ return (0);
+}
+
+/*
+ * Register syslog pipe read-ends in kqueue with EVFILT_READ.
+ * Called after posix_spawn, once lc_stdout_wfd/lc_stderr_wfd
+ * are closed in the parent and only the service side holds
+ * the write end.
+ */
+int
+log_register_pipe_fds(struct unit *u, int kq)
+{
+ struct kevent kev;
+
+ if (u->u_log.lc_stdout_pipefd >= 0) {
+ EV_SET(&kev, u->u_log.lc_stdout_pipefd, EVFILT_READ,
+ EV_ADD, 0, 0, u);
+ if (kevent(kq, &kev, 1, NULL, 0, NULL) < 0) {
+ log_warn("%s: kevent stdout pipe: %s",
+ u->u_name, strerror(errno));
+ return (-1);
+ }
+ }
+
+ if (u->u_log.lc_stderr_pipefd >= 0) {
+ EV_SET(&kev, u->u_log.lc_stderr_pipefd, EVFILT_READ,
+ EV_ADD, 0, 0, u);
+ if (kevent(kq, &kev, 1, NULL, 0, NULL) < 0) {
+ log_warn("%s: kevent stderr pipe: %s",
+ u->u_name, strerror(errno));
+ return (-1);
+ }
+ }
+
+ return (0);
+}
+
+/*
+ * Forward data from a syslog pipe to syslog(3).
+ *
+ * Reads whatever is available and processes complete lines
+ * (delimited by '\n'). Partial data at the end is buffered
+ * in the unit's residual buffer and prepended to the next
+ * read. On EOF (read returns 0), any remaining residual is
+ * flushed.
+ */
+void
+log_handle_pipe_event(struct unit *u, int fd)
+{
+ int priority;
+ char *resid;
+ size_t *resid_len;
+ char buf[4096];
+ ssize_t nread;
+ bool is_eof;
+
+ /* Determine which fd triggered and pick the right state */
+ if (fd == u->u_log.lc_stdout_pipefd) {
+ priority = u->u_log.lc_stdout_priority;
+ resid = u->u_log.lc_stdout_resid;
+ resid_len = &u->u_log.lc_stdout_resid_len;
+ } else if (fd == u->u_log.lc_stderr_pipefd) {
+ priority = u->u_log.lc_stderr_priority;
+ resid = u->u_log.lc_stderr_resid;
+ resid_len = &u->u_log.lc_stderr_resid_len;
+ } else {
+ return;
+ }
+
+ nread = read(fd, buf, sizeof(buf));
+ if (nread < 0) {
+ if (errno == EINTR)
+ return;
+ /* EOF or error — flush residual and close */
+ is_eof = true;
+ nread = 0;
+ } else if (nread == 0) {
+ is_eof = true;
+ } else {
+ is_eof = false;
+ }
+
+ /*
+ * Process data: prepend any residual from the previous read,
+ * then syslog each complete line.
+ */
+ {
+ char *line_start, *nl;
+ size_t total, consumed;
+
+ /* Build a contiguous buffer: residual + new data */
+ total = *resid_len + (size_t)nread;
+ char combined[total + 1];
+
+ memcpy(combined, resid, *resid_len);
+ if (nread > 0)
+ memcpy(combined + *resid_len, buf, (size_t)nread);
+ combined[total] = '\0';
+
+ line_start = combined;
+ consumed = 0;
+
+ while ((nl = memchr(line_start, '\n',
+ total - consumed)) != NULL) {
+ *nl = '\0';
+ if (line_start != nl)
+ syslog(priority, "%s", line_start);
+ consumed += (size_t)(nl - line_start + 1);
+ line_start = nl + 1;
+ }
+
+ /* Keep remaining data (no newline) as residual */
+ *resid_len = total - consumed;
+ if (*resid_len > 0)
+ memcpy(resid, line_start, *resid_len);
+ else
+ *resid_len = 0;
+ }
+
+ /*
+ * On EOF or error, flush any remaining residual data
+ * and close the pipe fd.
+ */
+ if (is_eof) {
+ if (*resid_len > 0) {
+ syslog(priority, "%s", resid);
+ *resid_len = 0;
+ }
+ close(fd);
+ if (fd == u->u_log.lc_stdout_pipefd)
+ u->u_log.lc_stdout_pipefd = -1;
+ else
+ u->u_log.lc_stderr_pipefd = -1;
+ }
+}
+
+/*
+ * Flush any remaining pipe data and close pipe fds.
+ * Called on service exit (in proc_handle_exit) to ensure
+ * all output is forwarded before the unit is cleaned up.
+ */
+void
+log_flush_pipes(struct unit *u)
+{
+
+ if (u->u_log.lc_stdout_pipefd >= 0) {
+ log_handle_pipe_event(u, u->u_log.lc_stdout_pipefd);
+ /* fd is closed by log_handle_pipe_event on EOF */
+ }
+ if (u->u_log.lc_stderr_pipefd >= 0) {
+ log_handle_pipe_event(u, u->u_log.lc_stderr_pipefd);
+ /* fd is closed by log_handle_pipe_event on EOF */
+ }
+}
diff --git a/sbin/rcd/luaexec.c b/sbin/rcd/luaexec.c
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/luaexec.c
@@ -0,0 +1,602 @@
+/*
+ * Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+/*
+ * Embedded Lua interpreter for rcd(8).
+ *
+ * Allows unit hooks (start_precmd, stop_postcmd, etc.) and oneshot
+ * exec blocks to be written in Lua directly in the UCL unit file.
+ *
+ * The Lua is configured with posix and ucl module has built in,
+ * and package.path/cpath point to /usr/share/flua and /usr/lib/flua
+ * for dynamic modules (lfs, jail, hash, etc.). Dynamic modules
+ * require /usr being mounted (i.e., FILESYSTEMS dependency).
+ *
+ * Hook strings starting with "lua:" are evaluated here.
+ * All others are passed to /bin/sh -c as before.
+ */
+
+#include <sys/param.h>
+#include <sys/linker.h>
+#include <sys/module.h>
+#include <sys/sysctl.h>
+#include <sys/wait.h>
+
+#include <errno.h>
+#include <kenv.h>
+#include <paths.h>
+#include <spawn.h>
+#include <stdio.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+
+#include <lua.h>
+#include <lualib.h>
+#include <lauxlib.h>
+#include "lposix.h"
+#include "lua_ucl.h"
+
+#include "rcd.h"
+
+extern char **environ;
+
+#define FLUA_LUA_PATH \
+ "/usr/share/flua/?.lua;" \
+ "/usr/share/flua/?/init.lua;" \
+ "/usr/lib/flua/?.lua;" \
+ "/usr/lib/flua/?/init.lua"
+
+#define FLUA_C_PATH \
+ "/usr/lib/flua/?.so;" \
+ "/usr/lib/flua/loadall.so"
+
+static lua_State *rcd_lua_state;
+
+/*
+ * Safe os.execute replacement using posix_spawn instead of system(3).
+ * Lua signature: ok, status = os.execute(command)
+ * Returns: true/nil, "exit"/"signal", exit_code
+ */
+static int
+safe_os_execute(lua_State *L)
+{
+ const char *cmd;
+ pid_t pid;
+ int status, error;
+ char *argv[4];
+
+ cmd = luaL_optstring(L, 1, NULL);
+ if (cmd == NULL) {
+ /* os.execute() with no args: return true (shell available) */
+ lua_pushboolean(L, 1);
+ return (1);
+ }
+
+ argv[0] = __DECONST(char *, _PATH_BSHELL);
+ argv[1] = __DECONST(char *, "-c");
+ argv[2] = __DECONST(char *, cmd);
+ argv[3] = NULL;
+
+ error = posix_spawn(&pid, _PATH_BSHELL, NULL, NULL, argv, environ);
+ if (error != 0) {
+ lua_pushnil(L);
+ lua_pushstring(L, "exit");
+ lua_pushinteger(L, 127);
+ return (3);
+ }
+
+ if (xwaitpid(pid, &status, 0) < 0) {
+ lua_pushnil(L);
+ lua_pushstring(L, "exit");
+ lua_pushinteger(L, 127);
+ return (3);
+ }
+
+ if (WIFEXITED(status)) {
+ int code = WEXITSTATUS(status);
+
+ lua_pushboolean(L, code == 0);
+ lua_pushstring(L, "exit");
+ lua_pushinteger(L, code);
+ } else if (WIFSIGNALED(status)) {
+ lua_pushnil(L);
+ lua_pushstring(L, "signal");
+ lua_pushinteger(L, WTERMSIG(status));
+ } else {
+ lua_pushnil(L);
+ lua_pushstring(L, "exit");
+ lua_pushinteger(L, -1);
+ }
+ return (3);
+}
+
+/*
+ * rcd.sysctl(name [, value]) — read or write a sysctl.
+ * rcd.sysctl("kern.hostname") → returns string or nil, err
+ * rcd.sysctl("hw.usb.template", "-1") → sets and returns true or nil, err
+ */
+static int
+lua_rcd_sysctl(lua_State *L)
+{
+ const char *name;
+ char buf[1024];
+ size_t len;
+
+ name = luaL_checkstring(L, 1);
+
+ if (lua_gettop(L) >= 2) {
+ /* Write mode */
+ const char *val = luaL_checkstring(L, 2);
+
+ if (sysctlbyname(name, NULL, NULL, val,
+ strlen(val)) != 0) {
+ lua_pushnil(L);
+ lua_pushstring(L, strerror(errno));
+ return (2);
+ }
+ lua_pushboolean(L, 1);
+ return (1);
+ }
+
+ /* Read mode */
+ len = sizeof(buf) - 1;
+ if (sysctlbyname(name, buf, &len, NULL, 0) != 0) {
+ lua_pushnil(L);
+ lua_pushstring(L, strerror(errno));
+ return (2);
+ }
+ buf[len] = '\0';
+ lua_pushstring(L, buf);
+ return (1);
+}
+
+/*
+ * rcd.kenv(name) — read a kernel environment variable.
+ */
+static int
+lua_rcd_kenv(lua_State *L)
+{
+ const char *name;
+ char buf[1024];
+
+ name = luaL_checkstring(L, 1);
+ if (kenv(KENV_GET, name, buf, sizeof(buf)) < 0) {
+ lua_pushnil(L);
+ lua_pushstring(L, strerror(errno));
+ return (2);
+ }
+ lua_pushstring(L, buf);
+ return (1);
+}
+
+/*
+ * rcd.log(level, msg) — log via syslog.
+ * level: "info", "warn", "debug", "err"
+ */
+static int
+lua_rcd_log(lua_State *L)
+{
+ const char *level, *msg;
+
+ level = luaL_checkstring(L, 1);
+ msg = luaL_checkstring(L, 2);
+
+ if (strcmp(level, "info") == 0)
+ log_info("%s", msg);
+ else if (strcmp(level, "warn") == 0)
+ log_warn("%s", msg);
+ else if (strcmp(level, "debug") == 0)
+ log_debug("%s", msg);
+ else if (strcmp(level, "err") == 0)
+ log_warn("%s", msg);
+ else
+ log_info("%s", msg);
+
+ return (0);
+}
+
+/*
+ * rcd.sleep(seconds) — sleep without forking.
+ * Accepts fractional seconds: rcd.sleep(0.5)
+ */
+static int
+lua_rcd_sleep(lua_State *L)
+{
+ double secs;
+ struct timespec ts;
+
+ secs = luaL_checknumber(L, 1);
+ ts.tv_sec = (time_t)secs;
+ ts.tv_nsec = (long)((secs - ts.tv_sec) * 1000000000L);
+ while (nanosleep(&ts, &ts) < 0 && errno == EINTR)
+ ;
+ return (0);
+}
+
+/*
+ * rcd.exec_output(cmd) — run a command and return its stdout as string.
+ * Safe replacement for io.popen using posix_spawn + pipe.
+ * Returns: output_string or nil, errmsg
+ */
+static int
+lua_rcd_exec_output(lua_State *L)
+{
+ const char *cmd;
+ pid_t pid;
+ int pipefd[2], status, error;
+ char *argv[4];
+ char buf[4096];
+ ssize_t n;
+ luaL_Buffer b;
+
+ cmd = luaL_checkstring(L, 1);
+
+ if (pipe(pipefd) != 0) {
+ lua_pushnil(L);
+ lua_pushstring(L, strerror(errno));
+ return (2);
+ }
+
+ posix_spawn_file_actions_t fa;
+ posix_spawn_file_actions_init(&fa);
+ posix_spawn_file_actions_adddup2(&fa, pipefd[1], STDOUT_FILENO);
+ posix_spawn_file_actions_addclose(&fa, pipefd[0]);
+ posix_spawn_file_actions_addclose(&fa, pipefd[1]);
+
+ argv[0] = __DECONST(char *, _PATH_BSHELL);
+ argv[1] = __DECONST(char *, "-c");
+ argv[2] = __DECONST(char *, cmd);
+ argv[3] = NULL;
+
+ error = posix_spawn(&pid, _PATH_BSHELL, &fa, NULL, argv, environ);
+ posix_spawn_file_actions_destroy(&fa);
+ close(pipefd[1]);
+
+ if (error != 0) {
+ close(pipefd[0]);
+ lua_pushnil(L);
+ lua_pushstring(L, strerror(error));
+ return (2);
+ }
+
+ /* Read stdout from the child */
+ luaL_buffinit(L, &b);
+ for (;;) {
+ n = read(pipefd[0], buf, sizeof(buf));
+ if (n > 0) {
+ luaL_addlstring(&b, buf, n);
+ } else if (n < 0 && errno == EINTR) {
+ continue;
+ } else {
+ break;
+ }
+ }
+ close(pipefd[0]);
+
+ xwaitpid(pid, &status, 0);
+ luaL_pushresult(&b);
+
+ /* Strip trailing newline */
+ {
+ size_t len;
+ const char *s = lua_tolstring(L, -1, &len);
+ if (len > 0 && s[len - 1] == '\n') {
+ lua_pushlstring(L, s, len - 1);
+ lua_remove(L, -2);
+ }
+ }
+
+ return (1);
+}
+
+/*
+ * rcd.symlink(target, path) — create a symlink.
+ * Returns true or nil, errmsg.
+ */
+static int
+lua_rcd_symlink(lua_State *L)
+{
+ const char *target, *path;
+
+ target = luaL_checkstring(L, 1);
+ path = luaL_checkstring(L, 2);
+
+ /* Remove existing entry if present */
+ unlink(path);
+
+ if (symlink(target, path) != 0) {
+ lua_pushnil(L);
+ lua_pushstring(L, strerror(errno));
+ return (2);
+ }
+ lua_pushboolean(L, 1);
+ return (1);
+}
+
+/*
+ * rcd.kldload(module) — load a kernel module.
+ * Returns true if loaded (or already present), nil + errmsg on failure.
+ */
+static int
+lua_rcd_kldload(lua_State *L)
+{
+ const char *mod;
+
+ mod = luaL_checkstring(L, 1);
+
+ /* Already loaded? */
+ if (modfind(mod) != -1) {
+ lua_pushboolean(L, 1);
+ return (1);
+ }
+
+ if (kldload(mod) < 0 && errno != EEXIST) {
+ lua_pushnil(L);
+ lua_pushstring(L, strerror(errno));
+ return (2);
+ }
+ lua_pushboolean(L, 1);
+ return (1);
+}
+
+/*
+ * rcd.kldstat(module) — check if a kernel module is loaded.
+ * Returns true if loaded, false otherwise.
+ */
+static int
+lua_rcd_kldstat(lua_State *L)
+{
+ const char *mod;
+
+ mod = luaL_checkstring(L, 1);
+ lua_pushboolean(L, modfind(mod) != -1);
+ return (1);
+}
+
+/*
+ * rcd.exec_stdin(cmd, data) — run a command feeding data to its stdin.
+ * Returns true/false + exit code, like os.execute.
+ */
+static int
+lua_rcd_exec_stdin(lua_State *L)
+{
+ const char *cmd, *data;
+ size_t datalen;
+ pid_t pid;
+ int pipefd[2], status, error;
+ char *argv[4];
+ posix_spawn_file_actions_t fa;
+
+ cmd = luaL_checkstring(L, 1);
+ data = luaL_checklstring(L, 2, &datalen);
+
+ if (pipe(pipefd) != 0) {
+ lua_pushnil(L);
+ lua_pushstring(L, strerror(errno));
+ return (2);
+ }
+
+ posix_spawn_file_actions_init(&fa);
+ posix_spawn_file_actions_adddup2(&fa, pipefd[0], STDIN_FILENO);
+ posix_spawn_file_actions_addclose(&fa, pipefd[0]);
+ posix_spawn_file_actions_addclose(&fa, pipefd[1]);
+
+ argv[0] = __DECONST(char *, _PATH_BSHELL);
+ argv[1] = __DECONST(char *, "-c");
+ argv[2] = __DECONST(char *, cmd);
+ argv[3] = NULL;
+
+ error = posix_spawn(&pid, _PATH_BSHELL, &fa, NULL, argv, environ);
+ posix_spawn_file_actions_destroy(&fa);
+ close(pipefd[0]);
+
+ if (error != 0) {
+ close(pipefd[1]);
+ lua_pushnil(L);
+ lua_pushstring(L, strerror(error));
+ return (2);
+ }
+
+ /* Write data to child's stdin, then close to signal EOF */
+ if (xwrite(pipefd[1], data, datalen) < 0) {
+ if (errno != EPIPE) /* EPIPE = child closed stdin */
+ log_warn("write to exec_stdin pipe: %s",
+ strerror(errno));
+ }
+ close(pipefd[1]);
+
+ xwaitpid(pid, &status, 0);
+
+ if (WIFEXITED(status) && WEXITSTATUS(status) == 0) {
+ lua_pushboolean(L, 1);
+ return (1);
+ }
+ lua_pushnil(L);
+ lua_pushstring(L, "command failed");
+ return (2);
+}
+
+static const luaL_Reg rcd_lib[] = {
+ { "sysctl", lua_rcd_sysctl },
+ { "kenv", lua_rcd_kenv },
+ { "log", lua_rcd_log },
+ { "exec_output", lua_rcd_exec_output },
+ { "exec_stdin", lua_rcd_exec_stdin },
+ { "sleep", lua_rcd_sleep },
+ { "symlink", lua_rcd_symlink },
+ { "kldload", lua_rcd_kldload },
+ { "kldstat", lua_rcd_kldstat },
+ { NULL, NULL }
+};
+
+static int
+luaopen_rcd(lua_State *L)
+{
+
+ luaL_newlib(L, rcd_lib);
+ return (1);
+}
+
+/*
+ * Initialize the shared Lua state. Called once at startup.
+ */
+void
+lua_init(void)
+{
+ lua_State *L;
+
+ L = luaL_newstate();
+ if (L == NULL) {
+ log_warn("luaL_newstate failed");
+ return;
+ }
+
+ luaL_openlibs(L);
+
+ /*
+ * Replace os.execute with our safe posix_spawn version.
+ * Remove io.popen entirely — it uses popen(3) which is
+ * unsafe in a signal-modified context.
+ */
+ lua_getglobal(L, "os");
+ lua_pushcfunction(L, safe_os_execute);
+ lua_setfield(L, -2, "execute");
+ lua_pop(L, 1);
+
+ lua_getglobal(L, "io");
+ lua_pushnil(L);
+ lua_setfield(L, -2, "popen");
+ lua_pop(L, 1);
+
+ /* Register built-in modules */
+ luaL_requiref(L, "posix", luaopen_posix, 1);
+ lua_pop(L, 1);
+ luaL_requiref(L, "ucl", luaopen_ucl, 1);
+ lua_pop(L, 1);
+ luaL_requiref(L, "rcd", luaopen_rcd, 1);
+ lua_pop(L, 1);
+
+ /* Set module paths matching flua */
+ lua_getglobal(L, "package");
+ lua_pushstring(L, FLUA_LUA_PATH);
+ lua_setfield(L, -2, "path");
+ lua_pushstring(L, FLUA_C_PATH);
+ lua_setfield(L, -2, "cpath");
+ lua_pop(L, 1);
+
+ rcd_lua_state = L;
+}
+
+/*
+ * Helper: parse a UCL string and set it as a field on the rcd table.
+ * If the string is NULL or empty, sets an empty table.
+ */
+static void
+lua_set_ucl_field(lua_State *L, const char *field, const char *ucl_str)
+{
+
+ if (ucl_str != NULL && ucl_str[0] != '\0') {
+ char buf[256];
+
+ /*
+ * Push the UCL string as a Lua global _rcd_tmp,
+ * then parse it from Lua. This avoids C-level
+ * escaping of the UCL content.
+ */
+ lua_pushstring(L, ucl_str);
+ lua_setglobal(L, "_rcd_tmp");
+
+ snprintf(buf, sizeof(buf),
+ "do local p = ucl.parser();"
+ "p:parse_string(_rcd_tmp);"
+ "rcd.%s = p:get_object() or {} end;"
+ "_rcd_tmp = nil", field);
+ luaL_dostring(L, buf);
+ } else {
+ lua_getglobal(L, "rcd");
+ lua_newtable(L);
+ lua_setfield(L, -2, field);
+ lua_pop(L, 1);
+ }
+}
+
+/*
+ * Execute a Lua string in the shared state.
+ * Sets:
+ * rcd.instance — instance name (or nil)
+ * rcd.config — global service config from override file
+ * rcd.instance_config — per-instance config from instances {} block
+ * Returns 0 on success, -1 on error (error is logged).
+ */
+int
+lua_exec(const char *code, const char *source, const struct unit *u)
+{
+ lua_State *L;
+ int error;
+
+ L = rcd_lua_state;
+ if (L == NULL) {
+ log_warn("lua not initialized");
+ return (-1);
+ }
+
+ /* Set rcd.instance */
+ lua_getglobal(L, "rcd");
+ if (u != NULL && u->u_instance != NULL)
+ lua_pushstring(L, u->u_instance);
+ else
+ lua_pushnil(L);
+ lua_setfield(L, -2, "instance");
+ lua_pop(L, 1);
+
+ /* Set rcd.config — global override config (always, for any unit) */
+ lua_set_ucl_field(L, "config",
+ (u != NULL) ? u->u_override_conf : NULL);
+
+ /* Set rcd.instance_config — per-instance config (templates only) */
+ lua_set_ucl_field(L, "instance_config",
+ (u != NULL) ? u->u_instance_conf : NULL);
+
+ error = luaL_loadbuffer(L, code, strlen(code), source);
+ if (error != 0) {
+ log_warn("lua load error: %s", lua_tostring(L, -1));
+ lua_pop(L, 1);
+ return (-1);
+ }
+
+ error = lua_pcall(L, 0, 1, 0);
+ if (error != 0) {
+ log_warn("lua exec error: %s", lua_tostring(L, -1));
+ lua_pop(L, 1);
+ return (-1);
+ }
+
+ /*
+ * Check return value: if the chunk returns false or nil,
+ * treat it as a precondition failure (return -1).
+ * If it returns true or nothing, return 0 (success).
+ */
+ if (lua_isboolean(L, -1) && !lua_toboolean(L, -1)) {
+ lua_pop(L, 1);
+ return (-1);
+ }
+ lua_pop(L, 1);
+ return (0);
+}
+
+/*
+ * Clean up the Lua state.
+ */
+void
+lua_fini(void)
+{
+
+ if (rcd_lua_state != NULL) {
+ lua_close(rcd_lua_state);
+ rcd_lua_state = NULL;
+ }
+}
diff --git a/sbin/rcd/mum.h b/sbin/rcd/mum.h
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/mum.h
@@ -0,0 +1,400 @@
+/* SPDX-License-Identifier: MIT
+ *
+ * Copyright (c) 2016-2025
+ * Vladimir Makarov <vmakarov@gcc.gnu.org>
+ *
+ * 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.
+*/
+
+/* This file implements MUM (MUltiply and Mix) hashing. We randomize input data by 64x64-bit
+ multiplication and mixing hi- and low-parts of the multiplication result by using an addition and
+ then mix it into the current state. We use prime numbers randomly generated with the equal
+ probability of their bit values for the multiplication. When all primes are used once, the state
+ is randomized and the same prime numbers are used again for data randomization.
+
+ The MUM hashing passes all SMHasher tests. Pseudo Random Number Generator based on MUM also
+ passes NIST Statistical Test Suite for Random and Pseudorandom Number Generators for
+ Cryptographic Applications (version 2.2.1) with 1000 bitstreams each containing 1M bits. MUM
+ hashing is also faster Spooky64 and City64 on small strings (at least upto 512-bit) on Haswell
+ and Power7. The MUM bulk speed (speed on very long data) is bigger than Spooky and City on
+ Power7. On Haswell the bulk speed is bigger than Spooky one and close to City speed. */
+
+#ifndef __MUM_HASH__
+#define __MUM_HASH__
+
+#include <stddef.h>
+#include <stdlib.h>
+#include <string.h>
+#include <limits.h>
+
+#ifdef _MSC_VER
+typedef unsigned __int16 uint16_t;
+typedef unsigned __int32 uint32_t;
+typedef unsigned __int64 uint64_t;
+#else
+#include <stdint.h>
+#endif
+
+#ifdef __GNUC__
+#define _MUM_ATTRIBUTE_UNUSED __attribute__ ((unused))
+#define _MUM_INLINE inline __attribute__ ((always_inline))
+#else
+#define _MUM_ATTRIBUTE_UNUSED
+#define _MUM_INLINE inline
+#endif
+
+#if defined(MUM_QUALITY) && !defined(MUM_TARGET_INDEPENDENT_HASH)
+#define MUM_TARGET_INDEPENDENT_HASH
+#endif
+
+/* Macro saying to use 128-bit integers implemented by GCC for some targets. */
+#ifndef _MUM_USE_INT128
+/* In GCC uint128_t is defined if HOST_BITS_PER_WIDE_INT >= 64. HOST_WIDE_INT is long if
+ HOST_BITS_PER_LONG > HOST_BITS_PER_INT, otherwise int. */
+#if defined(__GNUC__) && UINT_MAX != ULONG_MAX
+#define _MUM_USE_INT128 1
+#else
+#define _MUM_USE_INT128 0
+#endif
+#endif
+
+/* Here are different primes randomly generated with the equal probability of their bit values. They
+ are used to randomize input values. */
+static uint64_t _mum_hash_step_prime = 0x2e0bb864e9ea7df5ULL;
+static uint64_t _mum_key_step_prime = 0xcdb32970830fcaa1ULL;
+static uint64_t _mum_block_start_prime = 0xc42b5e2e6480b23bULL;
+static uint64_t _mum_unroll_prime = 0x7b51ec3d22f7096fULL;
+static uint64_t _mum_tail_prime = 0xaf47d47c99b1461bULL;
+static uint64_t _mum_finish_prime1 = 0xa9a7ae7ceff79f3fULL;
+static uint64_t _mum_finish_prime2 = 0xaf47d47c99b1461bULL;
+
+static uint64_t _mum_primes[] = {
+ 0X9ebdcae10d981691, 0X32b9b9b97a27ac7d, 0X29b5584d83d35bbd, 0X4b04e0e61401255f,
+ 0X25e8f7b1f1c9d027, 0X80d4c8c000f3e881, 0Xbd1255431904b9dd, 0X8a3bd4485eee6d81,
+ 0X3bc721b2aad05197, 0X71b1a19b907d6e33, 0X525e6c1084a8534b, 0X9e4c2cd340c1299f,
+ 0Xde3add92e94caa37, 0X7e14eadb1f65311d, 0X3f5aa40f89812853, 0X33b15a3b587d15c9,
+};
+
+/* Multiply 64-bit V and P and return sum of high and low parts of the result. */
+static _MUM_INLINE uint64_t _mum (uint64_t v, uint64_t p) {
+ uint64_t hi, lo;
+#if _MUM_USE_INT128
+ __uint128_t r = (__uint128_t) v * (__uint128_t) p;
+ hi = (uint64_t) (r >> 64);
+ lo = (uint64_t) r;
+#else
+ /* Implementation of 64x64->128-bit multiplication by four 32x32->64 bit multiplication. */
+ uint64_t hv = v >> 32, hp = p >> 32;
+ uint64_t lv = (uint32_t) v, lp = (uint32_t) p;
+ uint64_t rh = hv * hp;
+ uint64_t rm_0 = hv * lp;
+ uint64_t rm_1 = hp * lv;
+ uint64_t rl = lv * lp;
+ uint64_t t, carry = 0;
+
+ /* We could ignore a carry bit here if we did not care about the same hash for 32-bit and 64-bit
+ targets. */
+ t = rl + (rm_0 << 32);
+#ifdef MUM_TARGET_INDEPENDENT_HASH
+ carry = t < rl;
+#endif
+ lo = t + (rm_1 << 32);
+#ifdef MUM_TARGET_INDEPENDENT_HASH
+ carry += lo < t;
+#endif
+ hi = rh + (rm_0 >> 32) + (rm_1 >> 32) + carry;
+#endif
+ /* We could use XOR here too but, for some reasons, on Haswell and Power7 using an addition
+ improves hashing performance by 10% for small strings. */
+ return hi + lo;
+}
+
+#if defined(_MSC_VER)
+#define _mum_bswap32(x) _byteswap_uint32_t (x)
+#define _mum_bswap64(x) _byteswap_uint64_t (x)
+#elif defined(__APPLE__)
+#include <libkern/OSByteOrder.h>
+#define _mum_bswap32(x) OSSwapInt32 (x)
+#define _mum_bswap64(x) OSSwapInt64 (x)
+#elif defined(__GNUC__)
+#define _mum_bswap32(x) __builtin_bswap32 (x)
+#define _mum_bswap64(x) __builtin_bswap64 (x)
+#else
+#include <byteswap.h>
+#define _mum_bswap32(x) bswap32 (x)
+#define _mum_bswap64(x) bswap64 (x)
+#endif
+
+static _MUM_INLINE uint64_t _mum_le (uint64_t v) {
+#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ || !defined(MUM_TARGET_INDEPENDENT_HASH)
+ return v;
+#elif __BYTE_ORDER__ == __ORDER_BIG_ENDIAN__
+ return _mum_bswap64 (v);
+#else
+#error "Unknown endianess"
+#endif
+}
+
+static _MUM_INLINE uint32_t _mum_le32 (uint32_t v) {
+#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ || !defined(MUM_TARGET_INDEPENDENT_HASH)
+ return v;
+#elif __BYTE_ORDER__ == __ORDER_BIG_ENDIAN__
+ return _mum_bswap32 (v);
+#else
+#error "Unknown endianess"
+#endif
+}
+
+static _MUM_INLINE uint64_t _mum_le16 (uint16_t v) {
+#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ || !defined(MUM_TARGET_INDEPENDENT_HASH)
+ return v;
+#elif __BYTE_ORDER__ == __ORDER_BIG_ENDIAN__
+ return (v >> 8) | ((v & 0xff) << 8);
+#else
+#error "Unknown endianess"
+#endif
+}
+
+/* Macro defining how many times the most nested loop in _mum_hash_aligned will be unrolled by the
+ compiler (although it can make an own decision:). Use only a constant here to help a compiler to
+ unroll a major loop.
+
+ The macro value affects the result hash for strings > 128 bit. The unroll factor greatly affects
+ the hashing speed. We prefer the speed. */
+#ifndef _MUM_UNROLL_FACTOR_POWER
+#if defined(__PPC64__) && !defined(MUM_TARGET_INDEPENDENT_HASH)
+#define _MUM_UNROLL_FACTOR_POWER 3
+#elif defined(__aarch64__) && !defined(MUM_TARGET_INDEPENDENT_HASH)
+#define _MUM_UNROLL_FACTOR_POWER 4
+#elif defined(MUM_V1) || defined(MUM_V2)
+#define _MUM_UNROLL_FACTOR_POWER 2
+#else
+#define _MUM_UNROLL_FACTOR_POWER 3
+#endif
+#endif
+
+#if _MUM_UNROLL_FACTOR_POWER < 1
+#error "too small unroll factor"
+#elif _MUM_UNROLL_FACTOR_POWER > 4
+#error "We have not enough primes for such unroll factor"
+#endif
+
+#define _MUM_UNROLL_FACTOR (1 << _MUM_UNROLL_FACTOR_POWER)
+
+/* Rotate V left by SH. */
+static _MUM_INLINE uint64_t _mum_rotl (uint64_t v, int sh) { return v << sh | v >> (64 - sh); }
+
+static _MUM_INLINE uint64_t _mum_xor (uint64_t a, uint64_t b) {
+#ifdef MUM_V3
+ return a ^ b;
+#else
+ return (a ^ b) != 0 ? a ^ b : b;
+#endif
+}
+
+#if defined(MUM_V1) || defined(MUM_V2) || !defined(MUM_QUALITY)
+#define _MUM_TAIL_START(v) 0
+#else
+#define _MUM_TAIL_START(v) v
+#endif
+static _MUM_INLINE uint64_t
+#if defined(__GNUC__) && !defined(__clang__)
+ __attribute__ ((__optimize__ ("unroll-loops")))
+#endif
+ _mum_hash_aligned (uint64_t start, const void *key, size_t len) {
+ uint64_t result = start;
+ const unsigned char *str = (const unsigned char *) key;
+ uint64_t u64;
+ size_t i;
+ size_t n;
+
+#ifndef MUM_V2
+ result = _mum (result, _mum_block_start_prime);
+#endif
+ while (len > _MUM_UNROLL_FACTOR * sizeof (uint64_t)) {
+ /* This loop could be vectorized when we have vector insns for 64x64->128-bit multiplication.
+ AVX2 currently only have vector insns for 4 32x32->64-bit multiplication and for 1
+ 64x64->128-bit multiplication (pclmulqdq). */
+#if defined(MUM_V1) || defined(MUM_V2)
+ for (i = 0; i < _MUM_UNROLL_FACTOR; i++)
+ result ^= _mum (_mum_le (((uint64_t *) str)[i]), _mum_primes[i]);
+#else
+ for (i = 0; i < _MUM_UNROLL_FACTOR; i += 2)
+ result ^= _mum (_mum_xor (_mum_le (((uint64_t *) str)[i]), _mum_primes[i]),
+ _mum_xor (_mum_le (((uint64_t *) str)[i + 1]), _mum_primes[i + 1]));
+#endif
+ len -= _MUM_UNROLL_FACTOR * sizeof (uint64_t);
+ str += _MUM_UNROLL_FACTOR * sizeof (uint64_t);
+ /* We will use the same prime numbers on the next iterations -- randomize the state. */
+ result = _mum (result, _mum_unroll_prime);
+ }
+ n = len / sizeof (uint64_t);
+#if defined(MUM_V1) || defined(MUM_V2) || !defined(MUM_QUALITY)
+ for (i = 0; i < n; i++) result ^= _mum (_mum_le (((uint64_t *) str)[i]), _mum_primes[i]);
+#else
+ for (i = 0; i < n; i++)
+ result ^= _mum (_mum_le (((uint64_t *) str)[i]) + _mum_primes[i], _mum_primes[i]);
+#endif
+ len -= n * sizeof (uint64_t);
+ str += n * sizeof (uint64_t);
+ switch (len) {
+ case 7:
+ u64 = _MUM_TAIL_START (_mum_primes[0]) + _mum_le32 (*(uint32_t *) str);
+ u64 += _mum_le16 (*(uint16_t *) (str + 4)) << 32;
+ u64 += (uint64_t) str[6] << 48;
+ return result ^ _mum (u64, _mum_tail_prime);
+ case 6:
+ u64 = _MUM_TAIL_START (_mum_primes[1]) + _mum_le32 (*(uint32_t *) str);
+ u64 += _mum_le16 (*(uint16_t *) (str + 4)) << 32;
+ return result ^ _mum (u64, _mum_tail_prime);
+ case 5:
+ u64 = _MUM_TAIL_START (_mum_primes[2]) + _mum_le32 (*(uint32_t *) str);
+ u64 += (uint64_t) str[4] << 32;
+ return result ^ _mum (u64, _mum_tail_prime);
+ case 4:
+ u64 = _MUM_TAIL_START (_mum_primes[3]) + _mum_le32 (*(uint32_t *) str);
+ return result ^ _mum (u64, _mum_tail_prime);
+ case 3:
+ u64 = _MUM_TAIL_START (_mum_primes[4]) + _mum_le16 (*(uint16_t *) str);
+ u64 += (uint64_t) str[2] << 16;
+ return result ^ _mum (u64, _mum_tail_prime);
+ case 2:
+ u64 = _MUM_TAIL_START (_mum_primes[5]) + _mum_le16 (*(uint16_t *) str);
+ return result ^ _mum (u64, _mum_tail_prime);
+ case 1:
+ u64 = _MUM_TAIL_START (_mum_primes[6]) + str[0];
+ return result ^ _mum (u64, _mum_tail_prime);
+ }
+ return result;
+}
+
+/* Final randomization of H. */
+static _MUM_INLINE uint64_t _mum_final (uint64_t h) {
+#if defined(MUM_V1)
+ h ^= _mum (h, _mum_finish_prime1);
+ h ^= _mum (h, _mum_finish_prime2);
+#elif defined(MUM_V2)
+ h ^= _mum_rotl (h, 33);
+ h ^= _mum (h, _mum_finish_prime1);
+#else
+ h = _mum (h, h);
+#endif
+ return h;
+}
+
+#ifndef _MUM_UNALIGNED_ACCESS
+#if defined(__x86_64__) || defined(__i386__) || defined(__PPC64__) || defined(__s390__) \
+ || defined(__m32c__) || defined(cris) || defined(__CR16__) || defined(__vax__) \
+ || defined(__m68k__) || defined(__aarch64__) || defined(_M_AMD64) || defined(_M_IX86)
+#define _MUM_UNALIGNED_ACCESS 1
+#else
+#define _MUM_UNALIGNED_ACCESS 0
+#endif
+#endif
+
+/* When we need an aligned access to data being hashed we move part of the unaligned data to an
+ aligned block of given size and then process it, repeating processing the data by the block. */
+#ifndef _MUM_BLOCK_LEN
+#define _MUM_BLOCK_LEN 1024
+#endif
+
+#if _MUM_BLOCK_LEN < 8
+#error "too small block length"
+#endif
+
+static _MUM_INLINE uint64_t
+#if defined(__x86_64__) && defined(__GNUC__) && !defined(__clang__)
+ __attribute__ ((__target__ ("inline-all-stringops")))
+#endif
+ _mum_hash_default (const void *key, size_t len, uint64_t seed) {
+ uint64_t result;
+ const unsigned char *str = (const unsigned char *) key;
+ size_t block_len;
+ uint64_t buf[_MUM_BLOCK_LEN / sizeof (uint64_t)];
+
+ result = seed + len;
+ if (((size_t) str & 0x7) == 0)
+ result = _mum_hash_aligned (result, key, len);
+ else {
+ while (len != 0) {
+ block_len = len < _MUM_BLOCK_LEN ? len : _MUM_BLOCK_LEN;
+ memcpy (buf, str, block_len);
+ result = _mum_hash_aligned (result, buf, block_len);
+ len -= block_len;
+ str += block_len;
+ }
+ }
+ return _mum_final (result);
+}
+
+static _MUM_INLINE uint64_t _mum_next_factor (void) {
+ uint64_t start = 0;
+ int i;
+
+ for (i = 0; i < 8; i++) start = (start << 8) | rand () % 256;
+ return start;
+}
+
+/* ++++++++++++++++++++++++++ Interface functions: +++++++++++++++++++ */
+
+/* Set random multiplicators depending on SEED. */
+static _MUM_INLINE void mum_hash_randomize (uint64_t seed) {
+ size_t i;
+
+ srand (seed);
+ _mum_hash_step_prime = _mum_next_factor ();
+ _mum_key_step_prime = _mum_next_factor ();
+ _mum_finish_prime1 = _mum_next_factor ();
+ _mum_finish_prime2 = _mum_next_factor ();
+ _mum_block_start_prime = _mum_next_factor ();
+ _mum_unroll_prime = _mum_next_factor ();
+ _mum_tail_prime = _mum_next_factor ();
+ for (i = 0; i < sizeof (_mum_primes) / sizeof (uint64_t); i++)
+ _mum_primes[i] = _mum_next_factor ();
+}
+
+/* Start hashing data with SEED. Return the state. */
+static _MUM_INLINE uint64_t mum_hash_init (uint64_t seed) { return seed; }
+
+/* Process data KEY with the state H and return the updated state. */
+static _MUM_INLINE uint64_t mum_hash_step (uint64_t h, uint64_t key) {
+ return _mum (h, _mum_hash_step_prime) ^ _mum (key, _mum_key_step_prime);
+}
+
+/* Return the result of hashing using the current state H. */
+static _MUM_INLINE uint64_t mum_hash_finish (uint64_t h) { return _mum_final (h); }
+
+/* Fast hashing of KEY with SEED. The hash is always the same for the same key on any target. */
+static _MUM_INLINE size_t mum_hash64 (uint64_t key, uint64_t seed) {
+ return mum_hash_finish (mum_hash_step (mum_hash_init (seed), key));
+}
+
+/* Hash data KEY of length LEN and SEED. The hash depends on the target endianess and the unroll
+ factor. */
+static _MUM_INLINE uint64_t mum_hash (const void *key, size_t len, uint64_t seed) {
+#if _MUM_UNALIGNED_ACCESS
+ return _mum_final (_mum_hash_aligned (seed + len, key, len));
+#else
+ return _mum_hash_default (key, len, seed);
+#endif
+}
+
+#endif
diff --git a/sbin/rcd/process.c b/sbin/rcd/process.c
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/process.c
@@ -0,0 +1,1245 @@
+/*
+ * Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+/*
+ * Process lifecycle management.
+ *
+ * Uses posix_spawn(3) with posix_spawnattr_setprocdescp_np(3) to launch
+ * services, obtaining a process descriptor fd without manual fork+exec.
+ * Pre-exec setup (credentials, jail, rlimits) is delegated to rcd-exec(8).
+ *
+ * rcd becomes a subreaper via procctl(PROC_REAP_ACQUIRE). Service subtrees
+ * are killed via procctl(PROC_REAP_KILL). Process deaths are detected via
+ * kqueue EVFILT_PROCDESC on the process descriptor fd.
+ */
+
+#include <sys/param.h>
+#include <sys/event.h>
+#include <sys/eventfd.h>
+#include <sys/linker.h>
+#include <sys/module.h>
+#include <sys/procdesc.h>
+#include <sys/procctl.h>
+#include <sys/stat.h>
+#include <sys/sysctl.h>
+#include <sys/time.h>
+#include <sys/wait.h>
+
+#include <errno.h>
+#include <fcntl.h>
+#include <paths.h>
+#include <signal.h>
+#include <spawn.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "rcd.h"
+
+extern char **environ;
+
+#define RCD_EXEC_PATH "/sbin/rcd-exec"
+#define LISTEN_FD_START 3
+
+/*
+ * Become a subreaper so all orphaned descendants are reparented to us.
+ */
+int
+proc_reaper_init(void)
+{
+ int error;
+
+ error = procctl(P_PID, getpid(), PROC_REAP_ACQUIRE, NULL);
+ if (error != 0) {
+ log_warn("PROC_REAP_ACQUIRE: %s", strerror(errno));
+ return (-1);
+ }
+ return (0);
+}
+
+/*
+ * Check preconditions before starting a service.
+ * Returns 0 if all preconditions are met, -1 otherwise.
+ */
+int
+proc_check_preconditions(struct unit *u)
+{
+ struct stat sb;
+ struct kv *sc;
+
+ vec_foreach(u->u_required_dirs, i) {
+ if (stat(u->u_required_dirs.d[i], &sb) != 0 ||
+ !S_ISDIR(sb.st_mode)) {
+ log_warn("%s: required directory missing: %s",
+ u->u_name,
+ u->u_required_dirs.d[i]);
+ return (-1);
+ }
+ }
+
+ vec_foreach(u->u_required_files, i) {
+ if (access(u->u_required_files.d[i],
+ R_OK) != 0) {
+ log_warn("%s: required file missing: %s",
+ u->u_name,
+ u->u_required_files.d[i]);
+ return (-1);
+ }
+ }
+
+ /*
+ * Check required_vars: verify that named keys exist in
+ * the override config. This ensures the admin has set
+ * mandatory per-site configuration before the service starts.
+ */
+ if (u->u_required_vars.len > 0 && u->u_override_conf != NULL) {
+ struct ucl_parser *vp;
+ ucl_object_t *vtop;
+
+ vp = ucl_parser_new(UCL_PARSER_DEFAULT);
+ if (ucl_parser_add_string(vp, u->u_override_conf,
+ strlen(u->u_override_conf))) {
+ vtop = ucl_parser_get_object(vp);
+ vec_foreach(u->u_required_vars, vi) {
+ if (ucl_object_lookup(vtop,
+ u->u_required_vars.d[vi]) == NULL) {
+ log_warn("%s: required variable "
+ "not set: %s", u->u_name,
+ u->u_required_vars.d[vi]);
+ ucl_object_unref(vtop);
+ ucl_parser_free(vp);
+ return (-1);
+ }
+ }
+ ucl_object_unref(vtop);
+ }
+ ucl_parser_free(vp);
+ } else if (u->u_required_vars.len > 0) {
+ log_warn("%s: required_vars set but no override config",
+ u->u_name);
+ return (-1);
+ }
+
+ /* Check required sysctl values */
+ STAILQ_FOREACH(sc, &u->u_required_sysctl, kv_entries) {
+ char buf[256];
+ size_t len;
+
+ len = sizeof(buf);
+ if (sysctlbyname(sc->kv_key, buf, &len, NULL, 0) != 0) {
+ log_warn("%s: required sysctl %s: %s",
+ u->u_name, sc->kv_key, strerror(errno));
+ return (-1);
+ }
+ /* Null-terminate if it's a string */
+ if (len < sizeof(buf))
+ buf[len] = '\0';
+
+ if (strcmp(buf, sc->kv_val) != 0) {
+ log_warn("%s: sysctl %s = \"%s\", want \"%s\"",
+ u->u_name, sc->kv_key, buf, sc->kv_val);
+ return (-1);
+ }
+ }
+
+ return (0);
+}
+
+/*
+ * Load required kernel modules via kldload(2).
+ * Returns 0 on success, -1 if a module could not be loaded.
+ */
+int
+proc_load_modules(struct unit *u)
+{
+
+ vec_foreach(u->u_required_modules, i) {
+ /* Check if already loaded */
+ if (modfind(u->u_required_modules.d[i]) != -1)
+ continue;
+ if (kldload(u->u_required_modules.d[i]) < 0 &&
+ errno != EEXIST) {
+ log_warn("%s: kldload(%s): %s", u->u_name,
+ u->u_required_modules.d[i],
+ strerror(errno));
+ return (-1);
+ }
+ log_info("%s: loaded module %s", u->u_name,
+ u->u_required_modules.d[i]);
+ }
+ return (0);
+}
+
+/*
+ * Run a hook command (precmd/postcmd) synchronously.
+ *
+ * If the command starts with "lua:", the remainder is evaluated as Lua
+ * code in rcd's embedded interpreter. Otherwise, the command is run
+ * via posix_spawn("/bin/sh", "-c", cmd).
+ *
+ * Returns 0 on success, -1 on failure.
+ */
+int
+proc_run_hook_inst(const char *cmd, const struct unit *u)
+{
+ posix_spawnattr_t attr;
+ pid_t pid;
+ int status, error;
+ char *argv[4];
+ const char *instance;
+
+ if (cmd == NULL || cmd[0] == '\0')
+ return (0);
+
+ instance = (u != NULL) ? u->u_instance : NULL;
+
+ /* Dispatch to the embedded Lua interpreter */
+ if (IS_LUA_HOOK(cmd))
+ return (lua_exec(cmd + strlen(LUA_HOOK_PREFIX), "hook", u));
+
+ /* Set RCD_INSTANCE for shell hooks (if template instance) */
+ if (instance != NULL)
+ setenv("RCD_INSTANCE", instance, 1);
+
+ /* Shell hook */
+ argv[0] = __DECONST(char *, _PATH_BSHELL);
+ argv[1] = __DECONST(char *, "-c");
+ argv[2] = __DECONST(char *, cmd);
+ argv[3] = NULL;
+
+ posix_spawnattr_init(&attr);
+ error = posix_spawn(&pid, _PATH_BSHELL, NULL, &attr, argv, environ);
+ posix_spawnattr_destroy(&attr);
+
+ if (instance != NULL)
+ unsetenv("RCD_INSTANCE");
+
+ if (error != 0) {
+ log_warn("hook posix_spawn: %s", strerror(error));
+ return (-1);
+ }
+
+ if (xwaitpid(pid, &status, 0) < 0)
+ return (-1);
+
+ return (WIFEXITED(status) ? WEXITSTATUS(status) : -1);
+}
+
+int
+proc_run_hook(const char *cmd)
+{
+
+ return (proc_run_hook_inst(cmd, NULL));
+}
+
+/*
+ * Run a shell hook and capture its stdout/stderr.
+ *
+ * Like proc_run_hook_inst, but redirects the child's stdout and stderr
+ * into a buffer. On success (exit code 0), *output is set to NULL.
+ * On failure, *output is set to a malloc'd string containing the
+ * captured output (caller must free). Returns 0 on success, -1 on
+ * failure (with *output set to NULL).
+ */
+int
+proc_run_hook_capture(const char *cmd, char **output)
+{
+ posix_spawnattr_t attr;
+ posix_spawn_file_actions_t fa;
+ pid_t pid;
+ int status, error, pipefd[2];
+ char *argv[4];
+ char buf[4096];
+ size_t total;
+
+ if (cmd == NULL || cmd[0] == '\0') {
+ *output = NULL;
+ return (0);
+ }
+
+ *output = NULL;
+
+ /* Lua hooks: run inline, no output capture */
+ if (IS_LUA_HOOK(cmd))
+ return (lua_exec(cmd + strlen(LUA_HOOK_PREFIX), "hook", NULL));
+
+ /* Create pipe for stdout/stderr capture */
+ if (pipe2(pipefd, O_CLOEXEC) != 0) {
+ log_warn("hook capture pipe: %s", strerror(errno));
+ return (-1);
+ }
+
+ /* Shell hook */
+ argv[0] = __DECONST(char *, _PATH_BSHELL);
+ argv[1] = __DECONST(char *, "-c");
+ argv[2] = __DECONST(char *, cmd);
+ argv[3] = NULL;
+
+ posix_spawn_file_actions_init(&fa);
+ posix_spawn_file_actions_adddup2(&fa, pipefd[1], STDOUT_FILENO);
+ posix_spawn_file_actions_adddup2(&fa, pipefd[1], STDERR_FILENO);
+
+ posix_spawnattr_init(&attr);
+ error = posix_spawn(&pid, _PATH_BSHELL, &fa, &attr, argv, environ);
+ posix_spawnattr_destroy(&attr);
+ posix_spawn_file_actions_destroy(&fa);
+
+ /* Close write end in parent */
+ close(pipefd[1]);
+
+ if (error != 0) {
+ log_warn("hook capture posix_spawn: %s", strerror(error));
+ close(pipefd[0]);
+ return (-1);
+ }
+
+ /* Read captured output */
+ total = 0;
+ for (;;) {
+ ssize_t n;
+
+ n = read(pipefd[0], buf, sizeof(buf));
+ if (n < 0) {
+ if (errno == EINTR)
+ continue;
+ break;
+ }
+ if (n == 0)
+ break;
+
+ char *newp = realloc(*output, total + n + 1);
+ if (newp == NULL) {
+ free(*output);
+ *output = NULL;
+ close(pipefd[0]);
+ /* Child still running; wait and discard */
+ xwaitpid(pid, &status, 0);
+ return (-1);
+ }
+ *output = newp;
+ memcpy(*output + total, buf, n);
+ total += n;
+ }
+ close(pipefd[0]);
+
+ if (xwaitpid(pid, &status, 0) < 0) {
+ free(*output);
+ *output = NULL;
+ return (-1);
+ }
+
+ if (WIFEXITED(status) && WEXITSTATUS(status) == 0) {
+ /* Success: discard captured output */
+ free(*output);
+ *output = NULL;
+ return (0);
+ }
+
+ /* Failure: trim trailing whitespace from captured output */
+ if (*output != NULL) {
+ while (total > 0 && ((*output)[total - 1] == '\n' ||
+ (*output)[total - 1] == '\r' ||
+ (*output)[total - 1] == ' '))
+ total--;
+ (*output)[total] = '\0';
+ }
+
+ return (WIFEXITED(status) ? WEXITSTATUS(status) : -1);
+}
+
+/*
+ * Free a NULL-terminated string array (environment from build_environ).
+ */
+static void
+free_argv(char **av)
+{
+
+ if (av == NULL)
+ return;
+ for (int i = 0; av[i] != NULL; i++)
+ free(av[i]);
+ free(av);
+}
+
+/*
+ * Tokenize a string into a charv_t, splitting on whitespace.
+ * Respects single and double quotes. Inside double quotes,
+ * backslash escapes are recognized: \" → ", \\ → \.
+ * Single quotes are fully literal (no escapes).
+ * The caller owns the resulting strings and must free them
+ * with vec_free_and_free.
+ */
+void
+tokenize(const char *str, charv_t *out)
+{
+ char *buf, *p, *start;
+ char quote;
+
+ buf = xstrdup(str);
+ p = buf;
+ while (*p != '\0') {
+ while (*p == ' ' || *p == '\t')
+ p++;
+ if (*p == '\0')
+ break;
+
+ if (*p == '\'' || *p == '"') {
+ quote = *p++;
+ start = p;
+
+ if (quote == '"') {
+ /*
+ * Double quotes: handle backslash escapes.
+ * Use separate read (r) and write (w) pointers
+ * to compact out escape backslashes in place.
+ */
+ char *r, *w;
+
+ r = p;
+ w = p;
+ while (*r != '\0' && *r != '"') {
+ if (*r == '\\' &&
+ (*(r + 1) == '"' ||
+ *(r + 1) == '\\'))
+ r++; /* skip backslash */
+ *w++ = *r++;
+ }
+ *w = '\0';
+ p = r;
+ if (*p == '"')
+ p++;
+ } else {
+ /* Single quotes: fully literal, no escapes */
+ while (*p != '\0' && *p != '\'')
+ p++;
+ if (*p != '\0')
+ p++;
+ }
+
+ vec_push(out, xstrdup(start));
+ } else {
+ start = p;
+ while (*p != '\0' && *p != ' ' && *p != '\t')
+ p++;
+
+ if (*p != '\0')
+ *p++ = '\0';
+
+ vec_push(out, xstrdup(start));
+ }
+ }
+
+ free(buf);
+}
+
+/*
+ * Build the environment for rcd-exec. This includes:
+ * - Unit's declared environment variables
+ * - RCD_* control variables for rcd-exec's pre-exec setup
+ * - LISTEN_FDS / LISTEN_FDNAMES for socket activation
+ */
+/*
+ * Push a formatted environment variable into the env vec.
+ */
+static void __printflike(2, 3)
+env_push(charv_t *ev, const char *fmt, ...)
+{
+ va_list ap;
+ char *s;
+
+ va_start(ap, fmt);
+ if (vasprintf(&s, fmt, ap) < 0)
+ abort();
+ va_end(ap);
+ vec_push(ev, s);
+}
+
+static char **
+build_environ(struct unit *u, int notify_fd, int listen_fds)
+{
+ struct kv *ue;
+ charv_t ev = vec_init();
+
+ /* Unit environment */
+ STAILQ_FOREACH(ue, &u->u_env, kv_entries)
+ env_push(&ev, "%s=%s", ue->kv_key, ue->kv_val);
+
+ /* Credentials for rcd-exec */
+ if (u->u_proc.pc_user != NULL)
+ env_push(&ev, "RCD_USER=%s", u->u_proc.pc_user);
+ if (u->u_proc.pc_group != NULL)
+ env_push(&ev, "RCD_GROUP=%s", u->u_proc.pc_group);
+ if (u->u_proc.pc_chdir != NULL)
+ env_push(&ev, "RCD_CHDIR=%s", u->u_proc.pc_chdir);
+
+ env_push(&ev, "RCD_UMASK=%o", u->u_proc.pc_umask);
+
+ if (u->u_proc.pc_nice != 0)
+ env_push(&ev, "RCD_NICE=%d", u->u_proc.pc_nice);
+ if (u->u_proc.pc_cpuset != NULL)
+ env_push(&ev, "RCD_CPUSET=%s", u->u_proc.pc_cpuset);
+ if (u->u_proc.pc_fib != 0)
+ env_push(&ev, "RCD_FIB=%d", u->u_proc.pc_fib);
+ if (u->u_proc.pc_chroot != NULL)
+ env_push(&ev, "RCD_CHROOT=%s", u->u_proc.pc_chroot);
+ if (u->u_proc.pc_login_class != NULL)
+ env_push(&ev, "RCD_LOGIN_CLASS=%s", u->u_proc.pc_login_class);
+ if (u->u_proc.pc_limits != NULL)
+ env_push(&ev, "RCD_LIMITS=%s", u->u_proc.pc_limits);
+ if (u->u_proc.pc_env_file != NULL)
+ env_push(&ev, "RCD_ENV_FILE=%s", u->u_proc.pc_env_file);
+
+ /* Template instance name */
+ if (u->u_instance != NULL)
+ env_push(&ev, "RCD_INSTANCE=%s", u->u_instance);
+
+ /* Supplementary groups as comma-separated list */
+ if (u->u_proc.pc_groups.len > 0) {
+ char *gbuf;
+ size_t gbuf_sz;
+ int off;
+
+ gbuf_sz = 0;
+ vec_foreach(u->u_proc.pc_groups, gi)
+ gbuf_sz += strlen(u->u_proc.pc_groups.d[gi]) + 1;
+ gbuf = xmalloc(gbuf_sz + 1);
+ off = 0;
+ vec_foreach(u->u_proc.pc_groups, gi) {
+ if (gi > 0)
+ gbuf[off++] = ',';
+ off += snprintf(gbuf + off, gbuf_sz + 1 - off,
+ "%s", u->u_proc.pc_groups.d[gi]);
+ }
+ env_push(&ev, "RCD_GROUPS=%s", gbuf);
+ free(gbuf);
+ }
+
+ /* Jail name for rcd-exec to attach to */
+ if (u->u_jail.jc_enable && u->u_jail.jc_jid > 0)
+ env_push(&ev, "RCD_JAIL=%s",
+ u->u_jail.jc_name != NULL ? u->u_jail.jc_name : "");
+
+ /* Readiness notification fd */
+ if (notify_fd >= 0)
+ env_push(&ev, "RCD_NOTIFY_FD=%d", notify_fd);
+
+ /* Socket activation */
+ if (listen_fds > 0) {
+ env_push(&ev, "LISTEN_FDS=%d", listen_fds);
+ env_push(&ev, "LISTEN_PID=0");
+ }
+
+ /* Sub-reaper mode for forking daemons */
+ if (u->u_type == UNIT_FORKING || u->u_type == UNIT_LEGACY_FORKING) {
+ env_push(&ev, "RCD_REAPER=1");
+ env_push(&ev, "RCD_SERVICE=%s", u->u_name);
+ }
+
+ /* NULL-terminate for execve */
+ vec_push(&ev, NULL);
+ return (ev.d);
+}
+
+/*
+ * Spawn a service using posix_spawn(3).
+ */
+int
+proc_spawn(struct rcd_ctx *ctx, struct unit *u)
+{
+ posix_spawnattr_t attr;
+ posix_spawn_file_actions_t fa;
+ sigset_t sigmask, sigdefault;
+ c_charv_t argv = vec_init(); /* Borrowed pointers, not owned */
+ charv_t argtoks = vec_init(); /* Owned: tokenized command_args */
+ char **envp;
+ pid_t pid;
+ int procdesc_fd;
+ int notify_fd, notify_efd;
+ int listen_fds;
+ int error;
+
+ procdesc_fd = -1;
+ notify_efd = -1;
+ listen_fds = 0;
+
+ /* Barriers are pure synchronisation points — no process to run */
+ if (u->u_type == UNIT_BARRIER) {
+ boottrace("rcd: barrier %s", u->u_name);
+ log_info("barrier %s reached", u->u_name);
+ u->u_state = STATE_DONE;
+ return (0);
+ }
+
+ /* Check preconditions */
+ if (proc_check_preconditions(u) != 0)
+ return (-1);
+
+ /* Load required kernel modules */
+ if (proc_load_modules(u) != 0)
+ return (-1);
+
+ /* Start delay — sleep before launching */
+ if (u->u_start_delay_ms > 0) {
+ struct timespec ts;
+
+ ts.tv_sec = u->u_start_delay_ms / 1000;
+ ts.tv_nsec = (u->u_start_delay_ms % 1000) * 1000000L;
+ log_info("%s: delaying start by %u ms", u->u_name,
+ u->u_start_delay_ms);
+ while (nanosleep(&ts, &ts) < 0 && errno == EINTR)
+ ;
+ }
+
+ /* Run setup_cmd hook (before precmd, like rc's _setup) */
+ if (u->u_setup_cmd != NULL) {
+ if (proc_run_hook_inst(u->u_setup_cmd, u) != 0) {
+ log_warn("%s: setup_cmd failed", u->u_name);
+ return (-1);
+ }
+ }
+
+ /* Run start_precmd hook */
+ if (u->u_start_precmd != NULL) {
+ if (proc_run_hook_inst(u->u_start_precmd, u) != 0) {
+ log_warn("%s: start_precmd failed", u->u_name);
+ return (-1);
+ }
+ }
+
+ /*
+ * Inline exec for oneshots: if the unit has an "exec" field,
+ * run it directly (lua: or shell) instead of spawning a process.
+ * This avoids needing a command binary for script-driven oneshots.
+ */
+ if (u->u_exec != NULL) {
+ int rc;
+
+ boottrace("rcd: exec %s", u->u_name);
+ rc = proc_run_hook_inst(u->u_exec, u);
+ if (u->u_start_postcmd != NULL)
+ proc_run_hook_inst(u->u_start_postcmd, u);
+ u->u_state = (rc == 0) ? STATE_DONE : STATE_FAILED;
+ log_info("%s: exec %s", u->u_name,
+ rc == 0 ? "succeeded" : "failed");
+ return (rc);
+ }
+
+ /* Create jail if needed (before spawn, so rcd-exec can attach) */
+ if (u->u_jail.jc_enable) {
+ if (jail_svc_create(u) != 0) {
+ log_warn("%s: jail creation failed", u->u_name);
+ return (-1);
+ }
+ }
+
+ /* Readiness notification fd (for READY_FD and READY_SOCKET) */
+ notify_fd = -1;
+ if (u->u_ready_method == READY_FD ||
+ u->u_ready_method == READY_SOCKET) {
+ notify_efd = eventfd(0, EFD_NONBLOCK);
+ if (notify_efd < 0) {
+ log_warn("%s: eventfd: %s", u->u_name,
+ strerror(errno));
+ return (-1);
+ }
+ notify_fd = notify_efd;
+ }
+
+ /*
+ * Build argv. All types go through rcd-exec now:
+ *
+ * simple: rcd-exec command [args...]
+ * forking: rcd-exec command [args...] (with RCD_REAPER)
+ * legacy: rcd-exec /bin/sh script start (with RCD_REAPER)
+ *
+ * The reaper mode makes rcd-exec become a sub-reaper that
+ * tracks the daemon after the initial process (shell or
+ * forking parent) exits.
+ */
+ vec_push(&argv, RCD_EXEC_PATH);
+ if (u->u_type == UNIT_LEGACY || u->u_type == UNIT_LEGACY_FORKING) {
+ vec_push(&argv, _PATH_BSHELL);
+ vec_push(&argv, u->u_path);
+ vec_push(&argv, "start");
+ } else {
+ if (u->u_command_prepend != NULL) {
+ tokenize(u->u_command_prepend, &argtoks);
+ vec_foreach(argtoks, pi)
+ vec_push(&argv, argtoks.d[pi]);
+ }
+ vec_push(&argv, u->u_command);
+ if (u->u_command_args != NULL) {
+ tokenize(u->u_command_args, &argtoks);
+ vec_foreach(argtoks, ti)
+ vec_push(&argv, argtoks.d[ti]);
+ }
+ if (u->u_instance != NULL)
+ vec_push(&argv, u->u_instance);
+ }
+ vec_push(&argv, NULL);
+
+ /* Set up file actions first — sockact_setup_fds sets listen_fds */
+ posix_spawn_file_actions_init(&fa);
+ log_setup_fds(u, &fa);
+ sockact_setup_fds(u, &fa, &listen_fds);
+ if (u->u_proc.pc_chdir != NULL)
+ posix_spawn_file_actions_addchdir_np(&fa, u->u_proc.pc_chdir);
+
+ /*
+ * Pass notification fd to child if needed.
+ * Place it right after the last listen socket fd so that
+ * closefrom doesn't close it. The child (rcd-exec) will
+ * write to it after pre-exec setup to signal readiness.
+ */
+ if (notify_efd >= 0) {
+ int notify_target = LISTEN_FD_START + listen_fds;
+
+ posix_spawn_file_actions_adddup2(&fa, notify_efd,
+ notify_target);
+ posix_spawn_file_actions_addclose(&fa, notify_efd);
+ notify_fd = notify_target;
+ listen_fds++; /* keep closefrom past the notify fd */
+ }
+
+ /*
+ * Close inherited fds (procdesc fds from other services,
+ * kqueue fd, control socket, etc.). The dup2 actions above
+ * place needed fds at low numbers (0-2 for stdio, 3+ for
+ * sockets + notification fd). Close everything from
+ * LISTEN_FD_START + listen_fds.
+ */
+ posix_spawn_file_actions_addclosefrom_np(&fa,
+ LISTEN_FD_START + listen_fds);
+
+ /* Now build environment with the correct listen_fds count */
+ envp = build_environ(u, notify_fd, listen_fds);
+ if (envp == NULL) {
+ posix_spawn_file_actions_destroy(&fa);
+ vec_free(&argv);
+ vec_free_and_free(&argtoks, free);
+ if (notify_efd >= 0) {
+ close(notify_efd);
+ notify_efd = -1;
+ }
+ return (-1);
+ }
+
+ /* Initialize posix_spawn attributes */
+ posix_spawnattr_init(&attr);
+ posix_spawnattr_setprocdescp_np(&attr, &procdesc_fd, PD_CLOEXEC);
+
+ sigemptyset(&sigmask);
+ posix_spawnattr_setsigmask(&attr, &sigmask);
+ sigfillset(&sigdefault);
+ posix_spawnattr_setsigdefault(&attr, &sigdefault);
+ posix_spawnattr_setflags(&attr,
+ POSIX_SPAWN_SETSIGMASK | POSIX_SPAWN_SETSIGDEF);
+
+ /* Spawn — all types go through rcd-exec now */
+ error = posix_spawn(&pid, RCD_EXEC_PATH, &fa, &attr,
+ __DECONST(char **, argv.d), envp);
+
+ posix_spawn_file_actions_destroy(&fa);
+ posix_spawnattr_destroy(&attr);
+ vec_free(&argv);
+ vec_free_and_free(&argtoks, free);
+ free_argv(envp);
+
+ /* Close syslog pipe write-ends now that the child inherited them */
+ if (u->u_log.lc_stdout_wfd >= 0) {
+ close(u->u_log.lc_stdout_wfd);
+ u->u_log.lc_stdout_wfd = -1;
+ }
+ if (u->u_log.lc_stderr_wfd >= 0) {
+ close(u->u_log.lc_stderr_wfd);
+ u->u_log.lc_stderr_wfd = -1;
+ }
+
+ if (error != 0) {
+ log_warn("%s: posix_spawn: %s", u->u_name, strerror(error));
+ if (notify_efd >= 0) {
+ close(notify_efd);
+ notify_efd = -1;
+ }
+ u->u_notify_fd = -1;
+ return (-1);
+ }
+
+ u->u_pid = pid;
+ u->u_procdesc_fd = procdesc_fd;
+ clock_gettime(CLOCK_MONOTONIC, &u->u_last_start);
+
+ /* Monitor the process descriptor via kqueue */
+ {
+ struct kevent kev;
+
+ EV_SET(&kev, procdesc_fd, EVFILT_PROCDESC, EV_ADD | EV_ONESHOT,
+ NOTE_EXIT, 0, u);
+ if (kevent(ctx->ctx_kq, &kev, 1, NULL, 0, NULL) < 0) {
+ log_warn("%s: kevent EVFILT_PROCDESC: %s",
+ u->u_name, strerror(errno));
+ }
+ }
+
+ /* Monitor readiness notification if using fd method */
+ if (notify_efd >= 0) {
+ struct kevent kev;
+
+ u->u_notify_fd = notify_efd;
+ EV_SET(&kev, notify_efd, EVFILT_READ, EV_ADD | EV_ONESHOT,
+ 0, 0, u);
+ if (kevent(ctx->ctx_kq, &kev, 1, NULL, 0, NULL) < 0)
+ log_warn("%s: kevent readiness fd: %s",
+ u->u_name, strerror(errno));
+ }
+
+ /* Register syslog pipe fds in kqueue for draining */
+ log_register_pipe_fds(u, ctx->ctx_kq);
+
+ /* Apply rctl rules */
+ rctl_apply(u);
+
+ /* OOM protection via procctl(PROC_SPROTECT) */
+ if (u->u_proc.pc_oom_protect) {
+ int flags = PPROT_SET | PPROT_DESCEND | PPROT_INHERIT;
+
+ if (procctl(P_PID, pid, PROC_SPROTECT, &flags) != 0)
+ log_warn("%s: PROC_SPROTECT: %s", u->u_name,
+ strerror(errno));
+ }
+
+ boottrace("rcd: started %s", u->u_name);
+ log_info("started %s (pid %d, procdesc fd %d)",
+ u->u_name, pid, procdesc_fd);
+
+ /*
+ * simple/forking: mark running immediately (procdesc
+ * tracks the daemon or the sub-reaper).
+ * legacy (oneshot): stay STATE_STARTING until the shell
+ * exits and procdesc event gives the exit status.
+ */
+ if (u->u_type == UNIT_SIMPLE || u->u_type == UNIT_FORKING ||
+ u->u_type == UNIT_LEGACY_FORKING)
+ u->u_state = STATE_RUNNING;
+
+ /* Run start_postcmd hook */
+ if (u->u_start_postcmd != NULL)
+ proc_run_hook_inst(u->u_start_postcmd, u);
+
+ return (0);
+}
+
+/*
+ * Send a signal via process descriptor, retrying on EINTR.
+ * Returns 0 on success, -1 on error (ESRCH is not an error).
+ */
+static int
+pdkill_retry(int fd, int sig)
+{
+ int ret;
+
+ do {
+ ret = pdkill(fd, sig);
+ } while (ret < 0 && errno == EINTR);
+
+ if (ret < 0 && errno != ESRCH)
+ return (-1);
+ return (0);
+}
+
+/*
+ * Stop a running service.
+ *
+ * In the normal (async) case used during the kqueue event loop, sends
+ * SIGTERM and sets a watchdog timer. If the process doesn't exit in
+ * time, the timer fires and proc_kill_subtree SIGKILL's everything.
+ *
+ * During shutdown, proc_stop_sync() can be used for a blocking wait
+ * using pdwait(2) on the process descriptor.
+ */
+int
+proc_stop(struct rcd_ctx *ctx, struct unit *u)
+{
+ struct kevent kev;
+
+ if (u->u_state != STATE_RUNNING && u->u_state != STATE_STARTING &&
+ u->u_state != STATE_DONE)
+ return (0);
+
+ u->u_state = STATE_STOPPING;
+
+ /* Run stop_precmd hook */
+ if (u->u_stop_precmd != NULL)
+ proc_run_hook_inst(u->u_stop_precmd, u);
+
+ /*
+ * If the unit has an explicit stop_command, run it instead of
+ * sending a signal. This covers oneshots (apm -e disable) and
+ * services with custom teardown logic.
+ */
+ if (u->u_stop_command != NULL) {
+ int rc = proc_run_hook_inst(u->u_stop_command, u);
+
+ if (u->u_stop_postcmd != NULL)
+ proc_run_hook_inst(u->u_stop_postcmd, u);
+ u->u_state = (rc == 0) ? STATE_INACTIVE : STATE_FAILED;
+ return (rc);
+ }
+
+ /* No running process to signal (e.g., done oneshot without stop_command) */
+ if (u->u_procdesc_fd < 0) {
+ u->u_state = STATE_INACTIVE;
+ return (0);
+ }
+
+ /* Send stop signal via pdkill(2) using the process descriptor */
+ if (pdkill_retry(u->u_procdesc_fd, u->u_sig_stop) != 0)
+ log_warn("%s: pdkill(%d): %s", u->u_name,
+ u->u_sig_stop, strerror(errno));
+
+ /* Set a watchdog timer; if the process doesn't exit in time, SIGKILL */
+ EV_SET(&kev, (uintptr_t)u, EVFILT_TIMER, EV_ADD | EV_ONESHOT,
+ NOTE_MSECONDS, ctx->ctx_config.cfg_stop_timeout_ms, u);
+ if (kevent(ctx->ctx_kq, &kev, 1, NULL, 0, NULL) < 0)
+ log_warn("%s: kevent stop timer: %s",
+ u->u_name, strerror(errno));
+
+ return (0);
+}
+
+/*
+ * Synchronous stop with timeout using kqueue + pdwait.
+ * Waits up to stop_timeout_ms for the process to exit after SIGTERM,
+ * then force-kills via PROC_REAP_KILL if it doesn't.
+ */
+int
+proc_stop_sync(struct rcd_ctx *ctx, struct unit *u)
+{
+ struct kevent kev;
+ struct timespec ts;
+ int kq, status, nev;
+
+ /*
+ * For units with no running process (e.g., legacy scripts in
+ * STATE_DONE or oneshots with stop_command), run the stop
+ * command directly without trying to signal a process.
+ */
+ if (u->u_procdesc_fd < 0) {
+ if (u->u_stop_command != NULL) {
+ if (u->u_stop_precmd != NULL)
+ proc_run_hook_inst(u->u_stop_precmd, u);
+ proc_run_hook_inst(u->u_stop_command, u);
+ if (u->u_stop_postcmd != NULL)
+ proc_run_hook_inst(u->u_stop_postcmd, u);
+ u->u_state = STATE_INACTIVE;
+ } else if (u->u_type == UNIT_LEGACY ||
+ u->u_type == UNIT_LEGACY_FORKING) {
+ char hook[PATH_MAX];
+
+ snprintf(hook, sizeof(hook), "%s %s %s",
+ _PATH_BSHELL, u->u_path, "stop");
+ proc_run_hook(hook);
+ u->u_state = STATE_INACTIVE;
+ }
+ return (0);
+ }
+
+ /*
+ * Remove any pending EVFILT_PROCDESC from the main kqueue
+ * before we wait on the temporary one. This prevents a stale
+ * event from firing in the main loop after we restart the unit.
+ */
+ EV_SET(&kev, u->u_procdesc_fd, EVFILT_PROCDESC, EV_DELETE, 0, 0,
+ NULL);
+ if (kevent(ctx->ctx_kq, &kev, 1, NULL, 0, NULL) < 0 &&
+ errno != ENOENT)
+ log_warn("%s: kevent delete procdesc: %s", u->u_name,
+ strerror(errno));
+
+ /* Send stop signal */
+ pdkill_retry(u->u_procdesc_fd, u->u_sig_stop);
+
+ /* Use a temporary kqueue to wait with timeout */
+ kq = kqueue();
+ if (kq < 0) {
+ log_warn("%s: kqueue: %s, falling back to polling wait",
+ u->u_name, strerror(errno));
+ /* Fallback: force-kill and poll until dead */
+ proc_kill_subtree(u);
+ for (int tries = 0; tries < 100; tries++) {
+ if (pdwait(u->u_procdesc_fd, &status,
+ WNOHANG, NULL, NULL) > 0)
+ break;
+ usleep(10000); /* 10ms, up to 1s total */
+ }
+ goto cleanup;
+ }
+
+ EV_SET(&kev, u->u_procdesc_fd, EVFILT_PROCDESC, EV_ADD | EV_ONESHOT,
+ NOTE_EXIT, 0, NULL);
+ kevent(kq, &kev, 1, NULL, 0, NULL);
+
+ ts.tv_sec = ctx->ctx_config.cfg_stop_timeout_ms / 1000;
+ ts.tv_nsec = (ctx->ctx_config.cfg_stop_timeout_ms % 1000) * 1000000;
+
+ nev = kevent(kq, NULL, 0, &kev, 1, &ts);
+ close(kq);
+
+ if (nev > 0) {
+ /* Process exited within timeout, reap it */
+ pdwait(u->u_procdesc_fd, &status, WNOHANG, NULL, NULL);
+ goto cleanup;
+ }
+
+ log_warn("%s: stop timeout, killing", u->u_name);
+ proc_kill_subtree(u);
+ /* Wait synchronously after force-kill */
+ while (pdwait(u->u_procdesc_fd, &status, 0, NULL, NULL) < 0 &&
+ errno == EINTR)
+ ;
+
+cleanup:
+ rctl_remove(u);
+ if (u->u_jail.jc_enable)
+ jail_svc_destroy(u);
+ if (u->u_log.lc_stdout_pipefd >= 0) {
+ close(u->u_log.lc_stdout_pipefd);
+ u->u_log.lc_stdout_pipefd = -1;
+ }
+ if (u->u_log.lc_stderr_pipefd >= 0) {
+ close(u->u_log.lc_stderr_pipefd);
+ u->u_log.lc_stderr_pipefd = -1;
+ }
+ close(u->u_procdesc_fd);
+ u->u_procdesc_fd = -1;
+ u->u_pid = -1;
+ u->u_state = STATE_INACTIVE;
+
+ return (0);
+}
+
+/*
+ * Send a reload signal (SIGHUP) to a running service.
+ */
+int
+proc_reload(struct rcd_ctx *ctx __unused, struct unit *u)
+{
+
+ if (u->u_state != STATE_RUNNING && u->u_state != STATE_DONE)
+ return (-1);
+
+ if (u->u_procdesc_fd >= 0) {
+ if (pdkill_retry(u->u_procdesc_fd, u->u_sig_reload) != 0) {
+ log_warn("%s: pdkill(%d): %s", u->u_name,
+ u->u_sig_reload, strerror(errno));
+ return (-1);
+ }
+ } else if (u->u_type == UNIT_LEGACY ||
+ u->u_type == UNIT_LEGACY_FORKING) {
+ char hook[PATH_MAX];
+
+ snprintf(hook, sizeof(hook), "%s %s %s",
+ _PATH_BSHELL, u->u_path, "reload");
+ return (proc_run_hook(hook));
+ } else
+ return (-1);
+ return (0);
+}
+
+/*
+ * Kill all processes in a service's reaper subtree using PROC_REAP_KILL.
+ */
+int
+proc_kill_subtree(struct unit *u)
+{
+ struct procctl_reaper_kill rk;
+ int error;
+
+ memset(&rk, 0, sizeof(rk));
+ rk.rk_sig = SIGKILL;
+ rk.rk_flags = REAPER_KILL_SUBTREE;
+ rk.rk_subtree = u->u_pid;
+
+ error = procctl(P_PID, getpid(), PROC_REAP_KILL, &rk);
+ if (error != 0 && errno != ESRCH) {
+ log_warn("%s: PROC_REAP_KILL: %s (killed %u)",
+ u->u_name, strerror(errno), rk.rk_killed);
+ return (-1);
+ }
+
+ if (rk.rk_killed > 0)
+ log_info("%s: killed %u remaining processes",
+ u->u_name, rk.rk_killed);
+
+ return (0);
+}
+
+/*
+ * Handle a service process exit.
+ */
+void
+proc_handle_exit(struct rcd_ctx *ctx, struct unit *u, int status)
+{
+ bool should_restart;
+
+ boottrace("rcd: stopped %s", u->u_name);
+ log_info("%s exited (status %d, pid %d)",
+ u->u_name, status, u->u_pid);
+
+ /* Run stop_postcmd hook */
+ if (u->u_stop_postcmd != NULL)
+ proc_run_hook_inst(u->u_stop_postcmd, u);
+
+ /*
+ * Kill remaining descendants. For forking/legacy services
+ * in reaper mode, the rcd-exec sub-reaper already killed
+ * its subtree before exiting, so this is a no-op.
+ */
+ proc_kill_subtree(u);
+
+ /* Clean up rctl rules */
+ rctl_remove(u);
+
+ /* Clean up jail */
+ if (u->u_jail.jc_enable)
+ jail_svc_destroy(u);
+
+ /* Clean up readiness notification fd */
+ if (u->u_notify_fd >= 0) {
+ close(u->u_notify_fd);
+ u->u_notify_fd = -1;
+ }
+
+ /* Flush and close syslog pipe fds */
+ log_flush_pipes(u);
+
+ /*
+ * Reap the zombie. EVFILT_PROCDESC delivers the exit status
+ * but does not consume the wait record. Without an explicit
+ * pdwait the zombie persists even after the procdesc fd is
+ * closed, because SA_NOCLDWAIT may not have been active when
+ * the process exited (e.g. during boot, before the event loop
+ * sets signal(SIGCHLD, SIG_IGN)).
+ */
+ if (u->u_procdesc_fd >= 0)
+ xpdwait(u->u_procdesc_fd, NULL, WNOHANG);
+
+ /* Close process descriptor */
+ if (u->u_procdesc_fd >= 0) {
+ close(u->u_procdesc_fd);
+ u->u_procdesc_fd = -1;
+ }
+ u->u_pid = -1;
+ u->u_exit_status = status;
+
+ /*
+ * Drain any children reparented from the subreaper (rcd-exec).
+ * When a legacy/forking service exits, rcd-exec's children
+ * (the daemon and any workers) may have died before or at the
+ * same time as rcd-exec. If reaper_kill_all() in rcd-exec
+ * didn't fully reap them (race), they become zombies under rcd
+ * and need an explicit drain.
+ */
+ while (waitpid(-1, NULL, WNOHANG) > 0)
+ ;
+
+ /* Evaluate restart policy */
+ should_restart = false;
+ switch (u->u_restart.rc_policy) {
+ case RESTART_ALWAYS:
+ should_restart = true;
+ break;
+ case RESTART_ON_FAILURE:
+ should_restart = (status != 0);
+ break;
+ case RESTART_NEVER:
+ break;
+ }
+
+ /* During shutdown, never restart */
+ if (ctx->ctx_shutting_down)
+ should_restart = false;
+
+ /* Oneshot services don't restart */
+ if (u->u_type == UNIT_ONESHOT) {
+ should_restart = false;
+ if (status == 0)
+ u->u_state = STATE_DONE;
+ else
+ u->u_state = STATE_FAILED;
+ return;
+ }
+
+ /*
+ * Forking and legacy services use the rcd-exec sub-reaper.
+ * The reaper stays alive while the daemon runs, and exits
+ * when the daemon exits (forwarding its exit status).
+ * So this exit event means the daemon is gone.
+ */
+ if (u->u_type == UNIT_LEGACY || u->u_type == UNIT_LEGACY_FORKING ||
+ u->u_type == UNIT_FORKING) {
+ u->u_state = (status == 0) ? STATE_DONE : STATE_FAILED;
+ return;
+ }
+
+ if (!should_restart) {
+ u->u_state = (status == 0) ? STATE_DONE : STATE_FAILED;
+ return;
+ }
+
+ /* Check retry limit */
+ u->u_retry_count++;
+ if (u->u_restart.rc_max_retries > 0 &&
+ u->u_retry_count > u->u_restart.rc_max_retries) {
+ log_warn("%s: max retries (%u) exceeded",
+ u->u_name, u->u_restart.rc_max_retries);
+ u->u_state = STATE_FAILED;
+ return;
+ }
+
+ /* Schedule restart with delay */
+ {
+ struct kevent kev;
+ unsigned int delay;
+
+ delay = u->u_restart.rc_delay_ms;
+
+ /* Apply backoff, capped to 1 hour */
+ switch (u->u_restart.rc_backoff) {
+ case BACKOFF_EXPONENTIAL:
+ {
+ unsigned int shift;
+
+ shift = u->u_retry_count - 1;
+ if (shift > 20)
+ shift = 20;
+ delay *= (1u << shift);
+ }
+ break;
+ case BACKOFF_LINEAR:
+ delay *= u->u_retry_count;
+ break;
+ case BACKOFF_NONE:
+ break;
+ }
+
+ /* Cap restart delay to 1 hour */
+ if (delay > 3600000)
+ delay = 3600000;
+
+ u->u_state = STATE_WAITING;
+ log_info("%s: scheduling restart in %u ms (attempt %u)",
+ u->u_name, delay, u->u_retry_count);
+
+ EV_SET(&kev, (uintptr_t)u, EVFILT_TIMER,
+ EV_ADD | EV_ONESHOT, NOTE_MSECONDS, delay, u);
+ if (kevent(ctx->ctx_kq, &kev, 1, NULL, 0, NULL) < 0)
+ log_warn("%s: kevent restart timer: %s",
+ u->u_name, strerror(errno));
+ }
+}
diff --git a/sbin/rcd/rcd-exec.8 b/sbin/rcd/rcd-exec.8
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/rcd-exec.8
@@ -0,0 +1,169 @@
+.\" Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+.\"
+.\" SPDX-License-Identifier: BSD-2-Clause
+.\"
+.Dd April 30, 2026
+.Dt RCD-EXEC 8
+.Os
+.Sh NAME
+.Nm rcd-exec
+.Nd pre-exec setup helper for rcd
+.Sh SYNOPSIS
+.Nm
+.Ar command
+.Op Ar args ...
+.Sh DESCRIPTION
+.Nm
+is a helper binary spawned by
+.Xr rcd 8
+via
+.Xr posix_spawn 3
+to perform privileged setup operations that cannot be expressed as
+.Xr posix_spawn 3
+file actions.
+After applying the setup,
+.Nm
+.Xr execvp 3 Ns s
+the actual service command.
+.Pp
+.Nm
+reads its configuration from environment variables set by
+.Xr rcd 8 :
+.Bl -tag -width "RCD_LOGIN_CLASS"
+.It Ev RCD_USER
+User to run the service as
+.Pq Xr setuid 2 .
+.It Ev RCD_GROUP
+Primary group
+.Pq Xr setgid 2 .
+.It Ev RCD_GROUPS
+Comma-separated supplementary groups
+.Pq Xr setgroups 2 .
+.It Ev RCD_UMASK
+File creation mask (octal).
+.It Ev RCD_NICE
+Process nice level
+.Pq Xr setpriority 2 .
+.It Ev RCD_CPUSET
+CPU affinity specification
+.Pq e.g., Dq 0-3,8 .
+.It Ev RCD_FIB
+Routing table number, validated against
+.Va net.fibs
+.Pq Xr setfib 2 .
+.It Ev RCD_CHROOT
+Chroot directory
+.Pq Xr chroot 2 .
+.It Ev RCD_CHDIR
+Working directory
+.Pq Xr chdir 2 .
+.It Ev RCD_JAIL
+Jail name to attach to
+.Pq Xr jail_attach 2 .
+.It Ev RCD_LOGIN_CLASS
+Login class for resource limits
+.Pq Xr setusercontext 3 .
+.It Ev RCD_ENV_FILE
+File with additional environment variables (must be root-owned).
+Dangerous variables
+.Pq Ev LD_PRELOAD , Ev LD_LIBRARY_PATH , Ev IFS
+are rejected.
+.It Ev RCD_REAPER
+When set,
+.Nm
+enters sub-reaper mode instead of directly exec'ing the command.
+See
+.Sx REAPER MODE
+below.
+.It Ev RCD_INSTANCE
+Template instance name (for template units).
+.El
+.Pp
+Setup is applied in this order:
+.Bl -enum -compact
+.It
+Load environment file
+.It
+Set umask
+.It
+Set nice level
+.It
+Set CPU affinity
+.It
+Set FIB
+.It
+Attach to jail
+.It
+Chroot
+.It
+Change directory
+.It
+Drop credentials (login class, supplementary groups, setuid)
+.El
+.Pp
+All
+.Ev RCD_*
+variables are removed from the environment before exec.
+.Sh REAPER MODE
+When
+.Ev RCD_REAPER
+is set,
+.Nm
+becomes a sub-reaper for forking daemons and legacy rc.d scripts.
+This is used for services of type
+.Dq forking
+and
+.Dq legacy-forking .
+.Pp
+In reaper mode,
+.Nm
+performs the following:
+.Bl -enum
+.It
+Acquires sub-reaper status via
+.Xr procctl 2
+.Dv PROC_REAP_ACQUIRE .
+.It
+Forks a child process that applies the normal setup
+.Pq see above
+and execs the actual service command.
+.It
+Waits for the initial child to exit.
+For daemons that double-fork,
+the real daemon process is reparented to
+.Nm
+.Pq as the sub-reaper .
+.It
+Finds the daemon PID using
+.Dv PROC_REAP_GETPIDS .
+No pidfiles are needed.
+.It
+Monitors the daemon until it exits.
+.El
+.Pp
+Signal handling in reaper mode:
+.Bl -tag -width SIGTERM
+.It Dv SIGTERM
+Kills the entire process subtree via
+.Dv PROC_REAP_KILL
+and exits.
+.It Other signals
+Forwarded to the daemon process
+.Pq e.g., Dv SIGHUP No for reload .
+.El
+.Pp
+.Xr rcd 8
+tracks the
+.Nm
+sub-reaper via its process descriptor.
+The sub-reaper tracks the daemon via the reaper mechanism.
+This provides reliable supervision of forking daemons without
+pidfile races.
+.Sh SEE ALSO
+.Xr jail 2 ,
+.Xr procctl 2 ,
+.Xr setuid 2 ,
+.Xr posix_spawn 3 ,
+.Xr rcd 8
+.Sh AUTHORS
+.An Baptiste Daroussin Aq Mt bapt@FreeBSD.org
diff --git a/sbin/rcd/rcd-exec.c b/sbin/rcd/rcd-exec.c
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/rcd-exec.c
@@ -0,0 +1,868 @@
+/*
+ * Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+/*
+ * rcd-exec — Pre-exec setup helper for rcd(8).
+ *
+ * Spawned by rcd via posix_spawn(3). Reads setup instructions from
+ * environment variables (set by rcd), applies them, and exec's the
+ * real service command.
+ *
+ * This helper exists to be able to express optioations like setuid,
+ * setgid, jail_attach, rlimits, cpuset, or umask.
+ * By having rcd posix_spawn this helper (getting a process descriptor),
+ * the helper then does the privileged setup and exec's the service.
+ * The process descriptor continues to track the process tree.
+ *
+ * Environment variables read:
+ * RCD_USER - setuid target
+ * RCD_GROUP - setgid target
+ * RCD_UMASK - umask (octal)
+ * RCD_NICE - nice level
+ * RCD_CPUSET - cpuset specification
+ * RCD_JAIL - jail name to attach to
+ * RCD_NOTIFY_FD - readiness notification fd
+ * RCD_LIMITS - resource limits (e.g. "openfiles=1024,stacksize=8388608")
+ * RCD_CHDIR - working directory (backup if not set via file_actions)
+ *
+ * argv[1..] is the service command and arguments.
+ */
+
+#include <sys/param.h>
+#include <sys/cpuset.h>
+#include <sys/jail.h>
+#include <sys/procctl.h>
+#include <sys/resource.h>
+#include <sys/socket.h>
+#include <sys/stat.h>
+#include <sys/sysctl.h>
+#include <sys/wait.h>
+
+#include <err.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <grp.h>
+#include <jail.h>
+#include <login_cap.h>
+#include <pwd.h>
+#include <signal.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+static void
+apply_umask(void)
+{
+ const char *val, *errstr;
+ mode_t mask;
+
+ val = getenv("RCD_UMASK");
+ if (val == NULL)
+ return;
+
+ mask = (mode_t)strtonum(val, 0, 0777, &errstr);
+ if (errstr != NULL) {
+ warnx("invalid RCD_UMASK: %s (%s)", val, errstr);
+ return;
+ }
+ umask(mask);
+}
+
+static void
+apply_nice(void)
+{
+ const char *val, *errstr;
+ int prio;
+
+ val = getenv("RCD_NICE");
+ if (val == NULL)
+ return;
+
+ prio = (int)strtonum(val, -20, 20, &errstr);
+ if (errstr != NULL) {
+ warnx("invalid RCD_NICE: %s (%s)", val, errstr);
+ return;
+ }
+ if (setpriority(PRIO_PROCESS, 0, prio) != 0)
+ warn("setpriority(%d)", prio);
+}
+
+/*
+ * Parse a cpuset specification string (e.g., "0-3,8,12-15") into a
+ * cpuset_t bitmask. Supports individual CPUs and ranges.
+ */
+static int
+parse_cpuset(const char *spec, cpuset_t *mask)
+{
+ const char *p, *errstr;
+ long lo, hi, i;
+
+ CPU_ZERO(mask);
+ p = spec;
+
+ while (*p != '\0') {
+ /* Skip whitespace */
+ while (*p == ' ' || *p == '\t')
+ p++;
+ if (*p == '\0')
+ break;
+
+ lo = strtonum(p, 0, CPU_SETSIZE - 1, &errstr);
+ if (errstr != NULL)
+ return (-1);
+ /* Advance past the number */
+ while (*p >= '0' && *p <= '9')
+ p++;
+
+ if (*p == '-') {
+ p++;
+ hi = strtonum(p, lo, CPU_SETSIZE - 1, &errstr);
+ if (errstr != NULL)
+ return (-1);
+ /* Advance past the number */
+ while (*p >= '0' && *p <= '9')
+ p++;
+ } else {
+ hi = lo;
+ }
+
+ for (i = lo; i <= hi; i++)
+ CPU_SET(i, mask);
+
+ if (*p == ',')
+ p++;
+ }
+
+ return (0);
+}
+
+static void
+apply_cpuset(void)
+{
+ const char *val;
+ cpuset_t mask;
+
+ val = getenv("RCD_CPUSET");
+ if (val == NULL)
+ return;
+
+ if (parse_cpuset(val, &mask) != 0) {
+ warnx("invalid cpuset specification: %s", val);
+ return;
+ }
+
+ if (cpuset_setaffinity(CPU_LEVEL_WHICH, CPU_WHICH_PID, -1,
+ sizeof(mask), &mask) != 0)
+ warn("cpuset_setaffinity");
+}
+
+/*
+ * Set the FIB (routing table) for this process.
+ * Validates against the kernel's net.fibs sysctl.
+ */
+static void
+apply_fib(void)
+{
+ const char *val, *errstr;
+ int fib, maxfibs;
+ size_t len;
+
+ val = getenv("RCD_FIB");
+ if (val == NULL)
+ return;
+
+ /* Query the number of FIBs supported by the running kernel */
+ maxfibs = 1;
+ len = sizeof(maxfibs);
+ sysctlbyname("net.fibs", &maxfibs, &len, NULL, 0);
+
+ fib = (int)strtonum(val, 0, maxfibs - 1, &errstr);
+ if (errstr != NULL) {
+ warnx("invalid RCD_FIB: %s (%s, max %d)", val, errstr,
+ maxfibs - 1);
+ return;
+ }
+ if (setfib(fib) != 0)
+ warn("setfib(%d)", fib);
+}
+
+static rlim_t
+parse_rlim(const char *s)
+{
+ const char *errstr;
+ long long val;
+
+ if (strcasecmp(s, "unlimited") == 0 ||
+ strcasecmp(s, "infinity") == 0 ||
+ strcmp(s, "-1") == 0)
+ return (RLIM_INFINITY);
+ val = strtonum(s, 0, LLONG_MAX, &errstr);
+ if (errstr != NULL) {
+ warnx("invalid rlimit value: %s (%s)", s, errstr);
+ return (RLIM_INFINITY);
+ }
+ return ((rlim_t)val);
+}
+
+/*
+ * Apply resource limits from RCD_LIMITS.
+ * Format: comma-separated "resource=soft:hard" pairs.
+ * Example: "stacksize=8388608:16777216,openfiles=1024:4096"
+ * Values of -1 or "unlimited" mean RLIM_INFINITY.
+ */
+static void
+apply_limits(void)
+{
+ static const struct {
+ const char *name;
+ int resource;
+ } rlmap[] = {
+ { "cputime", RLIMIT_CPU },
+ { "filesize", RLIMIT_FSIZE },
+ { "datasize", RLIMIT_DATA },
+ { "stacksize", RLIMIT_STACK },
+ { "coredumpsize", RLIMIT_CORE },
+ { "memoryuse", RLIMIT_RSS },
+ { "memorylocked", RLIMIT_MEMLOCK },
+ { "maxprocesses", RLIMIT_NPROC },
+ { "openfiles", RLIMIT_NOFILE },
+ { "sbsize", RLIMIT_SBSIZE },
+ { "vmemoryuse", RLIMIT_VMEM },
+ { "npts", RLIMIT_NPTS },
+ { "swapuse", RLIMIT_SWAP },
+ { "kqueues", RLIMIT_KQUEUES },
+ { "umtxp", RLIMIT_UMTXP },
+ };
+ const char *val;
+ char *buf, *pair, *eq, *colon;
+ struct rlimit rl;
+ size_t i;
+ int res;
+
+ val = getenv("RCD_LIMITS");
+ if (val == NULL)
+ return;
+
+ buf = strdup(val);
+ if (buf == NULL)
+ return;
+
+ for (pair = strtok(buf, ","); pair != NULL;
+ pair = strtok(NULL, ",")) {
+ eq = strchr(pair, '=');
+ if (eq == NULL)
+ continue;
+ *eq = '\0';
+
+ /* Look up the resource name */
+ res = -1;
+ for (i = 0; i < nitems(rlmap); i++) {
+ if (strcmp(pair, rlmap[i].name) == 0) {
+ res = rlmap[i].resource;
+ break;
+ }
+ }
+ if (res < 0) {
+ warnx("unknown rlimit resource: %s", pair);
+ continue;
+ }
+
+ /* Parse soft:hard — "unlimited" or "-1" mean RLIM_INFINITY */
+ colon = strchr(eq + 1, ':');
+ if (colon == NULL) {
+ rl.rlim_cur = parse_rlim(eq + 1);
+ rl.rlim_max = rl.rlim_cur;
+ } else {
+ *colon = '\0';
+ rl.rlim_cur = parse_rlim(eq + 1);
+ rl.rlim_max = parse_rlim(colon + 1);
+ }
+
+ if (setrlimit(res, &rl) != 0)
+ warn("setrlimit(%s)", pair);
+ }
+
+ free(buf);
+}
+
+/*
+ * Apply chroot before credential drop.
+ */
+static void
+apply_chroot(void)
+{
+ const char *val;
+
+ val = getenv("RCD_CHROOT");
+ if (val == NULL)
+ return;
+
+ if (chroot(val) != 0)
+ err(1, "chroot(%s)", val);
+ if (chdir("/") != 0)
+ err(1, "chdir(/) after chroot");
+}
+
+/*
+ * Load environment variables from a file.
+ * Format: one VAR=value per line, comments with #, blank lines ignored.
+ * Security: only opens files owned by root and not symlinks.
+ * Refuses to set LD_* or other dangerous variables.
+ */
+static const char *dangerous_env_prefixes[] = {
+ "LD_", "LIBPATH", "RCD_", "IFS", NULL
+};
+
+static bool
+is_dangerous_envvar(const char *name)
+{
+ int i;
+
+ for (i = 0; dangerous_env_prefixes[i] != NULL; i++) {
+ if (strncmp(name, dangerous_env_prefixes[i],
+ strlen(dangerous_env_prefixes[i])) == 0)
+ return (true);
+ }
+ return (false);
+}
+
+static void
+apply_env_file(void)
+{
+ const char *path;
+ FILE *fp;
+ struct stat sb;
+ char *line, *eq, *name, *val, *end;
+ size_t linecap;
+ ssize_t linelen;
+ int fd;
+
+ path = getenv("RCD_ENV_FILE");
+ if (path == NULL)
+ return;
+
+ /* Open with O_NOFOLLOW to prevent symlink attacks */
+ fd = open(path, O_RDONLY | O_NOFOLLOW);
+ if (fd < 0) {
+ warn("env_file: %s", path);
+ return;
+ }
+
+ /* Verify ownership is root */
+ if (fstat(fd, &sb) != 0 || sb.st_uid != 0) {
+ warnx("env_file: %s: not owned by root, skipping", path);
+ close(fd);
+ return;
+ }
+
+ fp = fdopen(fd, "r");
+ if (fp == NULL) {
+ close(fd);
+ return;
+ }
+
+ line = NULL;
+ linecap = 0;
+ while ((linelen = getline(&line, &linecap, fp)) > 0) {
+ /* Strip trailing newline/carriage return */
+ while (linelen > 0 &&
+ (line[linelen - 1] == '\n' || line[linelen - 1] == '\r'))
+ line[--linelen] = '\0';
+
+ /* Skip comments and blank lines */
+ name = line;
+ while (*name == ' ' || *name == '\t')
+ name++;
+ if (*name == '#' || *name == '\0')
+ continue;
+
+ eq = strchr(name, '=');
+ if (eq == NULL)
+ continue;
+ *eq = '\0';
+ val = eq + 1;
+
+ /* Reject dangerous environment variables */
+ if (is_dangerous_envvar(name)) {
+ warnx("env_file: refusing to set %s", name);
+ continue;
+ }
+
+ /* Strip quotes around value */
+ if ((*val == '"' || *val == '\'') && strlen(val) >= 2) {
+ char q = *val;
+ val++;
+ end = val + strlen(val) - 1;
+ if (*end == q)
+ *end = '\0';
+ }
+
+ setenv(name, val, 1);
+ }
+
+ free(line);
+ fclose(fp);
+}
+
+static void
+apply_jail(void)
+{
+ const char *name;
+ int jid;
+
+ name = getenv("RCD_JAIL");
+ if (name == NULL || name[0] == '\0')
+ return;
+
+ jid = jail_getid(name);
+ if (jid < 0)
+ errx(1, "jail not found: %s", name);
+
+ if (jail_attach(jid) != 0)
+ err(1, "jail_attach(%s)", name);
+}
+
+static void
+apply_credentials(void)
+{
+ const char *user, *group, *groups_str, *login_class;
+ struct passwd *pw;
+ struct group *gr;
+ gid_t gid, supp_gids[NGROUPS_MAX];
+ int nsupp;
+ char *buf, *tok;
+
+ group = getenv("RCD_GROUP");
+ user = getenv("RCD_USER");
+ groups_str = getenv("RCD_GROUPS");
+ login_class = getenv("RCD_LOGIN_CLASS");
+
+ /* Primary group */
+ if (group != NULL) {
+ gr = getgrnam(group);
+ if (gr == NULL)
+ errx(1, "unknown group: %s", group);
+ gid = gr->gr_gid;
+ if (setgid(gid) != 0)
+ err(1, "setgid(%s)", group);
+ }
+
+ if (user != NULL) {
+ pw = getpwnam(user);
+ if (pw == NULL)
+ errx(1, "unknown user: %s", user);
+
+ /* If no explicit group was set, drop to the user's gid */
+ if (group == NULL) {
+ if (setgid(pw->pw_gid) != 0)
+ err(1, "setgid(%d)", pw->pw_gid);
+ }
+
+ /*
+ * Apply login class before setuid if specified.
+ * setusercontext sets rlimits, umask, priority from
+ * login.conf.
+ */
+ if (login_class != NULL) {
+ login_cap_t *lc;
+
+ lc = login_getclass(login_class);
+ if (lc == NULL)
+ errx(1, "unknown login class: %s",
+ login_class);
+ if (setusercontext(lc, pw, pw->pw_uid,
+ LOGIN_SETRESOURCES | LOGIN_SETPRIORITY) != 0)
+ err(1, "setusercontext(%s)", login_class);
+ login_close(lc);
+ }
+
+ /* Supplementary groups */
+ if (groups_str != NULL) {
+ nsupp = 0;
+ /* Start with primary gid */
+ supp_gids[nsupp++] = pw->pw_gid;
+
+ buf = strdup(groups_str);
+ if (buf == NULL)
+ err(1, "strdup");
+ for (tok = strtok(buf, ","); tok != NULL;
+ tok = strtok(NULL, ",")) {
+ gr = getgrnam(tok);
+ if (gr == NULL) {
+ warnx("unknown group: %s", tok);
+ continue;
+ }
+ if (nsupp < NGROUPS_MAX)
+ supp_gids[nsupp++] = gr->gr_gid;
+ }
+ free(buf);
+ if (setgroups(nsupp, supp_gids) != 0)
+ err(1, "setgroups");
+ } else {
+ if (initgroups(pw->pw_name, pw->pw_gid) != 0)
+ err(1, "initgroups(%s)", user);
+ }
+
+ if (setuid(pw->pw_uid) != 0)
+ err(1, "setuid(%s)", user);
+ }
+}
+
+static void
+apply_chdir(void)
+{
+ const char *dir;
+
+ dir = getenv("RCD_CHDIR");
+ if (dir == NULL)
+ return;
+
+ if (chdir(dir) != 0)
+ warn("chdir(%s)", dir);
+}
+
+/*
+ * Sub-reaper mode for forking daemons and legacy scripts.
+ *
+ * When RCD_REAPER is set, rcd-exec becomes a sub-reaper for the
+ * service. It forks a child that execs the real command, then waits
+ * for the initial process to exit. Any daemon forked by the command
+ * is reparented to us (the sub-reaper). We then monitor the daemon
+ * until it exits.
+ *
+ * This gives rcd reliable supervision of forking daemons and legacy
+ * scripts without needing pidfiles. rcd tracks us via a process
+ * descriptor; we track the daemon via the reaper mechanism.
+ *
+ * Signals:
+ * SIGTERM → kill entire subtree (PROC_REAP_KILL) and exit
+ * Other → forward to the daemon process
+ */
+static volatile sig_atomic_t reaper_stop;
+static pid_t reaper_daemon_pid = -1;
+
+static void
+reaper_sighandler(int sig)
+{
+
+ if (sig == SIGTERM || sig == SIGINT)
+ reaper_stop = 1;
+ else if (reaper_daemon_pid > 0)
+ kill(reaper_daemon_pid, sig);
+}
+
+/*
+ * Find the daemon PID under our reaper.
+ * After the initial process (shell or forking parent) exits,
+ * the daemon is reparented to us. We find it via PROC_REAP_STATUS.
+ */
+static pid_t
+find_daemon_pid(void)
+{
+ struct procctl_reaper_status rs;
+ struct procctl_reaper_pids rp;
+ struct procctl_reaper_pidinfo *pids;
+ pid_t found;
+ unsigned int i;
+
+ if (procctl(P_PID, getpid(), PROC_REAP_STATUS, &rs) != 0)
+ return (-1);
+
+ if (rs.rs_children == 0)
+ return (-1);
+
+ pids = calloc(rs.rs_descendants, sizeof(*pids));
+ if (pids == NULL)
+ return (-1);
+
+ rp.rp_count = rs.rs_descendants;
+ rp.rp_pids = pids;
+ found = -1;
+
+ if (procctl(P_PID, getpid(), PROC_REAP_GETPIDS, &rp) == 0) {
+ for (i = 0; i < rp.rp_count; i++) {
+ if (pids[i].pi_flags & REAPER_PIDINFO_CHILD) {
+ found = pids[i].pi_pid;
+ break;
+ }
+ }
+ }
+
+ free(pids);
+ return (found);
+}
+
+static void
+reaper_kill_all(void)
+{
+ struct procctl_reaper_kill rk;
+
+ memset(&rk, 0, sizeof(rk));
+ rk.rk_sig = SIGKILL;
+ procctl(P_PID, getpid(), PROC_REAP_KILL, &rk);
+
+ /*
+ * Reap all children after killing them, otherwise they become
+ * zombies reparented to the nearest subreaper ancestor (rcd).
+ * Loop until ECHILD or we give up after 500ms total.
+ */
+ for (int tries = 0; tries < 10; tries++) {
+ while (waitpid(-1, NULL, WNOHANG) > 0)
+ ;
+ usleep(50000);
+ }
+}
+
+/*
+ * Signal readiness by writing to the notification fd (if set).
+ * The fd number comes from RCD_NOTIFY_FD in the environment.
+ * rcd is waiting on this eventfd before registering sockets.
+ */
+static void
+signal_readiness(void)
+{
+ const char *fdstr;
+ int fd;
+ uint64_t val = 1;
+
+ fdstr = getenv("RCD_NOTIFY_FD");
+ if (fdstr == NULL)
+ return;
+ fd = strtonum(fdstr, 0, 255, NULL);
+ if (fd <= 0)
+ return;
+ (void)write(fd, &val, sizeof(val));
+}
+
+static void
+cleanup_rcd_env(void)
+{
+
+ unsetenv("RCD_USER");
+ unsetenv("RCD_GROUP");
+ unsetenv("RCD_GROUPS");
+ unsetenv("RCD_UMASK");
+ unsetenv("RCD_NICE");
+ unsetenv("RCD_CPUSET");
+ unsetenv("RCD_FIB");
+ unsetenv("RCD_JAIL");
+ unsetenv("RCD_CHROOT");
+ unsetenv("RCD_LIMITS");
+ unsetenv("RCD_LOGIN_CLASS");
+ unsetenv("RCD_ENV_FILE");
+ unsetenv("RCD_NOTIFY_FD");
+ unsetenv("RCD_CHDIR");
+ unsetenv("RCD_REAPER");
+ unsetenv("RCD_SERVICE");
+ unsetenv("RCD_INSTANCE");
+}
+
+static int
+reaper_main(int argc __unused, char *argv[])
+{
+ struct sigaction sa;
+ pid_t child, wpid;
+ int status;
+
+ /* Become sub-reaper */
+ if (procctl(P_PID, getpid(), PROC_REAP_ACQUIRE, NULL) != 0)
+ err(1, "PROC_REAP_ACQUIRE");
+
+ /* Fork the actual command */
+ child = fork();
+ if (child < 0)
+ err(1, "fork");
+
+ if (child == 0) {
+ /*
+ * Grandchild: apply pre-exec setup and exec.
+ * For legacy scripts (no RCD_USER etc.), the apply
+ * functions are no-ops since the env vars aren't set.
+ */
+ apply_env_file();
+ apply_umask();
+ apply_nice();
+ apply_cpuset();
+ apply_fib();
+ apply_limits();
+ apply_jail();
+ apply_chroot();
+ apply_chdir();
+ apply_credentials();
+ signal_readiness();
+ cleanup_rcd_env();
+
+ execvp(argv[1], &argv[1]);
+ err(1, "exec %s", argv[1]);
+ }
+
+ /* Parent: sub-reaper supervisor */
+ memset(&sa, 0, sizeof(sa));
+ sigemptyset(&sa.sa_mask);
+ sa.sa_handler = reaper_sighandler;
+ sigaction(SIGTERM, &sa, NULL);
+ sigaction(SIGINT, &sa, NULL);
+ sigaction(SIGHUP, &sa, NULL);
+
+ /* Wait for the initial process (shell or forking parent) to exit */
+ while ((wpid = waitpid(child, &status, 0)) < 0) {
+ if (errno != EINTR)
+ break;
+ if (reaper_stop) {
+ reaper_kill_all();
+ return (0);
+ }
+ }
+
+ if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
+ /* Command failed — kill any orphans and exit */
+ reaper_kill_all();
+ /* Final drain — reap anything that died during the loop */
+ while (waitpid(-1, NULL, WNOHANG) > 0)
+ ;
+ return (WIFEXITED(status) ? WEXITSTATUS(status) : 1);
+ }
+
+ /*
+ * Find the daemon PID (reparented to us after the shell exited).
+ * Give a brief window for the reparenting to complete and for
+ * the daemon to finish its own daemonization (double fork).
+ */
+ {
+ int tries;
+
+ reaper_daemon_pid = -1;
+ for (tries = 0; tries < 20; tries++) {
+ reaper_daemon_pid = find_daemon_pid();
+ if (reaper_daemon_pid > 0)
+ break;
+ usleep(50000); /* 50ms, up to 1s total */
+ }
+ }
+
+ if (reaper_daemon_pid <= 0) {
+ /*
+ * No daemon found. The command may have forked short-lived
+ * processes that already exited. Reap any remaining children
+ * so they do not become zombies under rcd.
+ */
+ while (waitpid(-1, NULL, WNOHANG) > 0)
+ ;
+ return (0);
+ }
+
+ /* Set process title to show what we're supervising */
+ {
+ const char *svcname;
+
+ svcname = getenv("RCD_SERVICE");
+ if (svcname != NULL)
+ setproctitle("reaper: %s (pid %d)", svcname,
+ reaper_daemon_pid);
+ else
+ setproctitle("reaper: pid %d",
+ reaper_daemon_pid);
+ }
+
+ /* Monitor the daemon until it exits or we get SIGTERM */
+ for (;;) {
+ wpid = waitpid(-1, &status, 0);
+ if (wpid == reaper_daemon_pid) {
+ /*
+ * The tracked PID exited. This might be a
+ * double-fork daemonization where the first child
+ * exits and the real daemon is a grandchild
+ * (now reparented to us). Check if there are
+ * remaining children before giving up.
+ */
+ pid_t new_daemon;
+
+ new_daemon = find_daemon_pid();
+ if (new_daemon > 0) {
+ reaper_daemon_pid = new_daemon;
+ continue;
+ }
+ /* No more children — daemon is truly gone */
+ return (WIFEXITED(status) ? WEXITSTATUS(status) : 1);
+ }
+ if (wpid < 0) {
+ if (errno == EINTR) {
+ if (reaper_stop) {
+ reaper_kill_all();
+ return (0);
+ }
+ continue;
+ }
+ /* No more children */
+ return (0);
+ }
+ /* Some other child exited (e.g. session), keep waiting */
+ }
+}
+
+int
+main(int argc, char *argv[])
+{
+
+ if (argc < 2) {
+ fprintf(stderr, "usage: rcd-exec command [args...]\n");
+ return (1);
+ }
+
+ /* Sub-reaper mode for forking daemons and legacy scripts */
+ if (getenv("RCD_REAPER") != NULL)
+ return (reaper_main(argc, argv));
+
+ /*
+ * Apply setup in the correct order:
+ * 1. env_file (load extra vars before anything else)
+ * 2. umask (before any file creation)
+ * 3. nice
+ * 4. cpuset
+ * 5. fib (routing table)
+ * 6. jail (before credential drop, since jail_attach requires root)
+ * 7. chroot (before credential drop)
+ * 8. chdir
+ * 9. credentials (last, since we lose privileges; includes
+ * login_class and supplementary groups)
+ */
+ apply_env_file();
+ apply_umask();
+ apply_nice();
+ apply_cpuset();
+ apply_fib();
+ apply_limits();
+ apply_jail();
+ apply_chroot();
+ apply_chdir();
+ apply_credentials();
+
+ /* Signal readiness after all setup is done */
+ signal_readiness();
+
+ /* Clean up RCD_* variables from the environment */
+ unsetenv("RCD_USER");
+ unsetenv("RCD_GROUP");
+ unsetenv("RCD_GROUPS");
+ unsetenv("RCD_UMASK");
+ unsetenv("RCD_NICE");
+ unsetenv("RCD_CPUSET");
+ unsetenv("RCD_FIB");
+ unsetenv("RCD_JAIL");
+ unsetenv("RCD_CHROOT");
+ unsetenv("RCD_LIMITS");
+ unsetenv("RCD_LOGIN_CLASS");
+ unsetenv("RCD_ENV_FILE");
+ unsetenv("RCD_NOTIFY_FD");
+ unsetenv("RCD_CHDIR");
+
+ /* exec the real service */
+ execvp(argv[1], &argv[1]);
+ err(1, "exec %s", argv[1]);
+}
diff --git a/sbin/rcd/rcd-lua.3 b/sbin/rcd/rcd-lua.3
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/rcd-lua.3
@@ -0,0 +1,445 @@
+.\" Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+.\"
+.\" SPDX-License-Identifier: BSD-2-Clause
+.\"
+.Dd April 29, 2026
+.Dt RCD-LUA 3
+.Os
+.Sh NAME
+.Nm rcd-lua
+.Nd Lua API for rcd service hooks
+.Sh DESCRIPTION
+.Xr rcd 8
+embeds a Lua interpreter for evaluating service hooks and oneshot
+.Cm exec
+blocks.
+Hook fields
+.Pq Cm start_precmd , start_postcmd , stop_precmd , stop_postcmd , exec , stop_command
+whose value begins with the prefix
+.Dq lua:
+are evaluated as Lua code in this interpreter.
+.Pp
+The Lua state is initialized once at daemon startup.
+It is configured identically to
+.Xr flua 1 :
+the standard Lua libraries are loaded, then the built-in modules
+.Cm posix ,
+.Cm ucl ,
+and
+.Cm rcd
+are registered.
+Module search paths point to
+.Pa /usr/share/flua/
+and
+.Pa /usr/lib/flua/
+for dynamic modules
+.Pq Cm lfs , jail , yaml , hash , etc. .
+.Pp
+Dynamic modules require
+.Pa /usr
+to be mounted and are therefore only available after the
+.Cm FILESYSTEMS
+barrier has been reached.
+The built-in modules are always available, including during early boot
+before any filesystem is mounted.
+.Ss Security
+.Bl -bullet -compact
+.It
+.Fn os.execute
+is replaced with a safe version using
+.Xr posix_spawn 3
+instead of
+.Xr system 3 .
+.It
+.Fn io.popen
+is removed entirely because
+.Xr popen 3
+is unsafe in a signal-modified context.
+.It
+.Fn rcd.exec_output
+and
+.Fn rcd.exec_stdin
+use
+.Xr posix_spawn 3
+with explicit pipe setup.
+.El
+.Ss Return value convention
+Hook functions that return
+.Dv false
+or
+.Dv nil
+cause the hook to be treated as a failure.
+Returning
+.Dv true
+or nothing indicates success.
+.Sh RCD MODULE
+The
+.Cm rcd
+module provides system operations that avoid
+.Xr fork 2
+overhead and are safe to use during early boot.
+.Bl -tag -width indent
+.It Fn rcd.sysctl "name" Op "value"
+Read or write a
+.Xr sysctl 3
+variable.
+.Pp
+In read mode
+.Pq one argument ,
+returns the sysctl value as a string, or
+.Dv nil
+and an error message on failure.
+.Pp
+In write mode
+.Pq two arguments ,
+sets the sysctl to
+.Fa value
+and returns
+.Dv true
+on success, or
+.Dv nil
+and an error message on failure.
+.Bd -literal -offset indent
+\-\- Read
+local hostname = rcd.sysctl("kern.hostname")
+
+\-\- Write
+rcd.sysctl("hw.usb.template", "\-1")
+.Ed
+.It Fn rcd.kenv "name"
+Read a kernel environment variable set by the boot loader.
+Returns the value as a string, or
+.Dv nil
+and an error message if the variable does not exist.
+.Bd -literal -offset indent
+local cloud = rcd.kenv("smbios.system.product")
+.Ed
+.It Fn rcd.kldload "module"
+Load a kernel module via
+.Xr kldload 2 .
+If the module is already loaded
+.Pq checked via Xr modfind 2 ,
+this is a no-op.
+Returns
+.Dv true
+on success, or
+.Dv nil
+and an error message on failure.
+.Bd -literal -offset indent
+rcd.kldload("if_bridge")
+.Ed
+.It Fn rcd.kldstat "module"
+Check if a kernel module is loaded via
+.Xr modfind 2 .
+Returns
+.Dv true
+if loaded,
+.Dv false
+otherwise.
+.Bd -literal -offset indent
+if not rcd.kldstat("ipfw") then
+ rcd.kldload("ipfw")
+end
+.Ed
+.It Fn rcd.exec_output "command"
+Run
+.Fa command
+via
+.Pa /bin/sh
+.Fl c
+and capture its standard output.
+Uses
+.Xr posix_spawn 3
+with a pipe.
+The trailing newline is stripped from the output.
+Returns the output as a string, or
+.Dv nil
+and an error message on failure.
+.Bd -literal -offset indent
+local ifaces = rcd.exec_output("ifconfig \-l")
+.Ed
+.It Fn rcd.exec_stdin "command" "data"
+Run
+.Fa command
+via
+.Pa /bin/sh
+.Fl c
+and feed
+.Fa data
+to its standard input.
+Uses
+.Xr posix_spawn 3
+with a pipe.
+Returns
+.Dv true
+on success
+.Pq exit code 0 ,
+or
+.Dv nil
+and
+.Dq command failed
+on failure.
+.Pp
+This is useful for feeding configuration to commands without
+temporary files:
+.Bd -literal -offset indent
+local rules = "add 100 allow ip from any to any\en"
+rcd.exec_stdin("ipfw \-q /dev/stdin", rules)
+.Ed
+.It Fn rcd.sleep "seconds"
+Sleep for the specified number of seconds using
+.Xr nanosleep 2 .
+Accepts fractional seconds.
+Does not fork a process.
+.Bd -literal -offset indent
+rcd.sleep(0.5) \-\- sleep 500 ms
+.Ed
+.It Fn rcd.symlink "target" "path"
+Create a symbolic link at
+.Fa path
+pointing to
+.Fa target .
+If
+.Fa path
+already exists, it is removed first.
+Returns
+.Dv true
+on success, or
+.Dv nil
+and an error message on failure.
+.Bd -literal -offset indent
+rcd.symlink("/dev/null", "/var/run/syslogd.pid")
+.Ed
+.It Fn rcd.log "level" "message"
+Log a message via
+.Xr syslog 3 .
+.Fa level
+is one of:
+.Dq info ,
+.Dq warn ,
+.Dq debug ,
+or
+.Dq err .
+.Bd -literal -offset indent
+rcd.log("info", "network interfaces configured")
+.Ed
+.It Va rcd.config
+A Lua table containing the service's global configuration from
+its override file
+.Pa /etc/rcd.conf.d/<service> .
+This table is populated before each hook execution.
+If there is no override file, this is an empty table.
+.Pp
+Example override file:
+.Bd -literal -offset indent
+\-\- /etc/rcd.conf.d/sshd
+enable = true;
+command_args = "\-o UseDNS=no";
+port = 2222;
+.Ed
+.Pp
+Accessed in Lua:
+.Bd -literal -offset indent
+local port = rcd.config.port or 22
+.Ed
+.It Va rcd.instance
+The template instance name as a string, or
+.Dv nil
+if the unit is not a template instance.
+.Pp
+For a unit
+.Dq dhclient@em0 ,
+.Va rcd.instance
+is
+.Dq em0 .
+.It Va rcd.instance_config
+A Lua table containing per-instance configuration from the
+.Cm instances
+block in the template's override file.
+Only populated for template instances; otherwise an empty table.
+.Pp
+Example override file for a geli template:
+.Bd -literal -offset indent
+\-\- /etc/rcd.conf.d/geli
+instances {
+ ada0p4 { flags = "\-p \-k /boot/keys/ada0p4.key"; }
+ ada1p4 { }
+}
+.Ed
+.Pp
+Accessed in Lua:
+.Bd -literal -offset indent
+local flags = rcd.instance_config.flags or ""
+.Ed
+.El
+.Sh POSIX MODULE
+The built-in
+.Cm posix
+module provides POSIX system call wrappers from the flua
+.Pa lposix.c
+implementation.
+The most commonly used functions in service hooks are:
+.Bl -tag -width indent
+.It Fn posix.chmod "path" "mode"
+Change file mode bits.
+.Fa mode
+is an integer
+.Pq e.g., 0755 .
+.Bd -literal -offset indent
+posix.chmod("/var/run/foo.sock", 0660)
+.Ed
+.It Fn posix.chown "path" "owner"
+Change file owner.
+.Fa owner
+is a string
+.Pq Dq "root:wheel" .
+.El
+.Pp
+The full list of functions in the
+.Cm posix
+module includes:
+.Fn fork ,
+.Fn exec ,
+.Fn pipe ,
+.Fn dup2 ,
+.Fn close ,
+.Fn open ,
+.Fn read ,
+.Fn write ,
+.Fn stat ,
+.Fn mkdir ,
+.Fn rmdir ,
+.Fn unlink ,
+.Fn rename ,
+.Fn chdir ,
+.Fn getcwd ,
+.Fn getpid ,
+and others.
+See the flua
+.Pa lposix.c
+source for the complete API.
+.Sh UCL MODULE
+The built-in
+.Cm ucl
+module provides a UCL parser for reading configuration files
+in Lua.
+It is the same module available in
+.Xr flua 1 .
+.Bl -tag -width indent
+.It Fn ucl.parser
+Create a new UCL parser object.
+.It Fn parser:parse_string "string"
+Parse a UCL string.
+.It Fn parser:get_object
+Return the parsed object as a Lua table.
+.El
+.Bd -literal -offset indent
+local p = ucl.parser()
+p:parse_string('key = "value"; num = 42;')
+local obj = p:get_object()
+print(obj.key) \-\- "value"
+print(obj.num) \-\- 42
+.Ed
+.Sh DYNAMIC MODULES
+After the
+.Cm FILESYSTEMS
+barrier is reached and
+.Pa /usr
+is mounted, the following dynamic Lua modules from
+.Pa /usr/lib/flua/
+become available:
+.Bl -tag -width indent -compact
+.It Cm lfs
+LuaFileSystem: directory iteration, file attributes.
+.It Cm jail
+Jail parameter manipulation.
+.It Cm yaml
+YAML parser.
+.It Cm hash
+Cryptographic hash functions (sha256, etc.).
+.El
+.Pp
+These modules are loaded on demand via
+.Fn require :
+.Bd -literal -offset indent
+local lfs = require("lfs")
+for entry in lfs.dir("/var/run") do
+ \-\- ...
+end
+.Ed
+.Sh EXAMPLES
+A oneshot unit using Lua to load a kernel module and configure
+a sysctl:
+.Bd -literal -offset indent
+name = "mysetup";
+type = "oneshot";
+provide = ["mysetup"];
+require = ["mountcritlocal"];
+
+exec = <<EOD
+lua:
+rcd.kldload("my_driver")
+rcd.sysctl("hw.my_driver.enable", "1")
+rcd.log("info", "my_driver configured")
+EOD;
+.Ed
+.Pp
+A start_precmd hook that checks a condition:
+.Bd -literal -offset indent
+start_precmd = <<EOD
+lua:
+local prod = rcd.kenv("smbios.system.product")
+if prod == nil then
+ rcd.log("warn", "cannot detect hardware")
+ return false
+end
+EOD;
+.Ed
+.Pp
+A oneshot that cleans a directory using lfs (requires FILESYSTEMS):
+.Bd -literal -offset indent
+name = "cleanvar";
+type = "oneshot";
+provide = ["cleanvar"];
+require = ["FILESYSTEMS", "var"];
+
+exec = <<EOD
+lua:
+local lfs = require("lfs")
+for f in lfs.dir("/var/run") do
+ if f:match("%.pid$") then
+ os.remove("/var/run/" .. f)
+ end
+end
+EOD;
+.Ed
+.Pp
+A template hook using per-instance configuration:
+.Bd -literal -offset indent
+\-\- In the geli template unit:
+start_precmd = <<EOD
+lua:
+local dev = rcd.instance
+local flags = rcd.instance_config.flags or ""
+os.execute("geli attach " .. flags .. " /dev/" .. dev)
+EOD;
+.Ed
+.Pp
+Writing data to a command's stdin:
+.Bd -literal -offset indent
+exec = <<EOD
+lua:
+local conf = io.open("/etc/pf.conf"):read("*a")
+rcd.exec_stdin("pfctl -f /dev/stdin", conf)
+EOD;
+.Ed
+.Sh SEE ALSO
+.Xr rcd 8 ,
+.Xr rcd.d 5 ,
+.Xr flua 1 ,
+.Xr sysctl 3 ,
+.Xr kenv 2 ,
+.Xr kldload 2 ,
+.Xr posix_spawn 3
+.Sh AUTHORS
+.An Baptiste Daroussin Aq Mt bapt@FreeBSD.org
diff --git a/sbin/rcd/rcd.h b/sbin/rcd/rcd.h
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/rcd.h
@@ -0,0 +1,554 @@
+/*-
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
+ * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+ * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+ * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
+ * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+ * SUCH DAMAGE.
+ */
+
+#ifndef _RCD_H_
+#define _RCD_H_
+
+#include <sys/types.h>
+#include <sys/event.h>
+#include <sys/queue.h>
+#include <sys/socket.h>
+#include <sys/ucred.h>
+#include <spawn.h>
+#include <stdbool.h>
+
+#include <ucl.h>
+
+#include <setjmp.h>
+
+/*
+ * Allocation error handling.
+ * Default is abort(); rcd overrides to longjmp for graceful recovery.
+ */
+extern jmp_buf rcd_oom_env;
+#define HASH_ALLOC_ERROR longjmp(rcd_oom_env, 1)
+
+#include "hash.h"
+#include "vec.h"
+
+#define XALLOC_ERROR longjmp(rcd_oom_env, 1)
+#include "xmalloc.h"
+
+#include "xio.h"
+
+/*
+ * Forward declarations.
+ */
+struct unit;
+struct depgraph;
+struct rcd_config;
+
+/*
+ * Service types.
+ */
+enum unit_type {
+ UNIT_SIMPLE, /* Foreground daemon, tracked directly */
+ UNIT_FORKING, /* Daemonizes itself (native) */
+ UNIT_ONESHOT, /* Run-to-completion */
+ UNIT_BARRIER, /* Dependency synchronisation point, no command */
+ UNIT_LEGACY, /* Wrapped rc.d oneshot script */
+ UNIT_LEGACY_FORKING /* Wrapped rc.d daemon (has pidfile/command) */
+};
+
+/*
+ * Readiness notification methods.
+ */
+enum ready_method {
+ READY_IMMEDIATE, /* Ready once process is started */
+ READY_FD, /* Service writes to a notification fd */
+ READY_EXIT, /* Parent process exits (forking type) */
+ READY_SOCKET /* Pre-bound socket is serving */
+};
+
+/*
+ * Restart policies.
+ */
+enum restart_policy {
+ RESTART_NEVER,
+ RESTART_ON_FAILURE,
+ RESTART_ALWAYS
+};
+
+/*
+ * Backoff strategies for restart delay.
+ */
+enum restart_backoff {
+ BACKOFF_NONE,
+ BACKOFF_LINEAR,
+ BACKOFF_EXPONENTIAL
+};
+
+/*
+ * Service states.
+ */
+enum unit_state {
+ STATE_INACTIVE, /* Not started */
+ STATE_STARTING, /* Start in progress */
+ STATE_RUNNING, /* Running and ready */
+ STATE_STOPPING, /* Stop in progress */
+ STATE_FAILED, /* Exited with failure */
+ STATE_DONE, /* Oneshot completed successfully */
+ STATE_WAITING /* Waiting for restart delay */
+};
+
+/*
+ * Socket types for socket activation.
+ */
+enum sock_type {
+ SOCK_ACT_STREAM,
+ SOCK_ACT_DGRAM,
+ SOCK_ACT_SEQPACKET
+};
+
+/*
+ * Socket address families.
+ */
+enum sock_family {
+ SOCK_FAM_TCP,
+ SOCK_FAM_TCP6,
+ SOCK_FAM_UDP,
+ SOCK_FAM_UDP6,
+ SOCK_FAM_UNIX
+};
+
+/*
+ * Socket activation definition.
+ */
+struct unit_socket {
+ char *us_name;
+ enum sock_type us_type;
+ enum sock_family us_family;
+ char *us_address; /* "addr:port" or path */
+ int us_backlog;
+ int us_fd; /* Bound socket fd, -1 if not yet */
+ mode_t us_permissions;/* For unix sockets */
+ char *us_owner;
+ char *us_group;
+ struct kevent us_kev; /* Deferred kevent for READY_SOCKET */
+ TAILQ_ENTRY(unit_socket) us_entries;
+};
+
+TAILQ_HEAD(unit_socket_list, unit_socket);
+
+/*
+ * Restart configuration.
+ */
+struct restart_conf {
+ enum restart_policy rc_policy;
+ enum restart_backoff rc_backoff;
+ unsigned int rc_max_retries;
+ unsigned int rc_delay_ms;
+ unsigned int rc_reset_ms;
+};
+
+/*
+ * Process configuration (credentials, rlimits, etc.).
+ */
+struct proc_conf {
+ char *pc_user;
+ char *pc_group;
+ charv_t pc_groups; /* Supplementary groups */
+ char *pc_chdir;
+ char *pc_chroot;
+ mode_t pc_umask;
+ int pc_nice;
+ char *pc_cpuset;
+ int pc_fib; /* Routing table (setfib) */
+ char *pc_login_class; /* Login class for setusercontext */
+ char *pc_limits; /* Resource limits string */
+ char *pc_env_file; /* File with extra env vars */
+ bool pc_oom_protect;
+};
+
+/*
+ * rctl rule as parsed from unit file.
+ */
+struct rctl_conf {
+ char *rc_resource; /* "memoryuse", "pcpu", etc. */
+ char *rc_action; /* "deny", "log", etc. */
+ char *rc_amount; /* "2g", "256", etc. */
+ STAILQ_ENTRY(rctl_conf) rc_entries;
+};
+
+STAILQ_HEAD(rctl_conf_list, rctl_conf);
+
+/*
+ * Active rctl rule (applied to the system, needs cleanup on stop).
+ */
+struct rctl_active {
+ char ra_rule[256];
+ STAILQ_ENTRY(rctl_active) ra_entries;
+};
+
+STAILQ_HEAD(rctl_active_list, rctl_active);
+
+/*
+ * Service jail configuration.
+ */
+struct jail_conf {
+ bool jc_enable;
+ char *jc_name; /* Auto-generated if NULL */
+ char *jc_path; /* Default: "/" */
+ charv_t jc_options;
+ charv_t jc_ip4addr;
+ charv_t jc_ip6addr;
+ bool jc_devfs;
+ int jc_jid; /* Assigned jail ID, 0 if none */
+};
+
+/*
+ * Logging configuration.
+ */
+struct log_conf {
+ char *lc_stdout; /* "syslog:facility.level" or "file:path" */
+ char *lc_stderr;
+ int lc_stdout_pipefd; /* Read-end for syslog pipe, -1 if none */
+ int lc_stdout_wfd; /* Write-end, closed after spawn */
+ int lc_stderr_pipefd;
+ int lc_stderr_wfd;
+ int lc_stdout_priority; /* syslog priority for stdout pipe */
+ int lc_stderr_priority;
+ char lc_stdout_resid[4096]; /* Partial line buffer for stdout pipe */
+ size_t lc_stdout_resid_len;
+ char lc_stderr_resid[4096]; /* Partial line buffer for stderr pipe */
+ size_t lc_stderr_resid_len;
+};
+
+/*
+ * Generic key-value pair. Used for environment variables,
+ * required_sysctl checks, and other name=value mappings.
+ */
+struct kv {
+ char *kv_key;
+ char *kv_val;
+ STAILQ_ENTRY(kv) kv_entries;
+};
+
+STAILQ_HEAD(kv_list, kv);
+
+/*
+ * Dependency link in the graph.
+ */
+struct dep_link {
+ struct unit *dl_unit;
+ STAILQ_ENTRY(dep_link) dl_entries;
+};
+
+STAILQ_HEAD(dep_list, dep_link);
+
+/*
+ * A provision name (a unit may provide multiple names).
+ */
+struct provision {
+ char *pv_name;
+ struct unit *pv_unit; /* Back-pointer */
+ STAILQ_ENTRY(provision) pv_entries;
+};
+
+STAILQ_HEAD(provision_list, provision);
+
+/*
+ * Per-service access control.
+ * Each charv_t contains a list of principals: "username" or "@groupname".
+ */
+struct unit_access {
+ charv_t ua_start;
+ charv_t ua_stop;
+ charv_t ua_restart;
+ charv_t ua_reload;
+ charv_t ua_status;
+};
+
+/*
+ * Service unit — the central data structure.
+ */
+struct unit {
+ /* Identity */
+ char *u_name;
+ char *u_description;
+ char *u_path; /* Path to unit file or rc.d script */
+ enum unit_type u_type;
+ enum unit_state u_state;
+
+ /* Command */
+ char *u_command;
+ char *u_command_args;
+ char *u_command_prepend; /* Prepended before command in argv */
+ char *u_exec; /* Inline exec for oneshots (lua: or shell) */
+ char *u_stop_command;
+ char *u_off_command; /* Run at boot if service is disabled */
+ int u_sig_stop; /* Signal for stop (default SIGTERM) */
+ int u_sig_reload; /* Signal for reload (default SIGHUP) */
+ unsigned int u_start_delay_ms; /* Delay before start (0 = none) */
+
+ /* Preconditions — checked before start */
+ charv_t u_required_dirs;
+ charv_t u_required_files;
+ charv_t u_required_modules;
+ charv_t u_required_vars; /* Config keys that must be set */
+ struct kv_list u_required_sysctl; /* sysctl name=value checks */
+
+ /* Hooks — commands run before/after start/stop */
+ char *u_setup_cmd; /* Run before precmd on start/restart/reload */
+ char *u_start_precmd;
+ char *u_start_postcmd;
+ char *u_stop_precmd;
+ char *u_stop_postcmd;
+
+ /* Extra commands: name → exec code (lua: or shell) */
+ struct kv_list u_commands;
+
+ /* Dependencies (string lists parsed from unit file) */
+ charv_t u_provide;
+ charv_t u_require;
+ charv_t u_before;
+ charv_t u_keyword;
+
+ /* Resolved dependency links (populated by depgraph) */
+ struct dep_list u_deps; /* Units we depend on */
+ struct dep_list u_rdeps; /* Units that depend on us */
+ int u_unmet; /* Count of unmet dependencies */
+
+ /* Readiness */
+ enum ready_method u_ready_method;
+
+ /* Process tracking */
+ pid_t u_pid;
+ int u_procdesc_fd; /* Process descriptor from posix_spawn */
+ int u_notify_fd; /* Readiness notification eventfd, -1 if unused */
+ int u_exit_status;
+
+ /* Restart */
+ struct restart_conf u_restart;
+ unsigned int u_retry_count;
+ struct timespec u_last_start;
+
+ /* Subsystem configs */
+ struct proc_conf u_proc;
+ struct rctl_conf_list u_rctl;
+ struct rctl_active_list u_rctl_active;
+ struct jail_conf u_jail;
+ struct log_conf u_log;
+ struct kv_list u_env;
+ struct unit_socket_list u_sockets;
+ struct unit_access u_access;
+
+ /* Flags */
+ bool u_enabled;
+ bool u_nostart; /* nostart keyword: skip at boot, allow manual */
+ bool u_boot_only; /* firstboot keyword */
+ bool u_nojail; /* nojail keyword */
+ bool u_nojailvnet; /* nojailvnet keyword */
+ bool u_resume; /* restart on resume from suspend */
+ bool u_has_rcvar; /* Legacy script has rcvar= */
+ bool u_template; /* This is a template, not a real unit */
+ char *u_instance; /* Instance name (NULL if not from template) */
+ char *u_instance_conf; /* UCL string of per-instance config */
+ char *u_override_conf; /* UCL string of global override config */
+ struct unit *u_template_ref; /* Back-pointer to template */
+
+ /* List linkage */
+ TAILQ_ENTRY(unit) u_entries;
+};
+
+TAILQ_HEAD(unit_list, unit);
+
+/*
+ * Global rcd configuration.
+ */
+struct rcd_config {
+ bool cfg_parallel;
+ unsigned int cfg_max_parallel;
+ char **cfg_unit_paths;
+ int cfg_nunit_paths;
+ char **cfg_legacy_paths;
+ int cfg_nlegacy_paths;
+ char **cfg_legacy_rc_conf;
+ int cfg_nlegacy_rc_conf;
+ hash_t *cfg_rcvars; /* Cached rc.conf variables */
+ char *cfg_control_socket;
+ mode_t cfg_control_perms;
+ char *cfg_control_group;
+ int cfg_log_level;
+ unsigned int cfg_stop_timeout_ms;
+ unsigned int cfg_shutdown_timeout_ms;
+ char *cfg_firstboot_sentinel;
+ bool cfg_precious_machine; /* Refuse shutdown */
+ bool cfg_quiet_boot; /* Suppress info logs during boot */
+ bool cfg_veriexec; /* Verify unit file integrity */
+ ucl_object_t *cfg_unit_schema; /* Loaded once, shared */
+};
+
+/*
+ * Dependency graph.
+ */
+struct depgraph {
+ struct unit_list dg_units;
+ unsigned int dg_nunits;
+ hash_t *dg_provisions;
+};
+
+/*
+ * Main daemon context.
+ */
+struct rcd_ctx {
+ struct rcd_config ctx_config;
+ struct depgraph ctx_graph;
+ int ctx_kq; /* kqueue fd */
+ int ctx_ctlsock; /* Control socket fd */
+ int ctx_ctlsock_pathfd; /* Vnode watch fd */
+ bool ctx_booting; /* Still in boot phase */
+ bool ctx_shutting_down;
+ bool ctx_jailed; /* Running inside a jail */
+ bool ctx_diskless; /* Diskless boot detected */
+ unsigned int ctx_running; /* Services currently starting */
+};
+
+/*
+ * Control protocol commands.
+ */
+enum ctl_command {
+ CTL_START,
+ CTL_STOP,
+ CTL_RESTART,
+ CTL_RELOAD,
+ CTL_ENABLE,
+ CTL_DISABLE,
+ CTL_STATUS,
+ CTL_RESOURCES,
+ CTL_DEPS,
+ CTL_LIST,
+ CTL_RELOAD_CONFIG
+};
+
+/* rcd.c */
+int rcd_main_loop(struct rcd_ctx *);
+void rcd_signal_ready(struct rcd_ctx *);
+
+/* unit.c */
+struct unit *unit_parse(const char *, struct rcd_config *);
+void unit_free(struct unit *);
+struct unit *unit_instantiate(struct unit *, const char *);
+int ucl_parse_mode(const ucl_object_t *, mode_t *);
+struct unit *unit_alloc(void);
+int unit_apply_overrides(struct unit *, const char *);
+bool valid_service_name(const char *);
+
+/* depgraph.c */
+int depgraph_init(struct depgraph *);
+int depgraph_add(struct depgraph *, struct unit *);
+int depgraph_resolve(struct depgraph *);
+int depgraph_check_cycles(struct depgraph *);
+struct unit *depgraph_find(struct depgraph *, const char *);
+void depgraph_ready_set(struct depgraph *, struct unit **, int *);
+void depgraph_mark_done(struct depgraph *, struct unit *);
+void depgraph_shutdown_order(struct depgraph *, struct unit **, int *);
+void depgraph_free(struct depgraph *);
+
+/* process.c */
+int proc_spawn(struct rcd_ctx *, struct unit *);
+int proc_stop(struct rcd_ctx *, struct unit *);
+int proc_stop_sync(struct rcd_ctx *, struct unit *);
+int proc_reload(struct rcd_ctx *, struct unit *);
+void proc_handle_exit(struct rcd_ctx *, struct unit *, int);
+int proc_kill_subtree(struct unit *);
+int proc_reaper_init(void);
+int proc_check_preconditions(struct unit *);
+int proc_load_modules(struct unit *);
+int proc_run_hook(const char *);
+int proc_run_hook_inst(const char *, const struct unit *);
+int proc_run_hook_capture(const char *, char **);
+void tokenize(const char *, charv_t *);
+
+/* sockact.c */
+int parse_listen_addr(const char *, struct sockaddr_storage *,
+ socklen_t *, int *, int *);
+int sockact_bind(struct unit_socket *);
+void sockact_register(struct rcd_ctx *, struct unit *);
+void sockact_register_deferred(struct unit *);
+void sockact_deferred_register_all(struct rcd_ctx *, struct unit *);
+void sockact_close(struct unit *);
+int sockact_setup_fds(struct unit *, posix_spawn_file_actions_t *,
+ int *);
+
+/* compat.c */
+int compat_scan(struct rcd_ctx *, const char *);
+int compat_parse_headers(const char *, struct unit *);
+int compat_load_rcvars(struct rcd_config *);
+bool compat_is_enabled(const char *, struct rcd_config *);
+
+
+/* control.c */
+int control_init(struct rcd_ctx *);
+void control_reinit(struct rcd_ctx *);
+void control_handle(struct rcd_ctx *, int);
+void control_close(struct rcd_ctx *);
+bool access_check(const struct unit *, const char *,
+ const struct xucred *);
+
+/* rctl_mgr.c */
+int rctl_apply(struct unit *);
+void rctl_remove(struct unit *);
+int rctl_get_usage(struct unit *, char *, size_t);
+bool rctl_available(void);
+
+/* jail_svc.c */
+int jail_svc_create(struct unit *);
+int jail_svc_destroy(struct unit *);
+
+/* log.c */
+void log_init(int);
+void log_set_verbose(bool);
+void log_console_open(void);
+void log_console_close(void);
+void log_console(const char *, ...) __printflike(1, 2);
+void log_console_set_enabled(bool);
+void log_info(const char *, ...) __printflike(1, 2);
+void log_warn(const char *, ...) __printflike(1, 2);
+void log_err(int, const char *, ...) __printflike(2, 3);
+void log_debug(const char *, ...) __printflike(1, 2);
+int log_setup_fds(struct unit *, posix_spawn_file_actions_t *);
+int log_register_pipe_fds(struct unit *, int);
+void log_handle_pipe_event(struct unit *, int);
+void log_flush_pipes(struct unit *);
+void boottrace(const char *, ...) __printflike(1, 2);
+
+/* enable.c */
+int enable_service(const char *, const char *);
+int disable_service(const char *, const char *);
+int delete_override(const char *);
+
+/* luaexec.c */
+void lua_init(void);
+int lua_exec(const char *, const char *, const struct unit *);
+void lua_fini(void);
+
+#define LUA_HOOK_PREFIX "lua:"
+#define IS_LUA_HOOK(s) ((s) != NULL && \
+ strncmp((s), LUA_HOOK_PREFIX, 4) == 0)
+
+#endif /* !_RCD_H_ */
diff --git a/sbin/rcd/rcd.8 b/sbin/rcd/rcd.8
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/rcd.8
@@ -0,0 +1,333 @@
+.\" Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+.\"
+.\" SPDX-License-Identifier: BSD-2-Clause
+.\"
+.Dd April 30, 2026
+.Dt RCD 8
+.Os
+.Sh NAME
+.Nm rcd
+.Nd FreeBSD service manager
+.Sh SYNOPSIS
+.Nm
+.Op Fl v
+.Sh DESCRIPTION
+.Nm
+is a service manager daemon.
+It reads service definitions from UCL unit files, builds a dependency
+graph, and starts services in parallel.
+After boot completes, it remains running as a supervisor daemon,
+automatically restarting failed services and accepting control commands
+via a UNIX domain socket.
+.Pp
+.Nm
+is called by
+.Xr init 8 .
+When all boot services have started, it forks to background
+.Pq the parent exits, signaling init to proceed to multi-user mode
+and continues as the supervision daemon.
+.Pp
+During boot,
+.Nm
+prints progress messages to the system console
+.Pa /dev/console :
+starting service names as they are launched, warnings for services
+that fail to start, and a final
+.Dq Boot complete.
+message.
+Console output can be suppressed by setting
+.Cm quiet_boot
+to
+.Cm true
+in
+.Xr rcd.conf 5 .
+.Pp
+The following features are provided:
+.Bl -bullet -compact
+.It
+Parallel service startup via dependency DAG
+.It
+Process tracking via
+.Xr pdfork 2
+process descriptors (no PID files)
+.It
+Subreaper via
+.Xr procctl 2
+(no process escape)
+.It
+Socket activation (pre-bound sockets passed to services)
+.It
+Resource control via
+.Xr rctl 8
+.It
+Service isolation via
+.Xr jail 2
+.It
+OOM protection via
+.Xr procctl 2
+PROC_SPROTECT
+.It
+Automatic restart with configurable backoff
+.It
+Embedded Lua interpreter for service hooks
+.It
+Template units for per-instance services
+.It
+Per-service access control
+.It
+Suspend/resume support
+.It
+Safe in-place binary upgrade via
+.Dv SIGUSR1
+.It
+JSON Schema validation of unit files
+.It
+Full backward compatibility with legacy
+.Xr rc.d 8
+scripts
+.El
+.Sh OPTIONS
+.Bl -tag -width indent
+.It Fl v
+Verbose mode.
+Duplicates syslog messages to stderr, which is useful for debugging
+in jails where
+.Xr jail 8
+.Va exec.consolelog
+captures stderr output.
+.El
+.Sh UNIT FILES
+Service definitions are UCL files stored in:
+.Bl -bullet -compact
+.It
+.Pa /etc/rcd.d/
+\(em system units
+.It
+.Pa <localbase>/etc/rcd.d/
+\(em local/ports units
+.El
+.Pp
+See
+.Xr rcd.d 5
+for the unit file format.
+.Sh CONFIGURATION
+Global configuration is read from
+.Pa /etc/rcd.conf .
+Per-service overrides and template instances are in
+.Pa /etc/rcd.conf.d/<service> .
+.Pp
+See
+.Xr rcd.conf 5
+for details.
+.Sh CONTROL
+.Nm
+listens on
+.Pa /var/run/rcd.sock
+for commands from
+.Xr rcctl 8 .
+Client credentials are verified via
+.Dv LOCAL_PEERCRED .
+.Sh LEGACY COMPATIBILITY
+.Nm
+scans
+.Pa /etc/rc.d/
+and
+.Pa <localbase>/etc/rc.d/
+for legacy
+.Xr rc.d 8
+scripts.
+These are wrapped as virtual units in the dependency graph.
+Native UCL units take precedence over legacy scripts with the same
+.Cm provide
+name.
+.Pp
+For legacy scripts, all commands from
+.Xr rcctl 8
+are passed directly to the script as arguments:
+.Bd -literal -offset indent
+rcctl reload sshd \(-> /bin/sh /etc/rc.d/sshd reload
+rcctl status ntpd \(-> /bin/sh /etc/rc.d/ntpd status
+.Ed
+.Pp
+This includes custom commands declared in the script via
+.Va extra_commands .
+Only
+.Cm enable , disable , show ,
+and
+.Cm describe
+are handled by
+.Nm
+itself.
+.Pp
+When template instances of a legacy script are grouped on the
+.Xr rcctl 8
+command line, all instance names are passed as trailing arguments
+in a single invocation:
+.Bd -literal -offset indent
+rcctl restart netif@em0 netif@em1
+ \(-> /bin/sh /etc/rc.d/netif restart em0 em1
+.Ed
+.Pp
+This preserves the traditional calling convention where scripts
+accept multiple resource names after the command argument.
+.Pp
+Legacy scripts are auto-classified during loading:
+.Bl -bullet -compact
+.It
+Scripts containing
+.Va pidfile=
+or
+.Va command=
+.Pq without Va start_cmd=
+become
+.Dq legacy-forking
+units, which run under
+.Xr rcd-exec 8
+reaper mode for reliable process tracking without pidfiles.
+.It
+Scripts containing only comments and blank lines are auto-detected as
+.Dq barrier
+units (pure synchronization points).
+.It
+Scripts without
+.Va rcvar=
+are always enabled, matching
+.Xr rc.subr 8
+behavior.
+.El
+.Pp
+Legacy scripts have the following limitations compared to native units:
+.Bl -bullet -compact
+.It
+No resource control via
+.Xr rctl 8 .
+.It
+No OOM protection.
+.It
+No socket activation.
+.El
+.Pp
+Plain legacy scripts (without pidfile or command) additionally lack
+process descriptor tracking and automatic restart on failure.
+Legacy-forking scripts get process tracking through the
+.Xr rcd-exec 8
+sub-reaper.
+.Sh EMBEDDED LUA
+Hook fields
+.Pq Cm start_precmd , exec , stop_command , etc.
+that begin with
+.Dq lua:
+are evaluated in an embedded Lua interpreter.
+The following built-in modules are available:
+.Bl -tag -width "rcd.exec_output(cmd)" -compact
+.It Cm rcd.sysctl(name [, val])
+Read or write a sysctl.
+.It Cm rcd.kenv(name)
+Read a kernel environment variable.
+.It Cm rcd.kldload(mod)
+Load a kernel module.
+.It Cm rcd.kldstat(mod)
+Check if a kernel module is loaded.
+.It Cm rcd.exec_output(cmd)
+Run a command and capture stdout.
+.It Cm rcd.exec_stdin(cmd, data)
+Run a command feeding data to stdin.
+.It Cm rcd.sleep(secs)
+Sleep without forking.
+.It Cm rcd.symlink(target, path)
+Create a symlink.
+.It Cm rcd.log(level, msg)
+Log to syslog.
+.It Cm rcd.config
+Table of service config from override file.
+.It Cm rcd.instance
+Template instance name (or nil).
+.It Cm rcd.instance_config
+Per-instance config table (templates only).
+.It Cm posix.chmod(path, mode)
+Change file mode.
+.It Cm posix.chown(path, owner)
+Change file owner.
+.It Cm ucl
+Built-in UCL parser module.
+.El
+.Pp
+Dynamic Lua modules from
+.Pa /usr/lib/flua/
+.Pq lfs, jail, yaml, etc.
+are available after
+.Cm FILESYSTEMS
+is reached.
+.Sh DISKLESS BOOT
+If the sysctl
+.Va vfs.nfs.diskless_valid
+is set,
+.Nm
+detects a diskless boot and executes
+.Pa /etc/rc.initdiskless
+before loading any units.
+.Sh SIGNALS
+.Bl -tag -width SIGUSR1
+.It Dv SIGTERM , SIGINT
+Initiate ordered shutdown of all services.
+If
+.Cm precious_machine
+is set in
+.Xr rcd.conf 5 ,
+the signal is ignored and a warning is logged.
+.It Dv SIGHUP
+Reload configuration and rescan unit directories.
+.It Dv SIGUSR1
+Trigger a safe in-place binary upgrade.
+.Nm
+saves its state
+.Pq running service names, PIDs, and process descriptor fds
+to
+.Pa /var/run/rcd.state
+in UCL format, then re-execs itself.
+The new binary restores the saved state and continues supervising
+all running services without restarting them.
+Process descriptor fds survive the exec by temporarily clearing
+.Dv PD_CLOEXEC .
+The
+.Dv PROC_REAP_ACQUIRE
+status is preserved across exec by the kernel.
+.It Dv SIGALRM
+During the boot phase only: re-read per-service override files
+from
+.Pa /etc/rcd.conf.d/ .
+This allows enabling or disabling services while boot is in progress.
+.It Dv SIGCHLD
+Reap zombie children.
+As subreaper,
+.Nm
+inherits orphaned processes from legacy scripts and their children.
+Uses
+.Fn waitpid -1 WNOHANG
+to reap all pending zombies.
+.El
+.Sh FILES
+.Bl -tag -width "/var/run/rcd.state" -compact
+.It Pa /etc/rcd.conf
+Global configuration.
+.It Pa /etc/rcd.conf.d/
+Per-service overrides and template instances.
+.It Pa /etc/rcd.d/
+System unit files.
+.It Pa /var/run/rcd.sock
+Control socket.
+.It Pa /var/run/rcd.state
+Transient state file used during safe upgrade
+.Pq Dv SIGUSR1 .
+Created before re-exec and deleted after state is restored.
+.El
+.Sh SEE ALSO
+.Xr rcd-lua 3 ,
+.Xr rcd.conf 5 ,
+.Xr rcd.d 5 ,
+.Xr rcctl 8 ,
+.Xr rcd-exec 8 ,
+.Xr init 8 ,
+.Xr rc 8
+.Sh AUTHORS
+.An Baptiste Daroussin Aq Mt bapt@FreeBSD.org
diff --git a/sbin/rcd/rcd.c b/sbin/rcd/rcd.c
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/rcd.c
@@ -0,0 +1,1520 @@
+/*
+ * Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+/*
+ * rcd — FreeBSD Service Manager
+ *
+ * Main daemon. Called by init(8) during boot. Reads unit files, builds
+ * a dependency graph, starts services in parallel, then enters a kqueue
+ * supervision loop. Forks to background when boot completes,
+ * signaling init(8) via the parent's exit.
+ */
+
+#include <sys/param.h>
+#include <sys/event.h>
+#include <sys/eventfd.h>
+#include <sys/procctl.h>
+#include <sys/socket.h>
+#include <sys/un.h>
+
+#include <sys/stat.h>
+#include <sys/sysctl.h>
+#include <sys/wait.h>
+
+#include <dirent.h>
+#include <err.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <paths.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <syslog.h>
+#include <unistd.h>
+
+#include <ucl.h>
+
+#include "rcd.h"
+
+#define RCD_CONF_PATH "/etc/rcd.conf"
+#define RCD_UNIT_DIR "/etc/rcd.d"
+#define RCD_LEGACY_DIR "/etc/rc.d"
+#define RCD_CONTROL_SOCK "/var/run/rcd.sock"
+#define RCD_STATE_FILE "/var/run/rcd.state"
+
+/* Forward declarations for static functions used before definition */
+static int config_load(struct rcd_config *);
+static int load_units_from_dir(struct rcd_ctx *, const char *);
+static int load_legacy_from_dir(struct rcd_ctx *, const char *);
+static void rescan_localbase(struct rcd_ctx *);
+static void filter_units_by_context(struct rcd_ctx *);
+static void remove_firstboot(const char *);
+
+static char localbase[PATH_MAX];
+
+#include "conf_schema.h"
+#include "unit_schema.h"
+
+/*
+ * Determine localbase from sysctl user.localbase (default: /usr/local).
+ */
+static void
+get_localbase(void)
+{
+ size_t len;
+
+ len = sizeof(localbase);
+ if (sysctlbyname("user.localbase", localbase, &len, NULL, 0) != 0)
+ strlcpy(localbase, "/usr/local", sizeof(localbase));
+}
+
+/*
+ * Parse an embedded JSON schema string into a UCL object.
+ */
+static ucl_object_t *
+load_embedded_schema(const char *data, size_t len)
+{
+ struct ucl_parser *parser;
+ ucl_object_t *schema;
+
+ parser = ucl_parser_new(UCL_PARSER_DEFAULT);
+ if (parser == NULL)
+ return (NULL);
+ if (!ucl_parser_add_string(parser, data, len)) {
+ ucl_parser_free(parser);
+ return (NULL);
+ }
+ schema = ucl_parser_get_object(parser);
+ ucl_parser_free(parser);
+ return (schema);
+}
+
+/*
+ * Check if we are running inside a jail.
+ */
+static bool
+check_jailed(void)
+{
+ int jailed;
+ size_t len;
+
+ len = sizeof(jailed);
+ if (sysctlbyname("security.jail.jailed", &jailed, &len,
+ NULL, 0) != 0)
+ return (false);
+ return (jailed != 0);
+}
+
+/*
+ * Check if this is a diskless boot via vfs.nfs.diskless_valid sysctl.
+ */
+static bool
+check_diskless(void)
+{
+ int val;
+ size_t len;
+
+ val = 0;
+ len = sizeof(val);
+ sysctlbyname("vfs.nfs.diskless_valid", &val, &len, NULL, 0);
+ return (val != 0);
+}
+
+/*
+ * Detect if this is a re-exec upgrade.
+ * The state file must exist AND at least one saved procdesc fd
+ * must be valid (inherited from the old process).
+ */
+static bool
+check_upgrade_state(void)
+{
+ struct ucl_parser *sp;
+ ucl_object_t *st;
+ const ucl_object_t *arr, *sobj, *fobj;
+ ucl_object_iter_t sit;
+ bool found;
+
+ if (access(RCD_STATE_FILE, F_OK) != 0)
+ return (false);
+
+ found = false;
+ sp = ucl_parser_new(UCL_PARSER_DEFAULT);
+ if (ucl_parser_add_file(sp, RCD_STATE_FILE)) {
+ st = ucl_parser_get_object(sp);
+ if (st != NULL) {
+ arr = ucl_object_lookup(st, "services");
+ if (arr != NULL) {
+ sit = ucl_object_iterate_new(arr);
+ while ((sobj = ucl_object_iterate_safe(
+ sit, true)) != NULL) {
+ fobj = ucl_object_lookup(sobj, "fd");
+ if (fobj != NULL &&
+ fcntl(ucl_object_toint(fobj),
+ F_GETFD) >= 0) {
+ found = true;
+ break;
+ }
+ }
+ ucl_object_iterate_free(sit);
+ }
+ ucl_object_unref(st);
+ }
+ }
+ ucl_parser_free(sp);
+
+ if (!found) {
+ log_info("stale state file, ignoring");
+ unlink(RCD_STATE_FILE);
+ }
+ return (found);
+}
+
+/*
+ * Reload configuration: rescan unit directories and rebuild the DAG.
+ * Existing running services are not affected; new/changed units are
+ * picked up and stopped/removed units are marked for cleanup.
+ */
+static void
+do_reload_config(struct rcd_ctx *ctx)
+{
+ char local_unit_dir[PATH_MAX];
+ char local_legacy_dir[PATH_MAX];
+
+ log_info("reloading configuration");
+
+ snprintf(local_unit_dir, sizeof(local_unit_dir),
+ "%s/etc/rcd.d", localbase);
+ snprintf(local_legacy_dir, sizeof(local_legacy_dir),
+ "%s/etc/rc.d", localbase);
+
+ /*
+ * Re-read global config. This does not affect already-running
+ * services, but will be used for new starts.
+ */
+ config_load(&ctx->ctx_config);
+
+ /*
+ * Scan for new unit files. depgraph_add skips duplicates
+ * (units with provisions already registered).
+ */
+ load_units_from_dir(ctx, RCD_UNIT_DIR);
+ load_units_from_dir(ctx, local_unit_dir);
+ load_legacy_from_dir(ctx, RCD_LEGACY_DIR);
+ load_legacy_from_dir(ctx, local_legacy_dir);
+
+ /* Re-resolve to pick up new dependencies */
+ depgraph_resolve(&ctx->ctx_graph);
+}
+
+/*
+ * Load global configuration from /etc/rcd.conf.
+ */
+static int
+config_load(struct rcd_config *cfg)
+{
+ struct ucl_parser *parser;
+ ucl_object_t *top;
+ const ucl_object_t *val;
+
+ /* Free previous allocations (safe on first call — all NULL) */
+ free(cfg->cfg_control_socket);
+ free(cfg->cfg_control_group);
+ free(cfg->cfg_firstboot_sentinel);
+ for (int ci = 0; ci < cfg->cfg_nlegacy_rc_conf; ci++)
+ free(cfg->cfg_legacy_rc_conf[ci]);
+ free(cfg->cfg_legacy_rc_conf);
+
+ /* Set defaults */
+ cfg->cfg_parallel = true;
+ cfg->cfg_max_parallel = 0;
+ cfg->cfg_control_socket = xstrdup(RCD_CONTROL_SOCK);
+ cfg->cfg_control_perms = 0660;
+ cfg->cfg_control_group = xstrdup("wheel");
+ cfg->cfg_log_level = LOG_INFO;
+ cfg->cfg_stop_timeout_ms = 10000;
+ cfg->cfg_shutdown_timeout_ms = 90000;
+ cfg->cfg_firstboot_sentinel = xstrdup("/firstboot");
+
+ /* Default legacy rc.conf paths */
+ cfg->cfg_legacy_rc_conf = xcalloc(3, sizeof(char *));
+ cfg->cfg_legacy_rc_conf[0] = xstrdup("/etc/defaults/rc.conf");
+ cfg->cfg_legacy_rc_conf[1] = xstrdup("/etc/rc.conf");
+ cfg->cfg_legacy_rc_conf[2] = xstrdup("/etc/rc.conf.local");
+ cfg->cfg_nlegacy_rc_conf = 3;
+
+ /* Parse /etc/rcd.conf with libucl */
+ parser = ucl_parser_new(UCL_PARSER_DEFAULT);
+ if (parser == NULL)
+ return (0);
+
+ if (!ucl_parser_add_file(parser, RCD_CONF_PATH)) {
+ /* Config file is optional */
+ ucl_parser_free(parser);
+ return (0);
+ }
+
+ top = ucl_parser_get_object(parser);
+ ucl_parser_free(parser);
+ if (top == NULL)
+ return (0);
+
+ val = ucl_object_lookup(top, "parallel");
+ if (val != NULL)
+ cfg->cfg_parallel = ucl_object_toboolean(val);
+
+ val = ucl_object_lookup(top, "max_parallel");
+ if (val != NULL)
+ cfg->cfg_max_parallel = ucl_object_toint(val);
+
+ val = ucl_object_lookup(top, "control_socket");
+ if (val != NULL) {
+ free(cfg->cfg_control_socket);
+ cfg->cfg_control_socket =
+ xstrdup(ucl_object_tostring(val));
+ }
+
+ val = ucl_object_lookup(top, "control_permissions");
+ if (val != NULL) {
+ mode_t m;
+
+ if (ucl_parse_mode(val, &m) == 0)
+ cfg->cfg_control_perms = m;
+ else
+ log_warn("invalid control_permissions");
+ }
+
+ val = ucl_object_lookup(top, "control_group");
+ if (val != NULL) {
+ free(cfg->cfg_control_group);
+ cfg->cfg_control_group =
+ xstrdup(ucl_object_tostring(val));
+ }
+
+ val = ucl_object_lookup(top, "log_level");
+ if (val != NULL) {
+ const char *s = ucl_object_tostring(val);
+ if (strcmp(s, "debug") == 0)
+ cfg->cfg_log_level = LOG_DEBUG;
+ else if (strcmp(s, "warning") == 0)
+ cfg->cfg_log_level = LOG_WARNING;
+ else if (strcmp(s, "error") == 0)
+ cfg->cfg_log_level = LOG_ERR;
+ else
+ cfg->cfg_log_level = LOG_INFO;
+ }
+
+ val = ucl_object_lookup(top, "stop_timeout_ms");
+ if (val != NULL)
+ cfg->cfg_stop_timeout_ms = ucl_object_toint(val);
+
+ val = ucl_object_lookup(top, "shutdown_timeout_ms");
+ if (val != NULL)
+ cfg->cfg_shutdown_timeout_ms = ucl_object_toint(val);
+
+ val = ucl_object_lookup(top, "precious_machine");
+ if (val != NULL)
+ cfg->cfg_precious_machine = ucl_object_toboolean(val);
+
+ val = ucl_object_lookup(top, "quiet_boot");
+ if (val != NULL)
+ cfg->cfg_quiet_boot = ucl_object_toboolean(val);
+
+ val = ucl_object_lookup(top, "veriexec");
+ if (val != NULL)
+ cfg->cfg_veriexec = ucl_object_toboolean(val);
+
+ ucl_object_unref(top);
+ return (0);
+}
+
+/*
+ * Scan a directory for .ucl unit files and add them to the graph.
+ */
+static int
+load_units_from_dir(struct rcd_ctx *ctx, const char *dirpath)
+{
+ DIR *dp;
+ struct dirent *de;
+ struct unit *u;
+ char path[PATH_MAX];
+ size_t len;
+
+ dp = opendir(dirpath);
+ if (dp == NULL) {
+ if (errno == ENOENT)
+ return (0);
+ log_warn("opendir %s: %s", dirpath, strerror(errno));
+ return (-1);
+ }
+
+ while ((de = readdir(dp)) != NULL) {
+ len = strlen(de->d_name);
+ if (len < 5 || strcmp(de->d_name + len - 4, ".ucl") != 0)
+ continue;
+
+ if (snprintf(path, sizeof(path), "%s/%s",
+ dirpath, de->d_name) >= (int)sizeof(path))
+ continue;
+
+ /*
+ * VERIEXEC: if enabled, verify unit file ownership and
+ * permissions before trusting it. Unit files must be
+ * owned by root and not world-writable.
+ */
+ if (ctx->ctx_config.cfg_veriexec) {
+ struct stat sb;
+
+ if (stat(path, &sb) != 0)
+ continue;
+ if (sb.st_uid != 0) {
+ log_warn("veriexec: %s not owned by root, "
+ "skipping", path);
+ continue;
+ }
+ if (sb.st_mode & S_IWOTH) {
+ log_warn("veriexec: %s is world-writable, "
+ "skipping", path);
+ continue;
+ }
+ }
+
+ u = unit_parse(path, &ctx->ctx_config);
+ if (u == NULL) {
+ log_warn("failed to parse unit: %s", path);
+ continue;
+ }
+
+ /* Skip if this provision is already registered (rescan) */
+ if (u->u_provide.len > 0 &&
+ depgraph_find(&ctx->ctx_graph, u->u_provide.d[0]) != NULL) {
+ unit_free(u);
+ continue;
+ }
+
+ depgraph_add(&ctx->ctx_graph, u);
+ }
+
+ closedir(dp);
+ return (0);
+}
+
+/*
+ * Scan a directory for legacy rc.d scripts and wrap them as units.
+ */
+static int
+load_legacy_from_dir(struct rcd_ctx *ctx, const char *dirpath)
+{
+
+ return (compat_scan(ctx, dirpath));
+}
+
+/*
+ * Re-scan localbase directories for units and legacy scripts.
+ *
+ * Called when the FILESYSTEMS barrier is reached, meaning /usr (and
+ * localbase) is now mounted.
+ * This is equivalent of rc(8)'s two-phase boot where rcorder is called
+ * a second time after FILESYSTEMS to discover scripts from
+ * <localbase>/etc/rc.d/.
+ *
+ */
+static void
+rescan_localbase(struct rcd_ctx *ctx)
+{
+ char local_unit_dir[PATH_MAX];
+ char local_legacy_dir[PATH_MAX];
+ unsigned int before;
+ struct unit *u;
+
+ snprintf(local_unit_dir, sizeof(local_unit_dir),
+ "%s/etc/rcd.d", localbase);
+ snprintf(local_legacy_dir, sizeof(local_legacy_dir),
+ "%s/etc/rc.d", localbase);
+
+ before = ctx->ctx_graph.dg_nunits;
+
+ load_units_from_dir(ctx, local_unit_dir);
+ load_legacy_from_dir(ctx, local_legacy_dir);
+
+ if (ctx->ctx_graph.dg_nunits > before) {
+ log_info("FILESYSTEMS: loaded %u new units from %s",
+ ctx->ctx_graph.dg_nunits - before, localbase);
+
+ /* Re-resolve dependencies with the new units */
+ depgraph_resolve(&ctx->ctx_graph);
+
+ /* Apply keyword filtering to new units */
+ filter_units_by_context(ctx);
+
+ /* Apply overrides to new units */
+ TAILQ_FOREACH(u, &ctx->ctx_graph.dg_units, u_entries)
+ unit_apply_overrides(u, "/etc/rcd.conf.d");
+ }
+}
+
+/*
+ * Start all services that are ready (dependencies satisfied).
+ */
+static int
+schedule_ready(struct rcd_ctx *ctx)
+{
+ struct unit **ready;
+ int nready, i, cap;
+
+ cap = ctx->ctx_graph.dg_nunits;
+ if (cap == 0)
+ return (0);
+ ready = xcalloc(cap, sizeof(*ready));
+ if (ready == NULL)
+ return (-1);
+
+retry:
+ depgraph_ready_set(&ctx->ctx_graph, ready, &nready);
+
+ for (i = 0; i < nready; i++) {
+ if (ctx->ctx_config.cfg_max_parallel > 0 &&
+ ctx->ctx_running >= ctx->ctx_config.cfg_max_parallel)
+ break;
+
+ if (ready[i]->u_state != STATE_INACTIVE)
+ continue;
+
+ ready[i]->u_state = STATE_STARTING;
+ ctx->ctx_running++;
+
+ if (proc_spawn(ctx, ready[i]) != 0) {
+ log_warn("failed to start %s", ready[i]->u_name);
+ log_console("WARNING: failed to start %s",
+ ready[i]->u_name);
+ ready[i]->u_state = STATE_FAILED;
+ ctx->ctx_running--;
+ depgraph_mark_done(&ctx->ctx_graph, ready[i]);
+ /* Re-evaluate without recursion */
+ goto retry;
+ }
+
+ if (ready[i]->u_type != UNIT_BARRIER)
+ log_console("Starting %s.", ready[i]->u_name);
+
+ /*
+ * Barriers and inline-exec oneshots complete
+ * synchronously (STATE_DONE). Daemons under
+ * sub-reaper are immediately STATE_RUNNING.
+ * Both satisfy dependencies — mark done so
+ * dependants can be unblocked.
+ */
+ if (ready[i]->u_state == STATE_DONE ||
+ ready[i]->u_state == STATE_RUNNING) {
+ if (ready[i]->u_state == STATE_DONE)
+ ctx->ctx_running--;
+ depgraph_mark_done(&ctx->ctx_graph, ready[i]);
+
+ /*
+ * When FILESYSTEMS is reached, /usr may now be
+ * mounted. Re-scan localbase directories to
+ * pick up ports/packages units and legacy
+ * scripts, like rc(8) does with its two-phase
+ * rcorder approach.
+ */
+ vec_foreach(ready[i]->u_provide, pi) {
+ if (strcmp(ready[i]->u_provide.d[pi],
+ "FILESYSTEMS") == 0) {
+ rescan_localbase(ctx);
+ break;
+ }
+ }
+
+ goto retry;
+ }
+ }
+
+ free(ready);
+ return (0);
+}
+
+/*
+ * Handle a process exit event from kqueue.
+ */
+static void
+handle_procdesc_event(struct rcd_ctx *ctx, struct kevent *kev)
+{
+ struct unit *u;
+ int status;
+
+ u = (struct unit *)kev->udata;
+ status = (int)kev->data;
+
+ ctx->ctx_running--;
+ proc_handle_exit(ctx, u, status);
+
+ /*
+ * Always resolve deps and schedule, not just during boot.
+ * Legacy oneshots (cleanvar, var_run) exit quickly; their
+ * procdesc events unblock dependants (syslogd, FILESYSTEMS).
+ */
+ depgraph_mark_done(&ctx->ctx_graph, u);
+ schedule_ready(ctx);
+}
+
+/*
+ * Handle a socket activation event (incoming connection on pre-bound socket).
+ */
+static void
+handle_sockact_event(struct rcd_ctx *ctx, struct kevent *kev)
+{
+ struct unit *u;
+
+ u = (struct unit *)kev->udata;
+
+ if (u->u_state == STATE_INACTIVE || u->u_state == STATE_DONE) {
+ log_info("socket activation: starting %s", u->u_name);
+ u->u_state = STATE_STARTING;
+ ctx->ctx_running++;
+ if (proc_spawn(ctx, u) != 0) {
+ log_warn("socket activation failed for %s",
+ u->u_name);
+ u->u_state = STATE_FAILED;
+ ctx->ctx_running--;
+ }
+ }
+}
+
+/*
+ * Handle a readiness notification (service has signalled it is ready).
+ * For READY_SOCKET units, register deferred sockets in kqueue.
+ * For READY_FD units, mark the service as running.
+ */
+static void
+handle_readiness_event(struct rcd_ctx *ctx, struct kevent *kev)
+{
+ struct unit *u;
+
+ u = (struct unit *)kev->udata;
+
+ if (u->u_state != STATE_STARTING)
+ return;
+
+ switch (u->u_ready_method) {
+ case READY_SOCKET:
+ sockact_deferred_register_all(ctx, u);
+ u->u_state = STATE_RUNNING;
+ log_info("%s: socket activation ready", u->u_name);
+ break;
+ case READY_FD:
+ u->u_state = STATE_RUNNING;
+ log_info("%s: readiness signalled", u->u_name);
+ break;
+ default:
+ break;
+ }
+
+ if (u->u_notify_fd >= 0) {
+ close(u->u_notify_fd);
+ u->u_notify_fd = -1;
+ }
+}
+
+/*
+ * Finalize the boot phase. Called when boot_complete() returns true
+ * or when the boot timeout fires.
+ */
+static void
+finish_boot(struct rcd_ctx *ctx)
+{
+ struct unit *u;
+ struct kevent kev;
+ struct stat sb;
+
+ ctx->ctx_booting = false;
+
+ /* Cancel boot timeout if still pending */
+ EV_SET(&kev, 0xB007, EVFILT_TIMER, EV_DELETE, 0, 0, NULL);
+ kevent(ctx->ctx_kq, &kev, 1, NULL, 0, NULL);
+
+ /* Log stuck services */
+ TAILQ_FOREACH(u, &ctx->ctx_graph.dg_units, u_entries) {
+ if (u->u_enabled && u->u_state == STATE_INACTIVE &&
+ !u->u_template)
+ log_warn("boot: %s stuck (unmet dependencies)",
+ u->u_name);
+ }
+
+ /*
+ * After boot, verify the control socket is accessible.
+ * During early boot, /var/run (tmpfs) may not yet be mounted,
+ * or a tmpfs may have been mounted over the directory where
+ * the socket was originally created on the root filesystem,
+ * hiding the socket file. Recreate the socket on the now-
+ * visible filesystem so rcctl(8) can reach it.
+ */
+ if (ctx->ctx_ctlsock < 0) {
+ /* Never succeeded during early init — retry now */
+ log_info("creating control socket after boot");
+ control_init(ctx);
+ } else if (ctx->ctx_config.cfg_control_socket != NULL) {
+ if (stat(ctx->ctx_config.cfg_control_socket, &sb) != 0 ||
+ !S_ISSOCK(sb.st_mode)) {
+ log_info("control socket not accessible, "
+ "recreating after boot");
+ control_reinit(ctx);
+ }
+ }
+
+ if (ctx->ctx_config.cfg_quiet_boot) {
+ log_init(ctx->ctx_config.cfg_log_level);
+ log_console_set_enabled(true);
+ }
+ boottrace("rcd: boot complete");
+ log_info("boot complete");
+ log_console("Boot complete.");
+ log_console_close();
+ rcd_signal_ready(ctx);
+ remove_firstboot(ctx->ctx_config.cfg_firstboot_sentinel);
+}
+
+/*
+ * Handle a restart timer event.
+ */
+static void
+handle_timer_event(struct rcd_ctx *ctx, struct kevent *kev)
+{
+ struct unit *u;
+
+ /* Boot timeout timer */
+ if (kev->ident == 0xB007) {
+ if (ctx->ctx_booting) {
+ log_warn("boot timeout, forcing completion");
+ finish_boot(ctx);
+ }
+ return;
+ }
+
+ u = (struct unit *)kev->udata;
+
+ log_info("restart timer: starting %s", u->u_name);
+ u->u_state = STATE_STARTING;
+ ctx->ctx_running++;
+ if (proc_spawn(ctx, u) != 0) {
+ log_warn("restart failed for %s", u->u_name);
+ u->u_state = STATE_FAILED;
+ ctx->ctx_running--;
+ }
+}
+
+/*
+ * Check if boot is complete.
+ *
+ * Boot is complete when no more progress can be made: either all
+ * enabled services have reached a terminal state (running/done/failed),
+ * or no services are currently starting AND no new services can be
+ * scheduled (blocked by unsatisfied dependencies).
+ */
+static bool
+boot_complete(struct rcd_ctx *ctx)
+{
+ struct unit *u;
+
+ TAILQ_FOREACH(u, &ctx->ctx_graph.dg_units, u_entries) {
+ if (!u->u_enabled)
+ continue;
+ if (u->u_template)
+ continue;
+ if (!TAILQ_EMPTY(&u->u_sockets))
+ continue;
+ /*
+ * Boot is not complete if any enabled service is still
+ * STARTING (shell running) or INACTIVE (waiting for deps).
+ * RUNNING, DONE, FAILED are all terminal for boot purposes.
+ */
+ if (u->u_state == STATE_STARTING ||
+ u->u_state == STATE_INACTIVE)
+ return (false);
+ }
+
+
+
+ return (true);
+}
+
+static int ready_efd = -1;
+
+/*
+ * Daemonize early: fork before starting services so the child
+ * (which becomes the subreaper) owns all service processes.
+ * The parent blocks on an eventfd until the child signals boot
+ * complete, then exits to tell init(8) to proceed to multi_user.
+ */
+static void
+rcd_daemonize(void)
+{
+ int efd;
+ pid_t pid;
+
+ efd = eventfd(0, EFD_CLOEXEC);
+ if (efd < 0) {
+ log_warn("eventfd: %s", strerror(errno));
+ return;
+ }
+
+ pid = fork();
+ if (pid < 0) {
+ log_warn("fork: %s", strerror(errno));
+ close(efd);
+ return;
+ }
+
+ if (pid > 0) {
+ /* Parent: block until child signals boot complete */
+ uint64_t val;
+
+ xread(efd, &val, sizeof(val));
+ close(efd);
+ _exit(0);
+ }
+
+ /* Child: becomes the supervisor daemon */
+ ready_efd = efd;
+ setsid();
+ log_info("rcd: daemonized (pid %d)", getpid());
+}
+
+/*
+ * Signal init that boot is complete.
+ * Write to the eventfd; the parent reads it and exits.
+ */
+void
+rcd_signal_ready(struct rcd_ctx *ctx __unused)
+{
+ uint64_t val;
+
+ if (ready_efd < 0)
+ return;
+ val = 1;
+ xwrite(ready_efd, &val, sizeof(val));
+ close(ready_efd);
+ ready_efd = -1;
+}
+
+/*
+ * Perform ordered shutdown of all services with a global watchdog.
+ * If shutdown takes longer than cfg_shutdown_timeout_ms, force-kill
+ * all remaining services.
+ */
+static void
+do_shutdown(struct rcd_ctx *ctx)
+{
+ struct unit **order;
+ struct kevent kev;
+ int norder, i, cap;
+
+ cap = ctx->ctx_graph.dg_nunits;
+ if (cap == 0)
+ cap = 1;
+ order = xcalloc(cap, sizeof(*order));
+ if (order == NULL) {
+ log_warn("shutdown: out of memory");
+ return;
+ }
+
+ boottrace("rcd: shutdown started");
+ log_info("shutting down services");
+ ctx->ctx_shutting_down = true;
+
+ /* Set a global shutdown watchdog timer */
+ EV_SET(&kev, 0xDEAD, EVFILT_TIMER, EV_ADD | EV_ONESHOT,
+ NOTE_MSECONDS, ctx->ctx_config.cfg_shutdown_timeout_ms, NULL);
+ if (kevent(ctx->ctx_kq, &kev, 1, NULL, 0, NULL) < 0)
+ log_warn("kevent shutdown timer: %s", strerror(errno));
+
+ depgraph_shutdown_order(&ctx->ctx_graph, order, &norder);
+
+ for (i = 0; i < norder; i++) {
+ if (order[i]->u_state != STATE_RUNNING)
+ continue;
+ log_info("stopping %s", order[i]->u_name);
+ proc_stop_sync(ctx, order[i]);
+ }
+
+ /* Cancel the watchdog if we finished in time */
+ EV_SET(&kev, 0xDEAD, EVFILT_TIMER, EV_DELETE, 0, 0, NULL);
+ kevent(ctx->ctx_kq, &kev, 1, NULL, 0, NULL);
+
+ free(order);
+ boottrace("rcd: shutdown complete");
+}
+
+/*
+ * Check if /firstboot sentinel exists (first boot provisioning).
+ */
+static bool
+check_firstboot(const char *sentinel)
+{
+
+ if (sentinel == NULL)
+ return (false);
+ return (access(sentinel, F_OK) == 0);
+}
+
+/*
+ * Remove the firstboot sentinel after boot completes.
+ */
+static void
+remove_firstboot(const char *sentinel)
+{
+
+ if (sentinel != NULL)
+ unlink(sentinel);
+}
+
+/*
+ * Filter units based on keyword flags and runtime context.
+ * Disables units that should not run in the current environment.
+ */
+static void
+filter_units_by_context(struct rcd_ctx *ctx)
+{
+ struct unit *u;
+ bool is_firstboot;
+
+ is_firstboot = check_firstboot(
+ ctx->ctx_config.cfg_firstboot_sentinel);
+
+ TAILQ_FOREACH(u, &ctx->ctx_graph.dg_units, u_entries) {
+ /* Skip firstboot-only services if not first boot */
+ if (u->u_boot_only && !is_firstboot) {
+ u->u_enabled = false;
+ continue;
+ }
+
+ /* Skip nojail services when running inside a jail */
+ if (ctx->ctx_jailed && u->u_nojail) {
+ u->u_enabled = false;
+ continue;
+ }
+
+ /*
+ * nojailvnet: skip in jails without VNET.
+ * For simplicity, treat all jails as non-vnet unless
+ * we detect otherwise.
+ */
+ if (ctx->ctx_jailed && u->u_nojailvnet) {
+ u->u_enabled = false;
+ continue;
+ }
+ }
+}
+
+/*
+ * Save running state to a file for re-exec upgrade.
+ * Records procdesc fds, PIDs, and service states so the new
+ * binary can resume supervision without restarting services.
+ */
+static void
+save_state(struct rcd_ctx *ctx)
+{
+ ucl_object_t *top, *arr, *sobj;
+ struct unit *u;
+ unsigned char *buf;
+ FILE *fp;
+
+ top = ucl_object_typed_new(UCL_OBJECT);
+
+ arr = ucl_object_typed_new(UCL_ARRAY);
+ TAILQ_FOREACH(u, &ctx->ctx_graph.dg_units, u_entries) {
+ if (u->u_state != STATE_RUNNING)
+ continue;
+ if (u->u_procdesc_fd < 0)
+ continue;
+
+ sobj = ucl_object_typed_new(UCL_OBJECT);
+ ucl_object_insert_key(sobj,
+ ucl_object_fromstring(u->u_name), "name", 0, false);
+ ucl_object_insert_key(sobj,
+ ucl_object_fromint(u->u_pid), "pid", 0, false);
+ ucl_object_insert_key(sobj,
+ ucl_object_fromint(u->u_procdesc_fd), "fd", 0, false);
+ ucl_array_append(arr, sobj);
+
+ /* Clear CLOEXEC so the fd survives exec */
+ fcntl(u->u_procdesc_fd, F_SETFD, 0);
+ }
+ ucl_object_insert_key(top, arr, "services", 0, false);
+
+ buf = ucl_object_emit(top, UCL_EMIT_JSON_COMPACT);
+ ucl_object_unref(top);
+
+ if (buf != NULL) {
+ fp = fopen(RCD_STATE_FILE, "w");
+ if (fp != NULL) {
+ if (fprintf(fp, "%s\n", buf) < 0)
+ log_warn("write %s: %s", RCD_STATE_FILE,
+ strerror(errno));
+ if (fclose(fp) != 0)
+ log_warn("close %s: %s", RCD_STATE_FILE,
+ strerror(errno));
+ }
+ free(buf);
+ }
+}
+
+/*
+ * Restore state from a previous rcd instance after re-exec.
+ * Matches saved procdesc fds to loaded units and re-registers
+ * them in kqueue for continued supervision.
+ */
+static void
+restore_state(struct rcd_ctx *ctx)
+{
+ struct ucl_parser *parser;
+ ucl_object_t *top;
+ const ucl_object_t *arr, *sobj;
+ ucl_object_iter_t it;
+
+ parser = ucl_parser_new(UCL_PARSER_DEFAULT);
+ if (!ucl_parser_add_file(parser, RCD_STATE_FILE)) {
+ ucl_parser_free(parser);
+ return;
+ }
+ top = ucl_parser_get_object(parser);
+ ucl_parser_free(parser);
+ if (top == NULL)
+ return;
+
+ arr = ucl_object_lookup(top, "services");
+ if (arr == NULL) {
+ ucl_object_unref(top);
+ unlink(RCD_STATE_FILE);
+ return;
+ }
+
+ it = ucl_object_iterate_new(arr);
+ while ((sobj = ucl_object_iterate_safe(it, true)) != NULL) {
+ const ucl_object_t *nobj, *pobj, *fobj;
+ const char *name;
+ pid_t pid;
+ int fd;
+ struct unit *u;
+ struct kevent kev;
+
+ nobj = ucl_object_lookup(sobj, "name");
+ pobj = ucl_object_lookup(sobj, "pid");
+ fobj = ucl_object_lookup(sobj, "fd");
+ if (nobj == NULL || pobj == NULL || fobj == NULL)
+ continue;
+
+ name = ucl_object_tostring(nobj);
+ pid = (pid_t)ucl_object_toint(pobj);
+ fd = (int)ucl_object_toint(fobj);
+
+ /* Verify the procdesc fd survived exec */
+ if (fcntl(fd, F_GETFD) < 0) {
+ log_warn("upgrade: %s fd %d invalid", name, fd);
+ continue;
+ }
+
+ /* Match to loaded unit */
+ u = depgraph_find(&ctx->ctx_graph, name);
+ if (u == NULL) {
+ log_warn("upgrade: %s no longer exists", name);
+ close(fd);
+ continue;
+ }
+
+ u->u_pid = pid;
+ u->u_procdesc_fd = fd;
+ u->u_state = STATE_RUNNING;
+
+ /* Re-set CLOEXEC for normal operation */
+ fcntl(fd, F_SETFD, FD_CLOEXEC);
+
+ EV_SET(&kev, fd, EVFILT_PROCDESC, EV_ADD | EV_ONESHOT,
+ NOTE_EXIT, 0, u);
+ if (kevent(ctx->ctx_kq, &kev, 1, NULL, 0, NULL) < 0)
+ log_warn("upgrade: kevent for %s: %s",
+ name, strerror(errno));
+ else
+ log_info("upgrade: restored %s (pid %d fd %d)",
+ name, pid, fd);
+ }
+ ucl_object_iterate_free(it);
+
+ ucl_object_unref(top);
+ unlink(RCD_STATE_FILE);
+}
+
+/*
+ * Perform a safe upgrade by re-execing ourselves.
+ * The running services are preserved via their procdesc fds.
+ */
+static void
+do_upgrade(struct rcd_ctx *ctx, char *argv0)
+{
+
+ log_info("upgrade: saving state and re-execing %s", argv0);
+ save_state(ctx);
+
+ /* Re-exec ourselves — no extra args, just the binary */
+ execl(argv0, argv0, (char *)NULL);
+
+ /* If exec failed, clean up and continue */
+ log_warn("upgrade: execl failed: %s", strerror(errno));
+ unlink(RCD_STATE_FILE);
+}
+
+jmp_buf rcd_oom_env;
+static char *rcd_argv0;
+
+/*
+ * Main kqueue event loop.
+ */
+int
+rcd_main_loop(struct rcd_ctx *ctx)
+{
+ struct kevent events[64];
+ struct kevent sigkev[3];
+ int nevents, i;
+
+ /* Register signal handlers via kqueue */
+ EV_SET(&sigkev[0], SIGTERM, EVFILT_SIGNAL, EV_ADD, 0, 0, NULL);
+ EV_SET(&sigkev[1], SIGINT, EVFILT_SIGNAL, EV_ADD, 0, 0, NULL);
+ EV_SET(&sigkev[2], SIGHUP, EVFILT_SIGNAL, EV_ADD, 0, 0, NULL);
+ {
+ struct kevent extrakev[2];
+ EV_SET(&extrakev[0], SIGALRM, EVFILT_SIGNAL, EV_ADD, 0, 0, NULL);
+ EV_SET(&extrakev[1], SIGUSR1, EVFILT_SIGNAL, EV_ADD, 0, 0, NULL);
+ if (kevent(ctx->ctx_kq, extrakev, 2, NULL, 0, NULL) < 0)
+ log_warn("kevent signal registration: %s",
+ strerror(errno));
+ }
+ if (kevent(ctx->ctx_kq, sigkev, 3, NULL, 0, NULL) < 0)
+ log_warn("kevent signal registration: %s",
+ strerror(errno));
+
+ signal(SIGTERM, SIG_IGN);
+ signal(SIGINT, SIG_IGN);
+ signal(SIGALRM, SIG_IGN);
+ signal(SIGHUP, SIG_IGN);
+ signal(SIGCHLD, SIG_IGN);
+ signal(SIGUSR1, SIG_IGN);
+ signal(SIGPIPE, SIG_IGN);
+
+ /*
+ * Reap zombies left over from the boot phase. Services and
+ * hooks spawned by schedule_ready() may have created orphaned
+ * descendants that exited before SA_NOCLDWAIT was active.
+ * SA_NOCLDWAIT does not retroactively reap existing zombies.
+ */
+ while (waitpid(-1, NULL, WNOHANG) > 0)
+ ;
+
+ for (;;) {
+ if (setjmp(rcd_oom_env) != 0) {
+ log_warn("OOM: restarting event loop");
+ /*
+ * After longjmp from an allocation failure, some
+ * per-event heap state may be leaked, but the
+ * reaper and running services are unaffected.
+ */
+ }
+
+ nevents = kevent(ctx->ctx_kq, NULL, 0, events, 64, NULL);
+ if (nevents < 0) {
+ if (errno == EINTR)
+ continue;
+ log_err(1, "kevent");
+ }
+
+ for (i = 0; i < nevents; i++) {
+ struct kevent *kev = &events[i];
+
+ switch (kev->filter) {
+ case EVFILT_PROCDESC:
+ handle_procdesc_event(ctx, kev);
+ break;
+ case EVFILT_READ:
+ if ((int)kev->ident == ctx->ctx_ctlsock)
+ control_handle(ctx, (int)kev->ident);
+ else if (kev->udata != NULL) {
+ struct unit *u = kev->udata;
+
+ /* Readiness notification (eventfd) */
+ if ((int)kev->ident == u->u_notify_fd)
+ handle_readiness_event(ctx, kev);
+ /* Syslog pipe event */
+ else if ((int)kev->ident == u->u_log.lc_stdout_pipefd ||
+ (int)kev->ident == u->u_log.lc_stderr_pipefd)
+ log_handle_pipe_event(u, (int)kev->ident);
+ else
+ handle_sockact_event(ctx, kev);
+ }
+ break;
+ case EVFILT_TIMER:
+ handle_timer_event(ctx, kev);
+ break;
+ case EVFILT_VNODE:
+ /* Control socket deleted — recreate it */
+ control_reinit(ctx);
+ break;
+ case EVFILT_SIGNAL:
+ if (kev->ident == SIGTERM ||
+ kev->ident == SIGINT) {
+ if (ctx->ctx_config.cfg_precious_machine) {
+ log_warn("shutdown refused: "
+ "precious_machine is set");
+ break;
+ }
+ do_shutdown(ctx);
+ return (0);
+ }
+ if (kev->ident == SIGHUP) {
+ do_reload_config(ctx);
+ }
+ if (kev->ident == SIGUSR1) {
+ do_upgrade(ctx, rcd_argv0);
+ /* If exec failed, we're still here */
+ }
+ /*
+ * SIGALRM: re-read override files during
+ * boot. Allows enable/disable of services
+ * while boot is in progress (like rc's
+ * SIGALRM rc.conf reload mechanism).
+ */
+ if (kev->ident == SIGALRM &&
+ ctx->ctx_booting) {
+ struct unit *su;
+
+ log_info("SIGALRM: reloading "
+ "overrides during boot");
+ TAILQ_FOREACH(su,
+ &ctx->ctx_graph.dg_units,
+ u_entries)
+ unit_apply_overrides(su,
+ "/etc/rcd.conf.d");
+ }
+ break;
+ }
+ }
+
+ /* Check if boot phase is complete */
+ if (ctx->ctx_booting && boot_complete(ctx))
+ finish_boot(ctx);
+ }
+}
+
+/*
+ * Load all unit files and legacy scripts from system and localbase dirs.
+ */
+static void
+load_all_units(struct rcd_ctx *ctx)
+{
+ char local_unit_dir[PATH_MAX];
+ char local_legacy_dir[PATH_MAX];
+
+ snprintf(local_unit_dir, sizeof(local_unit_dir),
+ "%s/etc/rcd.d", localbase);
+ snprintf(local_legacy_dir, sizeof(local_legacy_dir),
+ "%s/etc/rc.d", localbase);
+
+ load_units_from_dir(ctx, RCD_UNIT_DIR);
+ load_units_from_dir(ctx, local_unit_dir);
+
+ compat_load_rcvars(&ctx->ctx_config);
+ load_legacy_from_dir(ctx, RCD_LEGACY_DIR);
+ load_legacy_from_dir(ctx, local_legacy_dir);
+}
+
+/*
+ * Filter units, apply overrides, resolve the DAG, and mark disabled
+ * units as done so their dependants are unblocked.
+ */
+static void
+prepare_graph(struct rcd_ctx *ctx)
+{
+ struct unit *u;
+ bool progress;
+
+ filter_units_by_context(ctx);
+
+ if (ctx->ctx_config.cfg_quiet_boot) {
+ log_init(LOG_WARNING);
+ log_console_set_enabled(false);
+ }
+
+ TAILQ_FOREACH(u, &ctx->ctx_graph.dg_units, u_entries)
+ unit_apply_overrides(u, "/etc/rcd.conf.d");
+
+ if (depgraph_resolve(&ctx->ctx_graph) != 0)
+ log_warn("dependency resolution had errors");
+ if (depgraph_check_cycles(&ctx->ctx_graph) != 0)
+ log_warn("dependency cycles detected");
+
+ do {
+ progress = false;
+ TAILQ_FOREACH(u, &ctx->ctx_graph.dg_units, u_entries) {
+ if (u->u_state != STATE_INACTIVE)
+ continue;
+ if (!u->u_enabled || u->u_nostart) {
+ u->u_state = STATE_DONE;
+ depgraph_mark_done(&ctx->ctx_graph, u);
+ progress = true;
+ }
+ }
+ } while (progress);
+}
+
+/*
+ * Instantiate templates from /etc/rcd.conf.d/<template> and apply
+ * per-instance overrides.
+ */
+static void
+instantiate_templates(struct rcd_ctx *ctx)
+{
+ struct unit *u;
+
+ TAILQ_FOREACH(u, &ctx->ctx_graph.dg_units, u_entries) {
+ struct ucl_parser *ip;
+ ucl_object_t *itop;
+ const ucl_object_t *inst_obj, *cur;
+ ucl_object_iter_t iit;
+ char ipath[PATH_MAX];
+
+ if (!u->u_template)
+ continue;
+
+ if (snprintf(ipath, sizeof(ipath),
+ "/etc/rcd.conf.d/%s", u->u_name) >= (int)sizeof(ipath))
+ continue;
+ ip = ucl_parser_new(UCL_PARSER_DEFAULT);
+ if (!ucl_parser_add_file(ip, ipath)) {
+ ucl_parser_free(ip);
+ continue;
+ }
+ itop = ucl_parser_get_object(ip);
+ ucl_parser_free(ip);
+ if (itop == NULL)
+ continue;
+
+ inst_obj = ucl_object_lookup(itop, "instances");
+ if (inst_obj == NULL) {
+ ucl_object_unref(itop);
+ continue;
+ }
+
+ iit = ucl_object_iterate_new(inst_obj);
+ while ((cur = ucl_object_iterate_safe(iit, true)) != NULL) {
+ const char *iname;
+ char fullname[256];
+ struct unit *inst;
+
+ if (ucl_object_type(cur) == UCL_STRING)
+ iname = ucl_object_tostring(cur);
+ else
+ iname = ucl_object_key(cur);
+ if (iname == NULL)
+ continue;
+
+ if (snprintf(fullname, sizeof(fullname),
+ "%s@%s", u->u_name, iname) >= (int)sizeof(fullname))
+ continue;
+ if (depgraph_find(&ctx->ctx_graph, fullname) != NULL)
+ continue;
+
+ inst = unit_instantiate(u, iname);
+ if (inst == NULL)
+ continue;
+
+ if (ucl_object_type(cur) == UCL_OBJECT) {
+ unsigned char *s;
+
+ s = ucl_object_emit(cur, UCL_EMIT_CONFIG);
+ if (s != NULL)
+ inst->u_instance_conf = (char *)s;
+ }
+ depgraph_add(&ctx->ctx_graph, inst);
+ }
+ ucl_object_iterate_free(iit);
+ ucl_object_unref(itop);
+ }
+
+ /* Apply overrides to instances created above */
+ TAILQ_FOREACH(u, &ctx->ctx_graph.dg_units, u_entries) {
+ if (u->u_instance != NULL)
+ unit_apply_overrides(u, "/etc/rcd.conf.d");
+ }
+}
+
+/*
+ * Run off_command for disabled services.
+ */
+static void
+run_off_commands(struct rcd_ctx *ctx)
+{
+ struct unit *u;
+
+ TAILQ_FOREACH(u, &ctx->ctx_graph.dg_units, u_entries) {
+ if (!u->u_enabled && u->u_off_command != NULL) {
+ log_info("%s: running off_command", u->u_name);
+ proc_run_hook_inst(u->u_off_command, u);
+ }
+ }
+}
+
+/*
+ * Bind sockets for socket-activated services and register them
+ * in kqueue.
+ */
+static void
+bind_sockets(struct rcd_ctx *ctx)
+{
+ struct unit *u;
+ struct unit_socket *us;
+
+ TAILQ_FOREACH(u, &ctx->ctx_graph.dg_units, u_entries) {
+ TAILQ_FOREACH(us, &u->u_sockets, us_entries) {
+ if (sockact_bind(us) != 0)
+ log_warn("socket bind failed for %s",
+ u->u_name);
+ }
+ if (TAILQ_EMPTY(&u->u_sockets))
+ continue;
+ if (u->u_ready_method == READY_SOCKET)
+ sockact_register_deferred(u);
+ else
+ sockact_register(ctx, u);
+ }
+}
+
+int
+main(int argc, char *argv[])
+{
+ struct rcd_ctx ctx;
+ int ch;
+
+ bool upgrading;
+
+ memset(&ctx, 0, sizeof(ctx));
+ ctx.ctx_booting = true;
+ ctx.ctx_ctlsock = -1;
+ ctx.ctx_ctlsock_pathfd = -1;
+ depgraph_init(&ctx.ctx_graph);
+ rcd_argv0 = argv[0];
+
+ upgrading = check_upgrade_state();
+
+ while ((ch = getopt(argc, argv, "v")) != -1) {
+ switch (ch) {
+ case 'v':
+ log_set_verbose(true);
+ break;
+ default:
+ fprintf(stderr, "usage: rcd [-v]\n");
+ return (1);
+ }
+ }
+
+ log_init(LOG_INFO);
+
+ lua_init();
+ get_localbase();
+
+ ctx.ctx_jailed = check_jailed();
+ ctx.ctx_diskless = check_diskless();
+
+ if (ctx.ctx_jailed)
+ log_info("rcd starting (jailed)");
+ else if (ctx.ctx_diskless)
+ log_info("rcd starting (diskless)");
+ else
+ log_info("rcd starting");
+
+ if (ctx.ctx_diskless && access("/etc/rc.initdiskless", X_OK) == 0) {
+ log_info("running rc.initdiskless");
+ proc_run_hook(_PATH_BSHELL " /etc/rc.initdiskless");
+ }
+
+ /*
+ * Fork early so the child (which will be the supervisor) is
+ * the one that acquires the reaper role and owns all service
+ * processes. The parent waits on a pipe until boot completes.
+ *
+ * Save a copy of stderr (connected to the console by init)
+ * before daemonization so the child can write boot progress
+ * messages to the console.
+ */
+ log_console_open();
+ if (!upgrading)
+ rcd_daemonize();
+
+ /*
+ * Become the subreaper for all service processes.
+ * This is now in the child process, so all services spawned
+ * below will be under our reaper subtree.
+ */
+ if (proc_reaper_init() != 0 && !upgrading)
+ log_err(1, "proc_reaper_init");
+
+ /*
+ * Ignore SIGCHLD so terminated children are auto-reaped.
+ * Without this, orphaned descendants (e.g., background
+ * processes forked by hook commands or legacy scripts)
+ * become zombies until the main event loop starts.
+ */
+ signal(SIGCHLD, SIG_IGN);
+
+ ctx.ctx_kq = kqueue();
+ if (ctx.ctx_kq < 0)
+ log_err(1, "kqueue");
+
+ if (config_load(&ctx.ctx_config) != 0)
+ log_err(1, "config_load");
+
+ ctx.ctx_config.cfg_unit_schema = load_embedded_schema(
+ unit_schema_json, sizeof(unit_schema_json));
+ if (ctx.ctx_config.cfg_unit_schema == NULL)
+ log_warn("failed to load unit schema");
+
+ load_all_units(&ctx);
+ prepare_graph(&ctx);
+ instantiate_templates(&ctx);
+ run_off_commands(&ctx);
+
+ boottrace("rcd: boot started");
+ bind_sockets(&ctx);
+
+ if (upgrading) {
+ /*
+ * Re-exec upgrade: restore running services from the
+ * state file. Skip normal boot — services are already
+ * running. Create a fresh control socket (the old one
+ * was closed by exec).
+ */
+ restore_state(&ctx);
+ ctx.ctx_booting = false;
+ if (control_init(&ctx) != 0)
+ log_warn("control socket init failed");
+ log_info("upgrade: state restored, entering supervision");
+ } else {
+ struct kevent kev;
+ /* Initialize control socket */
+ if (control_init(&ctx) != 0)
+ log_warn("control socket init failed");
+
+ /* Start the parallel boot sequence */
+ schedule_ready(&ctx);
+
+ /*
+ * Set a boot timeout timer. If boot hasn't completed
+ * when it fires, declare it complete anyway.
+ */
+ EV_SET(&kev, 0xB007, EVFILT_TIMER,
+ EV_ADD | EV_ONESHOT, NOTE_SECONDS, 30, NULL);
+ kevent(ctx.ctx_kq, &kev, 1, NULL, 0, NULL);
+ }
+
+ /* Enter supervision loop */
+ return (rcd_main_loop(&ctx));
+}
diff --git a/sbin/rcd/rcd.conf.5 b/sbin/rcd/rcd.conf.5
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/rcd.conf.5
@@ -0,0 +1,199 @@
+.\" Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+.\"
+.\" SPDX-License-Identifier: BSD-2-Clause
+.\"
+.Dd April 30, 2026
+.Dt RCD.CONF 5
+.Os
+.Sh NAME
+.Nm rcd.conf
+.Nd global configuration for rcd
+.Sh DESCRIPTION
+The
+.Nm
+file is a UCL-format configuration file read by
+.Xr rcd 8
+at startup.
+All fields are optional; sensible defaults are used.
+.Sh FIELDS
+.Bl -tag -width "shutdown_timeout_ms"
+.It Cm parallel
+Enable parallel service startup.
+Default:
+.Cm true .
+.It Cm max_parallel
+Maximum concurrent service starts.
+0 means unlimited.
+Default:
+.Cm 0 .
+.It Cm control_socket
+Path to the control UNIX socket.
+Default:
+.Pa /var/run/rcd.sock .
+.It Cm control_permissions
+Permissions on the control socket.
+Accepts integer (0660), octal string ("0660"), symbolic ("rw-rw----"),
+or chmod-style ("u=rw,g=rw").
+Default:
+.Cm 0660 .
+.It Cm control_group
+Group allowed to connect to the control socket.
+Default:
+.Cm wheel .
+.It Cm log_level
+Logging verbosity: "debug", "info", "warning", or "error".
+Default:
+.Cm info .
+.It Cm stop_timeout_ms
+Per-service stop timeout in milliseconds.
+Default:
+.Cm 10000 .
+.It Cm shutdown_timeout_ms
+Global shutdown watchdog timeout in milliseconds.
+Default:
+.Cm 90000 .
+.It Cm firstboot_sentinel
+Path to the firstboot sentinel file.
+Default:
+.Pa /firstboot .
+.It Cm precious_machine
+When set to
+.Cm true ,
+refuse shutdown signals
+.Pq Dv SIGTERM , SIGINT .
+Guards against misdirected shutdown commands.
+Default:
+.Cm false .
+.It Cm quiet_boot
+Suppress info-level log messages during boot.
+Normal logging is restored after boot completes.
+Default:
+.Cm false .
+.It Cm veriexec
+Verify unit file ownership and permissions before loading.
+Unit files not owned by root or world-writable are rejected.
+Default:
+.Cm false .
+.El
+.Sh PER-SERVICE OVERRIDES
+Per-service configuration files are stored in
+.Pa /etc/rcd.conf.d/<service> .
+These UCL files are merged on top of the unit's configuration.
+All unit fields are supported.
+.Ss Merge Semantics
+.Bl -tag -width "Objects"
+.It Scalars
+Replaced.
+For example,
+.Cm command , command_args , sig_stop , start_delay ,
+hooks
+.Pq Cm setup_cmd , start_precmd , No etc. .
+.It Arrays
+Appended, with duplicates skipped.
+For example,
+.Cm provides , requires , before , keywords , required_dirs ,
+.Cm required_files , required_modules , required_vars .
+.It Objects
+Merged key by key.
+For example,
+.Cm restart , process , environment , jail , logging .
+.It Cm access
+Arrays within
+.Cm access
+are replaced, not appended, since they represent a complete ACL.
+.El
+.Ss Removing Array Entries
+The
+.Cm remove {}
+block removes specific entries from arrays:
+.Bd -literal -offset indent
+remove {
+ requires = ["old_dep"];
+ keywords = ["nostart"];
+}
+.Ed
+.Ss Replacing Arrays Entirely
+The
+.Cm replace {}
+block replaces an array in its entirety, discarding the
+unit file values:
+.Bd -literal -offset indent
+replace {
+ keywords = ["nojail"];
+}
+.Ed
+.Ss Application Order
+.Bl -enum -compact
+.It
+.Cm remove {}
+entries are deleted.
+.It
+Scalars are replaced; arrays are appended; objects are merged.
+.It
+.Cm replace {}
+arrays overwrite the result.
+.Sh TEMPLATE INSTANCES
+For template units, the override file declares instances:
+.Bd -literal -offset indent
+# /etc/rcd.conf.d/dhclient
+instances ["em0", "wlan0"];
+.Ed
+.Pp
+With per-instance configuration:
+.Bd -literal -offset indent
+# /etc/rcd.conf.d/geli
+instances {
+ ada0p4 {
+ flags = "-p -k /boot/keys/ada0p4.key";
+ tries = 5;
+ }
+ ada1p4 {}
+}
+.Ed
+.Pp
+Instance config is accessible in Lua hooks via
+.Cm rcd.instance_config .
+The global section (outside
+.Cm instances )
+is accessible via
+.Cm rcd.config .
+.Sh EXAMPLES
+.Bd -literal -offset indent
+# /etc/rcd.conf
+parallel = true;
+max_parallel = 8;
+control_permissions = "rw-rw----";
+log_level = "info";
+stop_timeout_ms = 15000;
+.Ed
+.Bd -literal -offset indent
+# /etc/rcd.conf.d/nginx
+enable = true;
+restart {
+ policy = "always";
+ delay = 2000;
+}
+access {
+ reload = ["www", "@webadmins"];
+}
+.Ed
+.Pp
+Adding a dependency to a packaged service:
+.Bd -literal -offset indent
+# /etc/rcd.conf.d/apache24
+enable = true;
+requires = ["valkey"]; # appended to existing requires
+.Ed
+.Pp
+Overriding stop signal and adding a keyword:
+.Bd -literal -offset indent
+# /etc/rcd.conf.d/myapp
+sig_stop = "SIGUSR1";
+keywords = ["resume"];
+.Ed
+.Sh SEE ALSO
+.Xr rcd 8 ,
+.Xr rcd.d 5 ,
+.Xr rcctl 8
+.Sh AUTHORS
+.An Baptiste Daroussin Aq Mt bapt@FreeBSD.org
diff --git a/sbin/rcd/rcd.d.5 b/sbin/rcd/rcd.d.5
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/rcd.d.5
@@ -0,0 +1,351 @@
+.\" Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+.\"
+.\" SPDX-License-Identifier: BSD-2-Clause
+.\"
+.Dd April 30, 2026
+.Dt RCD.D 5
+.Os
+.Sh NAME
+.Nm rcd.d
+.Nd service unit file format for rcd
+.Sh DESCRIPTION
+Service units for
+.Xr rcd 8
+are UCL files stored in
+.Pa /etc/rcd.d/
+and
+.Pa <localbase>/etc/rcd.d/ .
+Each file defines one service.
+.Sh REQUIRED FIELDS
+.Bl -tag -width "provides"
+.It Cm name
+Service name.
+Must match
+.Ql [a-zA-Z0-9][a-zA-Z0-9._-]* .
+.It Cm provides
+Array of provision names for dependency resolution.
+.El
+.Sh SERVICE TYPES
+.Bl -tag -width "barrier"
+.It Cm simple
+Foreground daemon, tracked directly via process descriptor.
+Preferred type for new services.
+.It Cm forking
+Daemon that daemonizes itself (forks and parent exits).
+.Xr rcd-exec 8
+runs in reaper mode to supervise the daemon through its
+double-fork daemonization.
+.It Cm oneshot
+Run-to-completion command.
+May use
+.Cm command
+or
+.Cm exec .
+.It Cm barrier
+Dependency synchronization point.
+No command executed.
+Auto-detected from legacy scripts that contain only comments
+and blank lines.
+.El
+.Pp
+Legacy rc.d scripts are additionally classified as
+.Dq legacy
+or
+.Dq legacy-forking
+.Pq for scripts with Va pidfile= No or Va command=
+by
+.Xr rcd 8
+during loading.
+Scripts with
+.Ql KEYWORD: NORCD
+are ignored entirely, allowing them to opt out of
+.Xr rcd 8
+management.
+See
+.Xr rcd 8
+for details on the compatibility layer.
+.Sh COMMAND FIELDS
+.Bl -tag -width "command_args"
+.It Cm command
+Path to the service executable.
+Required for simple, forking, and oneshot types without
+.Cm exec .
+.It Cm command_args
+Arguments to the command.
+.It Cm exec
+Inline code for oneshots.
+Prefix with
+.Dq lua:
+for Lua, otherwise shell.
+Use UCL heredoc for multiline:
+.Bd -literal -offset indent
+exec = <<LUAEOF
+lua:
+\&...
+LUAEOF;
+.Ed
+.It Cm command_prepend
+Command prepended before the service binary.
+Used for wrappers
+.Pq e.g., valgrind, strace .
+Can be overridden from
+.Pa /etc/rcd.conf.d/ .
+.It Cm stop_command
+Custom stop command.
+Supports
+.Dq lua:
+prefix.
+.It Cm off_command
+Command executed at boot when the service is disabled.
+Used for cleanup or state-setting even when not started.
+Supports
+.Dq lua:
+prefix.
+.It Cm enable
+Enabled state
+.Pq default: false .
+Services must be explicitly enabled via the unit file or
+.Xr rcctl 8 .
+.El
+.Sh DEPENDENCIES
+.Bl -tag -width "keywords"
+.It Cm requires
+Array of provision names that must be running before start.
+.It Cm before
+Array of provision names this service must start before.
+.It Cm keywords
+Filtering tags:
+.Cm nojail , nojailvnet , nostart , firstboot , resume .
+.Pp
+.Cm nostart
+prevents the service from being started during boot, but does not
+prevent manual start via
+.Xr rcctl 8 .
+This is distinct from
+.Cm enable = false ,
+which blocks both boot and manual start
+.Pq unless overridden with force/one flags .
+.El
+.Pp
+Dependencies can be extended via per-service overrides in
+.Pa /etc/rcd.conf.d/ .
+See
+.Xr rcd.conf 5
+for the override merge semantics.
+.Sh PRECONDITIONS
+.Bl -tag -width "required_sysctl"
+.It Cm required_dirs
+Directories that must exist.
+.It Cm required_files
+Files that must be readable.
+.It Cm required_modules
+Kernel modules to load via
+.Xr kldload 2 .
+.It Cm required_sysctl
+Sysctl key-value pairs that must match.
+.Bd -literal -offset indent
+required_sysctl {
+ "hw.machine_arch" = "amd64";
+}
+.Ed
+.It Cm required_vars
+Config variable names that must be set in the override file
+.Pa /etc/rcd.conf.d/<service>
+before the service can start.
+Used to enforce mandatory per-site configuration.
+.El
+.Sh HOOKS
+All hook fields accept shell commands or
+.Dq lua:
+prefixed Lua code.
+.Bl -tag -width "start_postcmd"
+.It Cm setup_cmd
+Run before
+.Cm start_precmd
+on start, restart, and reload.
+Like
+.Va ${name}_setup
+in
+.Xr rc.subr 8 .
+.It Cm start_precmd
+Run before starting.
+Return false (Lua) or non-zero (shell) to abort start.
+.It Cm start_postcmd
+Run after successful start.
+.It Cm stop_precmd
+Run before stopping.
+.It Cm stop_postcmd
+Run after stopping.
+.El
+.Sh SIGNALS
+.Bl -tag -width "sig_reload"
+.It Cm sig_stop
+Signal for stop.
+Accepts a signal number (1\(en31) or name
+.Pq e.g., Dq SIGTERM , Dq TERM .
+Default: SIGTERM (15).
+.It Cm sig_reload
+Signal for reload.
+Accepts a signal number (1\(en31) or name
+.Pq e.g., Dq SIGHUP , Dq HUP .
+Default: SIGHUP (1).
+.El
+.Sh RESTART POLICY
+.Bd -literal -offset indent
+restart {
+ policy = "on-failure"; # never, on-failure, always
+ max_retries = 5;
+ delay = 5000; # milliseconds between retries
+ backoff = "exponential"; # none, linear, exponential
+ reset = 60000; # milliseconds before retry counter resets
+}
+.Ed
+.Sh PROCESS CONFIGURATION
+.Bd -literal -offset indent
+process {
+ user = "www";
+ group = "www";
+ groups = ["operator", "video"];
+ chdir = "/var/www";
+ chroot = "/var/empty";
+ umask = 022;
+ nice = 5;
+ cpuset = "0-3";
+ fib = 1;
+ login_class = "daemon";
+ limits = "openfiles=1024:4096,stacksize=8388608";
+ env_file = "/etc/default/myservice";
+ oom_protect = true;
+}
+.Ed
+.Sh RESOURCE CONTROL
+.Bd -literal -offset indent
+rctl {
+ memoryuse = { action = "deny"; amount = "2g"; };
+ maxproc = { action = "deny"; amount = "64"; };
+ pcpu = { action = "throttle"; amount = "80"; };
+}
+.Ed
+.Sh SOCKET ACTIVATION
+.Bd -literal -offset indent
+socket "http" {
+ type = "stream";
+ listen = "tcp:*:80";
+ backlog = 128;
+}
+.Ed
+.Sh SERVICE JAIL
+.Bd -literal -offset indent
+jail {
+ enable = true;
+ options = ["netv4"];
+ ip4addr = ["192.0.2.1"];
+}
+.Ed
+.Sh ACCESS CONTROL
+.Bd -literal -offset indent
+access {
+ reload = ["www", "@webadmins"];
+ status = ["@users"];
+}
+.Ed
+.Pp
+Principals prefixed with
+.Ql @
+match group names.
+Root and the global
+.Cm control_group
+always have full access.
+.Sh CUSTOM COMMANDS
+.Bd -literal -offset indent
+commands {
+ configtest = "/usr/sbin/sshd -t";
+ rotate_log = <<LUAEOF
+lua:
+\&...
+LUAEOF;
+}
+.Ed
+.Pp
+Invoked via
+.Xr rcctl 8 :
+.Dl rcctl configtest sshd
+.Sh TEMPLATES
+Template units create per-instance services:
+.Bd -literal -offset indent
+name = "dhclient";
+template = true;
+command = "/sbin/dhclient";
+.Ed
+.Pp
+The instance name is appended as the last command argument.
+In Lua hooks,
+.Cm rcd.instance
+contains the instance name and
+.Cm rcd.instance_config
+the per-instance config table.
+.Pp
+Instances are declared in
+.Pa /etc/rcd.conf.d/<template> :
+.Bd -literal -offset indent
+instances ["em0", "wlan0"];
+.Ed
+.Sh LOGGING
+.Bd -literal -offset indent
+logging {
+ stdout = "syslog:daemon.info";
+ stderr = "file:/var/log/myservice.log";
+}
+.Ed
+.Pp
+Targets:
+.Cm syslog:<facility>.<level> ,
+.Cm file:<path> ,
+.Cm null .
+.Sh OTHER FIELDS
+.Bl -tag -width "start_delay"
+.It Cm description
+Human-readable description.
+.It Cm start_delay
+Delay before start in milliseconds.
+.It Cm environment {}
+Key-value pairs added to the service environment.
+.El
+.Sh EXAMPLES
+Simple daemon:
+.Bd -literal -offset indent
+name = "sshd";
+description = "Secure Shell Daemon";
+type = "simple";
+command = "/usr/sbin/sshd";
+command_args = "-D";
+provides = ["sshd"];
+requires = ["LOGIN", "FILESYSTEMS"];
+.Ed
+.Pp
+Oneshot with Lua:
+.Bd -literal -offset indent
+name = "cleartmp";
+type = "oneshot";
+provides = ["cleartmp"];
+
+exec = <<LUAEOF
+lua:
+local lfs = require('lfs')
+\&-- clean /tmp
+LUAEOF;
+.Ed
+.Pp
+Barrier:
+.Bd -literal -offset indent
+name = "FILESYSTEMS";
+type = "barrier";
+provides = ["FILESYSTEMS"];
+requires = ["root", "mountcritlocal"];
+.Ed
+.Sh SEE ALSO
+.Xr rcd 8 ,
+.Xr rcd.conf 5 ,
+.Xr rcctl 8
+.Sh AUTHORS
+.An Baptiste Daroussin Aq Mt bapt@FreeBSD.org
diff --git a/sbin/rcd/rctl_mgr.c b/sbin/rcd/rctl_mgr.c
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/rctl_mgr.c
@@ -0,0 +1,263 @@
+/*
+ * Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+/*
+ * Resource control integration. Applies rctl(2) rules to services
+ * based on their unit configuration, targeting either a jail subject
+ * (when service jails are enabled) or a process subject.
+ */
+
+#include <sys/param.h>
+#include <sys/procctl.h>
+#include <sys/rctl.h>
+
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "rcd.h"
+
+/*
+ * Check if RACCT/RCTL is available in the running kernel.
+ */
+bool
+rctl_available(void)
+{
+ char outbuf[64];
+
+ /*
+ * Try a no-op query. If RCTL is not compiled in, the syscall
+ * returns ENOSYS.
+ */
+ if (rctl_get_rules(":::", 4, outbuf, sizeof(outbuf)) < 0 &&
+ errno == ENOSYS)
+ return (false);
+ return (true);
+}
+
+/*
+ * Apply rctl rules for a service.
+ * Returns 0 on success, -1 on fatal error (RCTL not available).
+ */
+int
+rctl_apply(struct unit *u)
+{
+ struct rctl_conf *rc;
+ struct rctl_active *ra;
+ char rule[256];
+ char outbuf[128];
+ const char *subject_type, *subject_id;
+ char pidbuf[32];
+
+ if (STAILQ_EMPTY(&u->u_rctl))
+ return (0);
+
+ if (!rctl_available()) {
+ log_warn("%s: RACCT/RCTL not available in kernel", u->u_name);
+ return (-1);
+ }
+
+ /* Determine subject: jail or process */
+ if (u->u_jail.jc_enable && u->u_jail.jc_name != NULL) {
+ subject_type = "jail";
+ subject_id = u->u_jail.jc_name;
+ } else {
+ subject_type = "process";
+ snprintf(pidbuf, sizeof(pidbuf), "%d", u->u_pid);
+ subject_id = pidbuf;
+ }
+
+ STAILQ_FOREACH(rc, &u->u_rctl, rc_entries) {
+ if ((size_t)snprintf(rule, sizeof(rule), "%s:%s:%s:%s=%s",
+ subject_type, subject_id,
+ rc->rc_resource, rc->rc_action,
+ rc->rc_amount) >= sizeof(rule)) {
+ log_warn("%s: rctl rule too long", u->u_name);
+ continue;
+ }
+
+ if (rctl_add_rule(rule, strlen(rule) + 1,
+ outbuf, sizeof(outbuf)) != 0) {
+ log_warn("%s: rctl_add_rule(%s): %s",
+ u->u_name, rule, strerror(errno));
+ continue;
+ }
+
+ /* Track active rule for cleanup */
+ ra = xcalloc(1, sizeof(*ra));
+ strlcpy(ra->ra_rule, rule, sizeof(ra->ra_rule));
+ STAILQ_INSERT_TAIL(&u->u_rctl_active, ra, ra_entries);
+
+ log_debug("%s: rctl rule applied: %s", u->u_name, rule);
+ }
+
+ return (0);
+}
+
+/*
+ * Remove all active rctl rules for a service.
+ */
+void
+rctl_remove(struct unit *u)
+{
+ struct rctl_active *ra, *ra_tmp;
+ char outbuf[128];
+
+ STAILQ_FOREACH_SAFE(ra, &u->u_rctl_active, ra_entries, ra_tmp) {
+ rctl_remove_rule(ra->ra_rule, strlen(ra->ra_rule) + 1,
+ outbuf, sizeof(outbuf));
+ STAILQ_REMOVE(&u->u_rctl_active, ra, rctl_active, ra_entries);
+ free(ra);
+ }
+}
+
+/*
+ * Collect resource usage for all PIDs under a subreaper process.
+ *
+ * rcd-exec (the subreaper) runs as a child of rcd and tracks forking
+ * daemons and legacy scripts. Given the PID of rcd-exec (u->u_pid),
+ * use PROC_REAP_GETPIDS to discover all descendant PIDs (daemon,
+ * workers, etc.) and query RACCT for each one.
+ *
+ * Returns 0 on success, -1 on failure.
+ */
+static int
+rctl_get_usage_reaper(struct unit *u, char *outbuf, size_t outlen)
+{
+ struct procctl_reaper_status rs;
+ struct procctl_reaper_pids rp;
+ struct procctl_reaper_pidinfo *pids;
+ char filter[32];
+ char racctbuf[4096];
+ char pidbuf[32];
+ size_t pos;
+ unsigned int i;
+
+ pos = 0;
+
+ /* Check if this process is a subreaper */
+ if (procctl(P_PID, u->u_pid, PROC_REAP_STATUS, &rs) != 0)
+ return (-1);
+
+ if (rs.rs_descendants == 0)
+ return (-1);
+
+ pids = calloc(rs.rs_descendants, sizeof(*pids));
+ if (pids == NULL)
+ return (-1);
+
+ rp.rp_count = rs.rs_descendants;
+ rp.rp_pids = pids;
+
+ if (procctl(P_PID, u->u_pid, PROC_REAP_GETPIDS, &rp) != 0) {
+ free(pids);
+ return (-1);
+ }
+
+ for (i = 0; i < rp.rp_count; i++) {
+ const char *p;
+ char *copy, *entry, *tofree;
+ size_t slen;
+
+ snprintf(filter, sizeof(filter), "process:%d",
+ pids[i].pi_pid);
+ if (rctl_get_racct(filter,
+ strlen(filter) + 1, racctbuf, sizeof(racctbuf)) != 0) {
+ log_debug("%s: reaper child %d rctl_get_racct: %s",
+ u->u_name, pids[i].pi_pid, strerror(errno));
+ continue;
+ }
+
+ /* Skip empty racct responses */
+ if (racctbuf[0] == '\0')
+ continue;
+
+ /* Prefix with PID label */
+ snprintf(pidbuf, sizeof(pidbuf), "pid %d:\n",
+ pids[i].pi_pid);
+ if (pos + strlen(pidbuf) + strlen(racctbuf) + 1 >= outlen)
+ break;
+ pos += strlcpy(outbuf + pos, pidbuf, outlen - pos);
+
+ /* Indent each comma-separated entry on its own line */
+ tofree = copy = strdup(racctbuf);
+ if (copy == NULL)
+ continue;
+ while ((entry = strsep(&copy, ",")) != NULL) {
+ if (*entry == '\0')
+ continue;
+ /* Strip the "process:PID:" prefix (3 colon-separated fields) */
+ p = entry;
+ for (int colons = 0; colons < 3; colons++) {
+ while (*p != '\0' && *p != ':')
+ p++;
+ if (*p == ':')
+ p++;
+ }
+ slen = strlen(p);
+ if (pos + 4 + slen + 1 >= outlen)
+ break;
+ pos += snprintf(outbuf + pos, outlen - pos,
+ " %s\n", *p != '\0' ? p : entry);
+ }
+ free(tofree);
+ }
+
+ free(pids);
+ return (pos > 0 ? 0 : -1);
+}
+
+/*
+ * Query resource usage for a service (for rcctl resources).
+ * Returns 0 on success, -1 on failure. On failure, outbuf contains
+ * a human-readable reason if outlen is large enough.
+ */
+int
+rctl_get_usage(struct unit *u, char *outbuf, size_t outlen)
+{
+ char filter[128];
+
+ if (!rctl_available()) {
+ snprintf(outbuf, outlen,
+ "resource usage tracking (RACCT/RCTL) is not "
+ "available in the running kernel");
+ return (-1);
+ }
+
+ if (u->u_jail.jc_enable && u->u_jail.jc_name != NULL) {
+ if ((size_t)snprintf(filter, sizeof(filter), "jail:%s",
+ u->u_jail.jc_name) >= sizeof(filter)) {
+ snprintf(outbuf, outlen, "jail name too long");
+ return (-1);
+ }
+ } else if (u->u_pid > 0) {
+ /*
+ * First try to find descendant PIDs under the subreaper
+ * (rcd-exec). For legacy scripts and forking daemons,
+ * u->u_pid is the subreaper PID, not the service itself.
+ * RACCT stats are attached to the actual daemon process(es).
+ */
+ if (rctl_get_usage_reaper(u, outbuf, outlen) == 0)
+ return (0);
+ /* Fallback: query the tracked PID directly */
+ snprintf(filter, sizeof(filter), "process:%d",
+ u->u_pid);
+ } else {
+ snprintf(outbuf, outlen,
+ "service not running");
+ return (-1);
+ }
+
+ if (rctl_get_racct(filter, strlen(filter) + 1,
+ outbuf, outlen) != 0) {
+ snprintf(outbuf, outlen,
+ "cannot read resource usage: %s", strerror(errno));
+ return (-1);
+ }
+
+ return (0);
+}
diff --git a/sbin/rcd/schema/override.schema.json b/sbin/rcd/schema/override.schema.json
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/schema/override.schema.json
@@ -0,0 +1,216 @@
+{
+ "title": "rcd override file",
+ "description": "Per-service override for rcd(8). Placed in /etc/rcd.conf.d/<service>. Scalar fields replace the unit value. Array fields are appended (duplicates skipped). Use remove{} to delete entries, replace{} to replace arrays entirely.",
+ "type": "object",
+ "properties": {
+ "enable": {
+ "description": "Override enabled state",
+ "type": "boolean"
+ },
+ "description": {
+ "description": "Override service description",
+ "type": "string"
+ },
+ "command": {
+ "description": "Override service executable path",
+ "type": "string"
+ },
+ "command_args": {
+ "description": "Override command arguments",
+ "type": "string"
+ },
+ "command_prepend": {
+ "description": "Override command prepend (e.g. strace, valgrind)",
+ "type": "string"
+ },
+ "exec": {
+ "description": "Override inline exec for oneshots",
+ "type": "string"
+ },
+ "stop_command": {
+ "description": "Override custom stop command",
+ "type": "string"
+ },
+ "off_command": {
+ "description": "Override off-command (run at boot when disabled)",
+ "type": "string"
+ },
+ "sig_stop": {
+ "description": "Override stop signal",
+ "oneOf": [
+ { "type": "integer", "minimum": 1, "maximum": 31 },
+ { "type": "string" }
+ ]
+ },
+ "sig_reload": {
+ "description": "Override reload signal",
+ "oneOf": [
+ { "type": "integer", "minimum": 1, "maximum": 31 },
+ { "type": "string" }
+ ]
+ },
+ "start_delay": {
+ "description": "Override start delay in milliseconds",
+ "type": "integer",
+ "minimum": 0
+ },
+ "setup_cmd": { "type": "string" },
+ "start_precmd": { "type": "string" },
+ "start_postcmd": { "type": "string" },
+ "stop_precmd": { "type": "string" },
+ "stop_postcmd": { "type": "string" },
+ "provides": {
+ "description": "Provision names to append",
+ "type": "array",
+ "items": { "$ref": "#/$defs/service_name" }
+ },
+ "requires": {
+ "description": "Dependency names to append",
+ "type": "array",
+ "items": { "$ref": "#/$defs/service_name" }
+ },
+ "before": {
+ "description": "Before names to append",
+ "type": "array",
+ "items": { "$ref": "#/$defs/service_name" }
+ },
+ "keywords": {
+ "description": "Keywords to append",
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["nojail", "nojailvnet", "nostart", "firstboot", "resume"]
+ }
+ },
+ "required_dirs": {
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "required_files": {
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "required_modules": {
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "required_vars": {
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "restart": {
+ "$ref": "#/$defs/restart"
+ },
+ "process": {
+ "$ref": "#/$defs/process"
+ },
+ "environment": {
+ "type": "object",
+ "additionalProperties": { "type": "string" }
+ },
+ "jail": {
+ "$ref": "#/$defs/jail"
+ },
+ "logging": {
+ "$ref": "#/$defs/logging"
+ },
+ "access": {
+ "$ref": "#/$defs/access"
+ },
+ "remove": {
+ "description": "Remove specific entries from arrays. Each key is an array field name, each value is an array of entries to remove.",
+ "$ref": "#/$defs/array_fields"
+ },
+ "replace": {
+ "description": "Replace arrays entirely. Each key is an array field name, each value is the new array.",
+ "$ref": "#/$defs/array_fields"
+ }
+ },
+ "additionalProperties": false,
+ "$defs": {
+ "service_name": {
+ "type": "string",
+ "pattern": "^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$"
+ },
+ "array_fields": {
+ "type": "object",
+ "properties": {
+ "provides": { "type": "array", "items": { "type": "string" } },
+ "requires": { "type": "array", "items": { "type": "string" } },
+ "before": { "type": "array", "items": { "type": "string" } },
+ "keywords": { "type": "array", "items": { "type": "string" } },
+ "required_dirs": { "type": "array", "items": { "type": "string" } },
+ "required_files": { "type": "array", "items": { "type": "string" } },
+ "required_modules": { "type": "array", "items": { "type": "string" } },
+ "required_vars": { "type": "array", "items": { "type": "string" } }
+ },
+ "additionalProperties": false
+ },
+ "principal_list": {
+ "type": "array",
+ "items": { "type": "string", "pattern": "^@?[a-zA-Z0-9_.-]+$" }
+ },
+ "access": {
+ "type": "object",
+ "properties": {
+ "start": { "$ref": "#/$defs/principal_list" },
+ "stop": { "$ref": "#/$defs/principal_list" },
+ "restart": { "$ref": "#/$defs/principal_list" },
+ "reload": { "$ref": "#/$defs/principal_list" },
+ "status": { "$ref": "#/$defs/principal_list" }
+ },
+ "additionalProperties": false
+ },
+ "restart": {
+ "type": "object",
+ "properties": {
+ "policy": { "type": "string", "enum": ["never", "on-failure", "always"] },
+ "max_retries": { "type": "integer", "minimum": 0 },
+ "delay": { "type": "integer", "minimum": 0 },
+ "backoff": { "type": "string", "enum": ["none", "linear", "exponential"] },
+ "reset": { "type": "integer", "minimum": 0 }
+ },
+ "additionalProperties": false
+ },
+ "process": {
+ "type": "object",
+ "properties": {
+ "user": { "type": "string" },
+ "group": { "type": "string" },
+ "groups": { "type": "array", "items": { "type": "string" } },
+ "chdir": { "type": "string" },
+ "chroot": { "type": "string" },
+ "umask": { "type": "integer", "minimum": 0, "maximum": 511 },
+ "nice": { "type": "integer", "minimum": -20, "maximum": 20 },
+ "cpuset": { "type": "string" },
+ "fib": { "type": "integer", "minimum": 0 },
+ "login_class": { "type": "string" },
+ "limits": { "type": "string" },
+ "env_file": { "type": "string" },
+ "oom_protect": { "type": "boolean" }
+ },
+ "additionalProperties": false
+ },
+ "jail": {
+ "type": "object",
+ "properties": {
+ "enable": { "type": "boolean" },
+ "name": { "type": "string" },
+ "path": { "type": "string" },
+ "options": { "type": "array", "items": { "type": "string" } },
+ "ip4addr": { "type": "array", "items": { "type": "string" } },
+ "ip6addr": { "type": "array", "items": { "type": "string" } },
+ "devfs": { "type": "boolean" }
+ },
+ "additionalProperties": false
+ },
+ "logging": {
+ "type": "object",
+ "properties": {
+ "stdout": { "type": "string" },
+ "stderr": { "type": "string" }
+ },
+ "additionalProperties": false
+ }
+ }
+}
diff --git a/sbin/rcd/schema/rcd.conf.schema.json b/sbin/rcd/schema/rcd.conf.schema.json
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/schema/rcd.conf.schema.json
@@ -0,0 +1,60 @@
+{
+ "title": "rcd.conf",
+ "description": "Global configuration for rcd(8), the FreeBSD service manager",
+ "type": "object",
+ "properties": {
+ "parallel": {
+ "description": "Enable parallel service startup",
+ "type": "boolean",
+ "default": true
+ },
+ "max_parallel": {
+ "description": "Maximum number of services starting concurrently (0 = unlimited)",
+ "type": "integer",
+ "minimum": 0,
+ "default": 0
+ },
+ "control_socket": {
+ "description": "Path to the control UNIX socket",
+ "type": "string",
+ "default": "/var/run/rcd.sock"
+ },
+ "control_permissions": {
+ "description": "Permissions on the control socket. Accepts integer (0660), octal string (\"0660\"), or symbolic (\"rw-rw----\", \"u=rw,g=rw\")",
+ "oneOf": [
+ { "type": "integer", "minimum": 0, "maximum": 511 },
+ { "type": "string" }
+ ],
+ "default": 432
+ },
+ "control_group": {
+ "description": "Group allowed to connect to the control socket",
+ "type": "string",
+ "default": "wheel"
+ },
+ "log_level": {
+ "description": "Logging verbosity",
+ "type": "string",
+ "enum": ["debug", "info", "warning", "error"],
+ "default": "info"
+ },
+ "stop_timeout_ms": {
+ "description": "Per-service stop timeout in milliseconds before SIGKILL",
+ "type": "integer",
+ "minimum": 0,
+ "default": 10000
+ },
+ "shutdown_timeout_ms": {
+ "description": "Global shutdown watchdog timeout in milliseconds",
+ "type": "integer",
+ "minimum": 0,
+ "default": 90000
+ },
+ "firstboot_sentinel": {
+ "description": "Path to the firstboot sentinel file",
+ "type": "string",
+ "default": "/firstboot"
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/sbin/rcd/schema/unit.schema.json b/sbin/rcd/schema/unit.schema.json
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/schema/unit.schema.json
@@ -0,0 +1,389 @@
+{
+ "title": "rcd unit file",
+ "description": "Service unit definition for rcd(8), the FreeBSD service manager",
+ "type": "object",
+ "properties": {
+ "name": {
+ "description": "Service name (must match [a-zA-Z0-9][a-zA-Z0-9._-]*)",
+ "$ref": "#/$defs/service_name"
+ },
+ "description": {
+ "description": "Human-readable description of the service",
+ "type": "string"
+ },
+ "enable": {
+ "description": "Enabled state (default: false). Services must be explicitly enabled via the unit file or rcctl enable.",
+ "type": "boolean",
+ "default": false
+ },
+ "template": {
+ "description": "Mark this unit as a template. Instances are created via service@instance.",
+ "type": "boolean",
+ "default": false
+ },
+ "type": {
+ "description": "Service type",
+ "type": "string",
+ "enum": ["simple", "forking", "oneshot", "barrier"],
+ "default": "simple"
+ },
+ "command": {
+ "description": "Full path to the service executable",
+ "type": "string"
+ },
+ "command_args": {
+ "description": "Arguments passed to the command",
+ "type": "string"
+ },
+ "command_prepend": {
+ "description": "Command prepended before the service binary (e.g. strace, valgrind)",
+ "type": "string"
+ },
+ "exec": {
+ "description": "Inline exec for oneshots. Prefix with 'lua:' for Lua, otherwise shell. Replaces command for script-driven oneshots.",
+ "type": "string"
+ },
+ "stop_command": {
+ "description": "Custom stop command (if different from sending sig_stop). Prefix with 'lua:' for Lua.",
+ "type": "string"
+ },
+ "off_command": {
+ "description": "Command executed at boot when the service is disabled. Used for cleanup or state-setting.",
+ "type": "string"
+ },
+ "start_delay": {
+ "description": "Delay in milliseconds before starting the service (0 = none)",
+ "type": "integer",
+ "minimum": 0,
+ "default": 0
+ },
+ "sig_stop": {
+ "description": "Signal for stop: integer (1-31) or name (e.g. \"SIGTERM\", \"TERM\"). Default: SIGTERM (15).",
+ "oneOf": [
+ { "type": "integer", "minimum": 1, "maximum": 31 },
+ { "type": "string" }
+ ],
+ "default": 15
+ },
+ "sig_reload": {
+ "description": "Signal for reload: integer (1-31) or name (e.g. \"SIGHUP\", \"HUP\"). Default: SIGHUP (1).",
+ "oneOf": [
+ { "type": "integer", "minimum": 1, "maximum": 31 },
+ { "type": "string" }
+ ],
+ "default": 1
+ },
+ "provides": {
+ "description": "Names this service provides (for dependency resolution)",
+ "type": "array",
+ "items": { "$ref": "#/$defs/service_name" },
+ "minItems": 1
+ },
+ "requires": {
+ "description": "Names this service requires before starting",
+ "type": "array",
+ "items": { "$ref": "#/$defs/service_name" }
+ },
+ "before": {
+ "description": "Names that this service must start before",
+ "type": "array",
+ "items": { "$ref": "#/$defs/service_name" }
+ },
+ "keywords": {
+ "description": "Filtering keywords",
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["nojail", "nojailvnet", "nostart", "firstboot", "resume"]
+ }
+ },
+ "required_dirs": {
+ "description": "Directories that must exist before starting",
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "required_files": {
+ "description": "Files that must be readable before starting",
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "required_modules": {
+ "description": "Kernel modules to load before starting (via kldload)",
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "required_vars": {
+ "description": "Config variable names that must be set in the override file before the service can start",
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "required_sysctl": {
+ "description": "Sysctl values that must match before starting. Keys are sysctl names, values are expected string values.",
+ "type": "object",
+ "additionalProperties": { "type": "string" }
+ },
+ "setup_cmd": {
+ "description": "Command run before start_precmd on start, restart, and reload",
+ "type": "string"
+ },
+ "start_precmd": {
+ "description": "Shell command to run before starting the service",
+ "type": "string"
+ },
+ "start_postcmd": {
+ "description": "Shell command to run after starting the service",
+ "type": "string"
+ },
+ "stop_precmd": {
+ "description": "Shell command to run before stopping the service",
+ "type": "string"
+ },
+ "stop_postcmd": {
+ "description": "Shell command to run after stopping the service",
+ "type": "string"
+ },
+ "commands": {
+ "description": "Custom commands (name -> exec code). Prefix value with 'lua:' for Lua, otherwise shell.",
+ "type": "object",
+ "additionalProperties": { "type": "string" }
+ },
+ "ready": {
+ "description": "Readiness notification method",
+ "type": "object",
+ "properties": {
+ "method": {
+ "type": "string",
+ "enum": ["immediate", "fd", "exit", "socket"],
+ "default": "immediate"
+ }
+ },
+ "additionalProperties": false
+ },
+ "access": {
+ "$ref": "#/$defs/access"
+ },
+ "socket": {
+ "description": "Socket activation definitions (keyed by socket name)",
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/$defs/socket"
+ }
+ },
+ "process": {
+ "$ref": "#/$defs/process"
+ },
+ "restart": {
+ "$ref": "#/$defs/restart"
+ },
+ "rctl": {
+ "description": "Resource control rules (keyed by resource name)",
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/$defs/rctl_rule"
+ }
+ },
+ "jail": {
+ "$ref": "#/$defs/jail"
+ },
+ "environment": {
+ "description": "Environment variables for the service",
+ "type": "object",
+ "additionalProperties": { "type": "string" }
+ },
+ "logging": {
+ "$ref": "#/$defs/logging"
+ }
+ },
+ "required": ["name", "provides"],
+ "additionalProperties": false,
+ "$defs": {
+ "service_name": {
+ "type": "string",
+ "pattern": "^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$"
+ },
+ "principal_list": {
+ "type": "array",
+ "description": "List of principals: 'username' or '@groupname'",
+ "items": {
+ "type": "string",
+ "pattern": "^@?[a-zA-Z0-9_.-]+$"
+ }
+ },
+ "access": {
+ "type": "object",
+ "description": "Per-service access control. Principals are user names or @group names.",
+ "properties": {
+ "start": { "$ref": "#/$defs/principal_list" },
+ "stop": { "$ref": "#/$defs/principal_list" },
+ "restart": { "$ref": "#/$defs/principal_list" },
+ "reload": { "$ref": "#/$defs/principal_list" },
+ "status": { "$ref": "#/$defs/principal_list" }
+ },
+ "additionalProperties": false
+ },
+ "socket": {
+ "type": "object",
+ "description": "Socket activation definition",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["stream", "dgram", "seqpacket"],
+ "default": "stream"
+ },
+ "listen": {
+ "description": "Listen address: tcp:addr:port, tcp6:[addr]:port, udp:addr:port, udp6:[addr]:port, unix:/path. IPv6 addresses must be enclosed in square brackets.",
+ "type": "string",
+ "pattern": "^(tcp6?|udp6?|unix):.*$"
+ },
+ "backlog": {
+ "type": "integer",
+ "minimum": 1,
+ "default": 128
+ },
+ "permissions": {
+ "oneOf": [
+ { "type": "integer", "minimum": 0, "maximum": 511 },
+ { "type": "string" }
+ ],
+ "default": 438
+ },
+ "owner": { "type": "string" },
+ "group": { "type": "string" }
+ },
+ "required": ["listen"],
+ "additionalProperties": false
+ },
+ "process": {
+ "type": "object",
+ "description": "Process execution parameters",
+ "properties": {
+ "user": { "type": "string" },
+ "group": { "type": "string" },
+ "groups": {
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "chdir": { "type": "string" },
+ "chroot": { "type": "string" },
+ "umask": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 511,
+ "default": 18
+ },
+ "nice": {
+ "type": "integer",
+ "minimum": -20,
+ "maximum": 20,
+ "default": 0
+ },
+ "cpuset": {
+ "type": "string",
+ "pattern": "^[0-9][-0-9,]*$"
+ },
+ "fib": {
+ "type": "integer",
+ "minimum": 0,
+ "default": 0
+ },
+ "login_class": { "type": "string" },
+ "limits": { "type": "string" },
+ "env_file": { "type": "string" },
+ "oom_protect": {
+ "type": "boolean",
+ "default": false
+ }
+ },
+ "additionalProperties": false
+ },
+ "restart": {
+ "type": "object",
+ "description": "Restart policy configuration",
+ "properties": {
+ "policy": {
+ "type": "string",
+ "enum": ["never", "on-failure", "always"],
+ "default": "never"
+ },
+ "max_retries": {
+ "type": "integer",
+ "minimum": 0,
+ "default": 5
+ },
+ "delay": {
+ "description": "Delay in milliseconds between restart attempts",
+ "type": "integer",
+ "minimum": 0,
+ "default": 5000
+ },
+ "backoff": {
+ "type": "string",
+ "enum": ["none", "linear", "exponential"],
+ "default": "none"
+ },
+ "reset": {
+ "description": "Time in milliseconds after which the retry counter resets",
+ "type": "integer",
+ "minimum": 0,
+ "default": 60000
+ }
+ },
+ "additionalProperties": false
+ },
+ "rctl_rule": {
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "string",
+ "enum": ["deny", "log", "devctl", "throttle",
+ "sigterm", "sigkill", "sighup"]
+ },
+ "amount": { "type": "string" }
+ },
+ "required": ["action", "amount"],
+ "additionalProperties": false
+ },
+ "jail": {
+ "type": "object",
+ "properties": {
+ "enable": { "type": "boolean", "default": false },
+ "name": {
+ "$ref": "#/$defs/service_name"
+ },
+ "path": { "type": "string", "default": "/" },
+ "options": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["netv4", "netv6", "mlock", "sysvipc",
+ "allow.routing", "vmm"]
+ }
+ },
+ "ip4addr": {
+ "type": "array",
+ "items": { "type": "string", "format": "ipv4" }
+ },
+ "ip6addr": {
+ "type": "array",
+ "items": { "type": "string", "format": "ipv6" }
+ },
+ "devfs": { "type": "boolean", "default": false }
+ },
+ "additionalProperties": false
+ },
+ "logging": {
+ "type": "object",
+ "properties": {
+ "stdout": {
+ "type": "string",
+ "pattern": "^(syslog:[a-z0-9]+\\.[a-z]+|file:/.+|null)$"
+ },
+ "stderr": {
+ "type": "string",
+ "pattern": "^(syslog:[a-z0-9]+\\.[a-z]+|file:/.+|null)$"
+ }
+ },
+ "additionalProperties": false
+ }
+ }
+}
diff --git a/sbin/rcd/sockact.c b/sbin/rcd/sockact.c
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/sockact.c
@@ -0,0 +1,270 @@
+/*
+ * Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+/*
+ * Socket activation. Pre-bind sockets before services start, register
+ * them in kqueue, and pass them as inherited fds via posix_spawn file
+ * actions when the service is launched (either at boot or on first
+ * connection).
+ */
+
+#include <sys/param.h>
+#include <sys/event.h>
+#include <sys/socket.h>
+#include <sys/stat.h>
+#include <sys/un.h>
+
+#include <netinet/in.h>
+#include <arpa/inet.h>
+#include <netdb.h>
+
+#include <errno.h>
+#include <spawn.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "rcd.h"
+
+#define LISTEN_FD_START 3
+
+/*
+ * Parse a listen address string into sockaddr.
+ * Formats:
+ * "tcp:addr:port" → AF_INET, SOCK_STREAM
+ * "tcp6:addr:port" → AF_INET6, SOCK_STREAM
+ * "udp:addr:port" → AF_INET, SOCK_DGRAM
+ * "unix:/path" → AF_UNIX, SOCK_STREAM
+ */
+static int
+parse_host_port(const char *rest, int family, int socktype,
+ struct sockaddr_storage *ss, socklen_t *sslen)
+{
+ struct addrinfo hints, *res;
+ char host[256], port[32];
+ const char *colon;
+
+ colon = strrchr(rest, ':');
+ if (colon == NULL)
+ return (-1);
+
+ strlcpy(host, rest, MIN((size_t)(colon - rest + 1), sizeof(host)));
+ strlcpy(port, colon + 1, sizeof(port));
+
+ memset(&hints, 0, sizeof(hints));
+ hints.ai_family = family;
+ hints.ai_socktype = socktype;
+ hints.ai_flags = AI_PASSIVE;
+
+ if (getaddrinfo(strcmp(host, "*") == 0 ? NULL : host,
+ port, &hints, &res) != 0)
+ return (-1);
+
+ memcpy(ss, res->ai_addr, res->ai_addrlen);
+ *sslen = res->ai_addrlen;
+ freeaddrinfo(res);
+ return (0);
+}
+
+int
+parse_listen_addr(const char *spec, struct sockaddr_storage *ss,
+ socklen_t *sslen, int *domain, int *socktype)
+{
+ struct sockaddr_un *sun;
+ const char *rest;
+
+ if (strncmp(spec, "tcp:", 4) == 0) {
+ rest = spec + 4;
+ *domain = AF_INET;
+ *socktype = SOCK_STREAM;
+ } else if (strncmp(spec, "tcp6:", 5) == 0) {
+ rest = spec + 5;
+ *domain = AF_INET6;
+ *socktype = SOCK_STREAM;
+ } else if (strncmp(spec, "udp:", 4) == 0) {
+ rest = spec + 4;
+ *domain = AF_INET;
+ *socktype = SOCK_DGRAM;
+ } else if (strncmp(spec, "unix:", 5) == 0) {
+ *domain = AF_UNIX;
+ *socktype = SOCK_STREAM;
+ sun = (struct sockaddr_un *)ss;
+ memset(sun, 0, sizeof(*sun));
+ sun->sun_family = AF_UNIX;
+ strlcpy(sun->sun_path, spec + 5, sizeof(sun->sun_path));
+ sun->sun_len = SUN_LEN(sun);
+ *sslen = SUN_LEN(sun);
+ return (0);
+ } else {
+ return (-1);
+ }
+
+ return (parse_host_port(rest, *domain, *socktype, ss, sslen));
+}
+
+/*
+ * Create, bind, and listen on a socket.
+ */
+int
+sockact_bind(struct unit_socket *us)
+{
+ struct sockaddr_storage ss;
+ socklen_t sslen;
+ int domain, socktype;
+ int fd, opt;
+
+ if (us->us_address == NULL)
+ return (-1);
+
+ if (parse_listen_addr(us->us_address, &ss, &sslen,
+ &domain, &socktype) != 0) {
+ log_warn("invalid listen address: %s", us->us_address);
+ return (-1);
+ }
+
+ fd = socket(domain, socktype | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
+ if (fd < 0) {
+ log_warn("socket: %s", strerror(errno));
+ return (-1);
+ }
+
+ opt = 1;
+ if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) != 0)
+ log_warn("setsockopt SO_REUSEADDR: %s", strerror(errno));
+ if (setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt)) != 0)
+ log_warn("setsockopt SO_REUSEPORT: %s", strerror(errno));
+
+ /* Remove stale unix socket */
+ if (domain == AF_UNIX)
+ unlink(((struct sockaddr_un *)&ss)->sun_path);
+
+ if (bind(fd, (struct sockaddr *)&ss, sslen) != 0) {
+ log_warn("bind %s: %s", us->us_address, strerror(errno));
+ close(fd);
+ return (-1);
+ }
+
+ /* Set permissions for unix sockets */
+ if (domain == AF_UNIX)
+ chmod(((struct sockaddr_un *)&ss)->sun_path,
+ us->us_permissions);
+
+ if (socktype == SOCK_STREAM || socktype == SOCK_SEQPACKET) {
+ if (listen(fd, us->us_backlog) != 0) {
+ log_warn("listen %s: %s", us->us_address,
+ strerror(errno));
+ close(fd);
+ return (-1);
+ }
+ }
+
+ us->us_fd = fd;
+ log_info("bound socket %s: %s (fd %d)", us->us_name,
+ us->us_address, fd);
+ return (0);
+}
+
+/*
+ * Register socket-activated service sockets in kqueue.
+ * On first connection, the main loop will start the service.
+ */
+void
+sockact_register(struct rcd_ctx *ctx, struct unit *u)
+{
+ struct unit_socket *us;
+ struct kevent kev;
+
+ TAILQ_FOREACH(us, &u->u_sockets, us_entries) {
+ if (us->us_fd < 0)
+ continue;
+ EV_SET(&kev, us->us_fd, EVFILT_READ, EV_ADD, 0, 0, u);
+ if (kevent(ctx->ctx_kq, &kev, 1, NULL, 0, NULL) < 0)
+ log_warn("%s: kevent socket %s: %s",
+ u->u_name, us->us_name, strerror(errno));
+ }
+}
+
+/*
+ * Defer socket registration for READY_SOCKET units.
+ * Store the kevent in us_kev; it will be applied when the
+ * service signals readiness.
+ */
+void
+sockact_register_deferred(struct unit *u)
+{
+ struct unit_socket *us;
+
+ TAILQ_FOREACH(us, &u->u_sockets, us_entries) {
+ if (us->us_fd < 0)
+ continue;
+ EV_SET(&us->us_kev, us->us_fd, EVFILT_READ, EV_ADD, 0, 0, u);
+ }
+}
+
+/*
+ * Register all deferred sockets for a unit in kqueue.
+ * Called when the service signals readiness (eventfd notification).
+ */
+void
+sockact_deferred_register_all(struct rcd_ctx *ctx, struct unit *u)
+{
+ struct unit_socket *us;
+
+ TAILQ_FOREACH(us, &u->u_sockets, us_entries) {
+ if (us->us_fd < 0)
+ continue;
+ if (kevent(ctx->ctx_kq, &us->us_kev, 1, NULL, 0, NULL) < 0)
+ log_warn("%s: kevent socket %s: %s",
+ u->u_name, us->us_name, strerror(errno));
+ }
+}
+
+/*
+ * Set up posix_spawn file actions to pass socket fds to the child.
+ * Sockets are placed at fd LISTEN_FD_START, LISTEN_FD_START+1, ...
+ */
+int
+sockact_setup_fds(struct unit *u, posix_spawn_file_actions_t *fa,
+ int *listen_count)
+{
+ struct unit_socket *us;
+ int target_fd;
+
+ target_fd = LISTEN_FD_START;
+ *listen_count = 0;
+
+ TAILQ_FOREACH(us, &u->u_sockets, us_entries) {
+ if (us->us_fd < 0)
+ continue;
+
+ /*
+ * dup2 the bound socket to the expected fd number.
+ * The child inherits it; rcd keeps the original.
+ */
+ posix_spawn_file_actions_adddup2(fa, us->us_fd, target_fd);
+ target_fd++;
+ (*listen_count)++;
+ }
+
+ return (0);
+}
+
+/*
+ * Close all sockets for a unit.
+ */
+void
+sockact_close(struct unit *u)
+{
+ struct unit_socket *us;
+
+ TAILQ_FOREACH(us, &u->u_sockets, us_entries) {
+ if (us->us_fd >= 0) {
+ close(us->us_fd);
+ us->us_fd = -1;
+ }
+ }
+}
diff --git a/sbin/rcd/tests/Makefile b/sbin/rcd/tests/Makefile
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/Makefile
@@ -0,0 +1,89 @@
+.include <src.opts.mk>
+
+PACKAGE= tests
+
+LUASRC= ${SRCTOP}/contrib/lua/src
+UCLSRC= ${SRCTOP}/contrib/libucl
+
+CFLAGS+= -I${SRCTOP}/contrib/libucl/include
+CFLAGS+= -I${.CURDIR:H}
+CFLAGS+= -I${SRCTOP}/lib/liblua
+CFLAGS+= -I${LUASRC}
+CFLAGS+= -I${SRCTOP}/libexec/flua/modules
+CFLAGS+= -I${UCLSRC}/src -I${UCLSRC}/uthash
+CFLAGS+= -I${SRCTOP}/libexec/flua
+
+.PATH: ${.CURDIR:H}
+.PATH: ${SRCTOP}/libexec/flua/modules
+.PATH: ${UCLSRC}/lua
+
+ATF_TESTS_C+= depgraph_test
+ATF_TESTS_C+= unit_test
+ATF_TESTS_C+= compat_test
+ATF_TESTS_C+= config_test
+ATF_TESTS_C+= sockact_test
+ATF_TESTS_C+= enable_test
+ATF_TESTS_C+= process_test
+ATF_TESTS_C+= rctl_test
+ATF_TESTS_C+= luaexec_test
+ATF_TESTS_C+= jail_svc_test
+
+ATF_TESTS_SH+= rcd_jail_test
+
+${PACKAGE}FILES+= rcd_utils.subr
+
+SRCS.depgraph_test= depgraph_test.c depgraph.c unit.c log.c hash.c \
+ oom_stub.c
+SRCS.unit_test= unit_test.c unit.c log.c oom_stub.c
+SRCS.compat_test= compat_test.c compat.c unit.c depgraph.c log.c hash.c \
+ oom_stub.c
+SRCS.config_test= config_test.c unit.c log.c oom_stub.c
+SRCS.sockact_test= sockact_test.c sockact.c unit.c log.c oom_stub.c
+SRCS.enable_test= enable_test.c enable.c unit.c log.c oom_stub.c
+SRCS.process_test= process_test.c process.c unit.c log.c \
+ luaexec.c lposix.c lua_ucl.c \
+ sockact.c rctl_mgr.c jail_svc.c hash.c oom_stub.c
+SRCS.rctl_test= rctl_test.c rctl_mgr.c unit.c log.c oom_stub.c
+SRCS.luaexec_test= luaexec_test.c luaexec.c lposix.c lua_ucl.c \
+ unit.c log.c oom_stub.c
+SRCS.jail_svc_test= jail_svc_test.c jail_svc.c unit.c log.c oom_stub.c
+
+CWARNFLAGS.hash.c= -Wno-cast-align -Wno-cast-qual
+CWARNFLAGS.lua_ucl.c= -Wno-cast-align -Wno-cast-qual
+
+LIBADD= ucl
+LIBADD.process_test= ucl jail lua
+LIBADD.luaexec_test= ucl lua
+LIBADD.jail_svc_test= ucl jail
+
+.PATH: ${.CURDIR}/data
+
+${PACKAGE}FILES+= sshd.ucl
+${PACKAGE}FILES+= cleartmp.ucl
+${PACKAGE}FILES+= nginx.ucl
+${PACKAGE}FILES+= cycle_a.ucl
+${PACKAGE}FILES+= cycle_b.ucl
+${PACKAGE}FILES+= legacy_script
+${PACKAGE}FILES+= barrier_script
+${PACKAGE}FILES+= daemon_script
+${PACKAGE}FILES+= daemon_script2
+${PACKAGE}FILES+= oneshot_script
+${PACKAGE}FILES+= norcd_script
+${PACKAGE}FILES+= prepend.ucl
+${PACKAGE}FILES+= hooks.ucl
+${PACKAGE}FILES+= disabled.ucl
+${PACKAGE}FILES+= reqvars.ucl
+${PACKAGE}FILES+= offcmd.ucl
+${PACKAGE}FILES+= override_base.ucl
+${PACKAGE}FILES+= replace_test.ucl
+
+# Override test data: installed to ${TESTSDIR}/confdir/
+CONFDIR= ${TESTSDIR}/confdir
+CONFDIRFILES= override_base replace_test
+CONFDIRFILESDIR= ${CONFDIR}
+.PATH: ${.CURDIR}/data/confdir
+FILESGROUPS+= CONFDIRFILES
+
+WARNS?= 6
+
+.include <bsd.test.mk>
diff --git a/sbin/rcd/tests/compat_test.c b/sbin/rcd/tests/compat_test.c
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/compat_test.c
@@ -0,0 +1,340 @@
+/*-
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+ */
+
+/*
+ * Unit tests for the rc.d compatibility layer (header parsing).
+ */
+
+#include <sys/param.h>
+
+#include <stdio.h>
+#include <string.h>
+
+#include <atf-c.h>
+
+#include "rcd.h"
+
+ATF_TC(parse_provide_header);
+ATF_TC_HEAD(parse_provide_header, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Parse PROVIDE header from legacy script");
+}
+ATF_TC_BODY(parse_provide_header, tc)
+{
+ const char *srcdir = atf_tc_get_config_var(tc, "srcdir");
+ char path[PATH_MAX];
+ struct unit *u;
+
+ u = unit_alloc();
+ ATF_REQUIRE(u != NULL);
+
+ snprintf(path, sizeof(path), "%s/%s", srcdir, "legacy_script");
+ ATF_CHECK(compat_parse_headers(path, u) == 0);
+
+ ATF_CHECK_EQ(u->u_provide.len, 1);
+ ATF_CHECK_STREQ(u->u_provide.d[0], "test_legacy");
+
+ unit_free(u);
+}
+
+ATF_TC(parse_require_header);
+ATF_TC_HEAD(parse_require_header, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Parse REQUIRE header from legacy script");
+}
+ATF_TC_BODY(parse_require_header, tc)
+{
+ const char *srcdir = atf_tc_get_config_var(tc, "srcdir");
+ char path[PATH_MAX];
+ struct unit *u;
+
+ u = unit_alloc();
+ ATF_REQUIRE(u != NULL);
+
+ snprintf(path, sizeof(path), "%s/%s", srcdir, "legacy_script");
+ ATF_CHECK(compat_parse_headers(path, u) == 0);
+
+ ATF_CHECK_EQ(u->u_require.len, 2);
+ ATF_CHECK_STREQ(u->u_require.d[0], "NETWORKING");
+ ATF_CHECK_STREQ(u->u_require.d[1], "FILESYSTEMS");
+
+ unit_free(u);
+}
+
+ATF_TC(parse_before_header);
+ATF_TC_HEAD(parse_before_header, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Parse BEFORE header from legacy script");
+}
+ATF_TC_BODY(parse_before_header, tc)
+{
+ const char *srcdir = atf_tc_get_config_var(tc, "srcdir");
+ char path[PATH_MAX];
+ struct unit *u;
+
+ u = unit_alloc();
+ ATF_REQUIRE(u != NULL);
+
+ snprintf(path, sizeof(path), "%s/%s", srcdir, "legacy_script");
+ ATF_CHECK(compat_parse_headers(path, u) == 0);
+
+ ATF_CHECK_EQ(u->u_before.len, 1);
+ ATF_CHECK_STREQ(u->u_before.d[0], "LOGIN");
+
+ unit_free(u);
+}
+
+ATF_TC(parse_keyword_header);
+ATF_TC_HEAD(parse_keyword_header, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Parse KEYWORD header from legacy script");
+}
+ATF_TC_BODY(parse_keyword_header, tc)
+{
+ const char *srcdir = atf_tc_get_config_var(tc, "srcdir");
+ char path[PATH_MAX];
+ struct unit *u;
+
+ u = unit_alloc();
+ ATF_REQUIRE(u != NULL);
+
+ snprintf(path, sizeof(path), "%s/%s", srcdir, "legacy_script");
+ ATF_CHECK(compat_parse_headers(path, u) == 0);
+
+ ATF_CHECK_EQ(u->u_keyword.len, 1);
+ ATF_CHECK_STREQ(u->u_keyword.d[0], "nojail");
+
+ unit_free(u);
+}
+
+ATF_TC_WITHOUT_HEAD(parse_nonexistent_script);
+ATF_TC_BODY(parse_nonexistent_script, tc)
+{
+ struct unit *u;
+
+ u = unit_alloc();
+ ATF_REQUIRE(u != NULL);
+
+ ATF_CHECK(compat_parse_headers("/nonexistent/script", u) == -1);
+
+ unit_free(u);
+}
+
+ATF_TC(detect_barrier_script);
+ATF_TC_HEAD(detect_barrier_script, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Detect barrier script type");
+}
+ATF_TC_BODY(detect_barrier_script, tc)
+{
+ const char *srcdir = atf_tc_get_config_var(tc, "srcdir");
+ char path[PATH_MAX];
+ struct unit *u;
+
+ u = unit_alloc();
+ ATF_REQUIRE(u != NULL);
+
+ u->u_type = UNIT_LEGACY;
+ snprintf(path, sizeof(path), "%s/%s", srcdir, "barrier_script");
+ ATF_CHECK(compat_parse_headers(path, u) == 0);
+
+ ATF_CHECK(u->u_type == UNIT_BARRIER);
+ ATF_CHECK_EQ(u->u_provide.len, 1);
+ ATF_CHECK_STREQ(u->u_provide.d[0], "TEST_BARRIER");
+
+ unit_free(u);
+}
+
+ATF_TC(detect_legacy_forking);
+ATF_TC_HEAD(detect_legacy_forking, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Detect legacy forking daemon type");
+}
+ATF_TC_BODY(detect_legacy_forking, tc)
+{
+ const char *srcdir = atf_tc_get_config_var(tc, "srcdir");
+ char path[PATH_MAX];
+ struct unit *u;
+
+ u = unit_alloc();
+ ATF_REQUIRE(u != NULL);
+
+ u->u_type = UNIT_LEGACY;
+ snprintf(path, sizeof(path), "%s/%s", srcdir, "daemon_script");
+ ATF_CHECK(compat_parse_headers(path, u) == 0);
+
+ ATF_CHECK(u->u_type == UNIT_LEGACY_FORKING);
+ ATF_CHECK_EQ(u->u_provide.len, 1);
+ ATF_CHECK_STREQ(u->u_provide.d[0], "test_daemon");
+
+ unit_free(u);
+}
+
+ATF_TC(detect_legacy_forking_by_command);
+ATF_TC_HEAD(detect_legacy_forking_by_command, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "Detect legacy forking daemon by command variable");
+}
+ATF_TC_BODY(detect_legacy_forking_by_command, tc)
+{
+ const char *srcdir = atf_tc_get_config_var(tc, "srcdir");
+ char path[PATH_MAX];
+ struct unit *u;
+
+ u = unit_alloc();
+ ATF_REQUIRE(u != NULL);
+
+ u->u_type = UNIT_LEGACY;
+ snprintf(path, sizeof(path), "%s/%s", srcdir, "daemon_script2");
+ ATF_CHECK(compat_parse_headers(path, u) == 0);
+
+ /* command= without start_cmd= -> UNIT_LEGACY_FORKING */
+ ATF_CHECK(u->u_type == UNIT_LEGACY_FORKING);
+ ATF_CHECK_EQ(u->u_provide.len, 1);
+ ATF_CHECK_STREQ(u->u_provide.d[0], "test_daemon2");
+
+ unit_free(u);
+}
+
+ATF_TC(detect_legacy_oneshot);
+ATF_TC_HEAD(detect_legacy_oneshot, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Detect legacy oneshot script type");
+}
+ATF_TC_BODY(detect_legacy_oneshot, tc)
+{
+ const char *srcdir = atf_tc_get_config_var(tc, "srcdir");
+ char path[PATH_MAX];
+ struct unit *u;
+
+ u = unit_alloc();
+ ATF_REQUIRE(u != NULL);
+
+ u->u_type = UNIT_LEGACY;
+ snprintf(path, sizeof(path), "%s/%s", srcdir, "oneshot_script");
+ ATF_CHECK(compat_parse_headers(path, u) == 0);
+
+ /* start_cmd= present -> stays UNIT_LEGACY (oneshot) */
+ ATF_CHECK(u->u_type == UNIT_LEGACY);
+ ATF_CHECK_EQ(u->u_provide.len, 1);
+ ATF_CHECK_STREQ(u->u_provide.d[0], "test_oneshot");
+
+ unit_free(u);
+}
+
+ATF_TC(detect_rcvar);
+ATF_TC_HEAD(detect_rcvar, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Detect rcvar presence in scripts");
+}
+ATF_TC_BODY(detect_rcvar, tc)
+{
+ const char *srcdir = atf_tc_get_config_var(tc, "srcdir");
+ char path[PATH_MAX];
+ struct unit *u;
+
+ /* Script with rcvar= should have u_has_rcvar set */
+ u = unit_alloc();
+ ATF_REQUIRE(u != NULL);
+ u->u_type = UNIT_LEGACY;
+ snprintf(path, sizeof(path), "%s/%s", srcdir, "daemon_script");
+ ATF_CHECK(compat_parse_headers(path, u) == 0);
+ ATF_CHECK(u->u_has_rcvar == true);
+ unit_free(u);
+
+ /* Script without rcvar= should not have u_has_rcvar set */
+ u = unit_alloc();
+ ATF_REQUIRE(u != NULL);
+ u->u_type = UNIT_LEGACY;
+ snprintf(path, sizeof(path), "%s/%s", srcdir, "daemon_script2");
+ ATF_CHECK(compat_parse_headers(path, u) == 0);
+ ATF_CHECK(u->u_has_rcvar == false);
+ unit_free(u);
+}
+
+ATF_TC(scan_directory);
+ATF_TC_HEAD(scan_directory, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Scan a directory for legacy scripts");
+}
+ATF_TC_BODY(scan_directory, tc)
+{
+ const char *srcdir = atf_tc_get_config_var(tc, "srcdir");
+ struct rcd_ctx ctx;
+ struct unit *u;
+
+ memset(&ctx, 0, sizeof(ctx));
+ depgraph_init(&ctx.ctx_graph);
+ ctx.ctx_config.cfg_rcvars = hash_new();
+
+ /* Scanning a nonexistent directory should succeed quietly */
+ ATF_CHECK(compat_scan(&ctx, "/nonexistent/dir") == 0);
+
+ /* Scanning the data directory should find our legacy script */
+ ATF_CHECK(compat_scan(&ctx, srcdir) == 0);
+
+ /*
+ * Check if the legacy script was loaded. Note: compat_scan
+ * only loads files without a .ucl extension, so it should find
+ * legacy_script.
+ */
+ TAILQ_FOREACH(u, &ctx.ctx_graph.dg_units, u_entries) {
+ if (strcmp(u->u_name, "test_legacy") == 0) {
+ ATF_CHECK(u->u_type == UNIT_LEGACY ||
+ u->u_type == UNIT_LEGACY_FORKING);
+ ATF_CHECK_EQ(u->u_provide.len, 1);
+ break;
+ }
+ }
+
+ depgraph_free(&ctx.ctx_graph);
+}
+
+ATF_TC(norcd_keyword);
+ATF_TC_HEAD(norcd_keyword, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "Scripts with KEYWORD: NORCD are skipped by compat_scan");
+}
+ATF_TC_BODY(norcd_keyword, tc)
+{
+ const char *srcdir = atf_tc_get_config_var(tc, "srcdir");
+ struct rcd_ctx ctx;
+ struct unit *u;
+
+ memset(&ctx, 0, sizeof(ctx));
+ depgraph_init(&ctx.ctx_graph);
+ ctx.ctx_config.cfg_rcvars = hash_new();
+
+ ATF_CHECK(compat_scan(&ctx, srcdir) == 0);
+
+ /* norcd_test must NOT appear in the graph */
+ TAILQ_FOREACH(u, &ctx.ctx_graph.dg_units, u_entries)
+ ATF_CHECK_MSG(strcmp(u->u_name, "norcd_test") != 0,
+ "norcd_test should have been skipped");
+
+ depgraph_free(&ctx.ctx_graph);
+}
+
+ATF_TP_ADD_TCS(tp)
+{
+
+ ATF_TP_ADD_TC(tp, parse_provide_header);
+ ATF_TP_ADD_TC(tp, parse_require_header);
+ ATF_TP_ADD_TC(tp, parse_before_header);
+ ATF_TP_ADD_TC(tp, parse_keyword_header);
+ ATF_TP_ADD_TC(tp, parse_nonexistent_script);
+ ATF_TP_ADD_TC(tp, detect_barrier_script);
+ ATF_TP_ADD_TC(tp, detect_legacy_forking);
+ ATF_TP_ADD_TC(tp, detect_legacy_forking_by_command);
+ ATF_TP_ADD_TC(tp, detect_legacy_oneshot);
+ ATF_TP_ADD_TC(tp, detect_rcvar);
+ ATF_TP_ADD_TC(tp, scan_directory);
+ ATF_TP_ADD_TC(tp, norcd_keyword);
+
+ return (atf_no_error());
+}
diff --git a/sbin/rcd/tests/config_test.c b/sbin/rcd/tests/config_test.c
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/config_test.c
@@ -0,0 +1,305 @@
+/*-
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+ */
+
+/*
+ * Unit tests for configuration parsing helpers, in particular
+ * ucl_parse_mode() which accepts integer, octal string, and
+ * symbolic permission formats.
+ */
+
+#include <sys/param.h>
+#include <sys/stat.h>
+
+#include <stdio.h>
+#include <string.h>
+
+#include <atf-c.h>
+#include <ucl.h>
+
+#include "rcd.h"
+
+/*
+ * Helper: create a UCL integer object.
+ */
+static ucl_object_t *
+ucl_int(int64_t v)
+{
+
+ return (ucl_object_fromint(v));
+}
+
+/*
+ * Helper: create a UCL string object.
+ */
+static ucl_object_t *
+ucl_str(const char *s)
+{
+
+ return (ucl_object_fromstring(s));
+}
+
+/* ---- Integer mode tests ---- */
+
+ATF_TC_WITHOUT_HEAD(mode_int_0660);
+ATF_TC_BODY(mode_int_0660, tc)
+{
+ ucl_object_t *obj;
+ mode_t m;
+
+ obj = ucl_int(0660);
+ ATF_REQUIRE(ucl_parse_mode(obj, &m) == 0);
+ ATF_CHECK_EQ(m, 0660);
+ ucl_object_unref(obj);
+}
+
+ATF_TC_WITHOUT_HEAD(mode_int_0755);
+ATF_TC_BODY(mode_int_0755, tc)
+{
+ ucl_object_t *obj;
+ mode_t m;
+
+ obj = ucl_int(0755);
+ ATF_REQUIRE(ucl_parse_mode(obj, &m) == 0);
+ ATF_CHECK_EQ(m, 0755);
+ ucl_object_unref(obj);
+}
+
+ATF_TC_WITHOUT_HEAD(mode_int_0);
+ATF_TC_BODY(mode_int_0, tc)
+{
+ ucl_object_t *obj;
+ mode_t m;
+
+ obj = ucl_int(0);
+ ATF_REQUIRE(ucl_parse_mode(obj, &m) == 0);
+ ATF_CHECK_EQ(m, 0);
+ ucl_object_unref(obj);
+}
+
+ATF_TC_WITHOUT_HEAD(mode_int_0777);
+ATF_TC_BODY(mode_int_0777, tc)
+{
+ ucl_object_t *obj;
+ mode_t m;
+
+ obj = ucl_int(0777);
+ ATF_REQUIRE(ucl_parse_mode(obj, &m) == 0);
+ ATF_CHECK_EQ(m, 0777);
+ ucl_object_unref(obj);
+}
+
+ATF_TC_WITHOUT_HEAD(mode_int_negative);
+ATF_TC_BODY(mode_int_negative, tc)
+{
+ ucl_object_t *obj;
+ mode_t m;
+
+ obj = ucl_int(-1);
+ ATF_CHECK(ucl_parse_mode(obj, &m) == -1);
+ ucl_object_unref(obj);
+}
+
+ATF_TC_WITHOUT_HEAD(mode_int_too_large);
+ATF_TC_BODY(mode_int_too_large, tc)
+{
+ ucl_object_t *obj;
+ mode_t m;
+
+ /* 01000 = setuid bit — should be rejected */
+ obj = ucl_int(01000);
+ ATF_CHECK(ucl_parse_mode(obj, &m) == -1);
+ ucl_object_unref(obj);
+}
+
+/* ---- Octal string tests ---- */
+
+ATF_TC_WITHOUT_HEAD(mode_str_octal_0660);
+ATF_TC_BODY(mode_str_octal_0660, tc)
+{
+ ucl_object_t *obj;
+ mode_t m;
+
+ obj = ucl_str("0660");
+ ATF_REQUIRE(ucl_parse_mode(obj, &m) == 0);
+ ATF_CHECK_EQ(m, 0660);
+ ucl_object_unref(obj);
+}
+
+ATF_TC_WITHOUT_HEAD(mode_str_octal_0755);
+ATF_TC_BODY(mode_str_octal_0755, tc)
+{
+ ucl_object_t *obj;
+ mode_t m;
+
+ obj = ucl_str("0755");
+ ATF_REQUIRE(ucl_parse_mode(obj, &m) == 0);
+ ATF_CHECK_EQ(m, 0755);
+ ucl_object_unref(obj);
+}
+
+ATF_TC_WITHOUT_HEAD(mode_str_octal_0600);
+ATF_TC_BODY(mode_str_octal_0600, tc)
+{
+ ucl_object_t *obj;
+ mode_t m;
+
+ obj = ucl_str("0600");
+ ATF_REQUIRE(ucl_parse_mode(obj, &m) == 0);
+ ATF_CHECK_EQ(m, 0600);
+ ucl_object_unref(obj);
+}
+
+/* ---- Symbolic string tests (chmod-style: "u=rw,g=rw") ---- */
+
+ATF_TC_WITHOUT_HEAD(mode_str_chmod_u_rw_g_rw);
+ATF_TC_BODY(mode_str_chmod_u_rw_g_rw, tc)
+{
+ ucl_object_t *obj;
+ mode_t m;
+
+ /* "u=rw,g=rw" = 0660 */
+ obj = ucl_str("u=rw,g=rw");
+ ATF_REQUIRE(ucl_parse_mode(obj, &m) == 0);
+ ATF_CHECK_EQ(m, 0660);
+ ucl_object_unref(obj);
+}
+
+ATF_TC_WITHOUT_HEAD(mode_str_chmod_u_rwx_g_rx);
+ATF_TC_BODY(mode_str_chmod_u_rwx_g_rx, tc)
+{
+ ucl_object_t *obj;
+ mode_t m;
+
+ /* "u=rwx,g=rx" = 0750 */
+ obj = ucl_str("u=rwx,g=rx");
+ ATF_REQUIRE(ucl_parse_mode(obj, &m) == 0);
+ ATF_CHECK_EQ(m, 0750);
+ ucl_object_unref(obj);
+}
+
+ATF_TC_WITHOUT_HEAD(mode_str_chmod_a_rwx);
+ATF_TC_BODY(mode_str_chmod_a_rwx, tc)
+{
+ ucl_object_t *obj;
+ mode_t m;
+
+ /* "a=rwx" = 0777 */
+ obj = ucl_str("a=rwx");
+ ATF_REQUIRE(ucl_parse_mode(obj, &m) == 0);
+ ATF_CHECK_EQ(m, 0777);
+ ucl_object_unref(obj);
+}
+
+ATF_TC_WITHOUT_HEAD(mode_str_chmod_u_rw);
+ATF_TC_BODY(mode_str_chmod_u_rw, tc)
+{
+ ucl_object_t *obj;
+ mode_t m;
+
+ /* "u=rw" = 0600 */
+ obj = ucl_str("u=rw");
+ ATF_REQUIRE(ucl_parse_mode(obj, &m) == 0);
+ ATF_CHECK_EQ(m, 0600);
+ ucl_object_unref(obj);
+}
+
+/* ---- Error cases ---- */
+
+ATF_TC_WITHOUT_HEAD(mode_str_invalid);
+ATF_TC_BODY(mode_str_invalid, tc)
+{
+ ucl_object_t *obj;
+ mode_t m;
+
+ obj = ucl_str("not_a_mode");
+ ATF_CHECK(ucl_parse_mode(obj, &m) == -1);
+ ucl_object_unref(obj);
+}
+
+ATF_TC_WITHOUT_HEAD(mode_str_empty);
+ATF_TC_BODY(mode_str_empty, tc)
+{
+ ucl_object_t *obj;
+ mode_t m;
+
+ obj = ucl_str("");
+ /* Empty string: setmode returns NULL on some implementations */
+ ucl_parse_mode(obj, &m);
+ /* Just verify it doesn't crash */
+ ucl_object_unref(obj);
+}
+
+ATF_TC_WITHOUT_HEAD(mode_wrong_type);
+ATF_TC_BODY(mode_wrong_type, tc)
+{
+ ucl_object_t *obj;
+ mode_t m;
+
+ /* Boolean is neither INT nor STRING — should fail */
+ obj = ucl_object_frombool(true);
+ ATF_CHECK(ucl_parse_mode(obj, &m) == -1);
+ ucl_object_unref(obj);
+}
+
+/* ---- UCL integration: parse from config string ---- */
+
+ATF_TC_WITHOUT_HEAD(mode_ucl_string_octal);
+ATF_TC_BODY(mode_ucl_string_octal, tc)
+{
+ struct ucl_parser *parser;
+ ucl_object_t *top;
+ const ucl_object_t *val;
+ mode_t m;
+
+ parser = ucl_parser_new(UCL_PARSER_DEFAULT);
+ ATF_REQUIRE(ucl_parser_add_string(parser,
+ "control_permissions = \"0750\";", 0));
+ top = ucl_parser_get_object(parser);
+ ucl_parser_free(parser);
+ ATF_REQUIRE(top != NULL);
+
+ val = ucl_object_lookup(top, "control_permissions");
+ ATF_REQUIRE(val != NULL);
+ ATF_REQUIRE(ucl_parse_mode(val, &m) == 0);
+ ATF_CHECK_EQ(m, 0750);
+
+ ucl_object_unref(top);
+}
+
+ATF_TP_ADD_TCS(tp)
+{
+
+ /* Integer modes */
+ ATF_TP_ADD_TC(tp, mode_int_0660);
+ ATF_TP_ADD_TC(tp, mode_int_0755);
+ ATF_TP_ADD_TC(tp, mode_int_0);
+ ATF_TP_ADD_TC(tp, mode_int_0777);
+ ATF_TP_ADD_TC(tp, mode_int_negative);
+ ATF_TP_ADD_TC(tp, mode_int_too_large);
+
+ /* Octal string modes */
+ ATF_TP_ADD_TC(tp, mode_str_octal_0660);
+ ATF_TP_ADD_TC(tp, mode_str_octal_0755);
+ ATF_TP_ADD_TC(tp, mode_str_octal_0600);
+
+
+
+ /* Symbolic string modes (chmod-style) */
+ ATF_TP_ADD_TC(tp, mode_str_chmod_u_rw_g_rw);
+ ATF_TP_ADD_TC(tp, mode_str_chmod_u_rwx_g_rx);
+ ATF_TP_ADD_TC(tp, mode_str_chmod_a_rwx);
+ ATF_TP_ADD_TC(tp, mode_str_chmod_u_rw);
+
+ /* Error cases */
+ ATF_TP_ADD_TC(tp, mode_str_invalid);
+ ATF_TP_ADD_TC(tp, mode_str_empty);
+ ATF_TP_ADD_TC(tp, mode_wrong_type);
+
+ /* UCL integration */
+ ATF_TP_ADD_TC(tp, mode_ucl_string_octal);
+
+ return (atf_no_error());
+}
diff --git a/sbin/rcd/tests/data/barrier_script b/sbin/rcd/tests/data/barrier_script
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/data/barrier_script
@@ -0,0 +1,6 @@
+#!/bin/sh
+#
+# PROVIDE: TEST_BARRIER
+# REQUIRE: something
+#
+# This is a dummy barrier
diff --git a/sbin/rcd/tests/data/cleartmp.ucl b/sbin/rcd/tests/data/cleartmp.ucl
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/data/cleartmp.ucl
@@ -0,0 +1,10 @@
+name = "cleartmp";
+description = "Clear /tmp at boot";
+type = "oneshot";
+
+provides = ["cleartmp"];
+requires = ["mountcritlocal"];
+before = ["FILESYSTEMS"];
+
+command = "/bin/sh";
+command_args = "-c 'rm -rf /tmp/*'";
diff --git a/sbin/rcd/tests/data/confdir/override_base b/sbin/rcd/tests/data/confdir/override_base
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/data/confdir/override_base
@@ -0,0 +1,22 @@
+# Test override: append deps, replace scalar, add keyword, merge restart.
+
+# Scalars: replace
+command = "/usr/local/bin/overridden";
+sig_stop = "SIGUSR1";
+start_delay = 500;
+start_precmd = "/usr/bin/precheck";
+
+# Arrays: append (deduplicated)
+requires = ["FILESYSTEMS", "NETWORKING"];
+before = ["DAEMON"];
+keywords = ["resume"];
+
+# Objects: merge
+restart {
+ delay = 5000;
+}
+
+# Remove block
+remove {
+ provides = ["base_alias"];
+}
diff --git a/sbin/rcd/tests/data/confdir/replace_test b/sbin/rcd/tests/data/confdir/replace_test
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/data/confdir/replace_test
@@ -0,0 +1,7 @@
+# Test replace{}: completely replaces arrays.
+enable = true;
+
+replace {
+ requires = ["X", "Y"];
+ keywords = ["resume"];
+}
diff --git a/sbin/rcd/tests/data/cycle_a.ucl b/sbin/rcd/tests/data/cycle_a.ucl
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/data/cycle_a.ucl
@@ -0,0 +1,7 @@
+name = "cycle_a";
+description = "Cycle test A";
+type = "oneshot";
+command = "/bin/true";
+
+provides = ["cycle_a"];
+requires = ["cycle_b"];
diff --git a/sbin/rcd/tests/data/cycle_b.ucl b/sbin/rcd/tests/data/cycle_b.ucl
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/data/cycle_b.ucl
@@ -0,0 +1,7 @@
+name = "cycle_b";
+description = "Cycle test B";
+type = "oneshot";
+command = "/bin/true";
+
+provides = ["cycle_b"];
+requires = ["cycle_a"];
diff --git a/sbin/rcd/tests/data/daemon_script b/sbin/rcd/tests/data/daemon_script
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/data/daemon_script
@@ -0,0 +1,14 @@
+#!/bin/sh
+#
+# PROVIDE: test_daemon
+# REQUIRE: DAEMON
+# KEYWORD: shutdown
+
+. /etc/rc.subr
+name="test_daemon"
+rcvar="test_daemon_enable"
+command="/usr/sbin/test_daemon"
+pidfile="/var/run/test_daemon.pid"
+
+load_rc_config $name
+run_rc_command "$1"
diff --git a/sbin/rcd/tests/data/daemon_script2 b/sbin/rcd/tests/data/daemon_script2
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/data/daemon_script2
@@ -0,0 +1,11 @@
+#!/bin/sh
+#
+# PROVIDE: test_daemon2
+# REQUIRE: DAEMON
+
+. /etc/rc.subr
+name="test_daemon2"
+command="/usr/sbin/test_daemon2"
+
+load_rc_config $name
+run_rc_command "$1"
diff --git a/sbin/rcd/tests/data/disabled.ucl b/sbin/rcd/tests/data/disabled.ucl
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/data/disabled.ucl
@@ -0,0 +1,5 @@
+name = "disabled_test";
+type = "simple";
+command = "/usr/bin/test";
+enable = false;
+provides = ["disabled_test"];
diff --git a/sbin/rcd/tests/data/hooks.ucl b/sbin/rcd/tests/data/hooks.ucl
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/data/hooks.ucl
@@ -0,0 +1,7 @@
+name = "hooks_test";
+type = "simple";
+command = "/usr/bin/test";
+setup_cmd = "/usr/bin/setup";
+start_precmd = "/usr/bin/prestart";
+stop_postcmd = "/usr/bin/poststop";
+provides = ["hooks_test"];
diff --git a/sbin/rcd/tests/data/legacy_script b/sbin/rcd/tests/data/legacy_script
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/data/legacy_script
@@ -0,0 +1,15 @@
+#!/bin/sh
+#
+# PROVIDE: test_legacy
+# REQUIRE: NETWORKING FILESYSTEMS
+# BEFORE: LOGIN
+# KEYWORD: nojail
+
+. /etc/rc.subr
+
+name="test_legacy"
+rcvar="test_legacy_enable"
+command="/usr/bin/true"
+
+load_rc_config $name
+run_rc_command "$1"
diff --git a/sbin/rcd/tests/data/nginx.ucl b/sbin/rcd/tests/data/nginx.ucl
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/data/nginx.ucl
@@ -0,0 +1,26 @@
+name = "nginx";
+description = "Nginx HTTP Server";
+type = "simple";
+command = "/usr/local/sbin/nginx";
+command_args = "-g 'daemon off;'";
+
+provides = ["nginx", "http"];
+requires = ["NETWORKING", "sshd"];
+
+restart {
+ policy = "always";
+ max_retries = 10;
+ delay = 2000;
+ backoff = "linear";
+}
+
+process {
+ user = "www";
+ group = "www";
+ cpuset = "0-3";
+}
+
+rctl {
+ memoryuse = { action = "deny"; amount = "2g"; };
+ pcpu = { action = "throttle"; amount = "80"; };
+}
diff --git a/sbin/rcd/tests/data/norcd_script b/sbin/rcd/tests/data/norcd_script
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/data/norcd_script
@@ -0,0 +1,7 @@
+#!/bin/sh
+#
+# PROVIDE: norcd_test
+# REQUIRE:
+# KEYWORD: NORCD
+
+echo "this should never run under rcd"
diff --git a/sbin/rcd/tests/data/offcmd.ucl b/sbin/rcd/tests/data/offcmd.ucl
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/data/offcmd.ucl
@@ -0,0 +1,5 @@
+name = "offcmd_test";
+type = "simple";
+command = "/usr/bin/test";
+off_command = "/usr/bin/cleanup";
+provides = ["offcmd_test"];
diff --git a/sbin/rcd/tests/data/oneshot_script b/sbin/rcd/tests/data/oneshot_script
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/data/oneshot_script
@@ -0,0 +1,15 @@
+#!/bin/sh
+#
+# PROVIDE: test_oneshot
+# REQUIRE: FILESYSTEMS
+
+. /etc/rc.subr
+name="test_oneshot"
+start_cmd="test_oneshot_start"
+
+test_oneshot_start() {
+ echo "done"
+}
+
+load_rc_config $name
+run_rc_command "$1"
diff --git a/sbin/rcd/tests/data/override_base.ucl b/sbin/rcd/tests/data/override_base.ucl
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/data/override_base.ucl
@@ -0,0 +1,17 @@
+name = "override_base";
+description = "Base unit for override tests";
+type = "simple";
+command = "/usr/bin/test";
+
+provides = ["override_base", "base_alias"];
+requires = ["NETWORKING"];
+before = ["LOGIN"];
+keywords = ["nojail"];
+
+sig_stop = 15;
+
+restart {
+ policy = "on-failure";
+ max_retries = 3;
+ delay = 1000;
+}
diff --git a/sbin/rcd/tests/data/prepend.ucl b/sbin/rcd/tests/data/prepend.ucl
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/data/prepend.ucl
@@ -0,0 +1,5 @@
+name = "prepend_test";
+type = "simple";
+command = "/usr/bin/test";
+command_prepend = "/usr/bin/strace";
+provides = ["prepend_test"];
diff --git a/sbin/rcd/tests/data/replace_test.ucl b/sbin/rcd/tests/data/replace_test.ucl
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/data/replace_test.ucl
@@ -0,0 +1,7 @@
+name = "replace_test";
+type = "simple";
+command = "/usr/bin/test";
+
+provides = ["replace_test"];
+requires = ["A", "B", "C"];
+keywords = ["nojail", "nostart"];
diff --git a/sbin/rcd/tests/data/reqvars.ucl b/sbin/rcd/tests/data/reqvars.ucl
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/data/reqvars.ucl
@@ -0,0 +1,5 @@
+name = "reqvars_test";
+type = "simple";
+command = "/usr/bin/test";
+required_vars = ["api_key", "db_host"];
+provides = ["reqvars_test"];
diff --git a/sbin/rcd/tests/data/sshd.ucl b/sbin/rcd/tests/data/sshd.ucl
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/data/sshd.ucl
@@ -0,0 +1,54 @@
+name = "sshd";
+description = "Secure Shell Daemon";
+type = "simple";
+command = "/usr/sbin/sshd";
+command_args = "-D -f /etc/ssh/sshd_config";
+
+provides = ["sshd", "ssh"];
+requires = ["NETWORKING", "FILESYSTEMS"];
+before = ["LOGIN"];
+keywords = [];
+
+socket "ssh" {
+ type = "stream";
+ listen = "tcp:*:22";
+ backlog = 128;
+}
+
+process {
+ user = "root";
+ group = "wheel";
+ umask = "022";
+ nice = 0;
+ oom_protect = true;
+}
+
+restart {
+ policy = "on-failure";
+ max_retries = 5;
+ delay = 5000;
+ backoff = "exponential";
+ reset = 60000;
+}
+
+rctl {
+ memoryuse = { action = "deny"; amount = "1g"; };
+ openfiles = { action = "deny"; amount = "256"; };
+ maxproc = { action = "deny"; amount = "32"; };
+}
+
+jail {
+ enable = true;
+ options = ["netv4"];
+ ip4addr = ["192.0.2.1"];
+}
+
+environment {
+ LC_ALL = "C";
+ PATH = "/sbin:/bin:/usr/sbin:/usr/bin";
+}
+
+logging {
+ stdout = "syslog:daemon.info";
+ stderr = "syslog:daemon.err";
+}
diff --git a/sbin/rcd/tests/depgraph_test.c b/sbin/rcd/tests/depgraph_test.c
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/depgraph_test.c
@@ -0,0 +1,446 @@
+/*-
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+ */
+
+/*
+ * Unit tests for the dependency graph module.
+ */
+
+#include <sys/param.h>
+
+#include <stdio.h>
+#include <string.h>
+
+#include <atf-c.h>
+
+#include "rcd.h"
+
+/*
+ * Helper: create a minimal unit with given provide/require.
+ */
+static struct unit *
+make_unit(const char *name, const char **provide, int nprov,
+ const char **require, int nreq)
+{
+ struct unit *u;
+ int i;
+
+ u = unit_alloc();
+ u->u_name = xstrdup(name);
+ u->u_enabled = true;
+
+ for (i = 0; i < nprov; i++)
+ vec_push(&u->u_provide, xstrdup(provide[i]));
+
+ for (i = 0; i < nreq; i++)
+ vec_push(&u->u_require, xstrdup(require[i]));
+
+ return (u);
+}
+
+ATF_TC_WITHOUT_HEAD(empty_graph);
+ATF_TC_BODY(empty_graph, tc)
+{
+ struct depgraph dg;
+
+ depgraph_init(&dg);
+ ATF_CHECK_EQ(dg.dg_nunits, 0);
+ ATF_CHECK(depgraph_resolve(&dg) == 0);
+ ATF_CHECK(depgraph_check_cycles(&dg) == 0);
+ depgraph_free(&dg);
+}
+
+ATF_TC_WITHOUT_HEAD(single_unit_no_deps);
+ATF_TC_BODY(single_unit_no_deps, tc)
+{
+ struct depgraph dg;
+ struct unit *u, *ready[256];
+ int nready;
+ const char *prov[] = { "test" };
+
+ depgraph_init(&dg);
+
+ u = make_unit("test", prov, 1, NULL, 0);
+ depgraph_add(&dg, u);
+ ATF_CHECK_EQ(dg.dg_nunits, 1);
+
+ ATF_CHECK(depgraph_resolve(&dg) == 0);
+
+ depgraph_ready_set(&dg, ready, &nready);
+ ATF_CHECK_EQ(nready, 1);
+ ATF_CHECK_STREQ(ready[0]->u_name, "test");
+
+ depgraph_free(&dg);
+}
+
+ATF_TC_WITHOUT_HEAD(linear_dependency_chain);
+ATF_TC_BODY(linear_dependency_chain, tc)
+{
+ struct depgraph dg;
+ struct unit *a, *b, *c, *ready[256];
+ int nready;
+ const char *prov_a[] = { "A" };
+ const char *prov_b[] = { "B" };
+ const char *prov_c[] = { "C" };
+ const char *req_b[] = { "A" };
+ const char *req_c[] = { "B" };
+
+ depgraph_init(&dg);
+
+ a = make_unit("A", prov_a, 1, NULL, 0);
+ b = make_unit("B", prov_b, 1, req_b, 1);
+ c = make_unit("C", prov_c, 1, req_c, 1);
+
+ depgraph_add(&dg, a);
+ depgraph_add(&dg, b);
+ depgraph_add(&dg, c);
+
+ ATF_CHECK(depgraph_resolve(&dg) == 0);
+
+ /* Only A should be ready initially */
+ depgraph_ready_set(&dg, ready, &nready);
+ ATF_CHECK_EQ(nready, 1);
+ ATF_CHECK_STREQ(ready[0]->u_name, "A");
+
+ /* Mark A as done -> B should become ready */
+ a->u_state = STATE_RUNNING;
+ depgraph_mark_done(&dg, a);
+ depgraph_ready_set(&dg, ready, &nready);
+ ATF_CHECK_EQ(nready, 1);
+ ATF_CHECK_STREQ(ready[0]->u_name, "B");
+
+ /* Mark B as done -> C should become ready */
+ b->u_state = STATE_RUNNING;
+ depgraph_mark_done(&dg, b);
+ depgraph_ready_set(&dg, ready, &nready);
+ ATF_CHECK_EQ(nready, 1);
+ ATF_CHECK_STREQ(ready[0]->u_name, "C");
+
+ depgraph_free(&dg);
+}
+
+ATF_TC_WITHOUT_HEAD(parallel_independent);
+ATF_TC_BODY(parallel_independent, tc)
+{
+ struct depgraph dg;
+ struct unit *a, *b, *c, *ready[256];
+ int nready;
+ const char *prov_a[] = { "A" };
+ const char *prov_b[] = { "B" };
+ const char *prov_c[] = { "C" };
+
+ depgraph_init(&dg);
+
+ a = make_unit("A", prov_a, 1, NULL, 0);
+ b = make_unit("B", prov_b, 1, NULL, 0);
+ c = make_unit("C", prov_c, 1, NULL, 0);
+
+ depgraph_add(&dg, a);
+ depgraph_add(&dg, b);
+ depgraph_add(&dg, c);
+
+ ATF_CHECK(depgraph_resolve(&dg) == 0);
+
+ /* All three should be ready in parallel */
+ depgraph_ready_set(&dg, ready, &nready);
+ ATF_CHECK_EQ(nready, 3);
+
+ depgraph_free(&dg);
+}
+
+ATF_TC_WITHOUT_HEAD(diamond_dependency);
+ATF_TC_BODY(diamond_dependency, tc)
+{
+ struct depgraph dg;
+ struct unit *a, *b, *c, *d, *ready[256];
+ int nready;
+ const char *prov_a[] = { "A" };
+ const char *prov_b[] = { "B" };
+ const char *prov_c[] = { "C" };
+ const char *prov_d[] = { "D" };
+ const char *req_b[] = { "A" };
+ const char *req_c[] = { "A" };
+ const char *req_d[] = { "B", "C" };
+
+ depgraph_init(&dg);
+
+ a = make_unit("A", prov_a, 1, NULL, 0);
+ b = make_unit("B", prov_b, 1, req_b, 1);
+ c = make_unit("C", prov_c, 1, req_c, 1);
+ d = make_unit("D", prov_d, 1, req_d, 2);
+
+ depgraph_add(&dg, a);
+ depgraph_add(&dg, b);
+ depgraph_add(&dg, c);
+ depgraph_add(&dg, d);
+
+ ATF_CHECK(depgraph_resolve(&dg) == 0);
+
+ /* Only A should be ready initially */
+ depgraph_ready_set(&dg, ready, &nready);
+ ATF_CHECK_EQ(nready, 1);
+ ATF_CHECK_STREQ(ready[0]->u_name, "A");
+
+ /* Mark A as done -> B and C should be ready in parallel */
+ a->u_state = STATE_RUNNING;
+ depgraph_mark_done(&dg, a);
+ depgraph_ready_set(&dg, ready, &nready);
+ ATF_CHECK_EQ(nready, 2);
+
+ /* Mark B done -> D still blocked on C */
+ b->u_state = STATE_RUNNING;
+ depgraph_mark_done(&dg, b);
+ depgraph_ready_set(&dg, ready, &nready);
+ /* C is still inactive, D should not yet be ready */
+ ATF_CHECK_EQ(nready, 1);
+ ATF_CHECK_STREQ(ready[0]->u_name, "C");
+
+ /* Mark C done -> D should be ready */
+ c->u_state = STATE_RUNNING;
+ depgraph_mark_done(&dg, c);
+ depgraph_ready_set(&dg, ready, &nready);
+ ATF_CHECK_EQ(nready, 1);
+ ATF_CHECK_STREQ(ready[0]->u_name, "D");
+
+ depgraph_free(&dg);
+}
+
+ATF_TC(cycle_detection);
+ATF_TC_HEAD(cycle_detection, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Detect dependency cycles");
+}
+ATF_TC_BODY(cycle_detection, tc)
+{
+ const char *srcdir = atf_tc_get_config_var(tc, "srcdir");
+ char path_a[PATH_MAX], path_b[PATH_MAX];
+ struct depgraph dg;
+ struct rcd_config cfg;
+ struct unit *a, *b;
+
+ memset(&cfg, 0, sizeof(cfg));
+
+ depgraph_init(&dg);
+
+ snprintf(path_a, sizeof(path_a), "%s/%s", srcdir, "cycle_a.ucl");
+ snprintf(path_b, sizeof(path_b), "%s/%s", srcdir, "cycle_b.ucl");
+
+ a = unit_parse(path_a, &cfg);
+ b = unit_parse(path_b, &cfg);
+ ATF_REQUIRE(a != NULL);
+ ATF_REQUIRE(b != NULL);
+
+ depgraph_add(&dg, a);
+ depgraph_add(&dg, b);
+ depgraph_resolve(&dg);
+
+ /* Cycle detection should find the A -> B -> A cycle */
+ ATF_CHECK(depgraph_check_cycles(&dg) == -1);
+
+ depgraph_free(&dg);
+}
+
+ATF_TC_WITHOUT_HEAD(unresolved_dependency);
+ATF_TC_BODY(unresolved_dependency, tc)
+{
+ struct depgraph dg;
+ struct unit *a;
+ const char *prov[] = { "A" };
+ const char *req[] = { "NONEXISTENT" };
+
+ depgraph_init(&dg);
+
+ a = make_unit("A", prov, 1, req, 1);
+ depgraph_add(&dg, a);
+
+ /* Should return -1 for unresolved deps */
+ ATF_CHECK(depgraph_resolve(&dg) == -1);
+
+ depgraph_free(&dg);
+}
+
+ATF_TC_WITHOUT_HEAD(disabled_units_skipped);
+ATF_TC_BODY(disabled_units_skipped, tc)
+{
+ struct depgraph dg;
+ struct unit *a, *b, *ready[256];
+ int nready;
+ const char *prov_a[] = { "A" };
+ const char *prov_b[] = { "B" };
+
+ depgraph_init(&dg);
+
+ a = make_unit("A", prov_a, 1, NULL, 0);
+ b = make_unit("B", prov_b, 1, NULL, 0);
+ b->u_enabled = false;
+
+ depgraph_add(&dg, a);
+ depgraph_add(&dg, b);
+ depgraph_resolve(&dg);
+
+ depgraph_ready_set(&dg, ready, &nready);
+ ATF_CHECK_EQ(nready, 1);
+ ATF_CHECK_STREQ(ready[0]->u_name, "A");
+
+ depgraph_free(&dg);
+}
+
+ATF_TC_WITHOUT_HEAD(shutdown_order);
+ATF_TC_BODY(shutdown_order, tc)
+{
+ struct depgraph dg;
+ struct unit *a, *b, *c, *order[256];
+ int norder;
+ const char *prov_a[] = { "A" };
+ const char *prov_b[] = { "B" };
+ const char *prov_c[] = { "C" };
+
+ depgraph_init(&dg);
+
+ a = make_unit("A", prov_a, 1, NULL, 0);
+ b = make_unit("B", prov_b, 1, NULL, 0);
+ c = make_unit("C", prov_c, 1, NULL, 0);
+
+ a->u_state = STATE_RUNNING;
+ b->u_state = STATE_RUNNING;
+ c->u_state = STATE_RUNNING;
+
+ depgraph_add(&dg, a);
+ depgraph_add(&dg, b);
+ depgraph_add(&dg, c);
+
+ depgraph_shutdown_order(&dg, order, &norder);
+ ATF_CHECK_EQ(norder, 3);
+
+ /* Shutdown order is reverse of insertion */
+ ATF_CHECK_STREQ(order[0]->u_name, "C");
+ ATF_CHECK_STREQ(order[1]->u_name, "B");
+ ATF_CHECK_STREQ(order[2]->u_name, "A");
+
+ depgraph_free(&dg);
+}
+
+ATF_TC_WITHOUT_HEAD(find_by_provision);
+ATF_TC_BODY(find_by_provision, tc)
+{
+ struct depgraph dg;
+ struct unit *a, *found;
+ const char *prov[] = { "myservice", "myalias" };
+
+ depgraph_init(&dg);
+
+ a = make_unit("myservice", prov, 2, NULL, 0);
+ depgraph_add(&dg, a);
+
+ found = depgraph_find(&dg, "myservice");
+ ATF_CHECK(found == a);
+
+ found = depgraph_find(&dg, "myalias");
+ ATF_CHECK(found == a);
+
+ found = depgraph_find(&dg, "nonexistent");
+ ATF_CHECK(found == NULL);
+
+ depgraph_free(&dg);
+}
+
+ATF_TC_WITHOUT_HEAD(disabled_deps_satisfied);
+ATF_TC_BODY(disabled_deps_satisfied, tc)
+{
+ struct depgraph dg;
+ struct unit *a, *b, *c, *ready[256];
+ int nready;
+ const char *prov_a[] = { "A" };
+ const char *prov_b[] = { "B" };
+ const char *prov_c[] = { "C" };
+ const char *req_c[] = { "B" };
+
+ depgraph_init(&dg);
+
+ a = make_unit("A", prov_a, 1, NULL, 0);
+ b = make_unit("B", prov_b, 1, NULL, 0);
+ b->u_enabled = false;
+ c = make_unit("C", prov_c, 1, req_c, 1);
+
+ depgraph_add(&dg, a);
+ depgraph_add(&dg, b);
+ depgraph_add(&dg, c);
+
+ ATF_CHECK(depgraph_resolve(&dg) == 0);
+
+ /*
+ * B is disabled, so mark it as done immediately.
+ * C depends on B, so after B is done, C should become ready.
+ */
+ b->u_state = STATE_DONE;
+ depgraph_mark_done(&dg, b);
+
+ depgraph_ready_set(&dg, ready, &nready);
+ /*
+ * A is enabled and has no deps -> ready.
+ * C depended on B; B is now done -> C should also be ready.
+ */
+ ATF_CHECK_EQ(nready, 2);
+
+ depgraph_free(&dg);
+}
+
+ATF_TC_WITHOUT_HEAD(before_disabled_skipped);
+ATF_TC_BODY(before_disabled_skipped, tc)
+{
+ struct depgraph dg;
+ struct unit *a, *b, *ready[256];
+ int nready;
+ const char *prov_a[] = { "A" };
+ const char *prov_b[] = { "B" };
+
+ depgraph_init(&dg);
+
+ /*
+ * A declares BEFORE: B, meaning B depends on A.
+ * But A is disabled, so the edge should not be created
+ * and B should not be blocked.
+ */
+ a = make_unit("A", prov_a, 1, NULL, 0);
+ vec_push(&a->u_before, xstrdup("B"));
+ a->u_enabled = false;
+
+ b = make_unit("B", prov_b, 1, NULL, 0);
+
+ depgraph_add(&dg, a);
+ depgraph_add(&dg, b);
+
+ ATF_CHECK(depgraph_resolve(&dg) == 0);
+
+ depgraph_ready_set(&dg, ready, &nready);
+ /*
+ * A is disabled so should not appear in the ready set.
+ * B should be ready because the BEFORE edge from disabled
+ * A should not block it.
+ */
+ ATF_CHECK_EQ(nready, 1);
+ ATF_CHECK_STREQ(ready[0]->u_name, "B");
+
+ depgraph_free(&dg);
+}
+
+ATF_TP_ADD_TCS(tp)
+{
+
+ ATF_TP_ADD_TC(tp, empty_graph);
+ ATF_TP_ADD_TC(tp, single_unit_no_deps);
+ ATF_TP_ADD_TC(tp, linear_dependency_chain);
+ ATF_TP_ADD_TC(tp, parallel_independent);
+ ATF_TP_ADD_TC(tp, diamond_dependency);
+ ATF_TP_ADD_TC(tp, cycle_detection);
+ ATF_TP_ADD_TC(tp, unresolved_dependency);
+ ATF_TP_ADD_TC(tp, disabled_units_skipped);
+ ATF_TP_ADD_TC(tp, shutdown_order);
+ ATF_TP_ADD_TC(tp, find_by_provision);
+ ATF_TP_ADD_TC(tp, disabled_deps_satisfied);
+ ATF_TP_ADD_TC(tp, before_disabled_skipped);
+
+ return (atf_no_error());
+}
diff --git a/sbin/rcd/tests/enable_test.c b/sbin/rcd/tests/enable_test.c
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/enable_test.c
@@ -0,0 +1,226 @@
+/*-
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+ */
+
+/*
+ * Tests for enable.c: enable/disable/delete override persistence.
+ * Requires root (writes to /etc/rcd.conf.d/).
+ */
+
+#include <sys/param.h>
+#include <sys/stat.h>
+
+#include <errno.h>
+#include <stdio.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <atf-c.h>
+#include <ucl.h>
+
+#include "rcd.h"
+
+#define CONFD "/etc/rcd.conf.d"
+#define SVC "_rcd_test_enable"
+
+/* Read back and verify the enable state from the override file. */
+static bool
+read_enable_state(const char *name)
+{
+ struct ucl_parser *parser;
+ ucl_object_t *top;
+ const ucl_object_t *val;
+ char path[PATH_MAX];
+ bool enabled;
+
+ snprintf(path, sizeof(path), "%s/%s", CONFD, name);
+ parser = ucl_parser_new(UCL_PARSER_DEFAULT);
+ if (!ucl_parser_add_file(parser, path)) {
+ ucl_parser_free(parser);
+ return (false);
+ }
+ top = ucl_parser_get_object(parser);
+ ucl_parser_free(parser);
+ if (top == NULL)
+ return (false);
+
+ val = ucl_object_lookup(top, "enable");
+ enabled = (val != NULL && ucl_object_toboolean(val));
+ ucl_object_unref(top);
+ return (enabled);
+}
+
+ATF_TC_WITH_CLEANUP(enable_creates_file);
+ATF_TC_HEAD(enable_creates_file, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "enable_service creates override file with enable=true");
+ atf_tc_set_md_var(tc, "require.user", "root");
+}
+ATF_TC_BODY(enable_creates_file, tc)
+{
+ char path[PATH_MAX];
+
+ /* Clean up any leftover */
+ snprintf(path, sizeof(path), "%s/%s", CONFD, SVC);
+ unlink(path);
+
+ ATF_REQUIRE(enable_service(SVC, NULL) == 0);
+ ATF_CHECK(read_enable_state(SVC) == true);
+}
+ATF_TC_CLEANUP(enable_creates_file, tc)
+{
+ char path[PATH_MAX];
+ snprintf(path, sizeof(path), "%s/%s", CONFD, SVC);
+ unlink(path);
+}
+
+ATF_TC_WITH_CLEANUP(disable_sets_false);
+ATF_TC_HEAD(disable_sets_false, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "disable_service sets enable=false");
+ atf_tc_set_md_var(tc, "require.user", "root");
+}
+ATF_TC_BODY(disable_sets_false, tc)
+{
+ char path[PATH_MAX];
+
+ snprintf(path, sizeof(path), "%s/%s", CONFD, SVC);
+ unlink(path);
+
+ ATF_REQUIRE(enable_service(SVC, NULL) == 0);
+ ATF_CHECK(read_enable_state(SVC) == true);
+
+ ATF_REQUIRE(disable_service(SVC, NULL) == 0);
+ ATF_CHECK(read_enable_state(SVC) == false);
+}
+ATF_TC_CLEANUP(disable_sets_false, tc)
+{
+ char path[PATH_MAX];
+ snprintf(path, sizeof(path), "%s/%s", CONFD, SVC);
+ unlink(path);
+}
+
+ATF_TC_WITH_CLEANUP(enable_preserves_overrides);
+ATF_TC_HEAD(enable_preserves_overrides, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "enable_service preserves existing override keys");
+ atf_tc_set_md_var(tc, "require.user", "root");
+}
+ATF_TC_BODY(enable_preserves_overrides, tc)
+{
+ struct ucl_parser *parser;
+ ucl_object_t *top;
+ const ucl_object_t *val;
+ char path[PATH_MAX];
+ FILE *fp;
+
+ snprintf(path, sizeof(path), "%s/%s", CONFD, SVC);
+ unlink(path);
+
+ /* Create an override file with extra keys */
+ mkdir(CONFD, 0755);
+ fp = fopen(path, "w");
+ ATF_REQUIRE(fp != NULL);
+ fprintf(fp, "enable = false;\ncommand = \"/usr/local/bin/test\";\n");
+ fclose(fp);
+
+ /* Enable should preserve 'command' */
+ ATF_REQUIRE(enable_service(SVC, NULL) == 0);
+
+ parser = ucl_parser_new(UCL_PARSER_DEFAULT);
+ ATF_REQUIRE(ucl_parser_add_file(parser, path));
+ top = ucl_parser_get_object(parser);
+ ucl_parser_free(parser);
+ ATF_REQUIRE(top != NULL);
+
+ val = ucl_object_lookup(top, "enable");
+ ATF_CHECK(val != NULL);
+ ATF_CHECK(ucl_object_toboolean(val) == true);
+
+ val = ucl_object_lookup(top, "command");
+ ATF_CHECK(val != NULL);
+ ATF_CHECK_STREQ(ucl_object_tostring(val), "/usr/local/bin/test");
+
+ ucl_object_unref(top);
+}
+ATF_TC_CLEANUP(enable_preserves_overrides, tc)
+{
+ char path[PATH_MAX];
+ snprintf(path, sizeof(path), "%s/%s", CONFD, SVC);
+ unlink(path);
+}
+
+ATF_TC_WITH_CLEANUP(delete_removes_file);
+ATF_TC_HEAD(delete_removes_file, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "delete_override removes the file");
+ atf_tc_set_md_var(tc, "require.user", "root");
+}
+ATF_TC_BODY(delete_removes_file, tc)
+{
+ struct stat sb;
+ char path[PATH_MAX];
+
+ snprintf(path, sizeof(path), "%s/%s", CONFD, SVC);
+ unlink(path);
+
+ ATF_REQUIRE(enable_service(SVC, NULL) == 0);
+ ATF_CHECK(stat(path, &sb) == 0);
+
+ ATF_REQUIRE(delete_override(SVC) == 0);
+ ATF_CHECK(stat(path, &sb) != 0);
+ ATF_CHECK(errno == ENOENT);
+}
+ATF_TC_CLEANUP(delete_removes_file, tc)
+{
+ char path[PATH_MAX];
+ snprintf(path, sizeof(path), "%s/%s", CONFD, SVC);
+ unlink(path);
+}
+
+ATF_TC(delete_nonexistent);
+ATF_TC_HEAD(delete_nonexistent, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "delete_override on nonexistent file succeeds");
+ atf_tc_set_md_var(tc, "require.user", "root");
+}
+ATF_TC_BODY(delete_nonexistent, tc)
+{
+
+ ATF_CHECK(delete_override("_rcd_no_such_service") == 0);
+}
+
+ATF_TC(reject_path_traversal);
+ATF_TC_HEAD(reject_path_traversal, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "Service names with path traversal are rejected");
+ atf_tc_set_md_var(tc, "require.user", "root");
+}
+ATF_TC_BODY(reject_path_traversal, tc)
+{
+
+ ATF_CHECK(enable_service("../etc/passwd", NULL) != 0);
+ ATF_CHECK(enable_service("foo/bar", NULL) != 0);
+ ATF_CHECK(delete_override("../etc/passwd") != 0);
+}
+
+ATF_TP_ADD_TCS(tp)
+{
+
+ ATF_TP_ADD_TC(tp, enable_creates_file);
+ ATF_TP_ADD_TC(tp, disable_sets_false);
+ ATF_TP_ADD_TC(tp, enable_preserves_overrides);
+ ATF_TP_ADD_TC(tp, delete_removes_file);
+ ATF_TP_ADD_TC(tp, delete_nonexistent);
+ ATF_TP_ADD_TC(tp, reject_path_traversal);
+
+ return (atf_no_error());
+}
diff --git a/sbin/rcd/tests/jail_svc_test.c b/sbin/rcd/tests/jail_svc_test.c
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/jail_svc_test.c
@@ -0,0 +1,92 @@
+/*-
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+ */
+
+/*
+ * Tests for jail_svc.c: service jail creation/destruction.
+ */
+
+#include <sys/param.h>
+
+#include <string.h>
+
+#include <atf-c.h>
+
+#include "rcd.h"
+
+ATF_TC(jail_disabled_noop);
+ATF_TC_HEAD(jail_disabled_noop, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "jail_svc_create with jc_enable=false is a no-op");
+}
+ATF_TC_BODY(jail_disabled_noop, tc)
+{
+ struct unit *u;
+
+ u = unit_alloc();
+ u->u_name = xstrdup("test");
+ u->u_jail.jc_enable = false;
+
+ ATF_CHECK(jail_svc_create(u) == 0);
+ ATF_CHECK(u->u_jail.jc_jid == 0);
+ unit_free(u);
+}
+
+ATF_TC(jail_destroy_no_jid);
+ATF_TC_HEAD(jail_destroy_no_jid, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "jail_svc_destroy with jc_jid=0 is a no-op");
+}
+ATF_TC_BODY(jail_destroy_no_jid, tc)
+{
+ struct unit *u;
+
+ u = unit_alloc();
+ u->u_name = xstrdup("test");
+ u->u_jail.jc_jid = 0;
+
+ /* Should not crash or fail */
+ ATF_CHECK(jail_svc_destroy(u) == 0);
+ unit_free(u);
+}
+
+ATF_TC(jail_name_autogen);
+ATF_TC_HEAD(jail_name_autogen, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "jail_svc_create auto-generates jc_name from unit name");
+}
+ATF_TC_BODY(jail_name_autogen, tc)
+{
+ struct unit *u;
+
+ /*
+ * We cannot create a real jail in a unit test (jailparam_set
+ * may SIGSEGV in restricted ATF environments), but we can
+ * verify the name generation logic by checking the field
+ * after a disabled create.
+ */
+ u = unit_alloc();
+ u->u_name = xstrdup("myservice");
+ u->u_jail.jc_enable = false;
+
+ /* Disabled → no-op, name stays NULL */
+ ATF_CHECK(jail_svc_create(u) == 0);
+ ATF_CHECK(u->u_jail.jc_name == NULL);
+
+ unit_free(u);
+}
+
+ATF_TP_ADD_TCS(tp)
+{
+
+ ATF_TP_ADD_TC(tp, jail_disabled_noop);
+ ATF_TP_ADD_TC(tp, jail_destroy_no_jid);
+ ATF_TP_ADD_TC(tp, jail_name_autogen);
+
+ return (atf_no_error());
+}
diff --git a/sbin/rcd/tests/luaexec_test.c b/sbin/rcd/tests/luaexec_test.c
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/luaexec_test.c
@@ -0,0 +1,94 @@
+/*-
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+ */
+
+/*
+ * Tests for luaexec.c: embedded Lua execution.
+ */
+
+#include <sys/param.h>
+
+#include <string.h>
+
+#include <atf-c.h>
+
+#include "rcd.h"
+
+ATF_TC(lua_valid_code);
+ATF_TC_HEAD(lua_valid_code, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "lua_exec with valid code returns 0");
+}
+ATF_TC_BODY(lua_valid_code, tc)
+{
+
+ lua_init();
+ ATF_CHECK(lua_exec("return true", "test", NULL) == 0);
+}
+
+ATF_TC(lua_syntax_error);
+ATF_TC_HEAD(lua_syntax_error, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "lua_exec with syntax error returns non-zero");
+}
+ATF_TC_BODY(lua_syntax_error, tc)
+{
+
+ lua_init();
+ ATF_CHECK(lua_exec("this is not valid lua!!!", "test", NULL) != 0);
+}
+
+ATF_TC(lua_runtime_error);
+ATF_TC_HEAD(lua_runtime_error, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "lua_exec with runtime error returns non-zero");
+}
+ATF_TC_BODY(lua_runtime_error, tc)
+{
+
+ lua_init();
+ ATF_CHECK(lua_exec("error('boom')", "test", NULL) != 0);
+}
+
+ATF_TC(lua_return_false);
+ATF_TC_HEAD(lua_return_false, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "lua_exec returning false returns non-zero");
+}
+ATF_TC_BODY(lua_return_false, tc)
+{
+
+ lua_init();
+ ATF_CHECK(lua_exec("return false", "test", NULL) != 0);
+}
+
+ATF_TC(lua_no_return);
+ATF_TC_HEAD(lua_no_return, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "lua_exec with no return value succeeds");
+}
+ATF_TC_BODY(lua_no_return, tc)
+{
+
+ lua_init();
+ ATF_CHECK(lua_exec("local x = 1 + 1", "test", NULL) == 0);
+}
+
+ATF_TP_ADD_TCS(tp)
+{
+
+ ATF_TP_ADD_TC(tp, lua_valid_code);
+ ATF_TP_ADD_TC(tp, lua_syntax_error);
+ ATF_TP_ADD_TC(tp, lua_runtime_error);
+ ATF_TP_ADD_TC(tp, lua_return_false);
+ ATF_TP_ADD_TC(tp, lua_no_return);
+
+ return (atf_no_error());
+}
diff --git a/sbin/rcd/tests/oom_stub.c b/sbin/rcd/tests/oom_stub.c
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/oom_stub.c
@@ -0,0 +1,14 @@
+/*-
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+ */
+
+/*
+ * Stub definition for rcd_oom_env, used by tests that link against
+ * code using x* allocators (xmalloc.h). OOM never occurs in tests.
+ */
+
+#include "rcd.h"
+
+jmp_buf rcd_oom_env;
diff --git a/sbin/rcd/tests/process_test.c b/sbin/rcd/tests/process_test.c
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/process_test.c
@@ -0,0 +1,411 @@
+/*-
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+ */
+
+/*
+ * Tests for process.c: preconditions, module loading, hook execution.
+ */
+
+#include <sys/param.h>
+#include <sys/stat.h>
+#include <sys/sysctl.h>
+
+#include <stdio.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <atf-c.h>
+
+#include "rcd.h"
+
+/* ── proc_check_preconditions ── */
+
+ATF_TC(precond_dirs_ok);
+ATF_TC_HEAD(precond_dirs_ok, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "Preconditions pass when required dirs exist");
+}
+ATF_TC_BODY(precond_dirs_ok, tc)
+{
+ struct unit *u;
+
+ u = unit_alloc();
+ u->u_name = xstrdup("test");
+ vec_push(&u->u_required_dirs, xstrdup("/tmp"));
+ vec_push(&u->u_required_dirs, xstrdup("/var"));
+
+ ATF_CHECK(proc_check_preconditions(u) == 0);
+ unit_free(u);
+}
+
+ATF_TC(precond_dirs_missing);
+ATF_TC_HEAD(precond_dirs_missing, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "Preconditions fail when required dir is missing");
+}
+ATF_TC_BODY(precond_dirs_missing, tc)
+{
+ struct unit *u;
+
+ u = unit_alloc();
+ u->u_name = xstrdup("test");
+ vec_push(&u->u_required_dirs, xstrdup("/nonexistent_dir_12345"));
+
+ ATF_CHECK(proc_check_preconditions(u) != 0);
+ unit_free(u);
+}
+
+ATF_TC(precond_files_ok);
+ATF_TC_HEAD(precond_files_ok, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "Preconditions pass when required files are readable");
+}
+ATF_TC_BODY(precond_files_ok, tc)
+{
+ struct unit *u;
+
+ u = unit_alloc();
+ u->u_name = xstrdup("test");
+ vec_push(&u->u_required_files, xstrdup("/etc/rc.conf"));
+
+ ATF_CHECK(proc_check_preconditions(u) == 0);
+ unit_free(u);
+}
+
+ATF_TC(precond_files_missing);
+ATF_TC_HEAD(precond_files_missing, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "Preconditions fail when required file is missing");
+}
+ATF_TC_BODY(precond_files_missing, tc)
+{
+ struct unit *u;
+
+ u = unit_alloc();
+ u->u_name = xstrdup("test");
+ vec_push(&u->u_required_files, xstrdup("/no_such_file_99999"));
+
+ ATF_CHECK(proc_check_preconditions(u) != 0);
+ unit_free(u);
+}
+
+ATF_TC(precond_sysctl_match);
+ATF_TC_HEAD(precond_sysctl_match, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "Preconditions pass when sysctl matches expected value");
+}
+ATF_TC_BODY(precond_sysctl_match, tc)
+{
+ struct unit *u;
+ struct kv *sc;
+ char buf[256];
+ size_t len;
+
+ /* Read the actual hostname to use as expected value */
+ len = sizeof(buf);
+ if (sysctlbyname("kern.ostype", buf, &len, NULL, 0) != 0)
+ atf_tc_skip("cannot read kern.ostype");
+ if (len < sizeof(buf))
+ buf[len] = '\0';
+
+ u = unit_alloc();
+ u->u_name = xstrdup("test");
+ sc = xcalloc(1, sizeof(*sc));
+ sc->kv_key = xstrdup("kern.ostype");
+ sc->kv_val = xstrdup(buf);
+ STAILQ_INSERT_TAIL(&u->u_required_sysctl, sc, kv_entries);
+
+ ATF_CHECK(proc_check_preconditions(u) == 0);
+ unit_free(u);
+}
+
+ATF_TC(precond_sysctl_mismatch);
+ATF_TC_HEAD(precond_sysctl_mismatch, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "Preconditions fail when sysctl doesn't match");
+}
+ATF_TC_BODY(precond_sysctl_mismatch, tc)
+{
+ struct unit *u;
+ struct kv *sc;
+
+ u = unit_alloc();
+ u->u_name = xstrdup("test");
+ sc = xcalloc(1, sizeof(*sc));
+ sc->kv_key = xstrdup("kern.ostype");
+ sc->kv_val = xstrdup("NOT_AN_OS");
+ STAILQ_INSERT_TAIL(&u->u_required_sysctl, sc, kv_entries);
+
+ ATF_CHECK(proc_check_preconditions(u) != 0);
+ unit_free(u);
+}
+
+ATF_TC(precond_vars_present);
+ATF_TC_HEAD(precond_vars_present, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "Preconditions pass when required_vars are in override config");
+}
+ATF_TC_BODY(precond_vars_present, tc)
+{
+ struct unit *u;
+
+ u = unit_alloc();
+ u->u_name = xstrdup("test");
+ vec_push(&u->u_required_vars, xstrdup("api_key"));
+ u->u_override_conf = xstrdup("api_key = \"secret123\";");
+
+ ATF_CHECK(proc_check_preconditions(u) == 0);
+ unit_free(u);
+}
+
+ATF_TC(precond_vars_missing);
+ATF_TC_HEAD(precond_vars_missing, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "Preconditions fail when required_vars missing from config");
+}
+ATF_TC_BODY(precond_vars_missing, tc)
+{
+ struct unit *u;
+
+ u = unit_alloc();
+ u->u_name = xstrdup("test");
+ vec_push(&u->u_required_vars, xstrdup("api_key"));
+ u->u_override_conf = xstrdup("other_key = \"value\";");
+
+ ATF_CHECK(proc_check_preconditions(u) != 0);
+ unit_free(u);
+}
+
+ATF_TC(precond_vars_no_config);
+ATF_TC_HEAD(precond_vars_no_config, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "Preconditions fail when required_vars set but no override");
+}
+ATF_TC_BODY(precond_vars_no_config, tc)
+{
+ struct unit *u;
+
+ u = unit_alloc();
+ u->u_name = xstrdup("test");
+ vec_push(&u->u_required_vars, xstrdup("api_key"));
+ /* u->u_override_conf is NULL */
+
+ ATF_CHECK(proc_check_preconditions(u) != 0);
+ unit_free(u);
+}
+
+ATF_TC(precond_empty);
+ATF_TC_HEAD(precond_empty, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "No preconditions always passes");
+}
+ATF_TC_BODY(precond_empty, tc)
+{
+ struct unit *u;
+
+ u = unit_alloc();
+ u->u_name = xstrdup("test");
+
+ ATF_CHECK(proc_check_preconditions(u) == 0);
+ unit_free(u);
+}
+
+/* ── tokenize ── */
+
+ATF_TC(tokenize_simple);
+ATF_TC_HEAD(tokenize_simple, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Tokenize simple space-separated words");
+}
+ATF_TC_BODY(tokenize_simple, tc)
+{
+ charv_t v = vec_init();
+
+ tokenize("hello world foo", &v);
+ ATF_CHECK_EQ(v.len, 3);
+ ATF_CHECK_STREQ(v.d[0], "hello");
+ ATF_CHECK_STREQ(v.d[1], "world");
+ ATF_CHECK_STREQ(v.d[2], "foo");
+ vec_free_and_free(&v, free);
+}
+
+ATF_TC(tokenize_quoted);
+ATF_TC_HEAD(tokenize_quoted, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Tokenize handles single and double quotes");
+}
+ATF_TC_BODY(tokenize_quoted, tc)
+{
+ charv_t v = vec_init();
+
+ tokenize("\"hello world\" 'foo bar' baz", &v);
+ ATF_CHECK_EQ(v.len, 3);
+ ATF_CHECK_STREQ(v.d[0], "hello world");
+ ATF_CHECK_STREQ(v.d[1], "foo bar");
+ ATF_CHECK_STREQ(v.d[2], "baz");
+ vec_free_and_free(&v, free);
+}
+
+ATF_TC(tokenize_empty);
+ATF_TC_HEAD(tokenize_empty, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Tokenize empty string yields nothing");
+}
+ATF_TC_BODY(tokenize_empty, tc)
+{
+ charv_t v = vec_init();
+
+ tokenize("", &v);
+ ATF_CHECK_EQ(v.len, 0);
+ vec_free_and_free(&v, free);
+}
+
+ATF_TC(tokenize_whitespace_only);
+ATF_TC_HEAD(tokenize_whitespace_only, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Tokenize whitespace-only string yields nothing");
+}
+ATF_TC_BODY(tokenize_whitespace_only, tc)
+{
+ charv_t v = vec_init();
+
+ tokenize(" \t \t ", &v);
+ ATF_CHECK_EQ(v.len, 0);
+ vec_free_and_free(&v, free);
+}
+
+ATF_TC(tokenize_tabs);
+ATF_TC_HEAD(tokenize_tabs, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Tokenize splits on tabs too");
+}
+ATF_TC_BODY(tokenize_tabs, tc)
+{
+ charv_t v = vec_init();
+
+ tokenize("a\tb\t\tc", &v);
+ ATF_CHECK_EQ(v.len, 3);
+ ATF_CHECK_STREQ(v.d[0], "a");
+ ATF_CHECK_STREQ(v.d[1], "b");
+ ATF_CHECK_STREQ(v.d[2], "c");
+ vec_free_and_free(&v, free);
+}
+
+ATF_TC(tokenize_dq_escape);
+ATF_TC_HEAD(tokenize_dq_escape, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "Tokenize handles backslash escapes inside double quotes");
+}
+ATF_TC_BODY(tokenize_dq_escape, tc)
+{
+ charv_t v = vec_init();
+
+ tokenize("\"hello\\\"world\" 'no\\'escape'", &v);
+ ATF_CHECK_EQ(v.len, 2);
+ ATF_CHECK_STREQ(v.d[0], "hello\"world");
+ ATF_CHECK_STREQ(v.d[1], "no\\'escape");
+ vec_free_and_free(&v, free);
+}
+
+ATF_TC(tokenize_dq_backslash);
+ATF_TC_HEAD(tokenize_dq_backslash, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "Tokenize handles \\\\ escape inside double quotes");
+}
+ATF_TC_BODY(tokenize_dq_backslash, tc)
+{
+ charv_t v = vec_init();
+
+ tokenize("\"path\\\\to\\\\file\"", &v);
+ ATF_CHECK_EQ(v.len, 1);
+ ATF_CHECK_STREQ(v.d[0], "path\\to\\file");
+ vec_free_and_free(&v, free);
+}
+
+/* ── proc_run_hook ── */
+
+ATF_TC(hook_success);
+ATF_TC_HEAD(hook_success, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Hook returning 0 succeeds");
+}
+ATF_TC_BODY(hook_success, tc)
+{
+
+ ATF_CHECK(proc_run_hook("/usr/bin/true") == 0);
+}
+
+ATF_TC(hook_failure);
+ATF_TC_HEAD(hook_failure, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Hook returning non-zero fails");
+}
+ATF_TC_BODY(hook_failure, tc)
+{
+
+ ATF_CHECK(proc_run_hook("/usr/bin/false") != 0);
+}
+
+ATF_TC(hook_null);
+ATF_TC_HEAD(hook_null, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "NULL hook is a no-op success");
+}
+ATF_TC_BODY(hook_null, tc)
+{
+
+ ATF_CHECK(proc_run_hook(NULL) == 0);
+}
+
+ATF_TC(hook_empty);
+ATF_TC_HEAD(hook_empty, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Empty string hook is a no-op success");
+}
+ATF_TC_BODY(hook_empty, tc)
+{
+
+ ATF_CHECK(proc_run_hook("") == 0);
+}
+
+ATF_TP_ADD_TCS(tp)
+{
+
+ ATF_TP_ADD_TC(tp, precond_dirs_ok);
+ ATF_TP_ADD_TC(tp, precond_dirs_missing);
+ ATF_TP_ADD_TC(tp, precond_files_ok);
+ ATF_TP_ADD_TC(tp, precond_files_missing);
+ ATF_TP_ADD_TC(tp, precond_sysctl_match);
+ ATF_TP_ADD_TC(tp, precond_sysctl_mismatch);
+ ATF_TP_ADD_TC(tp, precond_vars_present);
+ ATF_TP_ADD_TC(tp, precond_vars_missing);
+ ATF_TP_ADD_TC(tp, precond_vars_no_config);
+ ATF_TP_ADD_TC(tp, precond_empty);
+ ATF_TP_ADD_TC(tp, hook_success);
+ ATF_TP_ADD_TC(tp, hook_failure);
+ ATF_TP_ADD_TC(tp, hook_null);
+ ATF_TP_ADD_TC(tp, hook_empty);
+ ATF_TP_ADD_TC(tp, tokenize_simple);
+ ATF_TP_ADD_TC(tp, tokenize_quoted);
+ ATF_TP_ADD_TC(tp, tokenize_empty);
+ ATF_TP_ADD_TC(tp, tokenize_whitespace_only);
+ ATF_TP_ADD_TC(tp, tokenize_tabs);
+ ATF_TP_ADD_TC(tp, tokenize_dq_escape);
+ ATF_TP_ADD_TC(tp, tokenize_dq_backslash);
+
+ return (atf_no_error());
+}
diff --git a/sbin/rcd/tests/rcd_jail_test.sh b/sbin/rcd/tests/rcd_jail_test.sh
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/rcd_jail_test.sh
@@ -0,0 +1,778 @@
+#-
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+#
+# Integration tests for rcd(8) running inside a jail.
+# Each test creates its own lightweight jail with nullfs + tmpfs,
+# populates it with test-specific units, and verifies behavior.
+#
+
+. $(atf_get_srcdir)/rcd_utils.subr
+
+# ---------------------------------------------------------------
+# Test: rcd boots in a jail and creates the control socket
+# ---------------------------------------------------------------
+atf_test_case boot_in_jail cleanup
+boot_in_jail_head()
+{
+ atf_set descr "rcd boots successfully in a jail"
+ atf_set require.user root
+ atf_set timeout 60
+}
+
+boot_in_jail_body()
+{
+ rcd_init
+
+ rcd_mkjail "boot"
+
+ # Add a simple barrier so rcd has something to process
+ rcd_add_unit "boot" "test_barrier.ucl" <<-EOF
+ name = "TEST_BOOT";
+ type = "barrier";
+ provides = ["TEST_BOOT"];
+ enable = true;
+ EOF
+
+ rcd_start "boot"
+ rcd_wait "boot"
+
+ # Control socket must exist
+ atf_check -s exit:0 \
+ test -S "/var/tmp/rcd_jail_boot.$$/var/run/rcd.sock"
+
+ # rcd process must be running
+ atf_check -s exit:0 -o match:"rcd" \
+ jexec "boot" ps aux
+}
+
+boot_in_jail_cleanup()
+{
+ rcd_cleanup
+}
+
+# ---------------------------------------------------------------
+# Test: rcctl status works with native units
+# ---------------------------------------------------------------
+atf_test_case rcctl_status cleanup
+rcctl_status_head()
+{
+ atf_set descr "rcctl status shows test services"
+ atf_set require.user root
+ atf_set timeout 60
+}
+
+rcctl_status_body()
+{
+ rcd_init
+
+ rcd_mkjail "status"
+
+ rcd_add_unit "status" "mybarrier.ucl" <<-EOF
+ name = "MYBARRIER";
+ type = "barrier";
+ provides = ["MYBARRIER"];
+ enable = true;
+ EOF
+
+ rcd_add_unit "status" "myoneshot.ucl" <<-EOF
+ name = "myoneshot";
+ type = "oneshot";
+ command = "/usr/bin/true";
+ provides = ["myoneshot"];
+ enable = true;
+ requires = ["MYBARRIER"];
+ EOF
+
+ rcd_start "status"
+ rcd_wait "status"
+
+ # rcctl status should show our services
+ atf_check -s exit:0 -o match:"SERVICE" \
+ jexec "status" /sbin/rcctl status
+ atf_check -s exit:0 -o match:"MYBARRIER" \
+ jexec "status" /sbin/rcctl status
+ atf_check -s exit:0 -o match:"myoneshot" \
+ jexec "status" /sbin/rcctl status
+}
+
+rcctl_status_cleanup()
+{
+ rcd_cleanup
+}
+
+# ---------------------------------------------------------------
+# Test: barriers reach done state
+# ---------------------------------------------------------------
+atf_test_case barriers_done cleanup
+barriers_done_head()
+{
+ atf_set descr "Barrier units reach done state"
+ atf_set require.user root
+ atf_set timeout 60
+}
+
+barriers_done_body()
+{
+ rcd_init
+
+ rcd_mkjail "barriers"
+
+ rcd_add_unit "barriers" "PHASE1.ucl" <<-EOF
+ name = "PHASE1";
+ type = "barrier";
+ provides = ["PHASE1"];
+ enable = true;
+ EOF
+
+ rcd_add_unit "barriers" "PHASE2.ucl" <<-EOF
+ name = "PHASE2";
+ type = "barrier";
+ provides = ["PHASE2"];
+ enable = true;
+ requires = ["PHASE1"];
+ EOF
+
+ rcd_start "barriers"
+ rcd_wait "barriers"
+
+ atf_check -s exit:0 -o match:"PHASE1.*done.*barrier" \
+ jexec "barriers" /sbin/rcctl status
+ atf_check -s exit:0 -o match:"PHASE2.*done.*barrier" \
+ jexec "barriers" /sbin/rcctl status
+}
+
+barriers_done_cleanup()
+{
+ rcd_cleanup
+}
+
+# ---------------------------------------------------------------
+# Test: oneshot service runs and completes
+# ---------------------------------------------------------------
+atf_test_case oneshot_completes cleanup
+oneshot_completes_head()
+{
+ atf_set descr "Oneshot service runs to completion"
+ atf_set require.user root
+ atf_set timeout 60
+}
+
+oneshot_completes_body()
+{
+ rcd_init
+
+ rcd_mkjail "oneshot"
+
+ rcd_add_unit "oneshot" "marker.ucl" <<-EOF
+ name = "marker";
+ type = "oneshot";
+ command = "/usr/bin/touch";
+ command_args = "/var/tmp/marker_done";
+ provides = ["marker"];
+ enable = true;
+ EOF
+
+ rcd_start "oneshot"
+ rcd_wait "oneshot"
+
+ # Wait for oneshot to complete
+ sleep 2
+
+ # The marker file should exist
+ atf_check -s exit:0 \
+ jexec "oneshot" test -f /var/tmp/marker_done
+
+ # Service should show done
+ atf_check -s exit:0 -o match:"marker.*done" \
+ jexec "oneshot" /sbin/rcctl status
+}
+
+oneshot_completes_cleanup()
+{
+ rcd_cleanup
+}
+
+# ---------------------------------------------------------------
+# Test: nojail keyword hides service
+# ---------------------------------------------------------------
+atf_test_case nojail_status cleanup
+nojail_status_head()
+{
+ atf_set descr "nojail keyword prevents service from starting in jail"
+ atf_set require.user root
+ atf_set timeout 60
+}
+
+nojail_status_body()
+{
+ rcd_init
+
+ rcd_mkjail "nojail"
+
+ rcd_add_unit "nojail" "hostonly.ucl" <<-EOF
+ name = "hostonly";
+ type = "oneshot";
+ command = "/usr/bin/true";
+ provides = ["hostonly"];
+ enable = true;
+ keywords = ["nojail"];
+ EOF
+
+ rcd_start "nojail"
+ rcd_wait "nojail"
+
+ # Service should show as nojail, not done
+ atf_check -s exit:0 -o match:"hostonly.*nojail" \
+ jexec "nojail" /sbin/rcctl status
+}
+
+nojail_status_cleanup()
+{
+ rcd_cleanup
+}
+
+# ---------------------------------------------------------------
+# Test: rcctl stop/start works for a native oneshot
+# ---------------------------------------------------------------
+atf_test_case stop_start cleanup
+stop_start_head()
+{
+ atf_set descr "rcctl start re-runs a completed oneshot"
+ atf_set require.user root
+ atf_set timeout 60
+}
+
+stop_start_body()
+{
+ rcd_init
+
+ rcd_mkjail "stopstart"
+
+ rcd_add_unit "stopstart" "touchfile.ucl" <<-EOF
+ name = "touchfile";
+ type = "oneshot";
+ command = "/usr/bin/touch";
+ command_args = "/var/tmp/started";
+ provides = ["touchfile"];
+ enable = true;
+ EOF
+
+ rcd_start "stopstart"
+ rcd_wait "stopstart"
+
+ # Wait for initial boot to complete
+ sleep 2
+
+ # Should be done after boot
+ atf_check -s exit:0 -o match:"touchfile.*done" \
+ jexec "stopstart" /sbin/rcctl status
+
+ # Remove the marker and re-start
+ jexec "stopstart" rm -f /var/tmp/started
+
+ atf_check -s exit:0 \
+ jexec "stopstart" /sbin/rcctl start touchfile
+
+ # Marker should exist again
+ atf_check -s exit:0 \
+ jexec "stopstart" test -f /var/tmp/started
+}
+
+stop_start_cleanup()
+{
+ rcd_cleanup
+}
+
+# ---------------------------------------------------------------
+# Test: legacy rc.d script wrapping
+# ---------------------------------------------------------------
+atf_test_case legacy_wrap cleanup
+legacy_wrap_head()
+{
+ atf_set descr "Legacy rc.d scripts are wrapped as units"
+ atf_set require.user root
+ atf_set timeout 60
+}
+
+legacy_wrap_body()
+{
+ rcd_init
+
+ rcd_mkjail "legacy"
+
+ rcd_add_legacy "legacy" "test_legacy" <<-'SCRIPT'
+#!/bin/sh
+#
+# PROVIDE: test_legacy
+# REQUIRE:
+# BEFORE:
+# KEYWORD:
+
+. /etc/rc.subr
+
+name="test_legacy"
+rcvar="test_legacy_enable"
+start_cmd="echo started > /var/tmp/legacy_started"
+stop_cmd=":"
+
+load_rc_config $name
+run_rc_command "$1"
+SCRIPT
+
+ # Enable the legacy service via rc.conf
+ echo 'test_legacy_enable="YES"' > \
+ "/var/tmp/rcd_jail_legacy.$$/etc/rc.conf"
+
+ rcd_start "legacy"
+ rcd_wait "legacy"
+
+ # Wait for services to complete
+ sleep 3
+
+ # The legacy service should appear in status
+ atf_check -s exit:0 -o match:"test_legacy" \
+ jexec "legacy" /sbin/rcctl status
+}
+
+legacy_wrap_cleanup()
+{
+ rcd_cleanup
+}
+
+# ---------------------------------------------------------------
+# Test: disabled service shows correct status
+# ---------------------------------------------------------------
+atf_test_case disabled_status cleanup
+disabled_status_head()
+{
+ atf_set descr "Disabled services show disabled status"
+ atf_set require.user root
+ atf_set timeout 60
+}
+
+disabled_status_body()
+{
+ rcd_init
+
+ rcd_mkjail "disabled"
+
+ rcd_add_unit "disabled" "off_svc.ucl" <<-EOF
+ name = "off_svc";
+ type = "oneshot";
+ command = "/usr/bin/true";
+ provides = ["off_svc"];
+ enable = false;
+ EOF
+
+ rcd_start "disabled"
+ rcd_wait "disabled"
+
+ # Query by name — disabled services may not appear in the global list
+ atf_check -s exit:0 -o match:"off_svc.*disabled" \
+ jexec "disabled" /sbin/rcctl status off_svc
+}
+
+disabled_status_cleanup()
+{
+ rcd_cleanup
+}
+
+# ---------------------------------------------------------------
+# Test: safe upgrade via SIGUSR1
+# ---------------------------------------------------------------
+atf_test_case safe_upgrade cleanup
+safe_upgrade_head()
+{
+ atf_set descr "SIGUSR1 triggers safe in-place upgrade"
+ atf_set require.user root
+ atf_set timeout 60
+}
+
+safe_upgrade_body()
+{
+ rcd_init
+
+ rcd_mkjail "upgrade"
+
+ rcd_add_unit "upgrade" "persistent.ucl" <<-EOF
+ name = "persistent";
+ type = "simple";
+ command = "/bin/sleep";
+ command_args = "3600";
+ provides = ["persistent"];
+ enable = true;
+ EOF
+
+ rcd_start "upgrade"
+ rcd_wait "upgrade"
+
+ # Wait for service to start
+ sleep 2
+
+ # Get rcd PID
+ local rcd_pid
+ rcd_pid=$(jexec "upgrade" ps -axo pid,comm | \
+ awk '/rcd$/{print $1; exit}')
+ if [ -z "$rcd_pid" ]; then
+ atf_fail "cannot find rcd PID"
+ fi
+
+ # Service should be running
+ atf_check -s exit:0 -o match:"persistent.*running" \
+ jexec "upgrade" /sbin/rcctl status
+
+ # Send SIGUSR1 to trigger upgrade — remove old socket first
+ # so rcd_wait detects the NEW socket from the re-exec'd rcd.
+ _root="/var/tmp/rcd_jail_upgrade.$$"
+ rm -f "$_root/var/run/rcd.sock"
+ jexec "upgrade" kill -USR1 "$rcd_pid"
+
+ # Wait for the new rcd to re-create the control socket
+ rcd_wait "upgrade"
+
+ # Control socket should still work
+ atf_check -s exit:0 -o match:"SERVICE" \
+ jexec "upgrade" /sbin/rcctl status
+
+ # The running service should still be tracked
+ atf_check -s exit:0 -o match:"persistent.*running" \
+ jexec "upgrade" /sbin/rcctl status
+}
+
+safe_upgrade_cleanup()
+{
+ rcd_cleanup
+}
+
+# ---------------------------------------------------------------
+# Test: dependency ordering
+# ---------------------------------------------------------------
+atf_test_case dep_ordering cleanup
+dep_ordering_head()
+{
+ atf_set descr "Services start in dependency order"
+ atf_set require.user root
+ atf_set timeout 60
+}
+
+dep_ordering_body()
+{
+ rcd_init
+
+ rcd_mkjail "deps"
+
+ # first runs before second (second requires first)
+ rcd_add_unit "deps" "first.ucl" <<-EOF
+ name = "first";
+ type = "oneshot";
+ command = "/bin/sh";
+ command_args = "-c 'date +%s%N > /var/tmp/first_ts'";
+ provides = ["first"];
+ enable = true;
+ EOF
+
+ rcd_add_unit "deps" "second.ucl" <<-EOF
+ name = "second";
+ type = "oneshot";
+ command = "/bin/sh";
+ command_args = "-c 'date +%s%N > /var/tmp/second_ts'";
+ provides = ["second"];
+ enable = true;
+ requires = ["first"];
+ EOF
+
+ rcd_start "deps"
+ rcd_wait "deps"
+
+ sleep 3
+
+ # Both should be done
+ atf_check -s exit:0 -o match:"first.*done" \
+ jexec "deps" /sbin/rcctl status
+ atf_check -s exit:0 -o match:"second.*done" \
+ jexec "deps" /sbin/rcctl status
+
+ # first timestamp should be <= second timestamp
+ local ts1 ts2
+ ts1=$(jexec "deps" cat /var/tmp/first_ts)
+ ts2=$(jexec "deps" cat /var/tmp/second_ts)
+ if [ -n "$ts1" ] && [ -n "$ts2" ]; then
+ atf_check -s exit:0 test "$ts1" -le "$ts2"
+ fi
+}
+
+dep_ordering_cleanup()
+{
+ rcd_cleanup
+}
+
+# ---------------------------------------------------------------
+# Test: restart on-failure restarts a crashed daemon
+# ---------------------------------------------------------------
+atf_test_case restart_on_failure cleanup
+restart_on_failure_head()
+{
+ atf_set descr "Daemon that exits non-zero is restarted by on-failure policy"
+ atf_set require.user root
+ atf_set timeout 60
+}
+
+restart_on_failure_body()
+{
+ rcd_init
+
+ rcd_mkjail "restart"
+
+ # Daemon that exits with code 1 on first run, then stays alive.
+ # Uses a marker file to track attempts.
+ rcd_add_unit "restart" "crasher.ucl" <<-EOF
+ name = "crasher";
+ type = "simple";
+ command = "/bin/sh";
+ command_args = "-c 'if [ ! -f /var/tmp/attempt2 ]; then touch /var/tmp/attempt2; exit 1; fi; exec /bin/sleep 3600'";
+ provides = ["crasher"];
+ enable = true;
+ restart {
+ policy = "on-failure";
+ max_retries = 3;
+ delay = 1000;
+ }
+ EOF
+
+ rcd_start "restart"
+ rcd_wait "restart"
+
+ # Wait for the first crash + restart delay + second spawn
+ sleep 5
+
+ # The service should be running (second attempt succeeds)
+ atf_check -s exit:0 -o match:"crasher.*running" \
+ jexec "restart" /sbin/rcctl status
+
+ # The marker file should exist (proves first attempt happened)
+ atf_check -s exit:0 \
+ jexec "restart" test -f /var/tmp/attempt2
+}
+
+restart_on_failure_cleanup()
+{
+ rcd_cleanup
+}
+
+# ---------------------------------------------------------------
+# Test: start_precmd failure aborts service start
+# ---------------------------------------------------------------
+atf_test_case precmd_abort cleanup
+precmd_abort_head()
+{
+ atf_set descr "start_precmd failure prevents service from starting"
+ atf_set require.user root
+ atf_set timeout 60
+}
+
+precmd_abort_body()
+{
+ rcd_init
+
+ rcd_mkjail "precmd"
+
+ rcd_add_unit "precmd" "guarded.ucl" <<-EOF
+ name = "guarded";
+ type = "oneshot";
+ command = "/usr/bin/touch";
+ command_args = "/var/tmp/should_not_exist";
+ provides = ["guarded"];
+ enable = true;
+ start_precmd = "/usr/bin/false";
+ EOF
+
+ rcd_start "precmd"
+ rcd_wait "precmd"
+
+ sleep 2
+
+ # The command should NOT have run (precmd returned non-zero)
+ atf_check -s exit:1 \
+ jexec "precmd" test -f /var/tmp/should_not_exist
+
+ # The service should show failed, not done
+ atf_check -s exit:0 -o match:"guarded.*failed" \
+ jexec "precmd" /sbin/rcctl status guarded
+}
+
+precmd_abort_cleanup()
+{
+ rcd_cleanup
+}
+
+# ---------------------------------------------------------------
+# Test: rcctl enable/disable persists across rcd restart
+# ---------------------------------------------------------------
+atf_test_case enable_disable_persist cleanup
+enable_disable_persist_head()
+{
+ atf_set descr "rcctl enable/disable writes persistent override"
+ atf_set require.user root
+ atf_set timeout 60
+}
+
+enable_disable_persist_body()
+{
+ rcd_init
+
+ rcd_mkjail "persist"
+
+ rcd_add_unit "persist" "mysvc.ucl" <<-EOF
+ name = "mysvc";
+ type = "oneshot";
+ command = "/usr/bin/true";
+ provides = ["mysvc"];
+ enable = false;
+ EOF
+
+ rcd_start "persist"
+ rcd_wait "persist"
+
+ # Service is disabled
+ atf_check -s exit:0 -o match:"mysvc.*disabled" \
+ jexec "persist" /sbin/rcctl status mysvc
+
+ # Enable it
+ atf_check -s exit:0 \
+ jexec "persist" /sbin/rcctl enable mysvc
+
+ # Override file should exist in the jail
+ _root="/var/tmp/rcd_jail_persist.$$"
+ atf_check -s exit:0 test -f "$_root/etc/rcd.conf.d/mysvc"
+
+ # Disable it
+ atf_check -s exit:0 \
+ jexec "persist" /sbin/rcctl disable mysvc
+
+ # Verify override file has enable=false
+ atf_check -s exit:0 -o match:"false" \
+ jexec "persist" cat /etc/rcd.conf.d/mysvc
+}
+
+enable_disable_persist_cleanup()
+{
+ rcd_cleanup
+}
+
+# ---------------------------------------------------------------
+# Test: process.user drops credentials via rcd-exec
+# ---------------------------------------------------------------
+atf_test_case rcdexec_user_drop cleanup
+rcdexec_user_drop_head()
+{
+ atf_set descr "rcd-exec drops privileges to the configured user"
+ atf_set require.user root
+ atf_set timeout 60
+}
+
+rcdexec_user_drop_body()
+{
+ rcd_init
+
+ rcd_mkjail "userdrop"
+
+ rcd_add_unit "userdrop" "whoami_svc.ucl" <<-EOF
+ name = "whoami_svc";
+ type = "oneshot";
+ command = "/bin/sh";
+ command_args = "-c '/usr/bin/id -un > /var/tmp/whoami'";
+ provides = ["whoami_svc"];
+ enable = true;
+ process {
+ user = "nobody";
+ }
+ EOF
+
+ rcd_start "userdrop"
+ rcd_wait "userdrop"
+
+ sleep 2
+
+ atf_check -s exit:0 -o match:"nobody" \
+ jexec "userdrop" cat /var/tmp/whoami
+}
+
+rcdexec_user_drop_cleanup()
+{
+ rcd_cleanup
+}
+
+# ---------------------------------------------------------------
+# Test: nostart keyword allows manual start but skips boot
+# ---------------------------------------------------------------
+atf_test_case nostart_manual cleanup
+nostart_manual_head()
+{
+ atf_set descr "nostart service can be started manually via rcctl"
+ atf_set require.user root
+ atf_set timeout 60
+}
+
+nostart_manual_body()
+{
+ rcd_init
+
+ rcd_mkjail "nostart"
+
+ rcd_add_unit "nostart" "manual_svc.ucl" <<-EOF
+ name = "manual_svc";
+ type = "oneshot";
+ command = "/usr/bin/touch";
+ command_args = "/var/tmp/manual_ran";
+ provides = ["manual_svc"];
+ enable = true;
+ keywords = ["nostart"];
+ EOF
+
+ rcd_start "nostart"
+ rcd_wait "nostart"
+
+ # Should NOT have run at boot (nostart skips it)
+ atf_check -s exit:1 \
+ jexec "nostart" test -f /var/tmp/manual_ran
+
+ # Status should show nostart
+ atf_check -s exit:0 -o match:"manual_svc.*nostart" \
+ jexec "nostart" /sbin/rcctl status manual_svc
+
+ # But manual start should work
+ atf_check -s exit:0 \
+ jexec "nostart" /sbin/rcctl start manual_svc
+
+ # Now the marker should exist
+ atf_check -s exit:0 \
+ jexec "nostart" test -f /var/tmp/manual_ran
+}
+
+nostart_manual_cleanup()
+{
+ rcd_cleanup
+}
+
+# ---------------------------------------------------------------
+
+atf_init_test_cases()
+{
+ atf_add_test_case boot_in_jail
+ atf_add_test_case rcctl_status
+ atf_add_test_case barriers_done
+ atf_add_test_case oneshot_completes
+ atf_add_test_case nojail_status
+ atf_add_test_case stop_start
+ atf_add_test_case legacy_wrap
+ atf_add_test_case disabled_status
+ atf_add_test_case safe_upgrade
+ atf_add_test_case dep_ordering
+ atf_add_test_case restart_on_failure
+ atf_add_test_case precmd_abort
+ atf_add_test_case enable_disable_persist
+ atf_add_test_case rcdexec_user_drop
+ atf_add_test_case nostart_manual
+}
diff --git a/sbin/rcd/tests/rcd_utils.subr b/sbin/rcd/tests/rcd_utils.subr
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/rcd_utils.subr
@@ -0,0 +1,179 @@
+# rcd(8) test utility functions
+#
+# Creates lightweight jails.
+# Uses nullfs to share the host filesystem read-only, with tmpfs
+# overlays for test-specific directories (rcd.d, rc.d, rcd.conf.d).
+#
+# Pattern:
+# rcd_init — check prerequisites
+# rcd_mkjail name — create a jail with clean rcd directories
+# rcd_start name — start rcd inside the jail
+# rcd_wait name — wait for the control socket
+# rcd_cleanup — stop rcd, destroy jails, unmount
+
+rcd_init()
+{
+ if [ "$(id -u)" -ne 0 ]; then
+ atf_skip "requires root"
+ fi
+ if [ ! -x /sbin/rcd ]; then
+ atf_skip "rcd not installed"
+ fi
+ if [ ! -x /sbin/rcctl ]; then
+ atf_skip "rcctl not installed"
+ fi
+ if ! jail -c name="rcd_probe_$$" persist path=/ \
+ exec.start="" exec.stop="" 2>/dev/null; then
+ atf_skip "cannot create jails"
+ fi
+ if ! jexec "rcd_probe_$$" /usr/bin/true 2>/dev/null; then
+ jail -r "rcd_probe_$$" 2>/dev/null
+ atf_skip "jexec not permitted"
+ fi
+ jail -r "rcd_probe_$$" 2>/dev/null
+}
+
+# rcd_mkjail name
+# Creates a jail with the host filesystem shared read-only via nullfs.
+# Test-writable directories are overlaid with tmpfs:
+# /etc/rcd.d/ — native unit files (empty, test populates)
+# /etc/rc.d/ — legacy scripts (empty, test populates)
+# /etc/rcd.conf.d/ — override files (empty, test populates)
+# /var/run/ — runtime (control socket, pidfiles)
+# /var/tmp/ — scratch
+rcd_mkjail()
+{
+ _jname="$1"
+ _root="/var/tmp/rcd_jail_${_jname}.$$"
+
+ mkdir -p "$_root"
+
+ # Create directories that may not exist on the host BEFORE nullfs
+ mkdir -p /etc/rcd.d /etc/rcd.conf.d 2>/dev/null || true
+
+ mount -t nullfs -o ro / "$_root"
+
+ # Writable overlays — these directories already exist from the
+ # nullfs mount of the host filesystem. Mounting tmpfs on top
+ # hides the host content and gives us a clean writable layer.
+ for _d in etc/rcd.d etc/rc.d etc/rcd.conf.d var/run var/tmp; do
+ mount -t tmpfs tmpfs "$_root/$_d"
+ done
+
+ # devfs for /dev/null etc.
+ mount -t devfs devfs "$_root/dev"
+
+ # Hide host rc.conf.d fragments
+ mount -t tmpfs tmpfs "$_root/etc/rc.conf.d"
+
+ jail -c name="${_jname}" \
+ host.hostname="${_jname}" \
+ path="$_root" \
+ persist \
+ exec.start="" \
+ exec.stop="" \
+ allow.raw_sockets
+
+ # Persist jail info to files so cleanup works across processes
+ echo "${_jname}" >> created_jails.lst
+ echo "${_root}" >> created_roots.lst
+}
+
+# rcd_add_unit jail_name filename
+# Write a native .ucl unit file into the jail's /etc/rcd.d/.
+# Content is read from stdin.
+rcd_add_unit()
+{
+ _jname="$1"
+ _fname="$2"
+ _root="/var/tmp/rcd_jail_${_jname}.$$"
+
+ cat > "$_root/etc/rcd.d/${_fname}"
+}
+
+# rcd_add_legacy jail_name filename
+# Write a legacy rc.d script into the jail's /etc/rc.d/.
+# Content is read from stdin. Marked executable.
+rcd_add_legacy()
+{
+ _jname="$1"
+ _fname="$2"
+ _root="/var/tmp/rcd_jail_${_jname}.$$"
+
+ cat > "$_root/etc/rc.d/${_fname}"
+ chmod 755 "$_root/etc/rc.d/${_fname}"
+}
+
+# rcd_add_override jail_name service_name
+# Write an override file into the jail's /etc/rcd.conf.d/.
+# Content is read from stdin.
+rcd_add_override()
+{
+ _jname="$1"
+ _sname="$2"
+ _root="/var/tmp/rcd_jail_${_jname}.$$"
+
+ cat > "$_root/etc/rcd.conf.d/${_sname}"
+}
+
+# rcd_start jail_name
+# Start rcd inside the jail in background.
+rcd_start()
+{
+ _jname="$1"
+
+ jexec "${_jname}" /sbin/rcd &
+}
+
+# rcd_wait jail_name [timeout_secs]
+# Wait for rcd's control socket to appear.
+rcd_wait()
+{
+ _jname="$1"
+ _timeout="${2:-30}"
+ _root="/var/tmp/rcd_jail_${_jname}.$$"
+ _i=0
+
+ while [ "$_i" -lt "$((_timeout * 2))" ]; do
+ if [ -S "$_root/var/run/rcd.sock" ]; then
+ return 0
+ fi
+ sleep 0.5
+ _i=$((_i + 1))
+ done
+ atf_fail "rcd did not boot within ${_timeout} seconds"
+}
+
+rcd_cleanup()
+{
+ # Stop rcd in each jail, then destroy
+ if [ -f created_jails.lst ]; then
+ while read _jname; do
+ _pid=$(jexec "${_jname}" ps -axo pid,comm 2>/dev/null | \
+ awk '/rcd$/{print $1; exit}')
+ if [ -n "$_pid" ]; then
+ jexec "${_jname}" kill "$_pid" 2>/dev/null
+ sleep 1
+ fi
+ jail -r "${_jname}" 2>/dev/null
+ done < created_jails.lst
+ rm -f created_jails.lst
+ fi
+
+ # Unmount and remove jail roots
+ if [ -f created_roots.lst ]; then
+ while read _root; do
+ if [ -d "$_root" ]; then
+ umount "$_root/etc/rc.conf.d" 2>/dev/null
+ umount "$_root/dev" 2>/dev/null
+ for _d in var/tmp var/run etc/rcd.conf.d \
+ etc/rc.d etc/rcd.d; do
+ umount "$_root/$_d" 2>/dev/null
+ done
+ umount "$_root" 2>/dev/null
+ rmdir "$_root" 2>/dev/null
+ fi
+ done < created_roots.lst
+ rm -f created_roots.lst
+ fi
+}
diff --git a/sbin/rcd/tests/rctl_test.c b/sbin/rcd/tests/rctl_test.c
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/rctl_test.c
@@ -0,0 +1,96 @@
+/*-
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+ */
+
+/*
+ * Tests for rctl_mgr.c: rctl availability check, apply/remove.
+ */
+
+#include <sys/param.h>
+
+#include <string.h>
+
+#include <atf-c.h>
+
+#include "rcd.h"
+
+ATF_TC(rctl_available_returns);
+ATF_TC_HEAD(rctl_available_returns, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "rctl_available returns a boolean without crashing");
+}
+ATF_TC_BODY(rctl_available_returns, tc)
+{
+ bool avail;
+
+ /* Just verify it doesn't crash — result depends on kernel config */
+ avail = rctl_available();
+ (void)avail;
+}
+
+ATF_TC(rctl_apply_empty);
+ATF_TC_HEAD(rctl_apply_empty, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "rctl_apply with no rules is a no-op");
+}
+ATF_TC_BODY(rctl_apply_empty, tc)
+{
+ struct unit *u;
+
+ u = unit_alloc();
+ u->u_name = xstrdup("test");
+
+ ATF_CHECK(rctl_apply(u) == 0);
+ unit_free(u);
+}
+
+ATF_TC(rctl_remove_empty);
+ATF_TC_HEAD(rctl_remove_empty, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "rctl_remove with no active rules is a no-op");
+}
+ATF_TC_BODY(rctl_remove_empty, tc)
+{
+ struct unit *u;
+
+ u = unit_alloc();
+ u->u_name = xstrdup("test");
+
+ /* Should not crash */
+ rctl_remove(u);
+ unit_free(u);
+}
+
+ATF_TC(rctl_get_usage_no_pid);
+ATF_TC_HEAD(rctl_get_usage_no_pid, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "rctl_get_usage returns -1 when no pid and no jail");
+}
+ATF_TC_BODY(rctl_get_usage_no_pid, tc)
+{
+ struct unit *u;
+ char buf[256];
+
+ u = unit_alloc();
+ u->u_name = xstrdup("test");
+
+ ATF_CHECK(rctl_get_usage(u, buf, sizeof(buf)) == -1);
+ unit_free(u);
+}
+
+ATF_TP_ADD_TCS(tp)
+{
+
+ ATF_TP_ADD_TC(tp, rctl_available_returns);
+ ATF_TP_ADD_TC(tp, rctl_apply_empty);
+ ATF_TP_ADD_TC(tp, rctl_remove_empty);
+ ATF_TP_ADD_TC(tp, rctl_get_usage_no_pid);
+
+ return (atf_no_error());
+}
diff --git a/sbin/rcd/tests/sockact_test.c b/sbin/rcd/tests/sockact_test.c
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/sockact_test.c
@@ -0,0 +1,307 @@
+/*-
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+ */
+
+/*
+ * Unit tests for socket activation address parsing.
+ */
+
+#include <sys/param.h>
+#include <sys/event.h>
+#include <sys/socket.h>
+#include <sys/un.h>
+
+#include <netinet/in.h>
+#include <arpa/inet.h>
+
+#include <stdio.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <atf-c.h>
+
+#include "rcd.h"
+
+ATF_TC_WITHOUT_HEAD(parse_tcp_wildcard);
+ATF_TC_BODY(parse_tcp_wildcard, tc)
+{
+ struct sockaddr_storage ss;
+ struct sockaddr_in *sin;
+ socklen_t sslen;
+ int domain, socktype;
+
+ ATF_REQUIRE(parse_listen_addr("tcp:*:80", &ss, &sslen,
+ &domain, &socktype) == 0);
+ ATF_CHECK_EQ(domain, AF_INET);
+ ATF_CHECK_EQ(socktype, SOCK_STREAM);
+
+ sin = (struct sockaddr_in *)&ss;
+ ATF_CHECK_EQ(sin->sin_family, AF_INET);
+ ATF_CHECK_EQ(ntohs(sin->sin_port), 80);
+ ATF_CHECK_EQ(sin->sin_addr.s_addr, htonl(INADDR_ANY));
+}
+
+ATF_TC_WITHOUT_HEAD(parse_tcp_localhost);
+ATF_TC_BODY(parse_tcp_localhost, tc)
+{
+ struct sockaddr_storage ss;
+ struct sockaddr_in *sin;
+ socklen_t sslen;
+ int domain, socktype;
+
+ ATF_REQUIRE(parse_listen_addr("tcp:127.0.0.1:8080", &ss, &sslen,
+ &domain, &socktype) == 0);
+ ATF_CHECK_EQ(domain, AF_INET);
+ ATF_CHECK_EQ(socktype, SOCK_STREAM);
+
+ sin = (struct sockaddr_in *)&ss;
+ ATF_CHECK_EQ(ntohs(sin->sin_port), 8080);
+ ATF_CHECK_EQ(sin->sin_addr.s_addr, htonl(INADDR_LOOPBACK));
+}
+
+ATF_TC_WITHOUT_HEAD(parse_tcp6);
+ATF_TC_BODY(parse_tcp6, tc)
+{
+ struct sockaddr_storage ss;
+ struct sockaddr_in6 *sin6;
+ socklen_t sslen;
+ int domain, socktype;
+
+ ATF_REQUIRE(parse_listen_addr("tcp6:::1:443", &ss, &sslen,
+ &domain, &socktype) == 0);
+ ATF_CHECK_EQ(domain, AF_INET6);
+ ATF_CHECK_EQ(socktype, SOCK_STREAM);
+
+ sin6 = (struct sockaddr_in6 *)&ss;
+ ATF_CHECK_EQ(ntohs(sin6->sin6_port), 443);
+}
+
+ATF_TC_WITHOUT_HEAD(parse_udp);
+ATF_TC_BODY(parse_udp, tc)
+{
+ struct sockaddr_storage ss;
+ struct sockaddr_in *sin;
+ socklen_t sslen;
+ int domain, socktype;
+
+ ATF_REQUIRE(parse_listen_addr("udp:*:514", &ss, &sslen,
+ &domain, &socktype) == 0);
+ ATF_CHECK_EQ(domain, AF_INET);
+ ATF_CHECK_EQ(socktype, SOCK_DGRAM);
+
+ sin = (struct sockaddr_in *)&ss;
+ ATF_CHECK_EQ(ntohs(sin->sin_port), 514);
+}
+
+ATF_TC_WITHOUT_HEAD(parse_unix);
+ATF_TC_BODY(parse_unix, tc)
+{
+ struct sockaddr_storage ss;
+ struct sockaddr_un *sun;
+ socklen_t sslen;
+ int domain, socktype;
+
+ ATF_REQUIRE(parse_listen_addr("unix:/var/run/test.sock", &ss, &sslen,
+ &domain, &socktype) == 0);
+ ATF_CHECK_EQ(domain, AF_UNIX);
+ ATF_CHECK_EQ(socktype, SOCK_STREAM);
+
+ sun = (struct sockaddr_un *)&ss;
+ ATF_CHECK_STREQ(sun->sun_path, "/var/run/test.sock");
+}
+
+ATF_TC_WITHOUT_HEAD(parse_invalid_proto);
+ATF_TC_BODY(parse_invalid_proto, tc)
+{
+ struct sockaddr_storage ss;
+ socklen_t sslen;
+ int domain, socktype;
+
+ ATF_CHECK(parse_listen_addr("sctp:*:80", &ss, &sslen,
+ &domain, &socktype) != 0);
+}
+
+ATF_TC_WITHOUT_HEAD(parse_missing_port);
+ATF_TC_BODY(parse_missing_port, tc)
+{
+ struct sockaddr_storage ss;
+ socklen_t sslen;
+ int domain, socktype;
+
+ ATF_CHECK(parse_listen_addr("tcp:127.0.0.1", &ss, &sslen,
+ &domain, &socktype) != 0);
+}
+
+ATF_TC_WITHOUT_HEAD(parse_empty_spec);
+ATF_TC_BODY(parse_empty_spec, tc)
+{
+ struct sockaddr_storage ss;
+ socklen_t sslen;
+ int domain, socktype;
+
+ ATF_CHECK(parse_listen_addr("", &ss, &sslen,
+ &domain, &socktype) != 0);
+}
+
+/*
+ * Helper: create a minimal unit with one TCP socket on a random port.
+ * The socket is bound; the caller must close it via sockact_close + unit_free.
+ */
+static struct unit *
+make_test_unit(void)
+{
+ struct unit *u;
+ struct unit_socket *us;
+
+ u = calloc(1, sizeof(*u));
+ ATF_REQUIRE(u != NULL);
+ u->u_state = STATE_INACTIVE;
+ u->u_procdesc_fd = -1;
+ u->u_pid = -1;
+ u->u_ready_method = READY_IMMEDIATE;
+
+ STAILQ_INIT(&u->u_deps);
+ STAILQ_INIT(&u->u_rdeps);
+ STAILQ_INIT(&u->u_rctl);
+ STAILQ_INIT(&u->u_rctl_active);
+ STAILQ_INIT(&u->u_env);
+ TAILQ_INIT(&u->u_sockets);
+
+ us = calloc(1, sizeof(*us));
+ ATF_REQUIRE(us != NULL);
+ us->us_name = strdup("test_sock");
+ us->us_address = strdup("tcp:127.0.0.1:0");
+ us->us_fd = -1;
+ us->us_type = SOCK_ACT_STREAM;
+ us->us_backlog = 1;
+
+ TAILQ_INSERT_TAIL(&u->u_sockets, us, us_entries);
+ return (u);
+}
+
+ATF_TC_WITHOUT_HEAD(deferred_register_store);
+ATF_TC_BODY(deferred_register_store, tc)
+{
+ struct unit *u;
+ struct unit_socket *us;
+
+ u = make_test_unit();
+ us = TAILQ_FIRST(&u->u_sockets);
+
+ /* Bind the socket */
+ ATF_REQUIRE(sockact_bind(us) == 0);
+ ATF_CHECK(us->us_fd >= 0);
+
+ /* Initially the kevent should be zeroed */
+ ATF_CHECK(us->us_kev.filter == 0);
+
+ /* Register deferred */
+ sockact_register_deferred(u);
+
+ /* Verify the kevent was stored correctly */
+ ATF_CHECK_EQ(us->us_kev.ident, (uintptr_t)us->us_fd);
+ ATF_CHECK_EQ(us->us_kev.filter, EVFILT_READ);
+ ATF_CHECK_EQ(us->us_kev.flags, EV_ADD);
+ ATF_CHECK(us->us_kev.udata == u);
+
+ sockact_close(u);
+ unit_free(u);
+}
+
+ATF_TC_WITHOUT_HEAD(deferred_register_apply);
+ATF_TC_BODY(deferred_register_apply, tc)
+{
+ struct unit *u;
+ struct unit_socket *us;
+ struct rcd_ctx ctx;
+ struct kevent kev;
+ struct sockaddr_in sin;
+ socklen_t sinlen;
+ int kq, client_fd;
+
+ u = make_test_unit();
+ us = TAILQ_FIRST(&u->u_sockets);
+
+ /* Bind the socket */
+ ATF_REQUIRE(sockact_bind(us) == 0);
+ ATF_REQUIRE(us->us_fd >= 0);
+
+ /* Register deferred */
+ sockact_register_deferred(u);
+
+ /* Create a kqueue and set up a minimal context */
+ kq = kqueue();
+ ATF_REQUIRE(kq >= 0);
+ memset(&ctx, 0, sizeof(ctx));
+ ctx.ctx_kq = kq;
+
+ /* Apply the deferred registration */
+ sockact_deferred_register_all(&ctx, u);
+
+ /* Get the bound port so we can connect */
+ sinlen = sizeof(sin);
+ ATF_REQUIRE(getsockname(us->us_fd,
+ (struct sockaddr *)&sin, &sinlen) == 0);
+
+ /* Connect a client to trigger an incoming connection */
+ client_fd = socket(AF_INET, SOCK_STREAM, 0);
+ ATF_REQUIRE(client_fd >= 0);
+ ATF_REQUIRE(connect(client_fd,
+ (struct sockaddr *)&sin, sizeof(sin)) == 0);
+
+ /* Poll kqueue — should get the event */
+ memset(&kev, 0, sizeof(kev));
+ ATF_CHECK(kevent(kq, NULL, 0, &kev, 1,
+ &(struct timespec){ .tv_sec = 1, .tv_nsec = 0 }) == 1);
+
+ /* Verify event details */
+ ATF_CHECK_EQ(kev.ident, (uintptr_t)us->us_fd);
+ ATF_CHECK_EQ(kev.filter, EVFILT_READ);
+ ATF_CHECK(kev.udata == u);
+
+ close(client_fd);
+ close(kq);
+ sockact_close(u);
+ unit_free(u);
+}
+
+ATF_TC_WITHOUT_HEAD(deferred_unbound_socket);
+ATF_TC_BODY(deferred_unbound_socket, tc)
+{
+ struct unit *u;
+ struct unit_socket *us;
+
+ u = make_test_unit();
+ us = TAILQ_FIRST(&u->u_sockets);
+
+ /* Socket not bound — us_fd stays -1 */
+ ATF_CHECK_EQ(us->us_fd, -1);
+
+ /* Register deferred — should be a no-op */
+ sockact_register_deferred(u);
+
+ /* Verify kevent was NOT populated */
+ ATF_CHECK(us->us_kev.filter == 0);
+
+ unit_free(u);
+}
+
+ATF_TP_ADD_TCS(tp)
+{
+
+ ATF_TP_ADD_TC(tp, parse_tcp_wildcard);
+ ATF_TP_ADD_TC(tp, parse_tcp_localhost);
+ ATF_TP_ADD_TC(tp, parse_tcp6);
+ ATF_TP_ADD_TC(tp, parse_udp);
+ ATF_TP_ADD_TC(tp, parse_unix);
+ ATF_TP_ADD_TC(tp, parse_invalid_proto);
+ ATF_TP_ADD_TC(tp, parse_missing_port);
+ ATF_TP_ADD_TC(tp, parse_empty_spec);
+ ATF_TP_ADD_TC(tp, deferred_register_store);
+ ATF_TP_ADD_TC(tp, deferred_register_apply);
+ ATF_TP_ADD_TC(tp, deferred_unbound_socket);
+
+ return (atf_no_error());
+}
diff --git a/sbin/rcd/tests/unit_test.c b/sbin/rcd/tests/unit_test.c
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/tests/unit_test.c
@@ -0,0 +1,691 @@
+/*-
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+ */
+
+/*
+ * Unit tests for the UCL unit file parser.
+ */
+
+#include <sys/param.h>
+
+#include <signal.h>
+#include <stdio.h>
+#include <string.h>
+
+#include <atf-c.h>
+
+#include "rcd.h"
+
+ATF_TC(parse_simple_service);
+ATF_TC_HEAD(parse_simple_service, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Parse a simple service unit file");
+}
+ATF_TC_BODY(parse_simple_service, tc)
+{
+ const char *srcdir = atf_tc_get_config_var(tc, "srcdir");
+ char path[PATH_MAX];
+ struct rcd_config cfg;
+ struct unit *u;
+
+ memset(&cfg, 0, sizeof(cfg));
+
+ snprintf(path, sizeof(path), "%s/%s", srcdir, "sshd.ucl");
+ u = unit_parse(path, &cfg);
+ ATF_REQUIRE_MSG(u != NULL, "unit_parse returned NULL");
+ ATF_CHECK_STREQ(u->u_name, "sshd");
+ ATF_CHECK_STREQ(u->u_description, "Secure Shell Daemon");
+ ATF_CHECK(u->u_type == UNIT_SIMPLE);
+ ATF_CHECK_STREQ(u->u_command, "/usr/sbin/sshd");
+ ATF_CHECK_STREQ(u->u_command_args, "-D -f /etc/ssh/sshd_config");
+ ATF_CHECK(u->u_state == STATE_INACTIVE);
+
+ unit_free(u);
+}
+
+ATF_TC(parse_dependencies);
+ATF_TC_HEAD(parse_dependencies, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Parse unit dependency declarations");
+}
+ATF_TC_BODY(parse_dependencies, tc)
+{
+ const char *srcdir = atf_tc_get_config_var(tc, "srcdir");
+ char path[PATH_MAX];
+ struct rcd_config cfg;
+ struct unit *u;
+
+ memset(&cfg, 0, sizeof(cfg));
+
+ snprintf(path, sizeof(path), "%s/%s", srcdir, "sshd.ucl");
+ u = unit_parse(path, &cfg);
+ ATF_REQUIRE(u != NULL);
+
+ /* provide: sshd, ssh */
+ ATF_CHECK_EQ(u->u_provide.len, 2);
+ ATF_CHECK_STREQ(u->u_provide.d[0], "sshd");
+ ATF_CHECK_STREQ(u->u_provide.d[1], "ssh");
+
+ /* require: NETWORKING, FILESYSTEMS */
+ ATF_CHECK_EQ(u->u_require.len, 2);
+ ATF_CHECK_STREQ(u->u_require.d[0], "NETWORKING");
+ ATF_CHECK_STREQ(u->u_require.d[1], "FILESYSTEMS");
+
+ /* before: LOGIN */
+ ATF_CHECK_EQ(u->u_before.len, 1);
+ ATF_CHECK_STREQ(u->u_before.d[0], "LOGIN");
+
+ unit_free(u);
+}
+
+ATF_TC(parse_restart_config);
+ATF_TC_HEAD(parse_restart_config, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Parse restart configuration");
+}
+ATF_TC_BODY(parse_restart_config, tc)
+{
+ const char *srcdir = atf_tc_get_config_var(tc, "srcdir");
+ char path[PATH_MAX];
+ struct rcd_config cfg;
+ struct unit *u;
+
+ memset(&cfg, 0, sizeof(cfg));
+
+ snprintf(path, sizeof(path), "%s/%s", srcdir, "sshd.ucl");
+ u = unit_parse(path, &cfg);
+ ATF_REQUIRE(u != NULL);
+
+ ATF_CHECK(u->u_restart.rc_policy == RESTART_ON_FAILURE);
+ ATF_CHECK_EQ(u->u_restart.rc_max_retries, 5);
+ ATF_CHECK_EQ(u->u_restart.rc_delay_ms, 5000);
+ ATF_CHECK(u->u_restart.rc_backoff == BACKOFF_EXPONENTIAL);
+ ATF_CHECK_EQ(u->u_restart.rc_reset_ms, 60000);
+
+ unit_free(u);
+}
+
+ATF_TC(parse_restart_always);
+ATF_TC_HEAD(parse_restart_always, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Parse restart-always policy");
+}
+ATF_TC_BODY(parse_restart_always, tc)
+{
+ const char *srcdir = atf_tc_get_config_var(tc, "srcdir");
+ char path[PATH_MAX];
+ struct rcd_config cfg;
+ struct unit *u;
+
+ memset(&cfg, 0, sizeof(cfg));
+
+ snprintf(path, sizeof(path), "%s/%s", srcdir, "nginx.ucl");
+ u = unit_parse(path, &cfg);
+ ATF_REQUIRE(u != NULL);
+
+ ATF_CHECK(u->u_restart.rc_policy == RESTART_ALWAYS);
+ ATF_CHECK_EQ(u->u_restart.rc_max_retries, 10);
+ ATF_CHECK(u->u_restart.rc_backoff == BACKOFF_LINEAR);
+
+ unit_free(u);
+}
+
+ATF_TC(parse_process_config);
+ATF_TC_HEAD(parse_process_config, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Parse process configuration");
+}
+ATF_TC_BODY(parse_process_config, tc)
+{
+ const char *srcdir = atf_tc_get_config_var(tc, "srcdir");
+ char path[PATH_MAX];
+ struct rcd_config cfg;
+ struct unit *u;
+
+ memset(&cfg, 0, sizeof(cfg));
+
+ snprintf(path, sizeof(path), "%s/%s", srcdir, "sshd.ucl");
+ u = unit_parse(path, &cfg);
+ ATF_REQUIRE(u != NULL);
+
+ ATF_CHECK_STREQ(u->u_proc.pc_user, "root");
+ ATF_CHECK_STREQ(u->u_proc.pc_group, "wheel");
+ ATF_CHECK(u->u_proc.pc_oom_protect == true);
+
+ unit_free(u);
+}
+
+ATF_TC(parse_process_cpuset);
+ATF_TC_HEAD(parse_process_cpuset, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Parse process cpuset configuration");
+}
+ATF_TC_BODY(parse_process_cpuset, tc)
+{
+ const char *srcdir = atf_tc_get_config_var(tc, "srcdir");
+ char path[PATH_MAX];
+ struct rcd_config cfg;
+ struct unit *u;
+
+ memset(&cfg, 0, sizeof(cfg));
+
+ snprintf(path, sizeof(path), "%s/%s", srcdir, "nginx.ucl");
+ u = unit_parse(path, &cfg);
+ ATF_REQUIRE(u != NULL);
+
+ ATF_CHECK_STREQ(u->u_proc.pc_user, "www");
+ ATF_CHECK_STREQ(u->u_proc.pc_group, "www");
+ ATF_CHECK_STREQ(u->u_proc.pc_cpuset, "0-3");
+
+ unit_free(u);
+}
+
+ATF_TC(parse_rctl_rules);
+ATF_TC_HEAD(parse_rctl_rules, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Parse RCTL resource limit rules");
+}
+ATF_TC_BODY(parse_rctl_rules, tc)
+{
+ const char *srcdir = atf_tc_get_config_var(tc, "srcdir");
+ char path[PATH_MAX];
+ struct rcd_config cfg;
+ struct unit *u;
+ struct rctl_conf *rc;
+ int count;
+
+ memset(&cfg, 0, sizeof(cfg));
+
+ snprintf(path, sizeof(path), "%s/%s", srcdir, "sshd.ucl");
+ u = unit_parse(path, &cfg);
+ ATF_REQUIRE(u != NULL);
+
+ count = 0;
+ STAILQ_FOREACH(rc, &u->u_rctl, rc_entries) {
+ if (strcmp(rc->rc_resource, "memoryuse") == 0) {
+ ATF_CHECK_STREQ(rc->rc_action, "deny");
+ ATF_CHECK_STREQ(rc->rc_amount, "1g");
+ } else if (strcmp(rc->rc_resource, "openfiles") == 0) {
+ ATF_CHECK_STREQ(rc->rc_action, "deny");
+ ATF_CHECK_STREQ(rc->rc_amount, "256");
+ } else if (strcmp(rc->rc_resource, "maxproc") == 0) {
+ ATF_CHECK_STREQ(rc->rc_action, "deny");
+ ATF_CHECK_STREQ(rc->rc_amount, "32");
+ }
+ count++;
+ }
+ ATF_CHECK_EQ(count, 3);
+
+ unit_free(u);
+}
+
+ATF_TC(parse_jail_config);
+ATF_TC_HEAD(parse_jail_config, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Parse jail configuration");
+}
+ATF_TC_BODY(parse_jail_config, tc)
+{
+ const char *srcdir = atf_tc_get_config_var(tc, "srcdir");
+ char path[PATH_MAX];
+ struct rcd_config cfg;
+ struct unit *u;
+
+ memset(&cfg, 0, sizeof(cfg));
+
+ snprintf(path, sizeof(path), "%s/%s", srcdir, "sshd.ucl");
+ u = unit_parse(path, &cfg);
+ ATF_REQUIRE(u != NULL);
+
+ ATF_CHECK(u->u_jail.jc_enable == true);
+ ATF_CHECK_EQ(u->u_jail.jc_options.len, 1);
+ ATF_CHECK_STREQ(u->u_jail.jc_options.d[0], "netv4");
+ ATF_CHECK_EQ(u->u_jail.jc_ip4addr.len, 1);
+ ATF_CHECK_STREQ(u->u_jail.jc_ip4addr.d[0], "192.0.2.1");
+
+ unit_free(u);
+}
+
+ATF_TC(parse_socket_activation);
+ATF_TC_HEAD(parse_socket_activation, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Parse socket activation configuration");
+}
+ATF_TC_BODY(parse_socket_activation, tc)
+{
+ const char *srcdir = atf_tc_get_config_var(tc, "srcdir");
+ char path[PATH_MAX];
+ struct rcd_config cfg;
+ struct unit *u;
+ struct unit_socket *us;
+
+ memset(&cfg, 0, sizeof(cfg));
+
+ snprintf(path, sizeof(path), "%s/%s", srcdir, "sshd.ucl");
+ u = unit_parse(path, &cfg);
+ ATF_REQUIRE(u != NULL);
+
+ us = TAILQ_FIRST(&u->u_sockets);
+ ATF_REQUIRE(us != NULL);
+ ATF_CHECK_STREQ(us->us_name, "ssh");
+ ATF_CHECK_STREQ(us->us_address, "tcp:*:22");
+ ATF_CHECK(us->us_type == SOCK_ACT_STREAM);
+ ATF_CHECK_EQ(us->us_backlog, 128);
+ ATF_CHECK_EQ(us->us_fd, -1);
+
+ unit_free(u);
+}
+
+ATF_TC(parse_environment);
+ATF_TC_HEAD(parse_environment, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Parse environment variable settings");
+}
+ATF_TC_BODY(parse_environment, tc)
+{
+ const char *srcdir = atf_tc_get_config_var(tc, "srcdir");
+ char path[PATH_MAX];
+ struct rcd_config cfg;
+ struct unit *u;
+ struct kv *ue;
+ int count;
+
+ memset(&cfg, 0, sizeof(cfg));
+
+ snprintf(path, sizeof(path), "%s/%s", srcdir, "sshd.ucl");
+ u = unit_parse(path, &cfg);
+ ATF_REQUIRE(u != NULL);
+
+ count = 0;
+ STAILQ_FOREACH(ue, &u->u_env, kv_entries) {
+ if (strcmp(ue->kv_key, "LC_ALL") == 0)
+ ATF_CHECK_STREQ(ue->kv_val, "C");
+ else if (strcmp(ue->kv_key, "PATH") == 0)
+ ATF_CHECK_STREQ(ue->kv_val,
+ "/sbin:/bin:/usr/sbin:/usr/bin");
+ count++;
+ }
+ ATF_CHECK_EQ(count, 2);
+
+ unit_free(u);
+}
+
+ATF_TC(parse_logging);
+ATF_TC_HEAD(parse_logging, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Parse logging configuration");
+}
+ATF_TC_BODY(parse_logging, tc)
+{
+ const char *srcdir = atf_tc_get_config_var(tc, "srcdir");
+ char path[PATH_MAX];
+ struct rcd_config cfg;
+ struct unit *u;
+
+ memset(&cfg, 0, sizeof(cfg));
+
+ snprintf(path, sizeof(path), "%s/%s", srcdir, "sshd.ucl");
+ u = unit_parse(path, &cfg);
+ ATF_REQUIRE(u != NULL);
+
+ ATF_CHECK_STREQ(u->u_log.lc_stdout, "syslog:daemon.info");
+ ATF_CHECK_STREQ(u->u_log.lc_stderr, "syslog:daemon.err");
+
+ unit_free(u);
+}
+
+ATF_TC(parse_oneshot);
+ATF_TC_HEAD(parse_oneshot, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Parse a oneshot unit file");
+}
+ATF_TC_BODY(parse_oneshot, tc)
+{
+ const char *srcdir = atf_tc_get_config_var(tc, "srcdir");
+ char path[PATH_MAX];
+ struct rcd_config cfg;
+ struct unit *u;
+
+ memset(&cfg, 0, sizeof(cfg));
+
+ snprintf(path, sizeof(path), "%s/%s", srcdir, "cleartmp.ucl");
+ u = unit_parse(path, &cfg);
+ ATF_REQUIRE(u != NULL);
+ ATF_CHECK_STREQ(u->u_name, "cleartmp");
+ ATF_CHECK(u->u_type == UNIT_ONESHOT);
+ ATF_CHECK_EQ(u->u_provide.len, 1);
+ ATF_CHECK_STREQ(u->u_provide.d[0], "cleartmp");
+
+ unit_free(u);
+}
+
+ATF_TC_WITHOUT_HEAD(parse_nonexistent_file);
+ATF_TC_BODY(parse_nonexistent_file, tc)
+{
+ struct rcd_config cfg;
+ struct unit *u;
+
+ memset(&cfg, 0, sizeof(cfg));
+ u = unit_parse("/nonexistent/path.ucl", &cfg);
+ ATF_CHECK(u == NULL);
+}
+
+ATF_TC_WITHOUT_HEAD(unit_alloc_defaults);
+ATF_TC_BODY(unit_alloc_defaults, tc)
+{
+ struct unit *u;
+
+ u = unit_alloc();
+ ATF_REQUIRE(u != NULL);
+ ATF_CHECK(u->u_state == STATE_INACTIVE);
+ ATF_CHECK(u->u_type == UNIT_SIMPLE);
+ ATF_CHECK(u->u_procdesc_fd == -1);
+ ATF_CHECK(u->u_pid == -1);
+ ATF_CHECK(u->u_enabled == false);
+ ATF_CHECK(u->u_ready_method == READY_IMMEDIATE);
+ ATF_CHECK(u->u_restart.rc_policy == RESTART_NEVER);
+ ATF_CHECK(u->u_restart.rc_delay_ms == 5000);
+ ATF_CHECK(u->u_restart.rc_max_retries == 5);
+
+ unit_free(u);
+}
+
+ATF_TC(parse_command_prepend);
+ATF_TC_HEAD(parse_command_prepend, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Parse command prepend setting");
+}
+ATF_TC_BODY(parse_command_prepend, tc)
+{
+ const char *srcdir = atf_tc_get_config_var(tc, "srcdir");
+ char path[PATH_MAX];
+ struct rcd_config cfg;
+ struct unit *u;
+
+ memset(&cfg, 0, sizeof(cfg));
+
+ snprintf(path, sizeof(path), "%s/%s", srcdir, "prepend.ucl");
+ u = unit_parse(path, &cfg);
+ ATF_REQUIRE_MSG(u != NULL, "unit_parse returned NULL");
+ ATF_CHECK_STREQ(u->u_name, "prepend_test");
+ ATF_CHECK_STREQ(u->u_command, "/usr/bin/test");
+ ATF_CHECK_STREQ(u->u_command_prepend, "/usr/bin/strace");
+
+ unit_free(u);
+}
+
+ATF_TC(parse_setup_cmd);
+ATF_TC_HEAD(parse_setup_cmd, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Parse setup and hook commands");
+}
+ATF_TC_BODY(parse_setup_cmd, tc)
+{
+ const char *srcdir = atf_tc_get_config_var(tc, "srcdir");
+ char path[PATH_MAX];
+ struct rcd_config cfg;
+ struct unit *u;
+
+ memset(&cfg, 0, sizeof(cfg));
+
+ snprintf(path, sizeof(path), "%s/%s", srcdir, "hooks.ucl");
+ u = unit_parse(path, &cfg);
+ ATF_REQUIRE_MSG(u != NULL, "unit_parse returned NULL");
+ ATF_CHECK_STREQ(u->u_name, "hooks_test");
+ ATF_CHECK_STREQ(u->u_setup_cmd, "/usr/bin/setup");
+ ATF_CHECK_STREQ(u->u_start_precmd, "/usr/bin/prestart");
+ ATF_CHECK_STREQ(u->u_stop_postcmd, "/usr/bin/poststop");
+
+ unit_free(u);
+}
+
+ATF_TC(parse_enable_false);
+ATF_TC_HEAD(parse_enable_false, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Parse disabled unit file");
+}
+ATF_TC_BODY(parse_enable_false, tc)
+{
+ const char *srcdir = atf_tc_get_config_var(tc, "srcdir");
+ char path[PATH_MAX];
+ struct rcd_config cfg;
+ struct unit *u;
+
+ memset(&cfg, 0, sizeof(cfg));
+
+ snprintf(path, sizeof(path), "%s/%s", srcdir, "disabled.ucl");
+ u = unit_parse(path, &cfg);
+ ATF_REQUIRE_MSG(u != NULL, "unit_parse returned NULL");
+ ATF_CHECK_STREQ(u->u_name, "disabled_test");
+ ATF_CHECK(u->u_enabled == false);
+
+ unit_free(u);
+}
+
+ATF_TC(parse_required_vars);
+ATF_TC_HEAD(parse_required_vars, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Parse required variables");
+}
+ATF_TC_BODY(parse_required_vars, tc)
+{
+ const char *srcdir = atf_tc_get_config_var(tc, "srcdir");
+ char path[PATH_MAX];
+ struct rcd_config cfg;
+ struct unit *u;
+
+ memset(&cfg, 0, sizeof(cfg));
+
+ snprintf(path, sizeof(path), "%s/%s", srcdir, "reqvars.ucl");
+ u = unit_parse(path, &cfg);
+ ATF_REQUIRE_MSG(u != NULL, "unit_parse returned NULL");
+ ATF_CHECK_STREQ(u->u_name, "reqvars_test");
+ ATF_CHECK_EQ(u->u_required_vars.len, 2);
+ ATF_CHECK_STREQ(u->u_required_vars.d[0], "api_key");
+ ATF_CHECK_STREQ(u->u_required_vars.d[1], "db_host");
+
+ unit_free(u);
+}
+
+ATF_TC(parse_off_command);
+ATF_TC_HEAD(parse_off_command, tc)
+{
+ atf_tc_set_md_var(tc, "descr", "Parse off command setting");
+}
+ATF_TC_BODY(parse_off_command, tc)
+{
+ const char *srcdir = atf_tc_get_config_var(tc, "srcdir");
+ char path[PATH_MAX];
+ struct rcd_config cfg;
+ struct unit *u;
+
+ memset(&cfg, 0, sizeof(cfg));
+
+ snprintf(path, sizeof(path), "%s/%s", srcdir, "offcmd.ucl");
+ u = unit_parse(path, &cfg);
+ ATF_REQUIRE_MSG(u != NULL, "unit_parse returned NULL");
+ ATF_CHECK_STREQ(u->u_name, "offcmd_test");
+ ATF_CHECK_STREQ(u->u_off_command, "/usr/bin/cleanup");
+
+ unit_free(u);
+}
+
+/* ── Override tests ── */
+
+ATF_TC(override_append_and_remove);
+ATF_TC_HEAD(override_append_and_remove, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "Override: append arrays, remove entries, replace scalars");
+}
+ATF_TC_BODY(override_append_and_remove, tc)
+{
+ const char *srcdir = atf_tc_get_config_var(tc, "srcdir");
+ char path[PATH_MAX], confdir[PATH_MAX];
+ struct rcd_config cfg;
+ struct unit *u;
+
+ memset(&cfg, 0, sizeof(cfg));
+
+ snprintf(path, sizeof(path), "%s/%s", srcdir, "override_base.ucl");
+ u = unit_parse(path, &cfg);
+ ATF_REQUIRE_MSG(u != NULL, "unit_parse returned NULL");
+
+ /* Pre-override checks */
+ ATF_CHECK_EQ(u->u_provide.len, 2);
+ ATF_CHECK_EQ(u->u_require.len, 1);
+ ATF_CHECK_EQ(u->u_sig_stop, SIGTERM);
+
+ /* Apply override */
+ snprintf(confdir, sizeof(confdir), "%s/%s", srcdir, "confdir");
+ unit_apply_overrides(u, confdir);
+
+ /* Scalar: command replaced */
+ ATF_CHECK_STREQ(u->u_command, "/usr/local/bin/overridden");
+
+ /* Scalar: sig_stop replaced (SIGUSR1 = 30 on FreeBSD) */
+ ATF_CHECK_EQ(u->u_sig_stop, SIGUSR1);
+
+ /* Scalar: start_delay set */
+ ATF_CHECK_EQ(u->u_start_delay_ms, 500);
+
+ /* Scalar: hook set */
+ ATF_CHECK(u->u_start_precmd != NULL);
+ ATF_CHECK_STREQ(u->u_start_precmd, "/usr/bin/precheck");
+
+ /* remove{}: base_alias removed from provides */
+ ATF_CHECK_EQ(u->u_provide.len, 1);
+ ATF_CHECK_STREQ(u->u_provide.d[0], "override_base");
+
+ /* Array append: requires was ["NETWORKING"], now has FILESYSTEMS too */
+ /* NETWORKING is a dup, so only FILESYSTEMS is appended */
+ ATF_CHECK_EQ(u->u_require.len, 2);
+ ATF_CHECK_STREQ(u->u_require.d[0], "NETWORKING");
+ ATF_CHECK_STREQ(u->u_require.d[1], "FILESYSTEMS");
+
+ /* Array append: before had ["LOGIN"], now also has DAEMON */
+ ATF_CHECK_EQ(u->u_before.len, 2);
+ ATF_CHECK_STREQ(u->u_before.d[0], "LOGIN");
+ ATF_CHECK_STREQ(u->u_before.d[1], "DAEMON");
+
+ /* Array append: keywords had ["nojail"], now also "resume" */
+ ATF_CHECK_EQ(u->u_keyword.len, 2);
+ ATF_CHECK(u->u_nojail == true);
+ ATF_CHECK(u->u_resume == true);
+
+ /* Object merge: restart.delay changed, policy untouched */
+ ATF_CHECK(u->u_restart.rc_policy == RESTART_ON_FAILURE);
+ ATF_CHECK_EQ(u->u_restart.rc_delay_ms, 5000);
+ ATF_CHECK_EQ(u->u_restart.rc_max_retries, 3);
+
+ unit_free(u);
+}
+
+ATF_TC(override_replace);
+ATF_TC_HEAD(override_replace, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "Override: replace{} replaces arrays entirely");
+}
+ATF_TC_BODY(override_replace, tc)
+{
+ const char *srcdir = atf_tc_get_config_var(tc, "srcdir");
+ char path[PATH_MAX], confdir[PATH_MAX];
+ struct rcd_config cfg;
+ struct unit *u;
+
+ memset(&cfg, 0, sizeof(cfg));
+
+ snprintf(path, sizeof(path), "%s/%s", srcdir, "replace_test.ucl");
+ u = unit_parse(path, &cfg);
+ ATF_REQUIRE_MSG(u != NULL, "unit_parse returned NULL");
+
+ /* Pre-override: requires = [A, B, C], keywords = [nojail, nostart] */
+ ATF_CHECK_EQ(u->u_require.len, 3);
+ ATF_CHECK(u->u_nojail == true);
+ ATF_CHECK(u->u_nostart == true);
+
+ /* Apply override */
+ snprintf(confdir, sizeof(confdir), "%s/%s", srcdir, "confdir");
+ unit_apply_overrides(u, confdir);
+
+ /* enable=true from override re-enables */
+ ATF_CHECK(u->u_enabled == true);
+
+ /* replace{}: requires completely replaced with [X, Y] */
+ ATF_CHECK_EQ(u->u_require.len, 2);
+ ATF_CHECK_STREQ(u->u_require.d[0], "X");
+ ATF_CHECK_STREQ(u->u_require.d[1], "Y");
+
+ /* replace{}: keywords completely replaced with [resume] */
+ ATF_CHECK_EQ(u->u_keyword.len, 1);
+ ATF_CHECK_STREQ(u->u_keyword.d[0], "resume");
+
+ /* Keyword flags reprocessed: nojail cleared, resume set */
+ ATF_CHECK(u->u_nojail == false);
+ ATF_CHECK(u->u_resume == true);
+
+ unit_free(u);
+}
+
+ATF_TC(override_no_file);
+ATF_TC_HEAD(override_no_file, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "Override: missing override file is not an error");
+}
+ATF_TC_BODY(override_no_file, tc)
+{
+ const char *srcdir = atf_tc_get_config_var(tc, "srcdir");
+ char path[PATH_MAX];
+ struct rcd_config cfg;
+ struct unit *u;
+ int ret;
+
+ memset(&cfg, 0, sizeof(cfg));
+
+ snprintf(path, sizeof(path), "%s/%s", srcdir, "sshd.ucl");
+ u = unit_parse(path, &cfg);
+ ATF_REQUIRE(u != NULL);
+
+ /* Apply override from a non-existent directory */
+ ret = unit_apply_overrides(u, "/nonexistent/confdir");
+ ATF_CHECK_EQ(ret, 0);
+
+ /* Unit unchanged */
+ ATF_CHECK_STREQ(u->u_command, "/usr/sbin/sshd");
+ ATF_CHECK_EQ(u->u_require.len, 2);
+
+ unit_free(u);
+}
+
+ATF_TP_ADD_TCS(tp)
+{
+
+ ATF_TP_ADD_TC(tp, parse_simple_service);
+ ATF_TP_ADD_TC(tp, parse_dependencies);
+ ATF_TP_ADD_TC(tp, parse_restart_config);
+ ATF_TP_ADD_TC(tp, parse_restart_always);
+ ATF_TP_ADD_TC(tp, parse_process_config);
+ ATF_TP_ADD_TC(tp, parse_process_cpuset);
+ ATF_TP_ADD_TC(tp, parse_rctl_rules);
+ ATF_TP_ADD_TC(tp, parse_jail_config);
+ ATF_TP_ADD_TC(tp, parse_socket_activation);
+ ATF_TP_ADD_TC(tp, parse_environment);
+ ATF_TP_ADD_TC(tp, parse_logging);
+ ATF_TP_ADD_TC(tp, parse_oneshot);
+ ATF_TP_ADD_TC(tp, parse_nonexistent_file);
+ ATF_TP_ADD_TC(tp, unit_alloc_defaults);
+ ATF_TP_ADD_TC(tp, parse_command_prepend);
+ ATF_TP_ADD_TC(tp, parse_setup_cmd);
+ ATF_TP_ADD_TC(tp, parse_enable_false);
+ ATF_TP_ADD_TC(tp, parse_required_vars);
+ ATF_TP_ADD_TC(tp, parse_off_command);
+ ATF_TP_ADD_TC(tp, override_append_and_remove);
+ ATF_TP_ADD_TC(tp, override_replace);
+ ATF_TP_ADD_TC(tp, override_no_file);
+
+ return (atf_no_error());
+}
diff --git a/sbin/rcd/unit.c b/sbin/rcd/unit.c
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/unit.c
@@ -0,0 +1,1347 @@
+/*
+ * Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+/*
+ * Unit file parser. Reads UCL-format service definitions and populates
+ * the unit structure.
+ */
+
+#include <sys/param.h>
+
+#include <ctype.h>
+#include <errno.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <ucl.h>
+
+#include "rcd.h"
+
+/*
+ * Validate that a service name contains only safe characters.
+ * Prevents path traversal in enable.c and injection in rctl rules.
+ */
+/*
+ * Convert a signal name (with or without "SIG" prefix) to a signal number.
+ * Returns -1 if the name is not recognized.
+ */
+static int
+parse_signal(const ucl_object_t *val)
+{
+ const char *s;
+ int i;
+
+ if (ucl_object_type(val) == UCL_INT)
+ return ((int)ucl_object_toint(val));
+
+ s = ucl_object_tostring(val);
+ if (s == NULL)
+ return (-1);
+
+ /* Skip optional "SIG" prefix */
+ if (strncasecmp(s, "SIG", 3) == 0)
+ s += 3;
+
+ for (i = 1; i < NSIG; i++) {
+ if (strcasecmp(s, sys_signame[i]) == 0)
+ return (i);
+ }
+ return (-1);
+}
+
+bool
+valid_service_name(const char *name)
+{
+ const char *p;
+
+ if (name == NULL || name[0] == '\0')
+ return (false);
+ /* Must start with alphanumeric or underscore */
+ if (!isalnum((unsigned char)name[0]) && name[0] != '_')
+ return (false);
+ for (p = name; *p != '\0'; p++) {
+ if (!isalnum((unsigned char)*p) && *p != '_' &&
+ *p != '-' && *p != '.')
+ return (false);
+ }
+ return (true);
+}
+
+struct unit *
+unit_alloc(void)
+{
+ struct unit *u;
+
+ u = xcalloc(1, sizeof(*u));
+
+ u->u_procdesc_fd = -1;
+ u->u_notify_fd = -1;
+ u->u_pid = -1;
+ u->u_state = STATE_INACTIVE;
+ u->u_ready_method = READY_IMMEDIATE;
+ u->u_sig_stop = SIGTERM;
+ u->u_sig_reload = SIGHUP;
+ u->u_log.lc_stdout_pipefd = -1;
+ u->u_log.lc_stdout_wfd = -1;
+ u->u_log.lc_stderr_pipefd = -1;
+ u->u_log.lc_stderr_wfd = -1;
+ u->u_proc.pc_umask = 022;
+ u->u_restart.rc_policy = RESTART_NEVER;
+ u->u_restart.rc_backoff = BACKOFF_NONE;
+ u->u_restart.rc_delay_ms = 5000;
+ u->u_restart.rc_max_retries = 5;
+ u->u_restart.rc_reset_ms = 60000;
+ u->u_enabled = false;
+
+ STAILQ_INIT(&u->u_deps);
+ STAILQ_INIT(&u->u_rdeps);
+ STAILQ_INIT(&u->u_rctl);
+ STAILQ_INIT(&u->u_rctl_active);
+ STAILQ_INIT(&u->u_env);
+ STAILQ_INIT(&u->u_required_sysctl);
+ STAILQ_INIT(&u->u_commands);
+ TAILQ_INIT(&u->u_sockets);
+
+ return (u);
+}
+
+void
+unit_free(struct unit *u)
+{
+ struct rctl_conf *rc, *rc_tmp;
+ struct kv *ue, *ue_tmp;
+ struct unit_socket *us, *us_tmp;
+
+ if (u == NULL)
+ return;
+
+ free(u->u_name);
+ free(u->u_description);
+ free(u->u_path);
+ free(u->u_command);
+ free(u->u_command_args);
+ free(u->u_command_prepend);
+ free(u->u_exec);
+ free(u->u_stop_command);
+ free(u->u_off_command);
+ free(u->u_instance);
+ free(u->u_instance_conf);
+ free(u->u_override_conf);
+
+ vec_free_and_free(&u->u_provide, free);
+ vec_free_and_free(&u->u_require, free);
+ vec_free_and_free(&u->u_before, free);
+ vec_free_and_free(&u->u_keyword, free);
+
+ /* Free dependency links (allocated by depgraph_resolve) */
+ {
+ struct dep_link *dl, *dl_tmp;
+
+ STAILQ_FOREACH_SAFE(dl, &u->u_deps, dl_entries, dl_tmp)
+ free(dl);
+ STAILQ_INIT(&u->u_deps);
+ STAILQ_FOREACH_SAFE(dl, &u->u_rdeps, dl_entries, dl_tmp)
+ free(dl);
+ STAILQ_INIT(&u->u_rdeps);
+ }
+
+ free(u->u_setup_cmd);
+ free(u->u_start_precmd);
+ free(u->u_start_postcmd);
+ free(u->u_stop_precmd);
+ free(u->u_stop_postcmd);
+
+ vec_free_and_free(&u->u_required_dirs, free);
+ vec_free_and_free(&u->u_required_files, free);
+ vec_free_and_free(&u->u_required_modules, free);
+ vec_free_and_free(&u->u_required_vars, free);
+ {
+ struct kv *cmd, *cmd_tmp;
+ STAILQ_FOREACH_SAFE(cmd, &u->u_commands, kv_entries, cmd_tmp) {
+ free(cmd->kv_key);
+ free(cmd->kv_val);
+ free(cmd);
+ }
+ }
+
+ free(u->u_proc.pc_user);
+ free(u->u_proc.pc_group);
+ vec_free_and_free(&u->u_proc.pc_groups, free);
+ free(u->u_proc.pc_chdir);
+ free(u->u_proc.pc_chroot);
+ free(u->u_proc.pc_cpuset);
+ free(u->u_proc.pc_login_class);
+ free(u->u_proc.pc_limits);
+ free(u->u_proc.pc_env_file);
+
+ free(u->u_log.lc_stdout);
+ free(u->u_log.lc_stderr);
+ if (u->u_log.lc_stdout_pipefd >= 0)
+ close(u->u_log.lc_stdout_pipefd);
+ if (u->u_log.lc_stderr_pipefd >= 0)
+ close(u->u_log.lc_stderr_pipefd);
+
+ free(u->u_jail.jc_name);
+ free(u->u_jail.jc_path);
+ vec_free_and_free(&u->u_jail.jc_options, free);
+ vec_free_and_free(&u->u_jail.jc_ip4addr, free);
+ vec_free_and_free(&u->u_jail.jc_ip6addr, free);
+
+ vec_free_and_free(&u->u_access.ua_start, free);
+ vec_free_and_free(&u->u_access.ua_stop, free);
+ vec_free_and_free(&u->u_access.ua_restart, free);
+ vec_free_and_free(&u->u_access.ua_reload, free);
+ vec_free_and_free(&u->u_access.ua_status, free);
+
+ STAILQ_FOREACH_SAFE(rc, &u->u_rctl, rc_entries, rc_tmp) {
+ free(rc->rc_resource);
+ free(rc->rc_action);
+ free(rc->rc_amount);
+ free(rc);
+ }
+
+ STAILQ_FOREACH_SAFE(ue, &u->u_env, kv_entries, ue_tmp) {
+ free(ue->kv_key);
+ free(ue->kv_val);
+ free(ue);
+ }
+
+ STAILQ_FOREACH_SAFE(ue, &u->u_required_sysctl, kv_entries, ue_tmp) {
+ free(ue->kv_key);
+ free(ue->kv_val);
+ free(ue);
+ }
+
+ TAILQ_FOREACH_SAFE(us, &u->u_sockets, us_entries, us_tmp) {
+ free(us->us_name);
+ free(us->us_address);
+ free(us->us_owner);
+ free(us->us_group);
+ free(us);
+ }
+
+ if (u->u_procdesc_fd >= 0)
+ close(u->u_procdesc_fd);
+
+ free(u);
+}
+
+/*
+ * Parse a UCL value as a file mode. Supports:
+ * - UCL_INT: 0660 (UCL parses octal natively)
+ * - UCL_STRING: "0660" (octal string), "rw-rw----" (ls-style),
+ * "u=rw,g=rw" (chmod-style symbolic)
+ * Returns 0 on success, -1 on invalid input.
+ */
+int
+ucl_parse_mode(const ucl_object_t *obj, mode_t *modep)
+{
+
+ if (ucl_object_type(obj) == UCL_INT) {
+ int64_t v = ucl_object_toint(obj);
+
+ if (v < 0 || v > 0777)
+ return (-1);
+ *modep = (mode_t)v;
+ return (0);
+ }
+
+ if (ucl_object_type(obj) == UCL_STRING) {
+ void *set;
+
+ set = setmode(ucl_object_tostring(obj));
+ if (set == NULL)
+ return (-1);
+ *modep = getmode(set, 0);
+ free(set);
+ return (0);
+ }
+
+ return (-1);
+}
+
+/*
+ * Parse a string list from a UCL array into a charv_t.
+ */
+static void
+parse_string_list(const ucl_object_t *obj, charv_t *out)
+{
+ const ucl_object_t *elem;
+ ucl_object_iter_t it;
+
+ if (ucl_array_size(obj) <= 0)
+ return;
+
+ it = ucl_object_iterate_new(obj);
+ while ((elem = ucl_object_iterate_safe(it, true)) != NULL)
+ vec_push(out, xstrdup(ucl_object_tostring(elem)));
+ ucl_object_iterate_free(it);
+}
+
+/*
+ * Parse a "socket" block from the unit file.
+ */
+static int
+parse_socket(const ucl_object_t *obj, const char *name, struct unit *u)
+{
+ struct unit_socket *us;
+ const ucl_object_t *val;
+
+ us = xcalloc(1, sizeof(*us));
+ us->us_name = xstrdup(name);
+ us->us_fd = -1;
+ us->us_backlog = 128;
+ us->us_permissions = 0666;
+
+ val = ucl_object_lookup(obj, "listen");
+ if (val != NULL)
+ us->us_address = xstrdup(ucl_object_tostring(val));
+
+ val = ucl_object_lookup(obj, "type");
+ if (val != NULL) {
+ const char *s = ucl_object_tostring(val);
+ if (strcmp(s, "dgram") == 0)
+ us->us_type = SOCK_ACT_DGRAM;
+ else if (strcmp(s, "seqpacket") == 0)
+ us->us_type = SOCK_ACT_SEQPACKET;
+ else
+ us->us_type = SOCK_ACT_STREAM;
+ }
+
+ val = ucl_object_lookup(obj, "backlog");
+ if (val != NULL)
+ us->us_backlog = ucl_object_toint(val);
+
+ TAILQ_INSERT_TAIL(&u->u_sockets, us, us_entries);
+ return (0);
+}
+
+/*
+ * Parse a "restart" block.
+ */
+static void
+parse_restart(const ucl_object_t *obj, struct restart_conf *rc)
+{
+ const ucl_object_t *val;
+
+ val = ucl_object_lookup(obj, "policy");
+ if (val != NULL) {
+ const char *s = ucl_object_tostring(val);
+ if (strcmp(s, "always") == 0)
+ rc->rc_policy = RESTART_ALWAYS;
+ else if (strcmp(s, "on-failure") == 0)
+ rc->rc_policy = RESTART_ON_FAILURE;
+ else
+ rc->rc_policy = RESTART_NEVER;
+ }
+
+ val = ucl_object_lookup(obj, "max_retries");
+ if (val != NULL)
+ rc->rc_max_retries = ucl_object_toint(val);
+
+ val = ucl_object_lookup(obj, "delay");
+ if (val != NULL)
+ rc->rc_delay_ms = ucl_object_toint(val);
+
+ val = ucl_object_lookup(obj, "reset");
+ if (val != NULL)
+ rc->rc_reset_ms = ucl_object_toint(val);
+
+ val = ucl_object_lookup(obj, "backoff");
+ if (val != NULL) {
+ const char *s = ucl_object_tostring(val);
+ if (strcmp(s, "exponential") == 0)
+ rc->rc_backoff = BACKOFF_EXPONENTIAL;
+ else if (strcmp(s, "linear") == 0)
+ rc->rc_backoff = BACKOFF_LINEAR;
+ else
+ rc->rc_backoff = BACKOFF_NONE;
+ }
+}
+
+/*
+ * Parse a "process" block.
+ */
+static void
+parse_process(const ucl_object_t *obj, struct proc_conf *pc)
+{
+ const ucl_object_t *val;
+
+ val = ucl_object_lookup(obj, "user");
+ if (val != NULL)
+ pc->pc_user = xstrdup(ucl_object_tostring(val));
+
+ val = ucl_object_lookup(obj, "group");
+ if (val != NULL)
+ pc->pc_group = xstrdup(ucl_object_tostring(val));
+
+ val = ucl_object_lookup(obj, "chdir");
+ if (val != NULL)
+ pc->pc_chdir = xstrdup(ucl_object_tostring(val));
+
+ val = ucl_object_lookup(obj, "chroot");
+ if (val != NULL)
+ pc->pc_chroot = xstrdup(ucl_object_tostring(val));
+
+ val = ucl_object_lookup(obj, "umask");
+ if (val != NULL)
+ pc->pc_umask = (mode_t)ucl_object_toint(val);
+
+ val = ucl_object_lookup(obj, "nice");
+ if (val != NULL)
+ pc->pc_nice = ucl_object_toint(val);
+
+ val = ucl_object_lookup(obj, "cpuset");
+ if (val != NULL)
+ pc->pc_cpuset = xstrdup(ucl_object_tostring(val));
+
+ val = ucl_object_lookup(obj, "oom_protect");
+ if (val != NULL)
+ pc->pc_oom_protect = ucl_object_toboolean(val);
+
+ val = ucl_object_lookup(obj, "fib");
+ if (val != NULL)
+ pc->pc_fib = ucl_object_toint(val);
+
+ val = ucl_object_lookup(obj, "login_class");
+ if (val != NULL)
+ pc->pc_login_class = xstrdup(ucl_object_tostring(val));
+
+ val = ucl_object_lookup(obj, "limits");
+ if (val != NULL)
+ pc->pc_limits = xstrdup(ucl_object_tostring(val));
+
+ val = ucl_object_lookup(obj, "env_file");
+ if (val != NULL)
+ pc->pc_env_file = xstrdup(ucl_object_tostring(val));
+
+ /* Supplementary groups */
+ val = ucl_object_lookup(obj, "groups");
+ if (val != NULL && ucl_object_type(val) == UCL_ARRAY) {
+ const ucl_object_t *elem;
+ ucl_object_iter_t git;
+
+ git = ucl_object_iterate_new(val);
+ while ((elem = ucl_object_iterate_safe(git,
+ true)) != NULL)
+ vec_push(&pc->pc_groups,
+ xstrdup(ucl_object_tostring(elem)));
+ ucl_object_iterate_free(git);
+ }
+}
+
+/*
+ * Validate that an rctl field contains only safe characters:
+ * alphanumeric, underscore, dot, hyphen, and (for amounts) digits.
+ * Rejects colons, equals signs, spaces, shell metacharacters.
+ */
+static bool
+valid_rctl_field(const char *s)
+{
+ const char *p;
+
+ if (s == NULL || *s == '\0')
+ return (false);
+ for (p = s; *p != '\0'; p++) {
+ if (!isalnum((unsigned char)*p) && *p != '_' &&
+ *p != '.' && *p != '-' && *p != '/')
+ return (false);
+ }
+ return (true);
+}
+
+/*
+ * Parse an "rctl" block.
+ */
+static void
+parse_rctl(const ucl_object_t *obj, struct rctl_conf_list *list)
+{
+ const ucl_object_t *cur;
+ ucl_object_iter_t it;
+
+ it = ucl_object_iterate_new(obj);
+ while ((cur = ucl_object_iterate_safe(it, true)) != NULL) {
+ struct rctl_conf *rc;
+ const ucl_object_t *act, *amt;
+ const char *key;
+
+ key = ucl_object_key(cur);
+ if (!valid_rctl_field(key)) {
+ log_warn("rctl: invalid resource name '%s', "
+ "skipping", key);
+ continue;
+ }
+
+ rc = xcalloc(1, sizeof(*rc));
+ if (rc == NULL)
+ continue;
+
+ rc->rc_resource = xstrdup(key);
+
+ act = ucl_object_lookup(cur, "action");
+ if (act != NULL) {
+ const char *s = ucl_object_tostring(act);
+ if (valid_rctl_field(s))
+ rc->rc_action = xstrdup(s);
+ else
+ log_warn("rctl: invalid action '%s' "
+ "for resource '%s'", s, key);
+ }
+
+ amt = ucl_object_lookup(cur, "amount");
+ if (amt != NULL) {
+ const char *s = ucl_object_tostring(amt);
+ /* Amounts can contain digits, unit suffixes */
+ rc->rc_amount = xstrdup(s);
+ }
+
+ STAILQ_INSERT_TAIL(list, rc, rc_entries);
+ }
+ ucl_object_iterate_free(it);
+}
+
+/*
+ * Parse a "jail" block.
+ */
+static void
+parse_jail(const ucl_object_t *obj, struct jail_conf *jc)
+{
+ const ucl_object_t *val;
+
+ val = ucl_object_lookup(obj, "enable");
+ if (val != NULL)
+ jc->jc_enable = ucl_object_toboolean(val);
+
+ val = ucl_object_lookup(obj, "name");
+ if (val != NULL)
+ jc->jc_name = xstrdup(ucl_object_tostring(val));
+
+ val = ucl_object_lookup(obj, "path");
+ if (val != NULL)
+ jc->jc_path = xstrdup(ucl_object_tostring(val));
+
+ val = ucl_object_lookup(obj, "devfs");
+ if (val != NULL)
+ jc->jc_devfs = ucl_object_toboolean(val);
+
+ /* Parse options array */
+ val = ucl_object_lookup(obj, "options");
+ if (val != NULL && ucl_object_type(val) == UCL_ARRAY) {
+ const ucl_object_t *elem;
+ ucl_object_iter_t oit;
+
+ oit = ucl_object_iterate_new(val);
+ while ((elem = ucl_object_iterate_safe(oit,
+ true)) != NULL)
+ vec_push(&jc->jc_options,
+ xstrdup(ucl_object_tostring(elem)));
+ ucl_object_iterate_free(oit);
+ }
+
+ /* Parse ip4addr array */
+ val = ucl_object_lookup(obj, "ip4addr");
+ if (val != NULL && ucl_object_type(val) == UCL_ARRAY) {
+ const ucl_object_t *elem;
+ ucl_object_iter_t oit;
+
+ oit = ucl_object_iterate_new(val);
+ while ((elem = ucl_object_iterate_safe(oit,
+ true)) != NULL)
+ vec_push(&jc->jc_ip4addr,
+ xstrdup(ucl_object_tostring(elem)));
+ ucl_object_iterate_free(oit);
+ }
+
+ /* Parse ip6addr array */
+ val = ucl_object_lookup(obj, "ip6addr");
+ if (val != NULL && ucl_object_type(val) == UCL_ARRAY) {
+ const ucl_object_t *elem;
+ ucl_object_iter_t oit;
+
+ oit = ucl_object_iterate_new(val);
+ while ((elem = ucl_object_iterate_safe(oit,
+ true)) != NULL)
+ vec_push(&jc->jc_ip6addr,
+ xstrdup(ucl_object_tostring(elem)));
+ ucl_object_iterate_free(oit);
+ }
+}
+
+/*
+ * Parse an "environment" block.
+ */
+static void
+parse_environment(const ucl_object_t *obj, struct kv_list *list)
+{
+ const ucl_object_t *cur;
+ ucl_object_iter_t it;
+
+ it = ucl_object_iterate_new(obj);
+ while ((cur = ucl_object_iterate_safe(it, true)) != NULL) {
+ struct kv *ue;
+
+ ue = xcalloc(1, sizeof(*ue));
+ if (ue == NULL)
+ continue;
+
+ ue->kv_key = xstrdup(ucl_object_key(cur));
+ ue->kv_val = xstrdup(ucl_object_tostring(cur));
+ STAILQ_INSERT_TAIL(list, ue, kv_entries);
+ }
+ ucl_object_iterate_free(it);
+}
+
+/*
+ * Parse a "logging" block.
+ */
+static void
+parse_logging(const ucl_object_t *obj, struct log_conf *lc)
+{
+ const ucl_object_t *val;
+
+ val = ucl_object_lookup(obj, "stdout");
+ if (val != NULL)
+ lc->lc_stdout = xstrdup(ucl_object_tostring(val));
+
+ val = ucl_object_lookup(obj, "stderr");
+ if (val != NULL)
+ lc->lc_stderr = xstrdup(ucl_object_tostring(val));
+}
+
+/*
+ * Create an instance from a template unit.
+ * The instance gets its own name ("template@instance"), its own state,
+ * and shares the template's command/exec/hooks by copying them.
+ * The instance variable is available to Lua hooks via rcd.instance
+ * and to shell hooks via $RCD_INSTANCE.
+ */
+struct unit *
+unit_instantiate(struct unit *tmpl, const char *instance)
+{
+ struct unit *u;
+
+ if (!tmpl->u_template)
+ return (NULL);
+
+ u = unit_alloc();
+
+ /* Identity — name is "template@instance" */
+ xasprintf(&u->u_name, "%s@%s", tmpl->u_name, instance);
+ u->u_instance = xstrdup(instance);
+ u->u_template_ref = tmpl;
+
+ if (tmpl->u_description != NULL)
+ xasprintf(&u->u_description, "%s (%s)",
+ tmpl->u_description, instance);
+ if (tmpl->u_path != NULL)
+ u->u_path = xstrdup(tmpl->u_path);
+
+ /* Copy type and config from template */
+ u->u_type = tmpl->u_type;
+ u->u_sig_stop = tmpl->u_sig_stop;
+ u->u_sig_reload = tmpl->u_sig_reload;
+ u->u_start_delay_ms = tmpl->u_start_delay_ms;
+ u->u_ready_method = tmpl->u_ready_method;
+ u->u_restart = tmpl->u_restart;
+ u->u_proc = (struct proc_conf){ 0 }; /* reset, copy fields */
+ if (tmpl->u_proc.pc_user != NULL)
+ u->u_proc.pc_user = xstrdup(tmpl->u_proc.pc_user);
+ if (tmpl->u_proc.pc_group != NULL)
+ u->u_proc.pc_group = xstrdup(tmpl->u_proc.pc_group);
+ u->u_proc.pc_umask = tmpl->u_proc.pc_umask;
+ u->u_proc.pc_nice = tmpl->u_proc.pc_nice;
+ u->u_proc.pc_oom_protect = tmpl->u_proc.pc_oom_protect;
+ if (tmpl->u_proc.pc_cpuset != NULL)
+ u->u_proc.pc_cpuset = xstrdup(tmpl->u_proc.pc_cpuset);
+ u->u_proc.pc_fib = tmpl->u_proc.pc_fib;
+
+ /* Copy command/exec/hooks — these reference the instance */
+ if (tmpl->u_command != NULL)
+ u->u_command = xstrdup(tmpl->u_command);
+ if (tmpl->u_command_args != NULL)
+ u->u_command_args = xstrdup(tmpl->u_command_args);
+ if (tmpl->u_exec != NULL)
+ u->u_exec = xstrdup(tmpl->u_exec);
+ if (tmpl->u_stop_command != NULL)
+ u->u_stop_command = xstrdup(tmpl->u_stop_command);
+ if (tmpl->u_start_precmd != NULL)
+ u->u_start_precmd = xstrdup(tmpl->u_start_precmd);
+ if (tmpl->u_start_postcmd != NULL)
+ u->u_start_postcmd = xstrdup(tmpl->u_start_postcmd);
+ if (tmpl->u_stop_precmd != NULL)
+ u->u_stop_precmd = xstrdup(tmpl->u_stop_precmd);
+ if (tmpl->u_stop_postcmd != NULL)
+ u->u_stop_postcmd = xstrdup(tmpl->u_stop_postcmd);
+
+ /* Flags */
+ u->u_enabled = true;
+ u->u_nojail = tmpl->u_nojail;
+ u->u_nojailvnet = tmpl->u_nojailvnet;
+ u->u_resume = tmpl->u_resume;
+
+ /* Jail config */
+ u->u_jail.jc_enable = tmpl->u_jail.jc_enable;
+
+ log_info("instantiated %s from template %s",
+ u->u_name, tmpl->u_name);
+
+ return (u);
+}
+
+/*
+ * Parse a unit file and return a populated unit structure.
+ */
+struct unit *
+unit_parse(const char *path, struct rcd_config *cfg __unused)
+{
+ struct ucl_parser *parser;
+ ucl_object_t *top;
+ const ucl_object_t *val, *sub;
+ ucl_object_iter_t it;
+ struct unit *u;
+
+ parser = ucl_parser_new(UCL_PARSER_DEFAULT);
+ if (parser == NULL)
+ return (NULL);
+
+ if (!ucl_parser_add_file(parser, path)) {
+ log_warn("ucl parse error: %s: %s", path,
+ ucl_parser_get_error(parser));
+ ucl_parser_free(parser);
+ return (NULL);
+ }
+
+ top = ucl_parser_get_object(parser);
+ ucl_parser_free(parser);
+
+ if (top == NULL)
+ return (NULL);
+
+ /* Validate against schema if available */
+ if (cfg->cfg_unit_schema != NULL) {
+ struct ucl_schema_error serr;
+
+ if (!ucl_object_validate_root(cfg->cfg_unit_schema, top,
+ cfg->cfg_unit_schema, &serr)) {
+ log_warn("%s: schema validation failed: %s",
+ path, serr.msg);
+ ucl_object_unref(top);
+ return (NULL);
+ }
+ }
+
+ u = unit_alloc();
+ u->u_path = xstrdup(path);
+
+ /* Service name — required */
+ val = ucl_object_lookup(top, "name");
+ if (val == NULL) {
+ log_warn("%s: missing 'name' field", path);
+ ucl_object_unref(top);
+ unit_free(u);
+ return (NULL);
+ }
+ u->u_name = xstrdup(ucl_object_tostring(val));
+
+ if (!valid_service_name(u->u_name)) {
+ log_warn("invalid service name: %s in %s",
+ u->u_name, path);
+ ucl_object_unref(top);
+ unit_free(u);
+ return (NULL);
+ }
+
+ /* All fields are at top level */
+ val = ucl_object_lookup(top, "description");
+ if (val != NULL)
+ u->u_description = xstrdup(ucl_object_tostring(val));
+
+ val = ucl_object_lookup(top, "type");
+ if (val != NULL) {
+ const char *s = ucl_object_tostring(val);
+ if (strcmp(s, "forking") == 0)
+ u->u_type = UNIT_FORKING;
+ else if (strcmp(s, "oneshot") == 0)
+ u->u_type = UNIT_ONESHOT;
+ else if (strcmp(s, "barrier") == 0)
+ u->u_type = UNIT_BARRIER;
+ else
+ u->u_type = UNIT_SIMPLE;
+ }
+
+ val = ucl_object_lookup(top, "enable");
+ if (val != NULL)
+ u->u_enabled = ucl_object_toboolean(val);
+
+ val = ucl_object_lookup(top, "template");
+ if (val != NULL)
+ u->u_template = ucl_object_toboolean(val);
+
+ val = ucl_object_lookup(top, "command");
+ if (val != NULL)
+ u->u_command = xstrdup(ucl_object_tostring(val));
+
+ /*
+ * Barriers need nothing. Oneshots can use either command or exec.
+ * Daemons (simple/forking) always need command.
+ */
+ if (u->u_command == NULL && u->u_exec == NULL &&
+ u->u_type != UNIT_BARRIER) {
+ log_warn("%s: missing 'command' or 'exec' field", path);
+ ucl_object_unref(top);
+ unit_free(u);
+ return (NULL);
+ }
+
+ val = ucl_object_lookup(top, "command_args");
+ if (val != NULL)
+ u->u_command_args = xstrdup(ucl_object_tostring(val));
+
+ val = ucl_object_lookup(top, "exec");
+ if (val != NULL)
+ u->u_exec = xstrdup(ucl_object_tostring(val));
+
+ val = ucl_object_lookup(top, "command_prepend");
+ if (val != NULL)
+ u->u_command_prepend = xstrdup(ucl_object_tostring(val));
+
+ val = ucl_object_lookup(top, "stop_command");
+ if (val != NULL)
+ u->u_stop_command = xstrdup(ucl_object_tostring(val));
+
+ val = ucl_object_lookup(top, "off_command");
+ if (val != NULL)
+ u->u_off_command = xstrdup(ucl_object_tostring(val));
+
+ /* Signals */
+ val = ucl_object_lookup(top, "sig_stop");
+ if (val != NULL) {
+ int sig = parse_signal(val);
+ if (sig > 0)
+ u->u_sig_stop = sig;
+ }
+
+ val = ucl_object_lookup(top, "sig_reload");
+ if (val != NULL) {
+ int sig = parse_signal(val);
+ if (sig > 0)
+ u->u_sig_reload = sig;
+ }
+
+ val = ucl_object_lookup(top, "start_delay");
+ if (val != NULL)
+ u->u_start_delay_ms = ucl_object_toint(val);
+
+ /* Preconditions */
+ val = ucl_object_lookup(top, "required_dirs");
+ if (val != NULL)
+ parse_string_list(val, &u->u_required_dirs);
+
+ val = ucl_object_lookup(top, "required_files");
+ if (val != NULL)
+ parse_string_list(val, &u->u_required_files);
+
+ val = ucl_object_lookup(top, "required_modules");
+ if (val != NULL)
+ parse_string_list(val, &u->u_required_modules);
+
+ val = ucl_object_lookup(top, "required_vars");
+ if (val != NULL)
+ parse_string_list(val, &u->u_required_vars);
+
+ /* Required sysctl values — key=value pairs */
+ sub = ucl_object_lookup(top, "required_sysctl");
+ if (sub != NULL) {
+ const ucl_object_t *cur;
+ ucl_object_iter_t sit;
+
+ sit = ucl_object_iterate_new(sub);
+ while ((cur = ucl_object_iterate_safe(sit, true)) != NULL) {
+ struct kv *sc;
+
+ sc = xcalloc(1, sizeof(*sc));
+ sc->kv_key = xstrdup(ucl_object_key(cur));
+ sc->kv_val = xstrdup(ucl_object_tostring(cur));
+ STAILQ_INSERT_TAIL(&u->u_required_sysctl, sc,
+ kv_entries);
+ }
+ ucl_object_iterate_free(sit);
+ }
+
+ /* Hooks */
+ val = ucl_object_lookup(top, "setup_cmd");
+ if (val != NULL)
+ u->u_setup_cmd = xstrdup(ucl_object_tostring(val));
+
+ val = ucl_object_lookup(top, "start_precmd");
+ if (val != NULL)
+ u->u_start_precmd = xstrdup(ucl_object_tostring(val));
+
+ val = ucl_object_lookup(top, "start_postcmd");
+ if (val != NULL)
+ u->u_start_postcmd = xstrdup(ucl_object_tostring(val));
+
+ val = ucl_object_lookup(top, "stop_precmd");
+ if (val != NULL)
+ u->u_stop_precmd = xstrdup(ucl_object_tostring(val));
+
+ val = ucl_object_lookup(top, "stop_postcmd");
+ if (val != NULL)
+ u->u_stop_postcmd = xstrdup(ucl_object_tostring(val));
+
+ /* Extra commands */
+ /* Custom commands: name → exec code */
+ sub = ucl_object_lookup(top, "commands");
+ if (sub != NULL) {
+ const ucl_object_t *cur;
+ ucl_object_iter_t cit;
+
+ cit = ucl_object_iterate_new(sub);
+ while ((cur = ucl_object_iterate_safe(cit, true)) != NULL) {
+ struct kv *cmd;
+
+ cmd = xcalloc(1, sizeof(*cmd));
+ cmd->kv_key = xstrdup(ucl_object_key(cur));
+ cmd->kv_val = xstrdup(ucl_object_tostring(cur));
+ STAILQ_INSERT_TAIL(&u->u_commands, cmd, kv_entries);
+ }
+ ucl_object_iterate_free(cit);
+ }
+
+ /* Dependency lists */
+ val = ucl_object_lookup(top, "provides");
+ if (val != NULL)
+ parse_string_list(val, &u->u_provide);
+
+ val = ucl_object_lookup(top, "requires");
+ if (val != NULL)
+ parse_string_list(val, &u->u_require);
+
+ val = ucl_object_lookup(top, "before");
+ if (val != NULL)
+ parse_string_list(val, &u->u_before);
+
+ val = ucl_object_lookup(top, "keywords");
+ if (val != NULL) {
+ parse_string_list(val, &u->u_keyword);
+ /* Process keyword flags */
+ vec_foreach(u->u_keyword, ki) {
+ if (strcmp(u->u_keyword.d[ki],
+ "nojail") == 0)
+ u->u_nojail = true;
+ else if (strcmp(u->u_keyword.d[ki],
+ "nojailvnet") == 0)
+ u->u_nojailvnet = true;
+ else if (strcmp(u->u_keyword.d[ki],
+ "firstboot") == 0)
+ u->u_boot_only = true;
+ else if (strcmp(u->u_keyword.d[ki],
+ "nostart") == 0)
+ u->u_nostart = true;
+ else if (strcmp(u->u_keyword.d[ki],
+ "resume") == 0)
+ u->u_resume = true;
+ }
+ }
+
+ /* Readiness method */
+ val = ucl_object_lookup(top, "ready");
+ if (val != NULL) {
+ sub = ucl_object_lookup(val, "method");
+ if (sub != NULL) {
+ const char *s = ucl_object_tostring(sub);
+ if (strcmp(s, "fd") == 0)
+ u->u_ready_method = READY_FD;
+ else if (strcmp(s, "exit") == 0)
+ u->u_ready_method = READY_EXIT;
+ else if (strcmp(s, "socket") == 0)
+ u->u_ready_method = READY_SOCKET;
+ }
+ }
+
+ /* Socket blocks */
+ sub = ucl_object_lookup(top, "socket");
+ if (sub != NULL) {
+ it = ucl_object_iterate_new(sub);
+ while ((val = ucl_object_iterate_safe(it, true)) != NULL) {
+ if (ucl_object_type(val) == UCL_OBJECT)
+ parse_socket(val, ucl_object_key(val), u);
+ }
+ ucl_object_iterate_free(it);
+ }
+
+ /* Subsystem blocks */
+ sub = ucl_object_lookup(top, "process");
+ if (sub != NULL)
+ parse_process(sub, &u->u_proc);
+
+ sub = ucl_object_lookup(top, "restart");
+ if (sub != NULL)
+ parse_restart(sub, &u->u_restart);
+
+ sub = ucl_object_lookup(top, "rctl");
+ if (sub != NULL)
+ parse_rctl(sub, &u->u_rctl);
+
+ sub = ucl_object_lookup(top, "jail");
+ if (sub != NULL)
+ parse_jail(sub, &u->u_jail);
+
+ sub = ucl_object_lookup(top, "environment");
+ if (sub != NULL)
+ parse_environment(sub, &u->u_env);
+
+ sub = ucl_object_lookup(top, "logging");
+ if (sub != NULL)
+ parse_logging(sub, &u->u_log);
+
+ /* Access control */
+ sub = ucl_object_lookup(top, "access");
+ if (sub != NULL) {
+ val = ucl_object_lookup(sub, "start");
+ if (val != NULL)
+ parse_string_list(val, &u->u_access.ua_start);
+ val = ucl_object_lookup(sub, "stop");
+ if (val != NULL)
+ parse_string_list(val, &u->u_access.ua_stop);
+ val = ucl_object_lookup(sub, "restart");
+ if (val != NULL)
+ parse_string_list(val, &u->u_access.ua_restart);
+ val = ucl_object_lookup(sub, "reload");
+ if (val != NULL)
+ parse_string_list(val, &u->u_access.ua_reload);
+ val = ucl_object_lookup(sub, "status");
+ if (val != NULL)
+ parse_string_list(val, &u->u_access.ua_status);
+ }
+
+ ucl_object_unref(top);
+ return (u);
+}
+
+/*
+ * Override helpers for unit_apply_overrides().
+ */
+
+/* Replace a char * field if the key exists in the UCL object. */
+static void
+override_string(const ucl_object_t *top, const char *key, char **field)
+{
+ const ucl_object_t *val;
+
+ val = ucl_object_lookup(top, key);
+ if (val != NULL) {
+ free(*field);
+ *field = xstrdup(ucl_object_tostring(val));
+ }
+}
+
+/* Append UCL array elements to a charv_t, skipping duplicates. */
+static void
+override_append_array(const ucl_object_t *top, const char *key, charv_t *vec)
+{
+ const ucl_object_t *val, *elem;
+ ucl_object_iter_t it;
+ size_t i;
+ bool dup;
+
+ val = ucl_object_lookup(top, key);
+ if (val == NULL || ucl_object_type(val) != UCL_ARRAY)
+ return;
+
+ it = ucl_object_iterate_new(val);
+ while ((elem = ucl_object_iterate_safe(it, true)) != NULL) {
+ const char *s = ucl_object_tostring(elem);
+
+ dup = false;
+ for (i = 0; i < vec->len; i++) {
+ if (strcmp(vec->d[i], s) == 0) {
+ dup = true;
+ break;
+ }
+ }
+ if (!dup)
+ vec_push(vec, xstrdup(s));
+ }
+ ucl_object_iterate_free(it);
+}
+
+/* Remove named entries from a charv_t. */
+static void
+override_remove_array(const ucl_object_t *block, const char *key, charv_t *vec)
+{
+ const ucl_object_t *val, *elem;
+ ucl_object_iter_t it;
+
+ val = ucl_object_lookup(block, key);
+ if (val == NULL || ucl_object_type(val) != UCL_ARRAY)
+ return;
+
+ it = ucl_object_iterate_new(val);
+ while ((elem = ucl_object_iterate_safe(it, true)) != NULL) {
+ const char *s = ucl_object_tostring(elem);
+ size_t i;
+
+ for (i = 0; i < vec->len; i++) {
+ if (strcmp(vec->d[i], s) == 0) {
+ vec_remove_and_free(vec, i, free);
+ break;
+ }
+ }
+ }
+ ucl_object_iterate_free(it);
+}
+
+/* Replace a charv_t entirely from a UCL array. */
+static void
+override_replace_array(const ucl_object_t *block, const char *key, charv_t *vec)
+{
+ const ucl_object_t *val;
+
+ val = ucl_object_lookup(block, key);
+ if (val == NULL || ucl_object_type(val) != UCL_ARRAY)
+ return;
+
+ vec_free_and_free(vec, free);
+ parse_string_list(val, vec);
+}
+
+/*
+ * Reprocess keyword flags from the keyword array.
+ * Must be called after any modification to u_keyword.
+ */
+static void
+reprocess_keyword_flags(struct unit *u)
+{
+
+ u->u_nojail = false;
+ u->u_nojailvnet = false;
+ u->u_nostart = false;
+ u->u_boot_only = false;
+ u->u_resume = false;
+
+ vec_foreach(u->u_keyword, ki) {
+ if (strcmp(u->u_keyword.d[ki], "nojail") == 0)
+ u->u_nojail = true;
+ else if (strcmp(u->u_keyword.d[ki], "nojailvnet") == 0)
+ u->u_nojailvnet = true;
+ else if (strcmp(u->u_keyword.d[ki], "firstboot") == 0)
+ u->u_boot_only = true;
+ else if (strcmp(u->u_keyword.d[ki], "nostart") == 0)
+ u->u_nostart = true;
+ else if (strcmp(u->u_keyword.d[ki], "resume") == 0)
+ u->u_resume = true;
+ }
+}
+
+/*
+ * Apply per-service overrides from /etc/rcd.conf.d/<name>.
+ *
+ * Override files are UCL fragments merged on top of the unit's
+ * configuration. Merge semantics:
+ * - Scalar fields: replaced.
+ * - Array fields (provides, requires, before, keywords, required_*):
+ * appended (duplicates skipped).
+ * - Object fields (restart, process, environment, jail, logging, access):
+ * merged key-by-key.
+ * - remove {} block: entries listed are removed from arrays.
+ * - replace {} block: arrays listed are replaced entirely.
+ *
+ * Application order: remove first, then merge/append, then replace.
+ */
+int
+unit_apply_overrides(struct unit *u, const char *confdir)
+{
+ struct ucl_parser *parser;
+ ucl_object_t *top;
+ const ucl_object_t *val, *sub;
+ char path[PATH_MAX];
+ bool keywords_changed;
+
+ if (snprintf(path, sizeof(path), "%s/%s",
+ confdir, u->u_name) >= (int)sizeof(path))
+ return (0);
+
+ parser = ucl_parser_new(UCL_PARSER_DEFAULT);
+ if (parser == NULL)
+ return (0);
+
+ if (!ucl_parser_add_file(parser, path)) {
+ ucl_parser_free(parser);
+ return (0); /* File doesn't exist — not an error */
+ }
+
+ top = ucl_parser_get_object(parser);
+ ucl_parser_free(parser);
+ if (top == NULL)
+ return (0);
+
+ /* Store the raw UCL for rcd.config in Lua hooks */
+ {
+ unsigned char *raw;
+
+ raw = ucl_object_emit(top, UCL_EMIT_CONFIG);
+ if (raw != NULL) {
+ free(u->u_override_conf);
+ u->u_override_conf = (char *)raw;
+ }
+ }
+
+ keywords_changed = false;
+
+ sub = ucl_object_lookup(top, "remove");
+ if (sub != NULL) {
+ override_remove_array(sub, "provides", &u->u_provide);
+ override_remove_array(sub, "requires", &u->u_require);
+ override_remove_array(sub, "before", &u->u_before);
+ if (ucl_object_lookup(sub, "keywords") != NULL) {
+ override_remove_array(sub, "keywords", &u->u_keyword);
+ keywords_changed = true;
+ }
+ override_remove_array(sub, "required_dirs",
+ &u->u_required_dirs);
+ override_remove_array(sub, "required_files",
+ &u->u_required_files);
+ override_remove_array(sub, "required_modules",
+ &u->u_required_modules);
+ override_remove_array(sub, "required_vars",
+ &u->u_required_vars);
+ }
+
+ /* Replace scalars */
+ val = ucl_object_lookup(top, "enable");
+ if (val != NULL)
+ u->u_enabled = ucl_object_toboolean(val);
+
+ override_string(top, "command", &u->u_command);
+ override_string(top, "command_args", &u->u_command_args);
+ override_string(top, "command_prepend", &u->u_command_prepend);
+ override_string(top, "exec", &u->u_exec);
+ override_string(top, "stop_command", &u->u_stop_command);
+ override_string(top, "off_command", &u->u_off_command);
+ override_string(top, "description", &u->u_description);
+
+ /* Hooks */
+ override_string(top, "setup_cmd", &u->u_setup_cmd);
+ override_string(top, "start_precmd", &u->u_start_precmd);
+ override_string(top, "start_postcmd", &u->u_start_postcmd);
+ override_string(top, "stop_precmd", &u->u_stop_precmd);
+ override_string(top, "stop_postcmd", &u->u_stop_postcmd);
+
+ /* Signals */
+ val = ucl_object_lookup(top, "sig_stop");
+ if (val != NULL) {
+ int sig = parse_signal(val);
+ if (sig > 0)
+ u->u_sig_stop = sig;
+ }
+ val = ucl_object_lookup(top, "sig_reload");
+ if (val != NULL) {
+ int sig = parse_signal(val);
+ if (sig > 0)
+ u->u_sig_reload = sig;
+ }
+
+ /* Start delay */
+ val = ucl_object_lookup(top, "start_delay");
+ if (val != NULL)
+ u->u_start_delay_ms = ucl_object_toint(val);
+
+ /* arrays (append, deduplicate) */
+ override_append_array(top, "provides", &u->u_provide);
+ override_append_array(top, "requires", &u->u_require);
+ override_append_array(top, "before", &u->u_before);
+ if (ucl_object_lookup(top, "keywords") != NULL) {
+ override_append_array(top, "keywords", &u->u_keyword);
+ keywords_changed = true;
+ }
+ override_append_array(top, "required_dirs", &u->u_required_dirs);
+ override_append_array(top, "required_files", &u->u_required_files);
+ override_append_array(top, "required_modules",
+ &u->u_required_modules);
+ override_append_array(top, "required_vars", &u->u_required_vars);
+
+ /* objects (merge) */
+ sub = ucl_object_lookup(top, "restart");
+ if (sub != NULL)
+ parse_restart(sub, &u->u_restart);
+
+ sub = ucl_object_lookup(top, "process");
+ if (sub != NULL)
+ parse_process(sub, &u->u_proc);
+
+ sub = ucl_object_lookup(top, "environment");
+ if (sub != NULL)
+ parse_environment(sub, &u->u_env);
+
+ sub = ucl_object_lookup(top, "jail");
+ if (sub != NULL)
+ parse_jail(sub, &u->u_jail);
+
+ sub = ucl_object_lookup(top, "logging");
+ if (sub != NULL)
+ parse_logging(sub, &u->u_log);
+
+ /* Access control — arrays within access are replaced, not appended */
+ sub = ucl_object_lookup(top, "access");
+ if (sub != NULL) {
+ val = ucl_object_lookup(sub, "start");
+ if (val != NULL) {
+ vec_free_and_free(&u->u_access.ua_start, free);
+ parse_string_list(val, &u->u_access.ua_start);
+ }
+ val = ucl_object_lookup(sub, "stop");
+ if (val != NULL) {
+ vec_free_and_free(&u->u_access.ua_stop, free);
+ parse_string_list(val, &u->u_access.ua_stop);
+ }
+ val = ucl_object_lookup(sub, "restart");
+ if (val != NULL) {
+ vec_free_and_free(&u->u_access.ua_restart, free);
+ parse_string_list(val, &u->u_access.ua_restart);
+ }
+ val = ucl_object_lookup(sub, "reload");
+ if (val != NULL) {
+ vec_free_and_free(&u->u_access.ua_reload, free);
+ parse_string_list(val, &u->u_access.ua_reload);
+ }
+ val = ucl_object_lookup(sub, "status");
+ if (val != NULL) {
+ vec_free_and_free(&u->u_access.ua_status, free);
+ parse_string_list(val, &u->u_access.ua_status);
+ }
+ }
+
+ sub = ucl_object_lookup(top, "replace");
+ if (sub != NULL) {
+ override_replace_array(sub, "provides", &u->u_provide);
+ override_replace_array(sub, "requires", &u->u_require);
+ override_replace_array(sub, "before", &u->u_before);
+ if (ucl_object_lookup(sub, "keywords") != NULL) {
+ override_replace_array(sub, "keywords",
+ &u->u_keyword);
+ keywords_changed = true;
+ }
+ override_replace_array(sub, "required_dirs",
+ &u->u_required_dirs);
+ override_replace_array(sub, "required_files",
+ &u->u_required_files);
+ override_replace_array(sub, "required_modules",
+ &u->u_required_modules);
+ override_replace_array(sub, "required_vars",
+ &u->u_required_vars);
+ }
+
+ /* Recompute keyword-derived flags if keywords were touched */
+ if (keywords_changed)
+ reprocess_keyword_flags(u);
+
+ ucl_object_unref(top);
+ return (0);
+}
diff --git a/sbin/rcd/vec.h b/sbin/rcd/vec.h
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/vec.h
@@ -0,0 +1,139 @@
+/*
+ * Copyright(c) 2024-2025 Baptiste Daroussin <bapt@FreeBSD.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#ifndef VEC_H
+#define VEC_H
+
+#include <stdbool.h>
+#include <stdlib.h>
+#include <stddef.h>
+
+#define vec_t(Type) \
+ struct { Type *d; size_t len, cap; }
+
+#define vec_init() \
+ { .d = NULL, .len = 0, .cap = 0 }
+
+#define vec_foreach(list, __i) \
+ for (size_t __i = 0; __i < (list).len; __i++)
+
+/* ssize_t because the value can be negative */
+#define vec_rforeach(list, __i) \
+ for (ssize_t __i = ((ssize_t)(list).len) -1 ; __i >= 0; __i--)
+
+#define vec_free(v) \
+ do { \
+ free((v)->d); \
+ memset((v), 0, sizeof(*(v))); \
+ } while (0)
+
+#define vec_free_and_free(v, free_func) \
+ do { \
+ vec_foreach(*(v), _i) { \
+ free_func((v)->d[_i]); \
+ (v)->d[_i] = NULL; \
+ } \
+ vec_free((v)); \
+ } while(0)
+
+#define vec_first(v) \
+ (v)->d[0]
+
+#define vec_last(v) \
+ (v)->d[(v)->len -1]
+
+#define vec_clear(v) \
+ (v)->len = 0
+
+#define vec_clear_and_free(v, free_func) \
+ do { \
+ vec_foreach(*(v), _i) { \
+ free_func((v)->d[_i]); \
+ (v)->d[_i] = NULL; \
+ } \
+ (v)->len = 0; \
+ } while (0)
+
+#define vec_push(v, _d) \
+ do { \
+ if ((v)->len >= (v)->cap) { \
+ if ((v)->cap == 0) \
+ (v)->cap = 1; \
+ else \
+ (v)->cap *=2; \
+ (v)->d = realloc((v)->d, (v)->cap * sizeof(*(v)->d)); \
+ if ((v)->d == NULL) \
+ abort(); \
+ } \
+ (v)->d[(v)->len++] = (_d); \
+ } while (0) \
+
+#define vec_pop(v) \
+ (v)->d[--(v)->len]
+
+#define vec_remove(v, cnt) \
+ do { \
+ for (size_t _i = cnt; _i < (v)->len - 1; _i++) { \
+ (v)->d[_i] = (v)->d[_i + 1]; \
+ } \
+ (v)->len--; \
+ } while (0)
+
+#define vec_remove_and_free(v, cnt, free_func) \
+ do { \
+ free_func((v)->d[cnt]); \
+ vec_remove(v, cnt); \
+ } while (0)
+
+/*
+ * Remove the element at the given index and replace it with the last
+ * element in the vec. Does not preserve order, but is O(1).
+ */
+#define vec_swap_remove(v, index) \
+ do { \
+ assert((index) < (v)->len); \
+ assert((v)->len > 0); \
+ if ((index) < (v)->len - 1) { \
+ (v)->d[index] = vec_last(v); \
+ } \
+ (v)->len--; \
+ } while (0)
+
+#define vec_len(v) \
+ (v)->len
+
+#define DEFINE_VEC_INSERT_SORTED_PROTO(type, name, element_type) \
+ element_type *name##_insert_sorted(type *v, element_type el)
+
+#define DEFINE_VEC_INSERT_SORTED_FUNC(type, _name, element_type, compare_func) \
+ element_type *_name##_insert_sorted(type *v, element_type el) { \
+ /* Verify if the element already exists */ \
+ if (v->len > 0) { \
+ element_type *found = bsearch(&el, v->d, v->len, sizeof(element_type), compare_func); \
+ if (found != NULL){ \
+ return (found); \
+ } \
+ } \
+ if (v->len >= v->cap) { \
+ v->cap = (v->cap == 0) ? 1 : v->cap * 2; \
+ v->d = realloc(v->d, v->cap * sizeof(element_type)); \
+ if (v->d == NULL) \
+ abort(); \
+ } \
+ /* Find where to insert */ \
+ size_t i; \
+ for (i = v->len; i > 0 && compare_func(&v->d[i - 1], &el) > 0; i--) { \
+ v->d[i] = v->d[i - 1]; \
+ } \
+ v->d[i] = el; \
+ v->len++; \
+ return (NULL); \
+ }
+
+typedef vec_t(char *) charv_t;
+typedef vec_t(const char *) c_charv_t;
+
+#endif
diff --git a/sbin/rcd/xio.h b/sbin/rcd/xio.h
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/xio.h
@@ -0,0 +1,102 @@
+/*
+ * Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#ifndef XIO_H
+#define XIO_H
+
+#include <sys/procdesc.h>
+#include <sys/wait.h>
+#include <errno.h>
+#include <unistd.h>
+
+/*
+ * Read exactly n bytes from fd, handling EINTR and partial reads.
+ * Returns n on success, -1 on error (errno set).
+ */
+static inline ssize_t
+xread(int fd, void *buf, size_t n)
+{
+ size_t done = 0;
+ ssize_t ret;
+
+ while (done < n) {
+ ret = read(fd, (char *)buf + done, n - done);
+ if (ret < 0) {
+ if (errno == EINTR)
+ continue;
+ return (-1);
+ }
+ if (ret == 0) {
+ errno = EPIPE;
+ return (-1);
+ }
+ done += ret;
+ }
+ return (done);
+}
+
+/*
+ * Write exactly n bytes to fd, handling EINTR and partial writes.
+ * Returns n on success, -1 on error (errno set).
+ */
+static inline ssize_t
+xwrite(int fd, const void *buf, size_t n)
+{
+ size_t done = 0;
+ ssize_t ret;
+
+ while (done < n) {
+ ret = write(fd, (const char *)buf + done, n - done);
+ if (ret < 0) {
+ if (errno == EINTR)
+ continue;
+ return (-1);
+ }
+ if (ret == 0) {
+ /* blocking write returning 0 means no progress possible */
+ errno = EIO;
+ return (-1);
+ }
+ done += ret;
+ }
+ return (done);
+}
+
+/*
+ * Wait for a child process, retrying on EINTR.
+ * Returns the child PID on success, -1 on error (errno set), 0 if WNOHANG
+ * was set and no child was available.
+ */
+static inline pid_t
+xwaitpid(pid_t pid, int *status, int opts)
+{
+ pid_t ret;
+
+ do {
+ ret = waitpid(pid, status, opts);
+ } while (ret < 0 && errno == EINTR);
+
+ return (ret);
+}
+
+/*
+ * Wait on a process descriptor, retrying on EINTR.
+ * Returns the child PID on success, -1 on error (errno set), 0 if WNOHANG
+ * was set and no child was available.
+ */
+static inline pid_t
+xpdwait(int fd, int *status, int opts)
+{
+ pid_t ret;
+
+ do {
+ ret = pdwait(fd, status, opts, NULL, NULL);
+ } while (ret < 0 && errno == EINTR);
+
+ return (ret);
+}
+
+#endif /* !XIO_H */
diff --git a/sbin/rcd/xmalloc.h b/sbin/rcd/xmalloc.h
new file mode 100644
--- /dev/null
+++ b/sbin/rcd/xmalloc.h
@@ -0,0 +1,85 @@
+/*
+ * Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#ifndef XMALLOC_H
+#define XMALLOC_H
+
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+/*
+ * Allocation helpers with configurable error handling.
+ *
+ * By default, allocation failures call abort(). To use a custom handler
+ * (e.g., longjmp), define XALLOC_ERROR before including this header:
+ *
+ * #include <setjmp.h>
+ * extern jmp_buf my_jmp_buf;
+ * #define XALLOC_ERROR longjmp(my_jmp_buf, 1)
+ * #include "xmalloc.h"
+ */
+
+#ifndef XALLOC_ERROR
+#define XALLOC_ERROR abort()
+#endif
+
+static inline void *xmalloc(size_t size)
+{
+ void *ptr = malloc(size);
+ if (ptr == NULL)
+ XALLOC_ERROR;
+ return (ptr);
+}
+
+static inline void *xcalloc(size_t n, size_t size)
+{
+ void *ptr = calloc(n, size);
+ if (ptr == NULL)
+ XALLOC_ERROR;
+ return (ptr);
+}
+
+static inline void *xrealloc(void *ptr, size_t size)
+{
+ ptr = realloc(ptr, size);
+ if (ptr == NULL)
+ XALLOC_ERROR;
+ return (ptr);
+}
+
+static inline char *xstrdup(const char *str)
+{
+ char *s = strdup(str);
+ if (s == NULL)
+ XALLOC_ERROR;
+ return (s);
+}
+
+static inline char *xstrndup(const char *str, size_t n)
+{
+ char *s = strndup(str, n);
+ if (s == NULL)
+ XALLOC_ERROR;
+ return (s);
+}
+
+static inline int xasprintf(char **ret, const char *fmt, ...)
+{
+ va_list ap;
+ int i;
+
+ va_start(ap, fmt);
+ i = vasprintf(ret, fmt, ap);
+ va_end(ap);
+
+ if (i < 0 || *ret == NULL)
+ XALLOC_ERROR;
+
+ return (i);
+}
+#endif

File Metadata

Mime Type
text/plain
Expires
Tue, Jun 23, 1:02 PM (17 h, 22 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
34161028
Default Alt Text
D56835.diff (446 KB)

Event Timeline