From d5f27dab459d9eb1b717a6b0cb5ec58a2f399aa4 Mon Sep 17 00:00:00 2001 From: Chris Patterson Date: Mon, 23 Mar 2026 23:48:05 +0000 Subject: [PATCH] dhcp: add configurable backoff parameters for DHCPv4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In cloud and virtual environments the DHCP service is typically ready within hundreds of milliseconds of the interface coming up, and once ready, responds within single-digit milliseconds. The RFC 2131 defaults (4s initial interval, 64s backoff cap) are designed for congested broadcast networks and are unnecessarily conservative in this context. Add three new configuration options to tune DHCPv4 retransmission: initial_interval - initial retransmission interval (default 4s) backoff_cutoff - exponential backoff cap (default 64s) backoff_jitter - random jitter per retry (default ±1000ms) Defaults match RFC 2131 so existing behaviour is unchanged. Option naming aligns with dhclient (initial-interval, backoff-cutoff). Minimum of 1 is enforced at parse time for interval and cutoff; invalid values are logged and the default is used. These options are DHCPv4-only; DHCPv6 retransmission follows RFC 8415 constants and is not user-configurable. A test harness (tests/backoff/test_backoff.c) was used to validate timing assumptions and correctness across multiple scenarios (defaults, min-latency, cloud-recommended) and to confirm rejection of invalid values. It requires root and network namespaces so may not be suitable for all CI environments. It can be removed if it doesn't have long-term value. Example test output: ``` $ sudo ./tests/backoff/test_backoff -b ./src/dhcpcd -n 128 dhcpcd DHCPDISCOVER backoff integration test ============================================= Binary: /home/cpatterson/git/dhcpcd/src/dhcpcd Runs: 128 per test --- defaults (N=128) --- dhcpcd.conf: timeout=75 PASS: 128/128 runs produced data Retry Expected Min Avg Max N Result ------------------------------------------------------ Init 1±1.0s 0.0s 1.0s 2.0s 128 PASS 1 4±1.0s 3.0s 4.0s 5.0s 128 PASS 2 8±1.0s 7.0s 8.0s 9.0s 128 PASS 3 16±1.0s 15.0s 16.0s 17.0s 128 PASS 4 32±1.0s 31.1s 32.1s 33.0s 128 PASS 5 64±1.0s 63.0s 64.0s 65.0s 128 PASS Elapsed: 75.9s --- min-latency (N=128) --- dhcpcd.conf: timeout=12, nodelay, initial_interval=1, backoff_cutoff=1, backoff_jitter=0 PASS: 128/128 runs produced data Retry Expected Min Avg Max N Result ------------------------------------------------------ Init 0 - - - 128 PASS 1 1±0.0s 1.0s 1.0s 1.0s 128 PASS 2 1±0.0s 1.0s 1.0s 1.0s 128 PASS 3 1±0.0s 1.0s 1.0s 1.0s 128 PASS 4 1±0.0s 1.0s 1.0s 1.0s 128 PASS 5 1±0.0s 1.0s 1.0s 1.0s 128 PASS 6 1±0.0s 1.0s 1.0s 1.0s 128 PASS 7 1±0.0s 1.0s 1.0s 1.0s 128 PASS 8 1±0.0s 1.0s 1.0s 1.0s 128 PASS 9 1±0.0s 1.0s 1.0s 1.0s 128 PASS 10 1±0.0s 1.0s 1.0s 1.0s 128 PASS Elapsed: 12.9s --- cloud (N=128) --- dhcpcd.conf: timeout=12, nodelay, initial_interval=1, backoff_cutoff=1, backoff_jitter=100 PASS: 128/128 runs produced data Retry Expected Min Avg Max N Result ------------------------------------------------------ Init 0 - - - 128 PASS 1 1±0.1s 0.9s 1.0s 1.1s 128 PASS 2 1±0.1s 0.9s 1.0s 1.1s 128 PASS 3 1±0.1s 0.9s 1.0s 1.1s 128 PASS 4 1±0.1s 0.9s 1.0s 1.1s 128 PASS 5 1±0.1s 0.9s 1.0s 1.1s 128 PASS 6 1±0.1s 0.9s 1.0s 1.1s 128 PASS 7 1±0.1s 0.9s 1.0s 1.1s 128 PASS 8 1±0.1s 0.9s 1.0s 1.1s 128 PASS 9 1±0.1s 0.9s 1.0s 1.1s 128 PASS 10 1±0.1s 0.9s 1.0s 1.1s 128 PASS Elapsed: 13.0s --- reject initial_interval=0 --- dhcpcd.conf: initial_interval 0 (expect rejection) PASS: dhcpcd rejected initial_interval 0 (exit=0) output: invalid initial interval: 0 --- reject backoff_cutoff=0 --- dhcpcd.conf: backoff_cutoff 0 (expect rejection) PASS: dhcpcd rejected backoff_cutoff 0 (exit=0) output: invalid backoff cutoff: 0 ============================================= All tests passed. ``` Closes #406 Signed-off-by: Chris Patterson --- src/dhcp.c | 16 +- src/dhcpcd.conf.5.in | 36 ++ src/if-options.c | 33 ++ src/if-options.h | 9 + tests/Makefile | 4 +- tests/backoff/Makefile | 18 + tests/backoff/test_backoff.c | 898 +++++++++++++++++++++++++++++++++++ 7 files changed, 1007 insertions(+), 7 deletions(-) create mode 100644 tests/backoff/Makefile create mode 100644 tests/backoff/test_backoff.c diff --git a/src/dhcp.c b/src/dhcp.c index 4c1b35ea0..b55340cf5 100644 --- a/src/dhcp.c +++ b/src/dhcp.c @@ -1173,7 +1173,7 @@ make_message(struct bootp **bootpm, const struct interface *ifp, uint8_t type) *lp = (uint8_t)(*lp + vivco->len + 1); } } - + if (ifo->vsio_len && !has_option_mask(ifo->nomask, DHO_VIVSO)) { @@ -1864,15 +1864,21 @@ send_message(struct interface *ifp, uint8_t type, state->xid); RT = 0; /* bogus gcc warning */ } else { + unsigned int jitter = ifo->backoff_jitter; + if (state->interval == 0) - state->interval = 4; + state->interval = ifo->initial_interval; else { + unsigned int cutoff = ifo->backoff_cutoff; + state->interval *= 2; - if (state->interval > 64) - state->interval = 64; + if (state->interval > cutoff) + state->interval = cutoff; } + RT = (state->interval * MSEC_PER_SEC) + - (arc4random_uniform(MSEC_PER_SEC * 2) - MSEC_PER_SEC); + (arc4random_uniform(jitter * 2) - jitter); + /* No carrier? Don't bother sending the packet. * However, we do need to advance the timeout. */ if (!if_is_link_up(ifp)) diff --git a/src/dhcpcd.conf.5.in b/src/dhcpcd.conf.5.in index 179be73ee..23d66b570 100644 --- a/src/dhcpcd.conf.5.in +++ b/src/dhcpcd.conf.5.in @@ -304,6 +304,42 @@ You can use this option to stop this from happening. .It Ic fallback Ar profile Fall back to using this profile if DHCP fails. This allows you to configure a static profile instead of using ZeroConf. +.It Ic initial_interval Ar seconds +Set the initial DHCPv4 retransmission interval to +.Ar seconds . +The minimum value is 1. +The default is 4 seconds as per RFC 2131. +This option only affects DHCPv4; +DHCPv6 retransmission is governed by RFC 8415. +See also +.Ic backoff_cutoff +and +.Ic backoff_jitter . +.It Ic backoff_cutoff Ar seconds +Cap the DHCPv4 exponential backoff interval at +.Ar seconds . +The minimum value is 1. +The default is 64 seconds as per RFC 2131. +Setting this to 1 effectively disables exponential growth, so +retransmissions use only the initial interval plus jitter. +This option only affects DHCPv4; +DHCPv6 retransmission is governed by RFC 8415. +See also +.Ic initial_interval +and +.Ic backoff_jitter . +.It Ic backoff_jitter Ar milliseconds +Set the random jitter applied to each DHCPv4 retransmission interval. +The jitter is applied as \(+- +.Ar milliseconds . +The default is 1000 (\(+-1 second) as per RFC 2131. +A value of 0 disables jitter, producing deterministic retransmission timing. +This option only affects DHCPv4; +DHCPv6 retransmission is governed by RFC 8415. +See also +.Ic backoff_cutoff +and +.Ic initial_interval . .It Ic fallback_time Ar seconds Start fallback after .Ar seconds . diff --git a/src/if-options.c b/src/if-options.c index c4b1141f3..4d5429f6d 100644 --- a/src/if-options.c +++ b/src/if-options.c @@ -177,6 +177,9 @@ const struct option cf_options[] = { {"fallback_time", required_argument, NULL, O_FALLBACK_TIME}, {"ipv4ll_time", required_argument, NULL, O_IPV4LL_TIME}, {"nosyslog", no_argument, NULL, O_NOSYSLOG}, + {"initial_interval", required_argument, NULL, O_INITIAL_INTERVAL}, + {"backoff_cutoff", required_argument, NULL, O_BACKOFF_CUTOFF}, + {"backoff_jitter", required_argument, NULL, O_BACKOFF_JITTER}, {NULL, 0, NULL, '\0'} }; @@ -2580,6 +2583,33 @@ parse_option(struct dhcpcd_ctx *ctx, const char *ifname, struct if_options *ifo, logsetopts(logopts); } break; + case O_INITIAL_INTERVAL: + ARG_REQUIRED; + ifo->initial_interval = + (uint32_t)strtou(arg, NULL, 0, 1, UINT32_MAX, &e); + if (e) { + logerrx("invalid initial interval: %s", arg); + return -1; + } + break; + case O_BACKOFF_CUTOFF: + ARG_REQUIRED; + ifo->backoff_cutoff = + (uint32_t)strtou(arg, NULL, 0, 1, UINT32_MAX, &e); + if (e) { + logerrx("invalid backoff cutoff: %s", arg); + return -1; + } + break; + case O_BACKOFF_JITTER: + ARG_REQUIRED; + ifo->backoff_jitter = + (uint32_t)strtou(arg, NULL, 0, 0, UINT32_MAX, &e); + if (e) { + logerrx("invalid backoff jitter: %s", arg); + return -1; + } + break; default: return 0; } @@ -2667,6 +2697,9 @@ default_config(struct dhcpcd_ctx *ctx) #ifdef INET ifo->fallback_time = DEFAULT_FALLBACK; ifo->ipv4ll_time = DEFAULT_IPV4LL; + ifo->initial_interval = DEFAULT_INITIAL_INTERVAL; + ifo->backoff_cutoff = DEFAULT_BACKOFF_CUTOFF; + ifo->backoff_jitter = DEFAULT_BACKOFF_JITTER; #endif ifo->metric = -1; ifo->auth.options |= DHCPCD_AUTH_REQUIRE; diff --git a/src/if-options.h b/src/if-options.h index 51d282dca..ffab9f4cd 100644 --- a/src/if-options.h +++ b/src/if-options.h @@ -52,6 +52,9 @@ #define DEFAULT_REQUEST 180 /* secs to request, mirror DHCP6 */ #define DEFAULT_FALLBACK 5 /* secs until fallback */ #define DEFAULT_IPV4LL 5 /* secs until ipv4ll */ +#define DEFAULT_INITIAL_INTERVAL 4 /* DHCP_BASE per RFC 2131 */ +#define DEFAULT_BACKOFF_CUTOFF 64 /* DHCP_MAX per RFC 2131 */ +#define DEFAULT_BACKOFF_JITTER 1000 /* +/- milliseconds */ #ifndef HOSTNAME_MAX_LEN #define HOSTNAME_MAX_LEN 250 /* 255 - 3 (FQDN) - 2 (DNS enc) */ @@ -190,6 +193,9 @@ #define O_VSIO O_BASE + 57 #define O_VSIO6 O_BASE + 58 #define O_NOSYSLOG O_BASE + 59 +#define O_INITIAL_INTERVAL O_BASE + 60 +#define O_BACKOFF_CUTOFF O_BASE + 61 +#define O_BACKOFF_JITTER O_BASE + 62 extern const struct option cf_options[]; @@ -257,6 +263,9 @@ struct if_options { uint32_t request_time; uint32_t fallback_time; uint32_t ipv4ll_time; + uint32_t initial_interval; + uint32_t backoff_cutoff; + uint32_t backoff_jitter; unsigned long long options; bool randomise_hwaddr; diff --git a/tests/Makefile b/tests/Makefile index 16a229ff4..d4628405e 100644 --- a/tests/Makefile +++ b/tests/Makefile @@ -1,6 +1,6 @@ -SUBDIRS= crypt eloop-bench +SUBDIRS= crypt eloop-bench backoff -all: +all: for x in ${SUBDIRS}; do cd $$x; ${MAKE} $@ || exit $$?; cd ..; done install: diff --git a/tests/backoff/Makefile b/tests/backoff/Makefile new file mode 100644 index 000000000..68ad1d079 --- /dev/null +++ b/tests/backoff/Makefile @@ -0,0 +1,18 @@ +PROG= test_backoff +SRCS= test_backoff.c + +CFLAGS+= -Wall -Wextra -std=c99 -D_DEFAULT_SOURCE +LDFLAGS+= -lm + +all: ${PROG} + +${PROG}: ${SRCS} + ${CC} ${CFLAGS} -o ${PROG} ${SRCS} ${LDFLAGS} + +test: ${PROG} + @echo "Run as root: sudo ./${PROG} [-b path-to-dhcpcd]" + +clean: + rm -f ${PROG} + +distclean: clean diff --git a/tests/backoff/test_backoff.c b/tests/backoff/test_backoff.c new file mode 100644 index 000000000..4d2ca803e --- /dev/null +++ b/tests/backoff/test_backoff.c @@ -0,0 +1,898 @@ +/* SPDX-License-Identifier: BSD-2-Clause */ +/* + * test_backoff.c - test dhcpcd timings related to backoff behavior + * Copyright (c) 2026 Microsoft Corporation + * All rights reserved + * + * 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. + */ + +/* + * Test for dhcpcd DHCPDISCOVER backoff timing. + * + * Runs N parallel dhcpcd instances, each in its own network namespace + * with no DHCP server, and verifies the exponential backoff intervals + * from the debug log match the expected RFC 2131 behaviour. + * + * Tests both default backoff (4/8/16/32/64) and configurable + * initial_interval / backoff_cutoff options. + * + * Must be run as root. + * + * Usage: ./test_backoff [-b path-to-dhcpcd] [-n num-runs] + */ + +#include +#include +#include +#ifndef PATH_MAX +#define PATH_MAX 4096 +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* Defaults from dhcp.h */ +#define DHCP_BASE 4 +#define DHCP_MAX 64 +#define DFLT_JITTER_MS 1000 /* ±1s RFC default */ + +#define MAX_INTERVALS 20 +#define MAX_RETRIES 10 /* max retry positions to track */ +#define MAX_RUNS 256 +#define MAX_PARALLEL 256 /* concurrent namespaces */ +#define NS_PREFIX "testbackoff" +#define IF_NAME "testbackoff0" + +static const char *dhcpcd_bin; +static char tmpdir[256]; +static int failures; +static int num_runs = 128; +static int verbose; +static unsigned int scenarios; /* bitmask of scenarios to run */ + +#define SC_DEFAULTS (1u << 0) +#define SC_MINLAT (1u << 1) +#define SC_CLOUD (1u << 2) +#define SC_REJECT (1u << 3) +#define SC_ALL (SC_DEFAULTS | SC_MINLAT | SC_CLOUD | SC_REJECT) + +struct backoff_cfg { + const char *label; + int initial_interval; /* -1 = use default */ + int backoff_cutoff; /* -1 = use default */ + int jitter_ms; /* -1 = use default */ + unsigned int timeout_sec; + unsigned int min_retries; /* minimum expected retries */ + int nodelay; /* 1 = emit nodelay in conf */ +}; + +/* Per-run result */ +struct run_result { + double intervals[MAX_INTERVALS]; + int niv; + double initial_delay; /* -1 = not present */ +}; + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +static int +run_cmd(const char *cmd) +{ + int r; + + r = system(cmd); + if (r == -1) { + fprintf(stderr, "system(%s): %s\n", cmd, strerror(errno)); + return -1; + } + return WIFEXITED(r) ? WEXITSTATUS(r) : -1; +} + +static void +cleanup_ns(const char *ns) +{ + char cmd[1024]; + + snprintf(cmd, sizeof(cmd), + "ip netns pids %s 2>/dev/null | xargs -r kill -9 2>/dev/null; " + "rm -rf %s/%s_state 2>/dev/null; " + "ip netns del %s 2>/dev/null", + ns, tmpdir, ns, ns); + run_cmd(cmd); +} + +static int +setup_ns(const char *ns, const char *ifname) +{ + char cmd[1024]; + + cleanup_ns(ns); + + snprintf(cmd, sizeof(cmd), "ip netns add %s", ns); + if (run_cmd(cmd) != 0) + return -1; + + snprintf(cmd, sizeof(cmd), + "ip netns exec %s ip link add %s type dummy", ns, ifname); + if (run_cmd(cmd) != 0) + goto fail; + + snprintf(cmd, sizeof(cmd), + "ip netns exec %s ip link set %s up", ns, ifname); + if (run_cmd(cmd) != 0) + goto fail; + + snprintf(cmd, sizeof(cmd), + "ip netns exec %s ip link set lo up", ns); + run_cmd(cmd); + + /* Create per-namespace state dirs for dhcpcd */ + snprintf(cmd, sizeof(cmd), + "mkdir -p %s/%s_state/run %s/%s_state/db", + tmpdir, ns, tmpdir, ns); + run_cmd(cmd); + + return 0; + +fail: + cleanup_ns(ns); + return -1; +} + +/* + * Write a dhcpcd.conf, run dhcpcd inside the namespace, and collect + * the "next in X.X seconds" intervals from its debug log. + * + * Returns the number of intervals parsed, or -1 on error. + */ +static int +run_dhcpcd(const struct backoff_cfg *cfg, + const char *ns, const char *ifname, + double *intervals, int max_iv, double *initial_delay) +{ + char confpath[PATH_MAX], logpath[PATH_MAX], + cmd[PATH_MAX * 4], line[512]; + FILE *conf, *logfp; + int n; + char *p; + double v; + + *initial_delay = -1.0; + + snprintf(confpath, sizeof(confpath), "%s/%s.conf", tmpdir, ns); + snprintf(logpath, sizeof(logpath), "%s/%s.log", tmpdir, ns); + + conf = fopen(confpath, "w"); + if (conf == NULL) { + fprintf(stderr, "fopen(%s): %s\n", confpath, strerror(errno)); + return -1; + } + if (cfg->nodelay) + fprintf(conf, "nodelay\n"); + fprintf(conf, + "noipv4ll\n" + "noipv6\n" + "nohook *\n" + "reboot 0\n" + "timeout %u\n", cfg->timeout_sec); + + if (cfg->initial_interval >= 0) + fprintf(conf, "initial_interval %d\n", + cfg->initial_interval); + if (cfg->backoff_cutoff >= 0) + fprintf(conf, "backoff_cutoff %d\n", cfg->backoff_cutoff); + if (cfg->jitter_ms >= 0) + fprintf(conf, "backoff_jitter %d\n", cfg->jitter_ms); + fclose(conf); + + /* External timeout is required when using -B. + * Bind-mount private state dirs inside the same ip-netns-exec + * invocation so parallel instances don't collide. */ + snprintf(cmd, sizeof(cmd), + "timeout --signal=TERM %u " + "ip netns exec %s sh -c '" + "mount --bind %s/%s_state/run /var/run/dhcpcd && " + "mount --bind %s/%s_state/db /var/db/dhcpcd && " + "exec %s -B -d -1 -f %s %s' >%s 2>&1", + cfg->timeout_sec, + ns, tmpdir, ns, tmpdir, ns, dhcpcd_bin, confpath, ifname, + logpath); + + run_cmd(cmd); + + /* Parse intervals from log */ + logfp = fopen(logpath, "r"); + if (logfp == NULL) { + fprintf(stderr, "fopen(%s): %s\n", logpath, strerror(errno)); + unlink(confpath); + return -1; + } + + n = 0; + while (fgets(line, sizeof(line), logfp) != NULL) { + /* Capture "delaying IPv4 for X.X seconds" */ + p = strstr(line, "delaying IPv4 for "); + if (p != NULL) { + p += 18; /* strlen("delaying IPv4 for ") */ + v = strtod(p, NULL); + if (v >= 0.0) + *initial_delay = v; + continue; + } + if (n >= max_iv) + continue; + p = strstr(line, "next in "); + if (p == NULL) + continue; + p += 8; /* strlen("next in ") */ + v = strtod(p, NULL); + if (v > 0.0) + intervals[n++] = v; + } + + fclose(logfp); + unlink(confpath); + unlink(logpath); + return n; +} + +/* ------------------------------------------------------------------ */ +/* Single run in a child process — writes result to a temp file */ +/* ------------------------------------------------------------------ */ + +static void +do_one_run(const struct backoff_cfg *cfg, unsigned int parent_pid, + unsigned int idx) +{ + char ns[64], outpath[PATH_MAX]; + const char *ifname = IF_NAME; + double intervals[MAX_INTERVALS]; + double initial_delay; + int niv, i; + FILE *fp; + + snprintf(ns, sizeof(ns), "%s_%u_%u", + NS_PREFIX, parent_pid, idx); + snprintf(outpath, sizeof(outpath), "%s/%s.out", tmpdir, ns); + + fp = fopen(outpath, "w"); + if (fp == NULL) + _exit(1); + + if (setup_ns(ns, ifname) != 0) { + fprintf(fp, "SKIP\n"); + fclose(fp); + cleanup_ns(ns); + _exit(0); + } + + niv = run_dhcpcd(cfg, ns, ifname, intervals, MAX_INTERVALS, + &initial_delay); + cleanup_ns(ns); + + if (niv <= 0) { + fprintf(fp, "SKIP\n"); + } else { + fprintf(fp, "DELAY:%.3f\n", initial_delay); + for (i = 0; i < niv; i++) + fprintf(fp, "%.1f\n", intervals[i]); + } + fclose(fp); + _exit(0); +} + +/* ------------------------------------------------------------------ */ +/* Collect results from child output files */ +/* ------------------------------------------------------------------ */ + +static int +collect_results(unsigned int parent_pid, int nruns, + struct run_result *results) +{ + int good = 0; + int i, j; + + for (i = 0; i < nruns; i++) { + char outpath[PATH_MAX], line[64]; + FILE *fp; + int skip; + + snprintf(outpath, sizeof(outpath), "%s/%s_%u_%u.out", + tmpdir, NS_PREFIX, parent_pid, (unsigned)i); + + fp = fopen(outpath, "r"); + if (fp == NULL) + continue; + + skip = 0; + j = 0; + results[good].initial_delay = -1.0; + while (fgets(line, sizeof(line), fp) != NULL && + j < MAX_INTERVALS) + { + if (strncmp(line, "SKIP", 4) == 0) { + skip = 1; + break; + } + if (strncmp(line, "DELAY:", 6) == 0) { + results[good].initial_delay = + strtod(line + 6, NULL); + continue; + } + results[good].intervals[j++] = strtod(line, NULL); + } + fclose(fp); + unlink(outpath); + + if (!skip && j > 0) { + results[good].niv = j; + good++; + } + } + return good; +} + +/* ------------------------------------------------------------------ */ +/* Assertions */ +/* ------------------------------------------------------------------ */ + +static void +check(int ok, const char *fmt, ...) +{ + va_list ap; + + va_start(ap, fmt); + if (ok) { + printf(" PASS: "); + } else { + printf(" FAIL: "); + failures++; + } + vprintf(fmt, ap); + printf("\n"); + va_end(ap); +} + +static double +jitter_tolerance(const struct backoff_cfg *cfg) +{ + unsigned int jms; + + jms = cfg->jitter_ms >= 0 ? (unsigned)cfg->jitter_ms : DFLT_JITTER_MS; + return (double)jms / 1000.0; +} + +/* ------------------------------------------------------------------ */ +/* Compute expected base interval for a given retry position */ +/* pos=0 is the first interval reported (delay before 2nd DISCOVER) */ +/* ------------------------------------------------------------------ */ + +static unsigned int +expected_interval(const struct backoff_cfg *cfg, int pos) +{ + unsigned int base, cap, interval; + int i; + + base = cfg->initial_interval >= 0 + ? (unsigned)cfg->initial_interval : DHCP_BASE; + cap = cfg->backoff_cutoff >= 0 + ? (unsigned)cfg->backoff_cutoff : DHCP_MAX; + + interval = base; + for (i = 0; i < pos; i++) { + interval *= 2; + if (interval > cap) + interval = cap; + } + return interval; +} + +/* ------------------------------------------------------------------ */ +/* Verify results */ +/* ------------------------------------------------------------------ */ + +static int +cmp_double(const void *a, const void *b) +{ + double da = *(const double *)a; + double db = *(const double *)b; + + if (da < db) return -1; + if (da > db) return 1; + return 0; +} + +static void +verify_stats(const struct backoff_cfg *cfg, + const struct run_result *results, int good) +{ + int pos, max_pos, r; + double vals[MAX_RUNS]; + char expbuf[32], minbuf[16], avgbuf[16], maxbuf[16]; + + /* Find the maximum retry position across all runs */ + max_pos = 0; + for (r = 0; r < good; r++) { + if (results[r].niv > max_pos) + max_pos = results[r].niv; + } + if (max_pos > MAX_RETRIES) + max_pos = MAX_RETRIES; + + check(good == num_runs, "%d/%d runs produced data", good, num_runs); + + printf("\n %-8s %-10s %-7s %-7s %-7s %-4s %s\n", + "Retry", "Expected", "Min", "Avg", "Max", "N", "Result"); + printf(" ------------------------------------------------------\n"); + + /* Verify initial delay across all runs */ + if (!cfg->nodelay) { + int delay_count = 0, delay_ok = 0; + double delay_avg = 0.0; + + for (r = 0; r < good; r++) { + if (results[r].initial_delay >= 0.0) + vals[delay_count++] = + results[r].initial_delay; + } + if (delay_count > 0) { + qsort(vals, (size_t)delay_count, + sizeof(double), cmp_double); + for (r = 0; r < delay_count; r++) { + if (vals[r] >= 0.0 && vals[r] <= 2.0) + delay_ok++; + delay_avg += vals[r]; + } + delay_avg /= delay_count; + + snprintf(expbuf, sizeof(expbuf), + "%u\xc2\xb1%.1fs", 1, 1.0); + snprintf(minbuf, sizeof(minbuf), + "%.1fs", vals[0]); + snprintf(avgbuf, sizeof(avgbuf), + "%.1fs", delay_avg); + snprintf(maxbuf, sizeof(maxbuf), + "%.1fs", vals[delay_count - 1]); + printf(" %-8s %-11s %-7s %-7s %-7s %-4d %s\n", + "Init", expbuf, + minbuf, avgbuf, maxbuf, + delay_count, + delay_ok == delay_count ? "PASS" : "FAIL"); + if (verbose) { + printf(" observed values:"); + for (r = 0; r < delay_count; r++) + printf(" %.1fs", vals[r]); + printf("\n"); + } + if (delay_ok != delay_count) + failures++; + } else { + check(0, "initial delay: no data captured"); + } + } else { + int nodelay_ok = 1; + + for (r = 0; r < good; r++) { + if (results[r].initial_delay >= 0.0) { + nodelay_ok = 0; + break; + } + } + printf(" %-8s %-10s %-7s %-7s %-7s %-4d %s\n", + "Init", "0", "-", "-", "-", good, + nodelay_ok ? "PASS" : "FAIL"); + if (!nodelay_ok) + failures++; + } + + for (pos = 0; pos < max_pos; pos++) { + int count, in_range, ok; + unsigned int exp; + double lo, hi, jtol, avg; + + /* Collect values at this retry position */ + count = 0; + for (r = 0; r < good; r++) { + if (results[r].niv > pos) + vals[count++] = results[r].intervals[pos]; + } + if (count == 0) + continue; + + qsort(vals, (size_t)count, sizeof(double), cmp_double); + + exp = expected_interval(cfg, pos); + jtol = jitter_tolerance(cfg); + lo = (double)exp - jtol; + hi = (double)exp + jtol; + + in_range = 0; + avg = 0.0; + for (r = 0; r < count; r++) { + if (vals[r] >= lo && vals[r] <= hi) + in_range++; + avg += vals[r]; + } + avg /= count; + + ok = (in_range == count); + snprintf(expbuf, sizeof(expbuf), + "%u\xc2\xb1%.1fs", exp, jtol); + snprintf(minbuf, sizeof(minbuf), + "%.1fs", vals[0]); + snprintf(avgbuf, sizeof(avgbuf), + "%.1fs", avg); + snprintf(maxbuf, sizeof(maxbuf), + "%.1fs", vals[count - 1]); + printf(" %-8d %-11s %-7s %-7s %-7s %-4d %s\n", + pos + 1, expbuf, + minbuf, avgbuf, maxbuf, + count, + ok ? "PASS" : "FAIL"); + + if (verbose) { + printf(" observed values:"); + for (r = 0; r < count; r++) + printf(" %.1fs", vals[r]); + printf("\n"); + } + + if (!ok) + failures++; + } +} + +/* ------------------------------------------------------------------ */ +/* Test: run N instances and verify */ +/* ------------------------------------------------------------------ */ + +static void +test_backoff(const struct backoff_cfg *cfg) +{ + struct run_result *results; + unsigned int ppid; + int i, good, batch; + struct timespec t0, t1; + double elapsed; + + printf("\n--- %s (N=%d) ---\n", cfg->label, num_runs); + printf(" dhcpcd.conf: timeout=%u", cfg->timeout_sec); + if (cfg->nodelay) + printf(", nodelay"); + if (cfg->initial_interval >= 0) + printf(", initial_interval=%d", + cfg->initial_interval); + if (cfg->backoff_cutoff >= 0) + printf(", backoff_cutoff=%d", cfg->backoff_cutoff); + if (cfg->jitter_ms >= 0) + printf(", backoff_jitter=%d", cfg->jitter_ms); + printf("\n"); + + clock_gettime(CLOCK_MONOTONIC, &t0); + + results = calloc((size_t)num_runs, sizeof(*results)); + if (results == NULL) { + fprintf(stderr, "calloc: %s\n", strerror(errno)); + failures++; + return; + } + + ppid = (unsigned)getpid(); + + /* Launch runs in parallel batches */ + batch = 0; + for (i = 0; i < num_runs; i++) { + pid_t pid = fork(); + + if (pid == 0) { + do_one_run(cfg, ppid, (unsigned)i); + _exit(0); + } else if (pid < 0) { + fprintf(stderr, "fork: %s\n", strerror(errno)); + continue; + } + + batch++; + if (batch >= MAX_PARALLEL) { + while (wait(NULL) > 0) + ; + batch = 0; + } + } + /* Wait for remaining children */ + while (wait(NULL) > 0) + ; + + good = collect_results(ppid, num_runs, results); + + if (good == 0) { + fprintf(stderr, " FAIL: no successful runs\n"); + failures++; + free(results); + return; + } + + verify_stats(cfg, results, good); + + clock_gettime(CLOCK_MONOTONIC, &t1); + elapsed = (double)(t1.tv_sec - t0.tv_sec) + + (double)(t1.tv_nsec - t0.tv_nsec) / 1e9; + printf(" Elapsed: %.1fs\n", elapsed); + + free(results); +} + +/* ------------------------------------------------------------------ */ +/* Test: verify that dhcpcd rejects an invalid config option value */ +/* ------------------------------------------------------------------ */ + +static void +test_reject(const char *label, const char *option, const char *value) +{ + char confpath[PATH_MAX], logpath[PATH_MAX], + cmd[PATH_MAX * 4], line[512], + errmsg[512]; + FILE *conf, *logfp; + int rc, found; + + printf("\n--- %s ---\n", label); + printf(" dhcpcd.conf: %s %s (expect rejection)\n", option, value); + + snprintf(confpath, sizeof(confpath), "%s/reject-%s.conf", + tmpdir, option); + snprintf(logpath, sizeof(logpath), "%s/reject-%s.log", + tmpdir, option); + + conf = fopen(confpath, "w"); + if (conf == NULL) { + fprintf(stderr, "fopen(%s): %s\n", confpath, strerror(errno)); + failures++; + return; + } + fprintf(conf, "%s %s\n", option, value); + fclose(conf); + + snprintf(cmd, sizeof(cmd), + "%s -B -d -1 -f %s lo >%s 2>&1", + dhcpcd_bin, confpath, logpath); + + rc = run_cmd(cmd); + + /* Check log for the expected error message */ + found = 0; + errmsg[0] = '\0'; + logfp = fopen(logpath, "r"); + if (logfp != NULL) { + while (fgets(line, sizeof(line), logfp) != NULL) { + if (strstr(line, "invalid") != NULL) { + found = 1; + /* Save the error line, strip trailing newline */ + snprintf(errmsg, sizeof(errmsg), "%s", line); + errmsg[strcspn(errmsg, "\n")] = '\0'; + break; + } + } + fclose(logfp); + } + + unlink(confpath); + unlink(logpath); + + if (found) { + printf(" PASS: dhcpcd rejected %s %s (exit=%d)\n", + option, value, rc); + printf(" output: %s\n", errmsg); + } else { + printf(" FAIL: dhcpcd did not reject %s %s (exit=%d)\n", + option, value, rc); + failures++; + } +} + +/* ------------------------------------------------------------------ */ +/* main */ +/* ------------------------------------------------------------------ */ + +static void +usage(const char *prog) +{ + + fprintf(stderr, + "Usage: %s [-b path-to-dhcpcd] [-n num-runs] [-s name] [-v]\n" + "\n" + "Options:\n" + " -b path Path to dhcpcd binary " + "(default: ../../src/dhcpcd)\n" + " -n N Number of parallel runs per test " + "(default: 1, max: %d)\n" + " -s name Run only scenario name " + "(defaults, min-latency, cloud, reject)\n" + " -v Print all observed values per retry\n" + " -h Show this help\n", + prog, MAX_RUNS); +} + +int +main(int argc, char **argv) +{ + const char *bin = "../../src/dhcpcd"; + char resolved[PATH_MAX]; + int ch; + struct stat st; + + struct backoff_cfg test_defaults = { + .label = "defaults", + .initial_interval = -1, + .backoff_cutoff = -1, + .jitter_ms = -1, + .timeout_sec = 75, + .min_retries = 3, + }; + + struct backoff_cfg test_min_latency = { + .label = "min-latency", + .initial_interval = 1, + .backoff_cutoff = 1, + .jitter_ms = 0, + .timeout_sec = 12, + .min_retries = 4, + .nodelay = 1, + }; + + struct backoff_cfg test_cloud_recommended = { + .label = "cloud", + .initial_interval = 1, + .backoff_cutoff = 1, + .jitter_ms = 100, + .timeout_sec = 12, + .min_retries = 4, + .nodelay = 1, + }; + + + + while ((ch = getopt(argc, argv, "b:n:s:vh")) != -1) { + switch (ch) { + case 'b': + bin = optarg; + break; + case 'n': + num_runs = atoi(optarg); + break; + case 's': + if (strcmp(optarg, "defaults") == 0) + scenarios |= SC_DEFAULTS; + else if (strcmp(optarg, "min-latency") == 0) + scenarios |= SC_MINLAT; + else if (strcmp(optarg, "cloud") == 0) + scenarios |= SC_CLOUD; + else if (strcmp(optarg, "reject") == 0) + scenarios |= SC_REJECT; + else { + fprintf(stderr, + "ERROR: unknown scenario '%s'\n" + " valid: defaults, min-latency, " + "cloud, reject\n", + optarg); + return 1; + } + break; + case 'v': + verbose = 1; + break; + case 'h': + usage(argv[0]); + return 0; + default: + usage(argv[0]); + return 1; + } + } + + if (getuid() != 0) { + fprintf(stderr, "ERROR: must be run as root (sudo).\n"); + return 1; + } + + if (realpath(bin, resolved) == NULL) { + fprintf(stderr, + "ERROR: dhcpcd not found at %s: %s\n", + bin, strerror(errno)); + return 1; + } + dhcpcd_bin = strdup(resolved); + + if (num_runs < 1) + num_runs = 1; + if (num_runs > MAX_RUNS) + num_runs = MAX_RUNS; + + if (stat(dhcpcd_bin, &st) != 0 || !(st.st_mode & S_IXUSR)) { + fprintf(stderr, + "ERROR: dhcpcd not executable at %s\n", + dhcpcd_bin); + return 1; + } + + /* Create temp directory for all test artifacts */ + snprintf(tmpdir, sizeof(tmpdir), "/tmp/testbackoff.XXXXXX"); + if (mkdtemp(tmpdir) == NULL) { + fprintf(stderr, "mkdtemp: %s\n", strerror(errno)); + return 1; + } + + /* Clean stale state */ + run_cmd("pkill -9 dhcpcd 2>/dev/null; true"); + run_cmd("mkdir -p /var/db/dhcpcd 2>/dev/null"); + run_cmd("mkdir -p /var/run/dhcpcd 2>/dev/null"); + run_cmd("find /var/run/dhcpcd/ -type f -delete 2>/dev/null; true"); + run_cmd("find /var/run/dhcpcd/ -type s -delete 2>/dev/null; true"); + + if (scenarios == 0) + scenarios = SC_ALL; + + printf("dhcpcd DHCPDISCOVER backoff integration test\n"); + printf("=============================================\n"); + printf("Binary: %s\n", dhcpcd_bin); + printf("Runs: %d per test\n", num_runs); + + if (scenarios & SC_DEFAULTS) + test_backoff(&test_defaults); + if (scenarios & SC_MINLAT) + test_backoff(&test_min_latency); + if (scenarios & SC_CLOUD) + test_backoff(&test_cloud_recommended); + + if (scenarios & SC_REJECT) { + test_reject("reject initial_interval=0", + "initial_interval", "0"); + test_reject("reject backoff_cutoff=0", + "backoff_cutoff", "0"); + } + + /* Remove temp directory */ + { + char cmd[512]; + + snprintf(cmd, sizeof(cmd), "rm -rf %s", tmpdir); + run_cmd(cmd); + } + + printf("\n=============================================\n"); + if (failures == 0) + printf("All tests passed.\n"); + else + printf("%d test(s) FAILED.\n", failures); + + return failures ? 1 : 0; +}