linkd

Control plane daemon for unos
git clone git://git.finwo.net/app/linkd
Log | Files | Refs | README

commit dcecd3c0e451813776a15505a9d71ce99b449081
parent 2f8f841ece9f4b64aca630f854f5fcf7bc8b4dd2
Author: finwo <finwo@pm.me>
Date:   Wed, 30 Sep 2026 04:08:45 +0200

Apply method dhcp by spawning a configurable client

Diffstat:
MREADME.md | 45+++++++++++++++++++++++++++++++++++++--------
Msrc/cli/linkd.c | 18+++++++++++++-----
Msrc/config/daemon.c | 32++++++++++++++++++++++++++++++--
Msrc/config/daemon.h | 7++++++-
Msrc/config/ifaces.c | 8++++++++
Msrc/config/ifaces.h | 1+
Asrc/dhcp.h | 17+++++++++++++++++
Asrc/dhcp/client.c | 203+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/ipc.c | 16+++++++++++++---
Msrc/netlink/netlink.c | 2++
Atests/fixtures/dhcp-fake.sh | 6++++++
Atests/unit/test_dhcp.sh | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
12 files changed, 450 insertions(+), 19 deletions(-)

diff --git a/README.md b/README.md @@ -65,17 +65,17 @@ up with `-include`; the one existing triple has none, so it is currently empty. ### Dependencies -`target/common/.dep` declares three: `cofyc/argparse`, `finwo/cnfparse` and -`rxi/log`. A fourth, `finwo/buf`, arrives transitively through cnfparse's own -manifest. All four are compiled from source straight into the binary; nothing -is linked against a prebuilt library. +`target/common/.dep` declares five: `cofyc/argparse`, `finwo/cnfparse`, +`finwo/pbkdf2`, `finwo/resp` and `rxi/log`. A sixth, `finwo/buf`, arrives +transitively through cnfparse's own manifest. All are compiled from source +straight into the binary; nothing is linked against a prebuilt library. They are fetched from branch tips rather than tags, and are not checksummed. That means a build is not reproducible across upstream changes, and a sha256 on -a release tarball of *this* repository pins its own code but not the four -libraries compiled into the resulting binary. This is a deliberate, known -deviation from the pin-everything rule this project otherwise follows, and the fix -(tagging those repositories) is upstream of here. +a release tarball of *this* repository pins its own code but not the libraries +compiled into the resulting binary. This is a deliberate, known deviation from +the pin-everything rule this project otherwise follows, and the fix (tagging +those repositories) is upstream of here. Testing @@ -108,12 +108,18 @@ Configuration listen unix:///var/run/linkd.sock listen tcp://127.0.0.1:6789 authfile /etc/linkd.passwd + dhcp_client udhcpc -f -i {{interface}} plugin /usr/lib/linkd/bcm A missing file is not an error; the defaults above are what you get. Patterns are expanded by `source`, so one matching nothing is fine -- a system with no drop-ins is normal. +When `/etc/linkd.cnf` does not exist, `/etc/linkd/*.cnf` is read instead. That +is how a package configures linkd for something it ships without owning the +main config file. It is a fallback, not a merge: an explicit `--config`, or an +existing `/etc/linkd.cnf`, is the whole configuration. + Clients and authorization ------------------------- @@ -156,6 +162,29 @@ is a hard error**, so an unauthenticated network listener cannot be created by forgetting something. +DHCP +---- + +`iface eth0 inet dhcp` spawns the command named by `dhcp_client`, one per +interface, stopped again on `ifdown`. No client is known by name: the package +that ships one ships the config naming it. On UNOS that is busybox, which +drops `/etc/linkd/udhcpc.cnf`. + +`{{interface}}` is substituted in each argv element, as many times as it +appears, with the name exactly as written in `/etc/network/interfaces`. +Substitution is per element, so no shell ever sees the name. + +The command must stay in the foreground -- `-f` for udhcpc and sdhcp, `-B` for +dhcpcd. A client that forks leaves linkd holding a pid that has already exited, +so `ifdown` could not stop it and a detached client would keep renewing a lease +on an interface that is meant to be down. + +linkd does not apply the lease. The client does, or the script it runs does, +and linkd observes the result through its netlink mirror like any other change. +For udhcpc that script is `/usr/share/udhcpc/default.script`; without it a +lease is obtained and nothing is configured. + + Dataplane plugins ----------------- diff --git a/src/cli/linkd.c b/src/cli/linkd.c @@ -21,6 +21,7 @@ extern "C" { #include "config/ifaces.h" #include "dataplane.h" #include "dataplane/plugin.h" +#include "dhcp.h" #include "ipc.h" #include "netlink/netlink.h" #include "netlink/rtnl.h" @@ -161,9 +162,15 @@ static int apply_interfaces(void) { } log_info("apply_interfaces: link up %s", cur->name); rtnl_link_up(cur->name); - for (struct iface_addr *a = cur->addrs; a; a = a->next) { - log_info("apply_interfaces: addr %s dev %s", a->address, cur->name); - rtnl_addr_add(cur->name, a->address, a->netmask); + if (iface_method_is(cur, "dhcp")) { + if (dhcp_start(cur->name) != 0) log_error("apply_interfaces: dhcp failed for %s", cur->name); + } + // `manual` means the link only; addresses stay the operator's business. + if (!iface_method_is(cur, "manual")) { + for (struct iface_addr *a = cur->addrs; a; a = a->next) { + log_info("apply_interfaces: addr %s dev %s", a->address, cur->name); + rtnl_addr_add(cur->name, a->address, a->netmask); + } } if (cur->gateway) { log_info("apply_interfaces: gateway %s via %s", cur->gateway, cur->name); @@ -215,7 +222,7 @@ static void ready_remove(const char *path) { int main_linkd(int argc, char **argv) { // Cast for argparse which wants const char** const char **c_argv = (const char **)argv; - char *config_path = LINKD_CONFIG_PATH; + char *config_path = NULL; char *loglevel = "info"; char *logfile_path = NULL; char *ready_file = ""; @@ -340,7 +347,7 @@ int main_linkd(int argc, char **argv) { return 1; } - // Apply auto interfaces (needs netlink to be open for link creation, but we use system ip for now) + // After nl_open so the resync below observes what this creates. apply_interfaces(); // Fail loud: never publish readiness on partial state; runit retries. @@ -371,6 +378,7 @@ int main_linkd(int argc, char **argv) { ipc_fini(); ready_remove(ready_file); + dhcp_stop_all(); dp_stop(); ports_free(); ifaces_free(); diff --git a/src/config/daemon.c b/src/config/daemon.c @@ -88,6 +88,14 @@ static struct cnf_directive *h_listen(FILE *fd, struct cnf_directive *dir, void return NULL; } +// Not split on whitespace here: cnfparse already tokenised, so `dhcp_client +// udhcpc -f -i {{interface}}` arrives as four argv elements and no shell is +// ever involved in reassembling them. +static struct cnf_directive *h_dhcp_client(FILE *fd, struct cnf_directive *dir, void *user) { + (void)fd; (void)user; + return take_list(dir, &cfg.dhcp_client, &cfg.dhcp_client_n); +} + static struct cnf_directive *h_authfile(FILE *fd, struct cnf_directive *dir, void *user) { (void)fd; (void)user; if (dir->argc != 1) { @@ -175,6 +183,7 @@ void daemon_cfg_register(void) { cfg_register_directive(ns, "config_iface", h_config_iface); cfg_register_directive(ns, "listen", h_listen); cfg_register_directive(ns, "authfile", h_authfile); + cfg_register_directive(ns, "dhcp_client", h_dhcp_client); cfg_register_directive(ns, "plugin", h_plugin); } @@ -196,13 +205,31 @@ static void apply_defaults(void) { } } +// `source` expands the glob, so a directory with no drop-ins is not an error. +static int load_confdir(void) { + static const char stub[] = "source " LINKD_CONFIG_DIR "/*.cnf\n"; + + FILE *fd = fmemopen((void *)stub, sizeof(stub) - 1, "r"); + if (!fd) { + perror("Could not open in-memory config"); + return CFG_RET_ERROR; + } + + cfg_error_reset(); + int rc = cfg_parse(cfg_ns_get("linkd"), NULL, fd, NULL); + fclose(fd); + return rc; +} + int daemon_cfg_load(const char *path) { + int explicit_path = path != NULL; if (!path) path = LINKD_CONFIG_PATH; - // Missing file is fine: defaults are a working configuration. if (access(path, R_OK) != 0) { + int rc = CFG_RET_OK; + if (!explicit_path) rc = load_confdir(); apply_defaults(); - return CFG_RET_OK; + return rc; } FILE *fd = fopen(path, "r"); @@ -293,6 +320,7 @@ void daemon_cfg_free(void) { list_free(&cfg.ports, &cfg.ports_n); list_free(&cfg.iface, &cfg.iface_n); list_free(&cfg.listen, &cfg.listen_n); + list_free(&cfg.dhcp_client, &cfg.dhcp_client_n); free(cfg.authfile); cfg.authfile = NULL; diff --git a/src/config/daemon.h b/src/config/daemon.h @@ -18,6 +18,10 @@ struct linkd_daemon_cfg { char **listen; size_t listen_n; char *authfile; + // argv template for `method dhcp`, each element rendered per interface. + // Empty means autodetect. + char **dhcp_client; size_t dhcp_client_n; + struct linkd_plugin_cfg *plugins; }; @@ -44,7 +48,8 @@ const char *linkd_client_socket(void); // Shared by startup and ifreload so the two cannot drift apart. int load_namespace(const char *ns_name, char **patterns, size_t n); -// Default search path. +// Drop-in dir, used only when LINKD_CONFIG_PATH is absent. #define LINKD_CONFIG_PATH "/etc/linkd.cnf" +#define LINKD_CONFIG_DIR "/etc/linkd" #endif // __LINKD_CONFIG_DAEMON_H__ diff --git a/src/config/ifaces.c b/src/config/ifaces.c @@ -56,6 +56,14 @@ bool iface_is_vrf(const struct linkd_iface *ifc) { return ifc && ifc->vrf_table >= 0; } +// NULL method is `static`: `iface <name>` without `inet` is the preferred +// spelling, so it must not read as an unset method. +bool iface_method_is(const struct linkd_iface *ifc, const char *method) { + if (!ifc) return false; + if (!ifc->method) return !strcasecmp(method, "static"); + return !strcasecmp(ifc->method, method); +} + // `vrf-table auto`: lowest free id at or above VRF_TABLE_AUTO_BASE. // // 1001 is not arbitrary -- it is where Cumulus starts, and the S5248F-ON we diff --git a/src/config/ifaces.h b/src/config/ifaces.h @@ -51,6 +51,7 @@ struct linkd_iface { // let the two drift; keep exactly one definition. bool iface_is_bridge(const struct linkd_iface *ifc); bool iface_is_vrf(const struct linkd_iface *ifc); +bool iface_method_is(const struct linkd_iface *ifc, const char *method); void ifaces_register(void); diff --git a/src/dhcp.h b/src/dhcp.h @@ -0,0 +1,17 @@ +#ifndef __LINKD_DHCP_H__ +#define __LINKD_DHCP_H__ + +// Spawns `dhcp_client`, substituting {{interface}} per argv element, so no +// shell. No client is known by name: whoever ships one ships the config for +// it. The lease is applied by the client, not linkd. + +// Idempotent. -1 when unconfigured or the spawn failed, which fails the ifup. +int dhcp_start(const char *ifname); + +void dhcp_stop(const char *ifname); +void dhcp_stop_all(void); + +// Non-blocking sweep; an unexpected exit is logged at error level. +void dhcp_reap(void); + +#endif // __LINKD_DHCP_H__ diff --git a/src/dhcp/client.c b/src/dhcp/client.c @@ -0,0 +1,203 @@ +#define _GNU_SOURCE +#include <errno.h> +#include <signal.h> +#include <stdlib.h> +#include <string.h> +#include <sys/types.h> +#include <sys/wait.h> +#include <unistd.h> + +#include "rxi/log.h" + +#include "config/daemon.h" +#include "dhcp.h" + +#define TOKEN "{{interface}}" +#define TOKEN_LEN (sizeof(TOKEN) - 1) + +struct dhcp_proc { + char *ifname; + pid_t pid; + struct dhcp_proc *next; +}; + +static struct dhcp_proc *procs; + +// --------------------------------------------------------------------------- + +static char *render(const char *src, const char *ifname) { + size_t ilen = strlen(ifname); + size_t n = 0; + for (const char *p = src; (p = strstr(p, TOKEN)) != NULL; p += TOKEN_LEN) n++; + + // Subtract before adding: ifname may be shorter than the token. + size_t len = strlen(src) - (n * TOKEN_LEN) + (n * ilen); + char *out = malloc(len + 1); + if (!out) { + log_error("dhcp: out of memory rendering `%s'", src); + return NULL; + } + + char *w = out; + const char *r = src; + for (const char *p; (p = strstr(r, TOKEN)) != NULL; r = p + TOKEN_LEN) { + memcpy(w, r, (size_t)(p - r)); + w += p - r; + memcpy(w, ifname, ilen); + w += ilen; + } + strcpy(w, r); + return out; +} + +// --------------------------------------------------------------------------- +// PATH resolution + +static char *find_in_path(const char *bin) { + if (strchr(bin, '/')) return access(bin, X_OK) == 0 ? strdup(bin) : NULL; + + const char *path = getenv("PATH"); + if (!path || !*path) path = "/sbin:/usr/sbin:/bin:/usr/bin"; + + char *dup = strdup(path); + if (!dup) return NULL; + + char *found = NULL; + for (char *dir = strtok(dup, ":"); dir; dir = strtok(NULL, ":")) { + char cand[512]; + if ((size_t)snprintf(cand, sizeof(cand), "%s/%s", dir, bin) >= sizeof(cand)) continue; + if (access(cand, X_OK) == 0) { found = strdup(cand); break; } + } + free(dup); + return found; +} + +// --------------------------------------------------------------------------- +// process table + +static struct dhcp_proc *proc_find(const char *ifname) { + for (struct dhcp_proc *p = procs; p; p = p->next) { + if (!strcmp(p->ifname, ifname)) return p; + } + return NULL; +} + +static void proc_drop(struct dhcp_proc *victim) { + struct dhcp_proc **pp = &procs; + while (*pp) { + if (*pp == victim) { + *pp = victim->next; + free(victim->ifname); + free(victim); + return; + } + pp = &(*pp)->next; + } +} + +// --------------------------------------------------------------------------- + +int dhcp_start(const char *ifname) { + if (proc_find(ifname)) return 0; + + const struct linkd_daemon_cfg *cfg = daemon_cfg(); + size_t argc = cfg->dhcp_client_n; + if (!argc) { + log_error("dhcp: %s: no dhcp_client configured", ifname); + return -1; + } + const char *const *tpl = (const char *const *)cfg->dhcp_client; + + char **argv = calloc(argc + 1, sizeof(char *)); + if (!argv) { + log_error("dhcp: %s: out of memory", ifname); + return -1; + } + for (size_t i = 0; i < argc; i++) { + argv[i] = render(tpl[i], ifname); + if (!argv[i]) { + for (size_t j = 0; j < i; j++) free(argv[j]); + free(argv); + return -1; + } + } + + char *exe = find_in_path(argv[0]); + if (!exe) { + log_error("dhcp: %s: `%s' not found or not executable", ifname, argv[0]); + for (size_t i = 0; i < argc; i++) free(argv[i]); + free(argv); + return -1; + } + + pid_t pid = fork(); + if (pid < 0) { + log_error("dhcp: %s: fork: %s", ifname, strerror(errno)); + free(exe); + for (size_t i = 0; i < argc; i++) free(argv[i]); + free(argv); + return -1; + } + + if (pid == 0) { + signal(SIGPIPE, SIG_DFL); + execv(exe, argv); + _exit(127); + } + + struct dhcp_proc *p = calloc(1, sizeof(*p)); + if (!p) { + kill(pid, SIGTERM); + waitpid(pid, NULL, 0); + log_error("dhcp: %s: out of memory", ifname); + free(exe); + for (size_t i = 0; i < argc; i++) free(argv[i]); + free(argv); + return -1; + } + p->ifname = strdup(ifname); + p->pid = pid; + p->next = procs; + procs = p; + + log_info("dhcp: %s: started %s (pid %d)", ifname, exe, (int)pid); + + free(exe); + for (size_t i = 0; i < argc; i++) free(argv[i]); + free(argv); + return 0; +} + +void dhcp_stop(const char *ifname) { + struct dhcp_proc *p = proc_find(ifname); + if (!p) return; + log_info("dhcp: %s: stopping pid %d", ifname, (int)p->pid); + kill(p->pid, SIGTERM); + waitpid(p->pid, NULL, 0); + proc_drop(p); +} + +void dhcp_stop_all(void) { + while (procs) { + struct dhcp_proc *p = procs; + kill(p->pid, SIGTERM); + waitpid(p->pid, NULL, 0); + proc_drop(p); + } +} + +// Loud: the address survives until the lease expires, so the breakage is +// otherwise invisible for hours. +void dhcp_reap(void) { + struct dhcp_proc *p = procs; + while (p) { + struct dhcp_proc *next = p->next; + int status; + if (waitpid(p->pid, &status, WNOHANG) == p->pid) { + log_error("dhcp: %s: client pid %d exited (status %d), lease will not be renewed", + p->ifname, (int)p->pid, WIFEXITED(status) ? WEXITSTATUS(status) : -1); + proc_drop(p); + } + p = next; + } +} diff --git a/src/ipc.c b/src/ipc.c @@ -22,6 +22,7 @@ #include "config/daemon.h" #include "config/ifaces.h" #include "config/ports.h" +#include "dhcp.h" #include "ipc.h" #include "netlink/netlink.h" #include "netlink/rtnl.h" @@ -177,9 +178,16 @@ static int do_ifup(struct ipc_conn *c, const char *ifname) { reply_err(c, "ifup %s: link up failed", ifname); return -1; } - for (struct iface_addr *a = iface->addrs; a; a = a->next) { - log_info("ipc: ifup %s addr %s", ifname, a->address); - rtnl_addr_add(ifname, a->address, a->netmask); + if (iface_method_is(iface, "dhcp") && dhcp_start(ifname) != 0) { + reply_err(c, "ifup %s: no dhcp client", ifname); + return -1; + } + // `manual` means the link only; addresses stay the operator's business. + if (!iface_method_is(iface, "manual")) { + for (struct iface_addr *a = iface->addrs; a; a = a->next) { + log_info("ipc: ifup %s addr %s", ifname, a->address); + rtnl_addr_add(ifname, a->address, a->netmask); + } } if (iface->gateway) { log_info("ipc: ifup %s gateway %s", ifname, iface->gateway); @@ -215,6 +223,7 @@ static int do_ifdown(struct ipc_conn *c, const char *ifname) { reply_err(c, "pre-down hook failed for %s", ifname); return -1; } + dhcp_stop(ifname); if (rtnl_link_down(ifname) != 0) { reply_err(c, "ifdown %s failed", ifname); return -1; @@ -235,6 +244,7 @@ static int count_ifaces(void) { static int do_ifreload(struct ipc_conn *c) { log_info("ipc: ifreload start, old list has %d", count_ifaces()); // TODO: real diff-apply; this frees and re-parses. + dhcp_stop_all(); ifaces_free(); ports_free(); diff --git a/src/netlink/netlink.c b/src/netlink/netlink.c @@ -25,6 +25,7 @@ #include "dataplane.h" #include "dataplane/plugin.h" +#include "../dhcp.h" #include "filter.h" #include "netlink.h" @@ -621,6 +622,7 @@ int nl_run(int fd, int resync_interval_s) { log_error("netlink: poll failed: %s", strerror(errno)); return -1; } + dhcp_reap(); if (!pr) continue; if (nfds > 1) ipc_dispatch(&pfds[1], nfds - 1); diff --git a/tests/fixtures/dhcp-fake.sh b/tests/fixtures/dhcp-fake.sh @@ -0,0 +1,6 @@ +#!/bin/sh +# Stands in for a DHCP client. Records the argv linkd rendered, then exits, +# which also exercises the reap path. +set -eu +printf '%s\n' "$*" >> "${DHCP_LOG:?DHCP_LOG unset}" +exit 0 diff --git a/tests/unit/test_dhcp.sh b/tests/unit/test_dhcp.sh @@ -0,0 +1,114 @@ +#!/bin/sh +# `method dhcp`: command rendering, and the drop-in config fallback. +# +# PATH is pinned to an empty directory so nothing can reach a real dhcp client +# on the developer's host. +set -eu +HERE=$(cd "$(dirname "$0")" && pwd) +ROOT=$(cd "${HERE}/../.." && pwd) +. "${HERE}/../helpers.sh" + +echo "==> test_dhcp: method dhcp" + +BUILD_DIR=$(ensure_built "${ROOT}") +BIN="${BUILD_DIR}/linkd" +FIXTURE="${ROOT}/tests/fixtures/dhcp-fake.sh" +assert_path_exists "${FIXTURE}" "fake dhcp client fixture present" + +T=$(mktemp -d) +trap 'rm -rf "${T}"' EXIT +mkdir -p "${T}/net" "${T}/emptybin" + +# `auto` or nothing is applied. swp-test does not exist, so the link-up fails +# and is logged; dhcp_start still runs, which is what is under test. +cat > "${T}/net/interfaces" <<EOF +auto swp-test +iface swp-test inet dhcp + +auto swp-static +iface swp-static inet static + address 10.9.9.9/24 +EOF + +run_linkd() { + timeout 15 env PATH="${T}/emptybin" DHCP_LOG="${T}/dhcp.log" \ + "${BIN}" --config "$1" --resync-interval 0 \ + >"${T}/linkd.log" 2>&1 || true + cat "${T}/linkd.log" +} + +# --- rendering -------------------------------------------------------------- +cat > "${T}/render.cnf" <<EOF +config_iface ${T}/net/interfaces +config_ports /nonexistent +listen unix://${T}/linkd.sock +dhcp_client ${FIXTURE} --dev {{interface}} --pair {{interface}}:{{interface}} --literal plain +EOF + +: > "${T}/dhcp.log" +run_linkd "${T}/render.cnf" >/dev/null + +assert_file_contains "${T}/dhcp.log" "--dev swp-test" \ + "{{interface}} renders the interface name" +assert_file_contains "${T}/dhcp.log" "--pair swp-test:swp-test" \ + "every occurrence in one argument is substituted" +assert_file_contains "${T}/dhcp.log" "--literal plain" \ + "non-template arguments pass through" + +n=$(wc -l < "${T}/dhcp.log" | tr -d ' ') +assert_eq "${n}" "1" "only the dhcp interface spawns a client" + +out=$(cat "${T}/linkd.log") +assert_contains "${out}" "lease will not be renewed" \ + "a client that exits is reaped and reported" + +# --- nothing configured ----------------------------------------------------- +cat > "${T}/noclient.cnf" <<EOF +config_iface ${T}/net/interfaces +config_ports /nonexistent +listen unix://${T}/linkd.sock +EOF + +out=$(run_linkd "${T}/noclient.cnf") +assert_contains "${out}" "no dhcp_client configured" \ + "an unconfigured dhcp interface is an error, not a silent no-op" + +# --- drop-in supplies the client -------------------------------------------- +# Real fallback path, with /etc bind-mounted in a user namespace so the +# hardcoded /etc/linkd is exercised rather than a stand-in. No --config: the +# fallback only fires when the path was not given explicitly. +mkdir -p "${T}/etc/linkd" +cat > "${T}/etc/linkd/udhcpc.cnf" <<EOF +config_iface ${T}/net/interfaces +config_ports /nonexistent +listen unix://${T}/linkd.sock +dhcp_client ${FIXTURE} --from-dropin {{interface}} +EOF + +if unshare --user --map-root-user --mount true 2>/dev/null; then + : > "${T}/dhcp.log" + unshare --user --map-root-user --mount sh -c " + mount --bind '${T}/etc' /etc || exit 1 + timeout 15 env PATH='${T}/emptybin' DHCP_LOG='${T}/dhcp.log' \ + '${BIN}' --resync-interval 0 + " >"${T}/linkd.log" 2>&1 || true + + assert_file_contains "${T}/dhcp.log" "--from-dropin swp-test" \ + "/etc/linkd/*.cnf is read when /etc/linkd.cnf is absent" +else + echo "SKIP: user namespaces unavailable, cannot bind-mount /etc" +fi + +# --- unresolvable command --------------------------------------------------- +cat > "${T}/missing.cnf" <<EOF +config_iface ${T}/net/interfaces +config_ports /nonexistent +listen unix://${T}/linkd.sock +dhcp_client ${T}/emptybin/definitely-not-here {{interface}} +EOF + +out=$(run_linkd "${T}/missing.cnf") +assert_contains "${out}" "not found or not executable" \ + "a configured client that does not exist fails loudly" + +echo "==> test_dhcp done"