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:
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"