From vda.linux at googlemail.com Thu Oct 1 22:30:30 2026 From: vda.linux at googlemail.com (Denys Vlasenko) Date: Fri, 2 Oct 2026 00:30:30 +0200 Subject: [PATCH] bunzip2: reject blocks whose run-length count overflows In-Reply-To: <20260921203851.3831040-1-mattst88@gmail.com> References: <20260921203851.3831040-1-mattst88@gmail.com> Message-ID: On Mon, Sep 21, 2026 at 10:39?PM Matt Turner via busybox wrote: > get_next_block() accumulates a run length in the unsigned 32-bit runCnt: > > runCnt += (runPos << nextSym); > > A crafted stream with a long enough RUNA/RUNB sequence can drive runCnt > up to ~2^32. Once it is that large, the existing guard > > if (dbufCount + runCnt > bd->dbufSize) > > is bypassed: dbufCount + runCnt overflows the unsigned addition and > wraps to a small value, so the check passes. The following > byteCount[tmp_byte] += runCnt then overflows byteCount[] (an int) to a > negative value. The inverse Burrows-Wheeler sort loop computes negative > cumulative offsets from it and writes before the start of dbuf[], an > out-of-bounds heap write (confirmed with AddressSanitizer as a write 48 > bytes before the dbuf allocation). * +runPos if RUNA; +2*runPos if RUNB */ > -//The 32-bit overflow of runCnt wasn't yet seen, but probably can happen. > -//This would be the fix (catches too large count way before it can overflow): > -// if (runCnt > bd->dbufSize) { > -// dbg("runCnt:%u > dbufSize:%u RETVAL_DATA_ERROR", > -// runCnt, bd->dbufSize); > -// return RETVAL_DATA_ERROR; > -// } > + /* Catch too large count way before it can overflow: each step > + adds at most 2*runPos < 4*dbufSize, so runCnt can never wrap > + past this check. This also guarantees dbufCount+runCnt below > + cannot overflow. */ > + if (runCnt > bd->dbufSize) { How about just "if ((int)runCnt < 0)" instead? It adds literally just one instruction - "jump if sign": be 01 00 00 00 mov $0x1,%esi 89 f0 mov %esi,%eax d3 e0 shl %cl,%eax 01 04 24 add %eax,(%esp) +0f 88 69 fd ff ff js 1c6 39 73 40 cmp %esi,0x40(%ebx) 0f 86 b1 00 00 00 jbe 511 01 f6 add %esi,%esi e9 aa 00 00 00 jmp 511 85 f6 test %esi,%esi 74 5d je 4c8 8b 04 24 mov (%esp),%eax 01 e8 add %ebp,%eax 3b 43 40 cmp 0x40(%ebx),%eax Can your reproducer be made even smarter to slip through that? From vda.linux at googlemail.com Sat Oct 3 23:31:22 2026 From: vda.linux at googlemail.com (Denys Vlasenko) Date: Sun, 4 Oct 2026 01:31:22 +0200 Subject: tftp.c max blocksize incorrect In-Reply-To: <10A092B9-888F-4592-8B49-E5BBD8B87EEE@assmundsson.com> References: <10A092B9-888F-4592-8B49-E5BBD8B87EEE@assmundsson.com> Message-ID: Fixed. Thank you! On Fri, Sep 11, 2026 at 6:09?PM Valentin via busybox wrote: > > 65464 as according to RFC2348, > 65564 in code._______________________________________________ > busybox mailing list > busybox at busybox.net > https://lists.busybox.net/mailman/listinfo/busybox From rmy at pobox.com Sun Oct 4 08:03:15 2026 From: rmy at pobox.com (Ron Yorston) Date: Sun, 04 Oct 2026 09:03:15 +0100 Subject: [PATCH] xxd: '-p -c 0' outputs a single line Message-ID: <6ac20843.LPZxvXw1UAyUJfsd%rmy@pobox.com> vim's xxd allows the combination of options '-p -c 0' to output a single line of text [1]. A use case is handling cryptographic material [2]. Add this feature to BusyBox xxd. [1] https://github.com/vim/vim/pull/9524 [2] https://stackoverflow.com/questions/42192490/aes-cbc-incorrect-usage function old new delta xxd_main 1496 1526 +30 packed_usage 35515 35511 -4 ------------------------------------------------------------------------------ (add/remove: 0/0 grow/shrink: 1/1 up/down: 30/-4) Total: 26 bytes Signed-off-by: Ron Yorston --- testsuite/xxd.tests | 8 ++++++++ util-linux/hexdump_xxd.c | 11 ++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/testsuite/xxd.tests b/testsuite/xxd.tests index a0a8dd1f1..f785b5754 100755 --- a/testsuite/xxd.tests +++ b/testsuite/xxd.tests @@ -79,4 +79,12 @@ testing 'xxd -p -r ignores the nibble with 2nd char bad' \ 36 " +testing 'xxd -p -c0 outputs a single line' \ + 'xxd -p -c0' \ + "\ +68656c6c6f20776f726c640a68656c6c6f20776f726c640a68656c6c6f20776f726c640a +" \ + '' \ + 'hello world\nhello world\nhello world\n' + exit $FAILCOUNT diff --git a/util-linux/hexdump_xxd.c b/util-linux/hexdump_xxd.c index 783ca9b26..6961c88e4 100644 --- a/util-linux/hexdump_xxd.c +++ b/util-linux/hexdump_xxd.c @@ -50,7 +50,7 @@ //usage: "Hex dump FILE (or stdin)\n" //usage: "\n -g N Bytes per group (default 2)" //usage: "\n -c N Bytes per line (default:16, -ps:30, -i:12)" -//usage: "\n -ps Show only hex bytes (no offset/spaces)" +//usage: "\n -ps Show only hex bytes (-c 0: output as one line)" //usage: "\n -i C include file style" // exactly the same help text lines in hexdump and xxd: //usage: "\n -l LENGTH Show only first LENGTH bytes" @@ -238,6 +238,7 @@ int xxd_main(int argc UNUSED_PARAM, char **argv) unsigned bytes = 2; unsigned cols = 0; unsigned opt; + int omit_newline = FALSE; int r; setup_common_bufsiz(); @@ -278,8 +279,11 @@ int xxd_main(int argc UNUSED_PARAM, char **argv) } if (opt & OPT_p) { - if (cols == 0) + if (cols == 0) { + // '-p -c 0' prints as a single line + omit_newline = opt & OPT_c; cols = 30; + } bytes = cols; /* -p ignores -gN */ } else { if (cols == 0) @@ -329,7 +333,8 @@ int xxd_main(int argc UNUSED_PARAM, char **argv) sprintf(buf, "\" \"%u/1 \"%%_p\"\"\n\"", cols); // " ASCII\n" bb_dump_add(dumper, buf); } else { - bb_dump_add(dumper, "\"\n\""); + if (!omit_newline) + bb_dump_add(dumper, "\"\n\""); dumper->xxd_eofstring = "\n"; } -- 2.55.0 From rmy at pobox.com Sun Oct 4 08:42:28 2026 From: rmy at pobox.com (Ron Yorston) Date: Sun, 04 Oct 2026 09:42:28 +0100 Subject: [PATCH] ash: support PROMPT_COMMAND Message-ID: <6ac21174.w9hGAZJATyZLCYtC%rmy@pobox.com> The PROMPT_COMMAND variable in bash allows a command or commands to be executed in an interactive shell just before the primary prompt is printed. Add this to BusyBox ash as a bash compatibility feature. In bash PROMPT_COMMAND can be an array variable (to specify a number of commands) or a scalar. ash only supports the latter. function old new delta cmdloop 385 574 +189 .rodata 102358 102373 +15 ------------------------------------------------------------------------------ (add/remove: 0/0 grow/shrink: 2/0 up/down: 204/0) Total: 204 bytes Signed-off-by: Ron Yorston --- shell/ash.c | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/shell/ash.c b/shell/ash.c index b8ff67b16..93ac26fad 100644 --- a/shell/ash.c +++ b/shell/ash.c @@ -264,6 +264,7 @@ # define BASH_PROCESS_SUBST 0 # define IF_BASH_PROCESS_SUBST(...) #endif +#define BASH_PROMPT_COMMAND ENABLE_ASH_BASH_COMPAT #if defined(__ANDROID_API__) && __ANDROID_API__ <= 24 /* Bionic at least up to version 24 has no glob() */ @@ -14116,6 +14117,10 @@ evalcmd(int argc UNUSED_PARAM, char **argv, int flags) return 0; } +#if BASH_PROMPT_COMMAND +static void exitreset(void); +#endif + /* * Read and execute commands. * "Top" is nonzero for the top level command loop; @@ -14144,6 +14149,31 @@ cmdloop(int top) inter++; chkmail(); } +#if BASH_PROMPT_COMMAND + if (inter) { + const char *cmd = lookupvar("PROMPT_COMMAND"); + + if (cmd && *cmd) { + struct jmploc *volatile savehandler; + struct jmploc jmploc; + int cmdstatus = exitstatus; + + savehandler = exception_handler; + if (setjmp(jmploc.loc) == 0) { + exception_handler = &jmploc; + tokpushback = 0; + checkkwd = 0; + heredoclist = 0; + evalstring((char *)cmd, 0); + } else { + exitreset(); + exitshell(); + } + exitstatus = cmdstatus; + exception_handler = savehandler; + } + } +#endif n = parsecmd(inter); #if DEBUG if (DEBUG > 2 && debug && (n != NODE_EOF)) -- 2.55.0 From vda.linux at googlemail.com Sun Oct 4 15:50:57 2026 From: vda.linux at googlemail.com (Denys Vlasenko) Date: Sun, 4 Oct 2026 17:50:57 +0200 Subject: [PATCH] printf: fix handling of omitted arguments In-Reply-To: References: Message-ID: Applied, thank you. On Thu, Sep 24, 2026 at 11:19?PM Harald van Dijk via busybox wrote: > > For numeric formats, this is meant to behave as a "0" literal zero > argument, not an "" empty string argument. > > Add a test to ensure that an explicit "" empty string argument still is > treated as an error. > > function old new delta > printf_main 877 1319 +442 > print_direc 457 - -457 > ------------------------------------------------------------------------------ > (add/remove: 0/1 grow/shrink: 1/0 up/down: 442/-457) Total: -15 bytes > text data bss dec hex filename > 1015219 16603 1656 1033478 fc506 busybox_old > 1015204 16603 1656 1033463 fc4f7 busybox_unstripped > --- > coreutils/printf.c | 14 +++++++------- > testsuite/printf.tests | 10 ++++++++-- > 2 files changed, 15 insertions(+), 9 deletions(-) > > diff --git a/coreutils/printf.c b/coreutils/printf.c > index 3cd48cfcc..98b471a40 100644 > --- a/coreutils/printf.c > +++ b/coreutils/printf.c > @@ -355,6 +355,7 @@ static char **print_formatted(char *f, char **argv, int *conv_err) > { > static const char format_chars[] ALIGN1 = "diouxXfeEgGcs"; > char *p = (char*)strchr(format_chars, *f); > + const char *arg = "0"; > /* needed - try "printf %" without it */ > if (p == NULL || *f == '\0') { > bb_error_msg("%s: invalid format", direc_start); > @@ -373,15 +374,14 @@ static char **print_formatted(char *f, char **argv, int *conv_err) > direc_length += 2; > direc_start = p; > } else { > + if (p - format_chars >= 11) > + ++arg; > p = NULL; > } > - if (*argv) { > - print_direc(direc_start, direc_length, field_width, > - precision, *argv++); > - } else { > - print_direc(direc_start, direc_length, field_width, > - precision, ""); > - } > + if (*argv) > + arg = *argv++; > + print_direc(direc_start, direc_length, field_width, > + precision, arg); > *conv_err |= errno; > free(p); > } > diff --git a/testsuite/printf.tests b/testsuite/printf.tests > index 728bbf4bf..cdedf5e80 100755 > --- a/testsuite/printf.tests > +++ b/testsuite/printf.tests > @@ -116,6 +116,11 @@ testing "printf handles positive numbers for %f" \ > "0.420\n""0\n" \ > "" "" > > +testing "printf accepts missing arguments as empty or 0" \ > + "${bb}printf '%d %i %o %u %x %X %f %e %E %g %G %c %b %s' | ${bb}cat -v" \ > + "0 0 0 0 0 0 0.000000 0.000000e+00 0.000000E+00 0 0 ^@ " \ > + "" "" > + > > # "FIXED" now to act compatibly > ## We are "more correct" here than bash/coreutils: they happily print -2 > @@ -126,11 +131,12 @@ testing "printf handles positive numbers for %f" \ > # "" "" > > testing "printf handles %d bad_input" \ > - "${bb}printf '%d\n' 1 - 2 bad 3 123bad 4 2>&1; echo \$?" \ > + "${bb}printf '%d\n' 1 - 2 bad 3 123bad 4 '' 5 2>&1; echo \$?" \ > "1\n""printf: invalid number '-'\n""0\n"\ > "2\n""printf: invalid number 'bad'\n""0\n"\ > "3\n""printf: invalid number '123bad'\n""0\n"\ > -"4\n""1\n" \ > +"4\n""printf: invalid number ''\n""0\n"\ > +"5\n""1\n" \ > "" "" > > testing "printf aborts on bare %" \ > -- > 2.47.3 > > _______________________________________________ > busybox mailing list > busybox at busybox.net > https://lists.busybox.net/mailman/listinfo/busybox From vda.linux at googlemail.com Sun Oct 4 17:44:31 2026 From: vda.linux at googlemail.com (Denys Vlasenko) Date: Sun, 4 Oct 2026 19:44:31 +0200 Subject: [PATCH] chrt: add -R to set SCHED_RESET_ON_FORK In-Reply-To: <20260924132348.114566-1-michele.dionisio@gmail.com> References: <20260924132348.114566-1-michele.dionisio@gmail.com> Message-ID: Applied, thank you. On Thu, Sep 24, 2026 at 3:24?PM Michele Dionisio via busybox wrote: > > Add the -R option, as in util-linux's chrt, which ORs the > SCHED_RESET_ON_FORK flag into the policy passed to sched_setscheduler(). > Children created by fork() then do not inherit privileged scheduling > policies and priorities. > > Provide a fallback definition of SCHED_RESET_ON_FORK for libcs whose > headers lack it, so the #ifdef around the flag masking in the -p query > path is no longer needed. > > Signed-off-by: Michele Dionisio > --- > util-linux/chrt.c | 13 +++++++++---- > 1 file changed, 9 insertions(+), 4 deletions(-) > > diff --git a/util-linux/chrt.c b/util-linux/chrt.c > index f64fa6aa6..112a1c8e5 100644 > --- a/util-linux/chrt.c > +++ b/util-linux/chrt.c > @@ -17,7 +17,7 @@ > //kbuild:lib-$(CONFIG_CHRT) += chrt.o > > //usage:#define chrt_trivial_usage > -//usage: "-m | [-rfobi] { -p [PRIO] PID | PRIO PROG ARGS }" > +//usage: "-m | [-rfobiR] { -p [PRIO] PID | PRIO PROG ARGS }" > //usage:#define chrt_full_usage "\n\n" > //usage: "Change scheduling priority and class (default RR) for a process\n" > //usage: "\n -m Show min/max priorities" > @@ -27,6 +27,7 @@ > //usage: "\n -o Set SCHED_OTHER class" > //usage: "\n -b Set SCHED_BATCH class" > //usage: "\n -i Set SCHED_IDLE class" > +//usage: "\n -R Set SCHED_RESET_ON_FORK" > //usage: > //usage:#define chrt_example_usage > //usage: "$ chrt -r 4 sleep 900; x=$!\n" > @@ -38,6 +39,9 @@ > #ifndef SCHED_IDLE > # define SCHED_IDLE 5 > #endif > +#ifndef SCHED_RESET_ON_FORK > +# define SCHED_RESET_ON_FORK 0x40000000 > +#endif > > //musl has no __MUSL__ or similar define to check for, > //but its has these lines: > @@ -85,6 +89,7 @@ static void show_min_max(int pol) > #define OPT_o (1<<4) > #define OPT_b (1<<5) > #define OPT_i (1<<6) > +#define OPT_R (1<<7) > > int chrt_main(int argc, char **argv) MAIN_EXTERNALLY_VISIBLE; > int chrt_main(int argc UNUSED_PARAM, char **argv) > @@ -99,7 +104,7 @@ int chrt_main(int argc UNUSED_PARAM, char **argv) > int ret; > > opt = getopt32(argv, "^" > - "+" "mprfobi" > + "+" "mprfobiR" > "\0" > /* only one policy accepted: */ > "r--fobi:f--robi:o--rfbi:b--rfoi:i--rfob" > @@ -162,7 +167,6 @@ int chrt_main(int argc UNUSED_PARAM, char **argv) > #endif > if (pol < 0) > bb_perror_msg_and_die("can't %cet pid %u's policy", 'g', (int)pid); > -#ifdef SCHED_RESET_ON_FORK > /* "Since Linux 2.6.32, the SCHED_RESET_ON_FORK flag > * can be ORed in policy when calling sched_setscheduler(). > * As a result of including this flag, children created by > @@ -172,7 +176,6 @@ int chrt_main(int argc UNUSED_PARAM, char **argv) > * (TODO: do we want to show it?) > */ > pol &= ~SCHED_RESET_ON_FORK; > -#endif > printf("pid %u's %s scheduling policy: SCHED_%s\n", > pid, current_new, policy_name(pol) > ); > @@ -199,6 +202,8 @@ int chrt_main(int argc UNUSED_PARAM, char **argv) > sp.sched_priority = xstrtou_range(priority, 0, > sched_get_priority_min(policy), sched_get_priority_max(policy) > ); > + if (opt & OPT_R) > + policy |= SCHED_RESET_ON_FORK; > > #if LIBC_IS_MUSL > ret = syscall(SYS_sched_setscheduler, pid, policy, &sp); > -- > 2.53.0 > > _______________________________________________ > busybox mailing list > busybox at busybox.net > https://lists.busybox.net/mailman/listinfo/busybox From vda.linux at googlemail.com Sun Oct 4 22:19:37 2026 From: vda.linux at googlemail.com (Denys Vlasenko) Date: Mon, 5 Oct 2026 00:19:37 +0200 Subject: [Security] ftpd directory traversal / arbitrary file read-write in non-root mode (with PoC) In-Reply-To: References: Message-ID: On Wed, Sep 23, 2026 at 6:48?PM talha Arslan wrote: > Hello, > > Following up on my report from September 6 (directory traversal / arbitrary file read and write in ftpd's non-root mode, ASI-2026-0017, PDF writeup with full PoC attached to that email). It's now been a little over two weeks with no acknowledgement, so I wanted to check in before proceeding. Your analysis is correct. ftpd running under non-root UID allows users to access any files of this UID. This is not a bug in the sense where "bug " = "ftpd.c author assumes this is not the case". If you want to improve this, send a patch. I am not joking. I'm serious. This can be improved. You can improve it. If you want to. Probably would require adding code which checks that CWD and CDUP commands do not end up outside DIR, that LIST/NLST/STAT/.../.../... do not have any ".." path components. Should not be too difficult. -- vda From vda.linux at googlemail.com Sun Oct 4 22:37:13 2026 From: vda.linux at googlemail.com (Denys Vlasenko) Date: Mon, 5 Oct 2026 00:37:13 +0200 Subject: [PATCH] ash: fixed prompt when using non-root user In-Reply-To: References: Message-ID: Fixed in git. Thank you! On Thu, Sep 24, 2026 at 3:15?PM Robert Sandholzer via busybox wrote: > > 96b0607302500ed201a7816282efbaa8f990aa33 contained a typo which meant that no matter the user, you always got the ?#? sign as prompt. > > > > diff --git a/shell/ash.c b/shell/ash.c > > index 9142feb8c..b8ff67b16 100644 > > --- a/shell/ash.c > > +++ b/shell/ash.c > > @@ -2461,7 +2461,7 @@ initvar(void) > > #if ENABLE_FEATURE_EDITING && ENABLE_FEATURE_EDITING_FANCY_PROMPT > > vps1.var_text = "PS1=\\w \\$ "; > > #else > > - if (!get_cached_euid(&groupinfo.euid)) > > + if (!get_cached_euid(&groupinfo.euid)); > > vps1.var_text = "PS1=# "; > > #endif > > vp = varinit; > > > > _______________________________________________ > busybox mailing list > busybox at busybox.net > https://lists.busybox.net/mailman/listinfo/busybox From vda.linux at googlemail.com Sun Oct 4 22:48:12 2026 From: vda.linux at googlemail.com (Denys Vlasenko) Date: Mon, 5 Oct 2026 00:48:12 +0200 Subject: [PATCH] tar: complete CVE-2025-46394 fix for FEATURE_TAR_UNAME_GNAME builds In-Reply-To: References: Message-ID: On Thu, Sep 24, 2026 at 3:49?PM Ben Tasker via busybox wrote: > Hi, > > Commit f5e1bf966b19 ("archival/libarchive: sanitize filenames on output > (prevent control sequence attacks)"), which fixed CVE-2025-46394, routed > header_list() and the !FEATURE_TAR_UNAME_GNAME branch of > header_verbose_list() (plus the link target) through printable_string(), > but the FEATURE_TAR_UNAME_GNAME branch of header_verbose_list() still > prints the raw file_header->name. ... > diff --git a/archival/libarchive/header_verbose_list.c b/archival/libarchive/header_verbose_list.c > index e7a0943..bc0de0e 100644 > --- a/archival/libarchive/header_verbose_list.c > +++ b/archival/libarchive/header_verbose_list.c > @@ -40,7 +40,7 @@ void FAST_FUNC header_verbose_list(const file_header_t *file_header) > ptm->tm_hour, > ptm->tm_min, > ptm->tm_sec, > - file_header->name); > + printable_string(file_header->name)); > > #else /* !FEATURE_TAR_UNAME_GNAME */ Already fixed in git? commit 7473045ad3504db9b421427a452fd9b146346306 Author: Thorsten B Date: Thu Jul 30 20:03:15 2026 +0200 tar: fix verbose output with extract-to-stdout ... @@ -40,13 +43,13 @@ void FAST_FUNC header_verbose_list(const file_header_t *file_header) ptm->tm_hour, ptm->tm_min, ptm->tm_sec, - file_header->name); + printable_string(file_header->name)); From vda.linux at googlemail.com Sun Oct 4 22:52:25 2026 From: vda.linux at googlemail.com (Denys Vlasenko) Date: Mon, 5 Oct 2026 00:52:25 +0200 Subject: [PATCH] id: Fix output in case of groups error In-Reply-To: <20260920154228.1346799-1-wojtekka@toxygen.net> References: <20260920154228.1346799-1-wojtekka@toxygen.net> Message-ID: Applied, thank you. On Sun, Sep 20, 2026 at 5:44?PM Wojtek Kaniewski via busybox wrote: > > On a severely constrained system (m68k-nommu) I got confusing output from > `id` command: > > ~ # id > uid=0 gid=0id: can't get groups > > After this change it's a bit more readable: > > ~ # id > uid=0 gid=0 > id: can't get groups > > Signed-off-by: Wojtek Kaniewski > --- > coreutils/id.c | 1 + > 1 file changed, 1 insertion(+) > > diff --git a/coreutils/id.c b/coreutils/id.c > index 319e80c0c..8b43fcd1c 100644 > --- a/coreutils/id.c > +++ b/coreutils/id.c > @@ -230,6 +230,7 @@ int id_main(int argc UNUSED_PARAM, char **argv) > prefix = ","; > } > } else if (n < 0) { /* error in get_groups() */ > + bb_putchar('\n'); > if (ENABLE_DESKTOP) > bb_simple_error_msg_and_die("can't get groups"); > return EXIT_FAILURE; > -- > 2.54.0 > > _______________________________________________ > busybox mailing list > busybox at busybox.net > https://lists.busybox.net/mailman/listinfo/busybox From vda.linux at googlemail.com Mon Oct 5 15:43:06 2026 From: vda.linux at googlemail.com (Denys Vlasenko) Date: Mon, 5 Oct 2026 17:43:06 +0200 Subject: [PATCH v2] ash: fix wait -n behavior for multiple PIDs and pipe jobs In-Reply-To: <20260329234811.2216049-2-luizluca@gmail.com> References: <20260329234811.2216049-2-luizluca@gmail.com> Message-ID: On Mon, Mar 30, 2026 at 1:48?AM Luiz Angelo Daros de Luca via busybox wrote: > - "wait -n $p1 $p2" should return when the FIRST of the listed jobs > finishes, not wait for all of them (Bugzilla 15970). > - "wait -n" with a pipe job returns only when the entire job completes > (e.g., `(sleep 1; exit 1) | (sleep 2; exit 2) & wait -n` returns 2). > - "wait -n" with no arguments now correctly finds older finished jobs > instead of blocking on the newest running job > (e.g., `(exit 42) & sleep 1; sleep 5 & wait -n` now returns 42 immediately > instead of blocking for 5 seconds and returning 0). > - "wait -n" with no arguments now correctly returns 127 if all finished > jobs have already been waited for > (e.g., `(exit 42) & wait; wait -n` now returns 127 instead of 0). > - Added comprehensive test cases: wait8..12.tests, with wait8 and wait9 > from Dominique Martinet previous patch series. > > Link: https://www.mail-archive.com/busybox at busybox.net/msg30397.html > Link: https://bugs.busybox.net/show_bug.cgi?id=15970 > Co-developed-by: Dominique Martinet > Signed-off-by: Luiz Angelo Daros de Luca > --- > > v1->v2: > - Fixed wait -n state management: In the "wait without IDs" loop, the code now > uses continue instead of returning immediately after a process exits. > Returning immediately caused the job to never be marked as waited, leading to > subsequent wait -n calls incorrectly returning the same status. By continuing > the loop, the shell performs its standard job-scanning logic, which correctly > marks the job as waited and then returns the status. > - Added wait8.tests and wait9.tests from Dominique patches. > - Renamed existing tests for consistency (wait_n1 -> wait10, wait_n2 -> wait11) > - Added wait12.tests specifically for wait -n $p1 $p2 (multiple positional > arguments) This is an extremely fragile bit of code, AND we are trying to pick up fixes from dash, AND dash's code is even worse. I'll try to deobfuscate dash first. :/ Let's start with a smaller change. What *one* failure you can fix with a minimal change of code, and with small testcase? Or several small tesatcases. NOT one giant one. > diff --git a/shell/ash.c b/shell/ash.c > index fb887f31b..61811bc08 100644 > --- a/shell/ash.c > +++ b/shell/ash.c > @@ -3844,7 +3844,8 @@ struct job { > #endif > waited: 1, /* true if this entry has been waited for */ > used: 1, /* true if this entry is in used */ > - changed: 1; /* true if status has changed */ > + changed: 1, /* true if status has changed */ > + wait_n: 1; /* wait -n is interested in this job */ > struct job *prev_job; /* previous job */ > }; > > @@ -4462,9 +4463,17 @@ static int waitone(int block, struct job *job) > > #if BASH_WAIT_N > if (want_jobexitstatus) { > - pid = -1; > - if (thisjob && thisjob->state == JOBDONE) > - pid = thisjob->ps[thisjob->nprocs - 1].ps_status; > + if (pid > 0) { > + /* Reaped a child. If its job is done, return its status. > + * If not done yet, return -2 (indicates "reaped, but job still running"). > + */ > + int exit_status = -2; > + if (thisjob && thisjob->state == JOBDONE) > + exit_status = thisjob->ps[thisjob->nprocs - 1].ps_status; > + pid = exit_status; > + } else if (pid == 0) { > + pid = -1; /* No child reaped, prevent returning 0 (success) */ > + } > } > #endif > if (thisjob && thisjob == job) { > @@ -4493,10 +4502,30 @@ static int dowait(int block, struct job *jp) > if (block == DOWAIT_NONBLOCK && !gotchld) > return 1; > > + /* rpid tracks if we reaped a child (pid > 0) in every waitone call. > + * If any waitone call returns 0 (e.g. signal received but no child reaped), > + * rpid will become 0. It stays 1 if all calls reaped a child or if the > + * loop was not entered. > + */ > rpid = 1; > > +#if BASH_WAIT_N > + again: > +#endif > do { > pid = waitone(block, jp); > +#if BASH_WAIT_N > + if (block & DOWAIT_JOBSTATUS) { > + if (pid >= 0) > + return pid; > + if (pid == -2) > + goto again; > + if (block == DOWAIT_NONBLOCK || pending_sig) > + return -1; > + goto again; > + } > +#endif > + /* rpid remains 1 only if all waitone calls returned pid > 0 */ > rpid &= !!pid; > > if (!pid || (jp && jp->state != JOBRUNNING)) > @@ -4808,33 +4837,69 @@ stoppedjobs(void) > static int FAST_FUNC > waitcmd(int argc UNUSED_PARAM, char **argv) > { > - struct job *job; > int retval; > struct job *jp; > #if BASH_WAIT_N > int status; > char one = nextopt("n"); > + bool any_waitable = false; > #else > nextopt(nullstr); > #endif > retval = 0; > > argv = argptr; > + /* wait without IDs */ > if (!argv[0]) { > /* wait for all jobs / one job if -n */ > for (;;) { > - jp = curjob; > #if BASH_WAIT_N > - if (one && !jp) > - /* exitcode of "wait -n" with nothing to wait for is 127, not 0 */ > - retval = 127; > + bool found_running = false; > #endif > + jp = curjob; > while (1) { > - if (!jp) /* no running procs */ > + if (!jp) { /* no more jobs */ > +#if BASH_WAIT_N > + if (one) { > + if (found_running) > + /* if we have a running job, we can wait for it. > + * exit while(1), will call dowait aftewards > + */ > + break; > + /* without jobs, exitcode of "wait -n" is 127, not 0 */ > + retval = 127; > + } > +#endif > goto ret; > - if (jp->state == JOBRUNNING) > - break; > - jp->waited = 1; > + } > + > + /* non-running jobs are first in the list. For standard "wait", > + * when we reach a running one, we can stop scanning and block. > + */ > +#if BASH_WAIT_N > + if (one) { > + if (jp->state != JOBRUNNING) { > + /* when we have a non-running job not reported yet, we can > + * return "wait -n" immediately > + */ > + if (!jp->waited) { > + jp->waited = 1; > + retval = getstatus(jp); > + goto ret_n; > + } > + } else { > + found_running = true; > + } > + } else > +#endif > + { > + if (jp->state == JOBRUNNING) > + break; > + jp->waited = 1; > + } > + /* check the remaining jobs for a running job (to wait for) > + * or a non-running job to mark as waited or return if '-n' > + */ > jp = jp->prev_job; > } > /* man bash: > @@ -4862,40 +4927,92 @@ waitcmd(int argc UNUSED_PARAM, char **argv) > * should wait for 2 seconds. Not 1 or 3. > */ > if (status != -1 && !WIFSTOPPED(status)) { > - retval = WEXITSTATUS(status); > - if (WIFSIGNALED(status)) > - retval = 128 | WTERMSIG(status); > - goto ret; > + continue; > } > } > #endif > } > } > > - retval = 127; > +#if BASH_WAIT_N > + if (one) > + retval = 127; > +#endif > + > + /* wait with IDs */ > do { > - if (**argv != '%') { > + if (**argv == '%') { > + jp = getjob(*argv, 0); > + } else { > pid_t pid = number(*argv); > - job = curjob; > - while (1) { > - if (!job) > - goto repeat; > - if (job->ps[job->nprocs - 1].ps_pid == pid) > - break; > - job = job->prev_job; > + jp = curjob; > + while (jp && jp->ps[jp->nprocs - 1].ps_pid != pid) > + jp = jp->prev_job; > + } > + > +#if BASH_WAIT_N > + if (one) { > + if (jp) { > + if (jp->state != JOBRUNNING) { > + /* when we have a non-running job, we can > + * return "wait -n" immediately. > + * > + * Bash returns the job status if the non-running job > + * exists, even if it was already waited. It only returns > + * 127 if the job was reaped. In Bash, the job cleanup > + * moment might be influenced by whether it runs as a > + * command (-c), a script or interactive. > + */ > + jp->waited = 1; > + retval = getstatus(jp); > + goto ret_n; > + } else { > + any_waitable = true; > + jp->wait_n = 1; > + } > } > - } else { > - job = getjob(*argv, 0); > + continue; > } > +#endif > + if (!jp) > + continue; > + > /* loop until process terminated or stopped */ > - dowait(DOWAIT_CHILD_OR_SIG, job); > + dowait(DOWAIT_CHILD_OR_SIG, jp); > if (pending_sig) > goto sigout; > - job->waited = 1; > - retval = getstatus(job); > - repeat: ; > + jp->waited = 1; > + retval = getstatus(jp); > } while (*++argv); > > +#if BASH_WAIT_N > + if (one) { > + if (!any_waitable) > + goto ret_n; /* retval is 127 */ > + > + /* Wait for any job to finish */ > + for (;;) { > + if (dowait(DOWAIT_CHILD_OR_SIG | DOWAIT_JOBSTATUS, NULL) == -1) { > + retval = 127; > + break; > + } > + if (pending_sig) > + goto sigout; > + /* Check if any of our marked jobs is done */ > + for (jp = curjob; jp; jp = jp->prev_job) { > + if (jp->wait_n && jp->state == JOBDONE && !jp->waited) { > + jp->waited = 1; > + retval = getstatus(jp); > + goto ret_n; > + } > + } > + } > + ret_n: > + /* Unmark all jobs */ > + for (jp = curjob; jp; jp = jp->prev_job) jp->wait_n = 0; > + } > +#endif > + > ret: > return retval; > sigout: > diff --git a/shell/ash_test/ash-misc/wait10.right b/shell/ash_test/ash-misc/wait10.right > new file mode 100644 > index 000000000..edae8d87d > --- /dev/null > +++ b/shell/ash_test/ash-misc/wait10.right > @@ -0,0 +1,16 @@ > +Wait 1 returned: 42. (expects 42) > +p1 active: 0 (expects 0) > +p2 active: 1 (expects 1) > +Wait 2 returned: 41 (expects 41) > +Wait 3 returned: 41 (expects 41) > +Wait 4 returned: 127 (expects 127) > +Wait 5 returned: 42 (expects 42) > +Wait 6 returned: 42 (expects 42) > +Wait 7 returned: 42 (expects 42) > +Wait 8 returned: 42 (expects 42) > +Wait 9 returned: 42 (expects 42) > +Wait 10 returned: 42 (expects 42) > +Wait 11 returned: 127 (expects 127) > +Wait 12 returned: 42 (expects 42) > +p7 active: 0 (expects 0) > +Wait 13 returned: 127 (expects 127) > diff --git a/shell/ash_test/ash-misc/wait10.tests b/shell/ash_test/ash-misc/wait10.tests > new file mode 100755 > index 000000000..8864649b7 > --- /dev/null > +++ b/shell/ash_test/ash-misc/wait10.tests > @@ -0,0 +1,62 @@ > +# Wait for any of p1, p2. p2 finishes first. > +(sleep 2; exit 41) & p1=$! > +(sleep 1; exit 42) & p2=$! > +wait -n $p1 $p2 > +echo "Wait 1 returned: $?. (expects 42)" > +kill -0 $p1 2>/dev/null; echo "p1 active: $? (expects 0)" > +kill -0 $p2 2>/dev/null; echo "p2 active: $? (expects 1)" > + > +# wait $p1 > +wait $p1 > +echo "Wait 2 returned: $? (expects 41)" > + > +# wait -n $p1 again. > +# bash as script returns 41 while command (-c) returns 127 > +wait -n $p1 > +echo "Wait 3 returned: $? (expects 41)" > + > +# wait -n (no args) with only already-waited jobs should return 127 > +wait -n > +echo "Wait 4 returned: $? (expects 127)" > +# Test: wait -n with a job that finished before wait was called > +(sleep 1; exit 42) & p3=$! > +sleep 2 > +# p3 is done but not reaped. wait -n should return 42. > +wait -n $p3 > +echo "Wait 5 returned: $? (expects 42)" > +# wait -n $p3 again should return 42 > +wait -n $p3 > +echo "Wait 6 returned: $? (expects 42)" > +# wait $p3 again should return 42 > +wait $p3 > +echo "Wait 7 returned: $? (expects 42)" > +# wait -n $p3 should return 42 > +wait -n $p3 > +echo "Wait 8 returned: $? (expects 42)" > + > +# Test: wait -n with multiple already finished jobs > +(sleep 1; exit 42) & p4=$! > +(sleep 1; exit 42) & p5=$! > +sleep 2 > +# Both p4 and p5 are done. > +wait -n > +echo "Wait 9 returned: $? (expects 42)" > +wait -n > +echo "Wait 10 returned: $? (expects 42)" > +wait -n > +echo "Wait 11 returned: $? (expects 127)" > + > +# Test: wait -n with a buried finished job (p6 done, p7 running) > +# This used to deadlock. > +(sleep 1; exit 42) & p6=$! > +(sleep 5; exit 41) & p7=$! > +sleep 2 > +wait -n > +echo "Wait 12 returned: $? (expects 42)" > +# p7 should still be running > +kill -0 $p7 2>/dev/null; echo "p7 active: $? (expects 0)" > +wait $p7 > + > +# Test: wait -n with no jobs at all > +wait -n > +echo "Wait 13 returned: $? (expects 127)" > diff --git a/shell/ash_test/ash-misc/wait11.right b/shell/ash_test/ash-misc/wait11.right > new file mode 100644 > index 000000000..95e266646 > --- /dev/null > +++ b/shell/ash_test/ash-misc/wait11.right > @@ -0,0 +1,3 @@ > +P1 done > +P2 done > +Wait returned: 0 > diff --git a/shell/ash_test/ash-misc/wait11.tests b/shell/ash_test/ash-misc/wait11.tests > new file mode 100755 > index 000000000..e41b90d85 > --- /dev/null > +++ b/shell/ash_test/ash-misc/wait11.tests > @@ -0,0 +1,5 @@ > +# Testing wait -n with a pipe > +# It should wait for the WHOLE job. > +(sleep 1; echo "P1 done" >&2) | (sleep 3; echo "P2 done" >&2) & > +wait -n > +echo "Wait returned: $?" > diff --git a/shell/ash_test/ash-misc/wait12.right b/shell/ash_test/ash-misc/wait12.right > new file mode 100644 > index 000000000..20e4b1cfb > --- /dev/null > +++ b/shell/ash_test/ash-misc/wait12.right > @@ -0,0 +1,3 @@ > +First wait -n returned: 42 (expects 42) > +Second wait -n returned: 41 (expects 41) > +Third wait -n returned: 43 (expects 43) > diff --git a/shell/ash_test/ash-misc/wait12.tests b/shell/ash_test/ash-misc/wait12.tests > new file mode 100755 > index 000000000..11162f6b0 > --- /dev/null > +++ b/shell/ash_test/ash-misc/wait12.tests > @@ -0,0 +1,18 @@ > +# Test wait -n with multiple PIDs > +# It should return when ANY of the specified jobs finishes. > + > +(sleep 2; exit 41) & p1=$! > +(sleep 1; exit 42) & p2=$! > +(sleep 3; exit 43) & p3=$! > + > +# Should wait for p2 (1s) and return 42 > +wait -n $p1 $p2 > +echo "First wait -n returned: $? (expects 42)" > + > +# p1 should still be running. wait -n for p1 or p3 should return when p1 finishes (after another 1s) > +wait -n $p1 $p3 > +echo "Second wait -n returned: $? (expects 41)" > + > +# wait -n for p3 (still running) > +wait -n $p3 > +echo "Third wait -n returned: $? (expects 43)" > diff --git a/shell/ash_test/ash-misc/wait8.right b/shell/ash_test/ash-misc/wait8.right > new file mode 100644 > index 000000000..e3ebde532 > --- /dev/null > +++ b/shell/ash_test/ash-misc/wait8.right > @@ -0,0 +1,6 @@ > +Test1 (wait with zero exit status of bg process) > +Test2 (wait with non-zero exit status of bg process) > +Test3 (pipeline wait for whole job) > +Ok:0 > +Test4 (wait exit code with signal) > +Test5 (wait -n exits 127 if nothing to wait for) > diff --git a/shell/ash_test/ash-misc/wait8.tests b/shell/ash_test/ash-misc/wait8.tests > new file mode 100755 > index 000000000..85fd55c2c > --- /dev/null > +++ b/shell/ash_test/ash-misc/wait8.tests > @@ -0,0 +1,61 @@ > +# wait -n tests > + > +# should never wait for this one > +sleep 5 && echo "Background1: BUG!" & #A > +bg=$! > + > +# normal process > +echo "Test1 (wait with zero exit status of bg process)" > +sleep 1 & #B > +wait -n #B, A still 4s to end > +rc=$? > +if [ "$rc" != 0 ]; then > + echo "Test1 exit code was not 0: $rc" > +fi > + > +# non-zero return code > +echo "Test2 (wait with non-zero exit status of bg process)" > +sh -c 'sleep 1; false' & #C > +pid2=$! > +wait -n #C, #A still 3s to end > +rc=$? > +if [ "$rc" != 1 ]; then > + echo "Test2 exit code was not 1: $rc" > +fi > + > +wait $pid2 # asking to wait for a waited pid is prone to racing condition as it can both return C exit status, if the process was already cleaned, or 0 if already cleaned (or 127 if -n). A is still running > +rc=$? > +if [ "$rc" != 1 ]; then > + echo "Test2 exit code was not 1 on recheck: $rc" > +fi > + > +# pipeline... not too fast > +echo "Test3 (pipeline wait for whole job)" > +d1=$(date +%s) > +sleep 2 | sleep 1 & #D > +wait -n #D, A is still 2s to end > +rc=$? > +if [ "$rc" != 0 ]; then > + echo "Test3 exit code was not 0: $rc" > +fi > +d2=$(date +%s) > +if [ "$d2" -lt "$((d1 + 2))" ]; then > + echo "Test3 finished in less than 2s" > +fi > + > +kill $bg # kill A > +echo Ok:$? > + > +echo "Test4 (wait exit code with signal)" > +wait -n #A > +rc=$? > +if [ "$rc" != 143 ]; then > + echo "Test4 exit code was not 143 (128+SIGTERM): $rc" > +fi > + > +echo "Test5 (wait -n exits 127 if nothing to wait for)" > +wait -n #none > +rc=$? > +if [ "$rc" != 127 ]; then > + echo "wait -n with nothing to wait was not 127: $rc" > +fi > diff --git a/shell/ash_test/ash-misc/wait9.right b/shell/ash_test/ash-misc/wait9.right > new file mode 100644 > index 000000000..23db7738d > --- /dev/null > +++ b/shell/ash_test/ash-misc/wait9.right > @@ -0,0 +1,6 @@ > +wait -n > +from USR1 > +prints after USR1 > +wait > +from USR1 > +prints after USR1 (2) > diff --git a/shell/ash_test/ash-misc/wait9.tests b/shell/ash_test/ash-misc/wait9.tests > new file mode 100755 > index 000000000..3dda2e133 > --- /dev/null > +++ b/shell/ash_test/ash-misc/wait9.tests > @@ -0,0 +1,21 @@ > +# tests wait and signals > + > +trap 'echo from USR1' USR1 > + > +echo "wait -n" > +{ sleep 1; kill -USR1 $$; sleep 1; echo prints after USR1; } & > +wait -n > +rc=$? > +[ "$rc" = 138 ] || echo "rc not 138: $?" > +wait -n > +rc=$? > +[ "$rc" = 0 ] || echo "rc not 0: $?" > + > +echo "wait" > +{ sleep 1; kill -USR1 $$; sleep 1; echo "prints after USR1 (2)"; } & > +wait > +rc=$? > +[ "$rc" = 138 ] || echo "rc not 138: $?" > +wait > +rc=$? > +[ "$rc" = 0 ] || echo "rc not 0: $?" > -- > 2.53.0 > > _______________________________________________ > busybox mailing list > busybox at busybox.net > https://lists.busybox.net/mailman/listinfo/busybox From vda.linux at googlemail.com Mon Oct 5 19:19:08 2026 From: vda.linux at googlemail.com (Denys Vlasenko) Date: Mon, 5 Oct 2026 21:19:08 +0200 Subject: [PATCH] xxd: '-p -c 0' outputs a single line In-Reply-To: <6ac20843.LPZxvXw1UAyUJfsd%rmy@pobox.com> References: <6ac20843.LPZxvXw1UAyUJfsd%rmy@pobox.com> Message-ID: Applied, thank you. On Sun, Oct 4, 2026 at 10:11?AM Ron Yorston via busybox wrote: > > > vim's xxd allows the combination of options '-p -c 0' to output > a single line of text [1]. A use case is handling cryptographic > material [2]. > > Add this feature to BusyBox xxd. > > [1] https://github.com/vim/vim/pull/9524 > [2] https://stackoverflow.com/questions/42192490/aes-cbc-incorrect-usage > > function old new delta > xxd_main 1496 1526 +30 > packed_usage 35515 35511 -4 > ------------------------------------------------------------------------------ > (add/remove: 0/0 grow/shrink: 1/1 up/down: 30/-4) Total: 26 bytes > > Signed-off-by: Ron Yorston > --- > testsuite/xxd.tests | 8 ++++++++ > util-linux/hexdump_xxd.c | 11 ++++++++--- > 2 files changed, 16 insertions(+), 3 deletions(-) > > diff --git a/testsuite/xxd.tests b/testsuite/xxd.tests > index a0a8dd1f1..f785b5754 100755 > --- a/testsuite/xxd.tests > +++ b/testsuite/xxd.tests > @@ -79,4 +79,12 @@ testing 'xxd -p -r ignores the nibble with 2nd char bad' \ > 36 > " > > +testing 'xxd -p -c0 outputs a single line' \ > + 'xxd -p -c0' \ > + "\ > +68656c6c6f20776f726c640a68656c6c6f20776f726c640a68656c6c6f20776f726c640a > +" \ > + '' \ > + 'hello world\nhello world\nhello world\n' > + > exit $FAILCOUNT > diff --git a/util-linux/hexdump_xxd.c b/util-linux/hexdump_xxd.c > index 783ca9b26..6961c88e4 100644 > --- a/util-linux/hexdump_xxd.c > +++ b/util-linux/hexdump_xxd.c > @@ -50,7 +50,7 @@ > //usage: "Hex dump FILE (or stdin)\n" > //usage: "\n -g N Bytes per group (default 2)" > //usage: "\n -c N Bytes per line (default:16, -ps:30, -i:12)" > -//usage: "\n -ps Show only hex bytes (no offset/spaces)" > +//usage: "\n -ps Show only hex bytes (-c 0: output as one line)" > //usage: "\n -i C include file style" > // exactly the same help text lines in hexdump and xxd: > //usage: "\n -l LENGTH Show only first LENGTH bytes" > @@ -238,6 +238,7 @@ int xxd_main(int argc UNUSED_PARAM, char **argv) > unsigned bytes = 2; > unsigned cols = 0; > unsigned opt; > + int omit_newline = FALSE; > int r; > > setup_common_bufsiz(); > @@ -278,8 +279,11 @@ int xxd_main(int argc UNUSED_PARAM, char **argv) > } > > if (opt & OPT_p) { > - if (cols == 0) > + if (cols == 0) { > + // '-p -c 0' prints as a single line > + omit_newline = opt & OPT_c; > cols = 30; > + } > bytes = cols; /* -p ignores -gN */ > } else { > if (cols == 0) > @@ -329,7 +333,8 @@ int xxd_main(int argc UNUSED_PARAM, char **argv) > sprintf(buf, "\" \"%u/1 \"%%_p\"\"\n\"", cols); // " ASCII\n" > bb_dump_add(dumper, buf); > } else { > - bb_dump_add(dumper, "\"\n\""); > + if (!omit_newline) > + bb_dump_add(dumper, "\"\n\""); > dumper->xxd_eofstring = "\n"; > } > > -- > 2.55.0 > > _______________________________________________ > busybox mailing list > busybox at busybox.net > https://lists.busybox.net/mailman/listinfo/busybox From me at runxiyu.org Tue Oct 6 15:19:33 2026 From: me at runxiyu.org (Runxi Yu) Date: Tue, 6 Oct 2026 11:19:33 -0400 Subject: [PATCH busybox] sed: POSIX.1-2024 processes -e/-f in argument order Message-ID: <20261006152006.1610-2-me@runxiyu.org> Signed-off-by: Runxi Yu --- NOTE: Please To/CC me in replies since I do not receive messages from the list. editors/sed.c | 40 ++++++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/editors/sed.c b/editors/sed.c index 029e9b8e7..c707301f8 100644 --- a/editors/sed.c +++ b/editors/sed.c @@ -1525,11 +1525,30 @@ static void add_cmd_block(char *cmdstr) free(sv); } +static void add_cmd_file(const char *fname) +{ + char *line; + FILE *cmdfile; + + cmdfile = xfopen_stdin(fname); + while ((line = xmalloc_fgetline(cmdfile)) != NULL) { + add_cmd(line); + free(line); + } + fclose_if_not_stdin(cmdfile); +} + +static int is_in(const char *optarg, const char *arg) +{ + return optarg >= arg && optarg <= arg + strlen(arg); +} + int sed_main(int argc, char **argv) MAIN_EXTERNALLY_VISIBLE; int sed_main(int argc UNUSED_PARAM, char **argv) { unsigned opt; llist_t *opt_e, *opt_f; + char **argp; char *opt_i; #if ENABLE_LONG_OPTS @@ -1568,8 +1587,6 @@ int sed_main(int argc UNUSED_PARAM, char **argv) sed_longopts, &opt_i, &opt_e, &opt_f, &G.be_quiet); /* counter for -n */ - //argc -= optind; - argv += optind; if (opt & OPT_in_place) { // -i die_func = cleanup_outname; } @@ -1577,19 +1594,14 @@ int sed_main(int argc UNUSED_PARAM, char **argv) G.regex_type |= REG_EXTENDED; // -r or -E //if (opt & 8) // G.be_quiet++; // -n (implemented with a counter instead) - while (opt_e) { // -e - add_cmd_block(llist_pop(&opt_e)); - } - while (opt_f) { // -f - char *line; - FILE *cmdfile; - cmdfile = xfopen_stdin(llist_pop(&opt_f)); - while ((line = xmalloc_fgetline(cmdfile)) != NULL) { - add_cmd(line); - free(line); - } - fclose_if_not_stdin(cmdfile); + for (argp = argv + 1; opt_e || opt_f; argp++) { + if (opt_e && is_in(opt_e->data, *argp)) + add_cmd_block(llist_pop(&opt_e)); + else if (opt_f && is_in(opt_f->data, *argp)) + add_cmd_file(llist_pop(&opt_f)); } + //argc -= optind; + argv += optind; /* if we didn't get a pattern from -e or -f, use argv[0] */ if (!(opt & 0x30)) { if (!*argv) -- 2.56.0 From vda.linux at googlemail.com Tue Oct 6 22:23:12 2026 From: vda.linux at googlemail.com (Denys Vlasenko) Date: Wed, 7 Oct 2026 00:23:12 +0200 Subject: [PATCH v2] ash: fix wait -n behavior for multiple PIDs and pipe jobs In-Reply-To: References: <20260329234811.2216049-2-luizluca@gmail.com> Message-ID: On Mon, Oct 5, 2026 at 5:43?PM Denys Vlasenko wrote: > On Mon, Mar 30, 2026 at 1:48?AM Luiz Angelo Daros de Luca via busybox > wrote: > > - "wait -n $p1 $p2" should return when the FIRST of the listed jobs > > finishes, not wait for all of them (Bugzilla 15970). > > - "wait -n" with a pipe job returns only when the entire job completes > > (e.g., `(sleep 1; exit 1) | (sleep 2; exit 2) & wait -n` returns 2). > > - "wait -n" with no arguments now correctly finds older finished jobs > > instead of blocking on the newest running job > > (e.g., `(exit 42) & sleep 1; sleep 5 & wait -n` now returns 42 immediately > > instead of blocking for 5 seconds and returning 0). > > - "wait -n" with no arguments now correctly returns 127 if all finished > > jobs have already been waited for > > (e.g., `(exit 42) & wait; wait -n` now returns 127 instead of 0). > > - Added comprehensive test cases: wait8..12.tests, with wait8 and wait9 > > from Dominique Martinet previous patch series. > > > > Link: https://www.mail-archive.com/busybox at busybox.net/msg30397.html > > Link: https://bugs.busybox.net/show_bug.cgi?id=15970 > > Co-developed-by: Dominique Martinet > > Signed-off-by: Luiz Angelo Daros de Luca > > --- > > > > v1->v2: > > - Fixed wait -n state management: In the "wait without IDs" loop, the code now > > uses continue instead of returning immediately after a process exits. > > Returning immediately caused the job to never be marked as waited, leading to > > subsequent wait -n calls incorrectly returning the same status. By continuing > > the loop, the shell performs its standard job-scanning logic, which correctly > > marks the job as waited and then returns the status. > > - Added wait8.tests and wait9.tests from Dominique patches. > > - Renamed existing tests for consistency (wait_n1 -> wait10, wait_n2 -> wait11) > > - Added wait12.tests specifically for wait -n $p1 $p2 (multiple positional > > arguments) > > This is an extremely fragile bit of code, AND we are trying to pick up > fixes from dash, AND dash's code is even worse. > > I'll try to deobfuscate dash first. :/ > > Let's start with a smaller change. What *one* failure > you can fix with a minimal change of code, and with small > testcase? Or several small tesatcases. NOT one giant one. I "massaged" waiting code into better shape, and sent my changes to dash too. Then started fixing our "wait -n" code. I used your smaller testcases and I think I fixed all broken cases of "wait -n" without parameters. wait10.tests and wait12.tests still fail. I'll work on fixing "wait -n" with parameters. From luizluca at gmail.com Tue Oct 6 22:49:13 2026 From: luizluca at gmail.com (Luiz Angelo Daros de Luca) Date: Tue, 6 Oct 2026 19:49:13 -0300 Subject: [PATCH v2] ash: fix wait -n behavior for multiple PIDs and pipe jobs In-Reply-To: References: <20260329234811.2216049-2-luizluca@gmail.com> Message-ID: > > I'll try to deobfuscate dash first. :/ > > > > Let's start with a smaller change. What *one* failure > > you can fix with a minimal change of code, and with small > > testcase? Or several small tesatcases. NOT one giant one. > > I "massaged" waiting code into better shape, and sent my changes to dash too. > > Then started fixing our "wait -n" code. > I used your smaller testcases and I think I fixed all broken cases of "wait -n" > without parameters. > > wait10.tests and wait12.tests still fail. > > I'll work on fixing "wait -n" with parameters. Thanks, Denys. The code was indeed... complex. At some point, I stopped trying to fix specific cases and just rewrote most of it. This patch works (at least, it passes the tests). Hopefully, it serves as a good reference to help you fix the remaining bugs in whatever way makes the most sense for the BusyBox codebase. The tests should also be helpful as they cover real, existing bugs. Feel free to merge or alter them independently of the rest of the patch. I'm here if you need any help. Best regards, Luiz From pratik.farkase at est.tech Thu Oct 8 10:26:28 2026 From: pratik.farkase at est.tech (Pratik Farkase) Date: Thu, 8 Oct 2026 12:26:28 +0200 Subject: [PATCH] testsuite/wget: add regression test for CVE-2025-60876 Message-ID: <20261008102628.19107-1-pratik.farkase@est.tech> wget rejects URLs containing raw control characters or spaces (bytes <= 0x20), which could otherwise be used to inject extra lines into the request headers. Add a testsuite entry confirming such URLs are refused. The check in parse_url() runs before any network access, so with the fix in place no connection is attempted; the unroutable 0.0.0.0 host keeps the test offline regardless. Signed-off-by: Pratik Farkase --- testsuite/wget.tests | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100755 testsuite/wget.tests diff --git a/testsuite/wget.tests b/testsuite/wget.tests new file mode 100755 index 0000000..f5ae3d0 --- /dev/null +++ b/testsuite/wget.tests @@ -0,0 +1,31 @@ +#!/bin/sh +# Copyright 2026 by Pratik Farkase +# Licensed under GPLv2, see file LICENSE in this source tree. + +. ./testing.sh + +# testing "test name" "commands" "expected result" "file input" "stdin" + +# CVE-2025-60876: wget must reject URLs containing raw control characters +# or spaces (bytes <= 0x20). Such bytes in the URL could otherwise be used +# to inject additional lines into the request headers (HTTP header/request +# smuggling). parse_url() rejects them before any network access, so with +# the fix in place no connection is attempted; the unroutable 0.0.0.0 host +# keeps the test offline regardless. +optional WGET +testing "wget rejects control character in URL" \ + 'busybox wget "http://0.0.0.0/$(printf '\''\r'\'')Injected: 1" 2>&1; echo $?' \ +"wget: Unencoded control character found in the URL! +1 +" \ + "" "" + +testing "wget rejects space in URL" \ + 'busybox wget "http://0.0.0.0/ evil" 2>&1; echo $?' \ +"wget: Unencoded control character found in the URL! +1 +" \ + "" "" +SKIP= + +exit $FAILCOUNT -- 2.43.0 From radoslav.kolev at suse.com Thu Oct 8 11:13:32 2026 From: radoslav.kolev at suse.com (Radoslav Kolev) Date: Thu, 8 Oct 2026 14:13:32 +0300 Subject: [PATCH] testsuite/wget: add regression test for CVE-2025-60876 In-Reply-To: <20261008102628.19107-1-pratik.farkase@est.tech> References: <20261008102628.19107-1-pratik.farkase@est.tech> Message-ID: Hello Pratik! I believe the change you are referring to, which rejects URLs with special characters, has not made it to upstream busybox. Neither has the more feature complete implementation for percent encoding special characters proposed by Hayden Barnes here: https://lists.busybox.net/pipermail/busybox/2026-June/092350.html Best regards, Rado On Thu, Oct 8, 2026 at 1:26?PM Pratik Farkase via busybox wrote: > > wget rejects URLs containing raw control characters or spaces (bytes > <= 0x20), which could otherwise be used to inject extra lines into the > request headers. Add a testsuite entry confirming such URLs are > refused. > > The check in parse_url() runs before any network access, so with the > fix in place no connection is attempted; the unroutable 0.0.0.0 host > keeps the test offline regardless. > > Signed-off-by: Pratik Farkase > --- > testsuite/wget.tests | 31 +++++++++++++++++++++++++++++++ > 1 file changed, 31 insertions(+) > create mode 100755 testsuite/wget.tests > > diff --git a/testsuite/wget.tests b/testsuite/wget.tests > new file mode 100755 > index 0000000..f5ae3d0 > --- /dev/null > +++ b/testsuite/wget.tests > @@ -0,0 +1,31 @@ > +#!/bin/sh > +# Copyright 2026 by Pratik Farkase > +# Licensed under GPLv2, see file LICENSE in this source tree. > + > +. ./testing.sh > + > +# testing "test name" "commands" "expected result" "file input" "stdin" > + > +# CVE-2025-60876: wget must reject URLs containing raw control characters > +# or spaces (bytes <= 0x20). Such bytes in the URL could otherwise be used > +# to inject additional lines into the request headers (HTTP header/request > +# smuggling). parse_url() rejects them before any network access, so with > +# the fix in place no connection is attempted; the unroutable 0.0.0.0 host > +# keeps the test offline regardless. > +optional WGET > +testing "wget rejects control character in URL" \ > + 'busybox wget "http://0.0.0.0/$(printf '\''\r'\'')Injected: 1" 2>&1; echo $?' \ > +"wget: Unencoded control character found in the URL! > +1 > +" \ > + "" "" > + > +testing "wget rejects space in URL" \ > + 'busybox wget "http://0.0.0.0/ evil" 2>&1; echo $?' \ > +"wget: Unencoded control character found in the URL! > +1 > +" \ > + "" "" > +SKIP= > + > +exit $FAILCOUNT > -- > 2.43.0 > > _______________________________________________ > busybox mailing list > busybox at busybox.net > https://lists.busybox.net/mailman/listinfo/busybox From pratik.farkase at est.tech Thu Oct 8 11:19:52 2026 From: pratik.farkase at est.tech (Pratik Farkase) Date: Thu, 8 Oct 2026 11:19:52 +0000 Subject: [PATCH] testsuite/wget: add regression test for CVE-2025-60876 In-Reply-To: References: <20261008102628.19107-1-pratik.farkase@est.tech> Message-ID: Hello Rado, Thanks for clarifying! I did not see that. Once an approach is settled upstream, I'm happy to adapt this regression test to match (and resend alongside the fix if that's useful). Best Regards, Pratik ________________________________________ From: Radoslav Kolev Sent: Thursday, October 8, 2026 1:13 PM To: Pratik Farkase Cc: busybox at busybox.net; Andy Jiang H; pratik.farkase at ericsson.com; hbarnes at herodevs.com Subject: Re: [PATCH] testsuite/wget: add regression test for CVE-2025-60876 Hello Pratik! I believe the change you are referring to, which rejects URLs with special characters, has not made it to upstream busybox. Neither has the more feature complete implementation for percent encoding special characters proposed by Hayden Barnes here: https://lists.busybox.net/pipermail/busybox/2026-June/092350.html Best regards, Rado On Thu, Oct 8, 2026 at 1:26?PM Pratik Farkase via busybox wrote: > > wget rejects URLs containing raw control characters or spaces (bytes > <= 0x20), which could otherwise be used to inject extra lines into the > request headers. Add a testsuite entry confirming such URLs are > refused. > > The check in parse_url() runs before any network access, so with the > fix in place no connection is attempted; the unroutable 0.0.0.0 host > keeps the test offline regardless. > > Signed-off-by: Pratik Farkase > --- > testsuite/wget.tests | 31 +++++++++++++++++++++++++++++++ > 1 file changed, 31 insertions(+) > create mode 100755 testsuite/wget.tests > > diff --git a/testsuite/wget.tests b/testsuite/wget.tests > new file mode 100755 > index 0000000..f5ae3d0 > --- /dev/null > +++ b/testsuite/wget.tests > @@ -0,0 +1,31 @@ > +#!/bin/sh > +# Copyright 2026 by Pratik Farkase > +# Licensed under GPLv2, see file LICENSE in this source tree. > + > +. ./testing.sh > + > +# testing "test name" "commands" "expected result" "file input" "stdin" > + > +# CVE-2025-60876: wget must reject URLs containing raw control characters > +# or spaces (bytes <= 0x20). Such bytes in the URL could otherwise be used > +# to inject additional lines into the request headers (HTTP header/request > +# smuggling). parse_url() rejects them before any network access, so with > +# the fix in place no connection is attempted; the unroutable 0.0.0.0 host > +# keeps the test offline regardless. > +optional WGET > +testing "wget rejects control character in URL" \ > + 'busybox wget "http://0.0.0.0/$(printf '\''\r'\'')Injected: 1" 2>&1; echo $?' \ > +"wget: Unencoded control character found in the URL! > +1 > +" \ > + "" "" > + > +testing "wget rejects space in URL" \ > + 'busybox wget "http://0.0.0.0/ evil" 2>&1; echo $?' \ > +"wget: Unencoded control character found in the URL! > +1 > +" \ > + "" "" > +SKIP= > + > +exit $FAILCOUNT > -- > 2.43.0 > > _______________________________________________ > busybox mailing list > busybox at busybox.net > https://lists.busybox.net/mailman/listinfo/busybox From vda.linux at googlemail.com Thu Oct 8 11:42:03 2026 From: vda.linux at googlemail.com (Denys Vlasenko) Date: Thu, 8 Oct 2026 13:42:03 +0200 Subject: [PATCH v2] ash: fix wait -n behavior for multiple PIDs and pipe jobs In-Reply-To: References: <20260329234811.2216049-2-luizluca@gmail.com> Message-ID: On Wed, Oct 7, 2026 at 12:49?AM Luiz Angelo Daros de Luca wrote: > > > I'll try to deobfuscate dash first. :/ > > > > > > Let's start with a smaller change. What *one* failure > > > you can fix with a minimal change of code, and with small > > > testcase? Or several small tesatcases. NOT one giant one. > > > > I "massaged" waiting code into better shape, and sent my changes to dash too. > > > > Then started fixing our "wait -n" code. > > I used your smaller testcases and I think I fixed all broken cases of "wait -n" > > without parameters. > > > > wait10.tests and wait12.tests still fail. > > > > I'll work on fixing "wait -n" with parameters. > > Thanks, Denys. The code was indeed... complex. At some point, I > stopped trying to fix specific cases and just rewrote most of it. > > This patch works (at least, it passes the tests). Hopefully, it serves > as a good reference to help you fix the remaining bugs in whatever way > makes the most sense for the BusyBox codebase. The tests should also > be helpful as they cover real, existing bugs. Feel free to merge or > alter them independently of the rest of the patch. > > I'm here if you need any help. Can you try current git and find still-failing examples? (I forgot to attribute your work in the commit messages. Hopefully you ll find a few broken cases and a few more commits will be necessary) From asmadeus at codewreck.org Thu Oct 8 13:22:08 2026 From: asmadeus at codewreck.org (Dominique Martinet) Date: Thu, 8 Oct 2026 22:22:08 +0900 Subject: [PATCH v2] ash: fix wait -n behavior for multiple PIDs and pipe jobs In-Reply-To: References: <20260329234811.2216049-2-luizluca@gmail.com> Message-ID: Denys Vlasenko via busybox wrote on Thu, Oct 08, 2026 at 01:42:03PM +0200: > (I forgot to attribute your work in the commit messages. > Hopefully you ll find a few broken cases and a few more commits will > be necessary) You're welcome, I'm glad you found time to rework this. If ENABLE_ASH_BASH_COMPAT is not set this fals to build: ----- shell/ash.c: In function ?waitcmd?: shell/ash.c:5030:30: error: implicit declaration of function ?getjob_match_last_pid? [-Wimplicit-function-declaration] 5030 | jp = getjob_match_last_pid(pid); | ^~~~~~~~~~~~~~~~~~~~~ shell/ash.c:5030:28: error: assignment to ?struct job *? from ?int? makes pointer from integer without a cast [-Wint-conversion] 5030 | jp = getjob_match_last_pid(pid); | ^ ----- That aside the tests I added pass so I'm happy. Cheers, -- Dominique From vda.linux at googlemail.com Fri Oct 9 11:58:37 2026 From: vda.linux at googlemail.com (Denys Vlasenko) Date: Fri, 9 Oct 2026 13:58:37 +0200 Subject: [PATCH v2] ash: fix wait -n behavior for multiple PIDs and pipe jobs In-Reply-To: References: <20260329234811.2216049-2-luizluca@gmail.com> Message-ID: Fixed. On Thu, Oct 8, 2026 at 3:22?PM Dominique Martinet wrote: > > Denys Vlasenko via busybox wrote on Thu, Oct 08, 2026 at 01:42:03PM +0200: > > (I forgot to attribute your work in the commit messages. > > Hopefully you ll find a few broken cases and a few more commits will > > be necessary) > > You're welcome, I'm glad you found time to rework this. > > If ENABLE_ASH_BASH_COMPAT is not set this fals to build: > ----- > shell/ash.c: In function ?waitcmd?: > shell/ash.c:5030:30: error: implicit declaration of function ?getjob_match_last_pid? [-Wimplicit-function-declaration] > 5030 | jp = getjob_match_last_pid(pid); > | ^~~~~~~~~~~~~~~~~~~~~ > shell/ash.c:5030:28: error: assignment to ?struct job *? from ?int? makes pointer from integer without a cast [-Wint-conversion] > 5030 | jp = getjob_match_last_pid(pid); > | ^ > ----- > > That aside the tests I added pass so I'm happy. > > Cheers, > -- > Dominique From tomalok at gmail.com Fri Oct 9 19:29:47 2026 From: tomalok at gmail.com (=?UTF-8?q?Jake=20Buchholz=20G=C3=B6kt=C3=BCrk?=) Date: Fri, 9 Oct 2026 12:29:47 -0700 Subject: [PATCH 0/5] udhcp: fix lease timers and DHCPv6 lease handling Message-ID: Hello, While adding DHCPv6 support to Alpine's shared default.script, we found several pre-existing udhcpc6 lease-management problems and corresponding timer problems in udhcpc. This series adds the script information we need and fixes those client issues. Patch 1 exports ip_family for every IPv4/IPv6 script invocation, including events without lease options, and the IPv6 preferred address lifetime. This lets a shared script identify the family and configure both IPv6 address lifetimes. Patches 2 and 3 fix DHCPv6 Rebind message construction and recovery from zero valid lifetimes. Patch 4 honors IA T1/T2 and retains leases until valid expiry. Patch 5 makes the corresponding timer and expiry fixes for DHCPv4, using its RFC 2131 retry rules. Both timer patches preserve the lease on manual renewal and check expiry after lease-script execution, before reporting -q success or backgrounding. Patch 4 is based on patches 2 and 3. Patches 1 and 5 are independent. IPv4 already constructs the correct broadcast request for rebinding; the malformed Rebind message issue is IPv6-only. The client fixes were built and tested natively on aarch64 using Alpine's configuration and isolated Linux network namespaces. Tests cover renewal, DHCPv6 server failover and revocation, supplied/default timers, outages through expiry, short and infinite leases, manual renewal, and slow-script recovery. The IPv4 suite has 13 patched cases plus a comparison baseline, including DHCPNAK recovery and large finite timers. Packet captures, lease-hook events, and address snapshots verify the results. Fresh native aarch64 measurements use GCC 15.2.0 and the same Alpine configuration for unpatched upstream master and all five patches in order. The complete series adds 1,968 bytes of code/read-only data. Data (13,585 bytes) and BSS (1,616 bytes) are unchanged. Incremental bloat-o-meter increases: Patch 1: +163 bytes Patch 2: +74 bytes Patch 3: +131 bytes Patch 4: +1,000 bytes Patch 5: +600 bytes Total: +1,968 bytes GNU size reports text growth from 886,302 to 888,270 bytes, agreeing with bloat-o-meter. Each patch includes its per-patch output. These measurements are specific to this architecture, compiler, and configuration. The DHCPv6 client retains its existing shared state for IA_NA and IA_PD. Independent per-IA expiry, multiple leases per IA, partial-Reply handling, and broader DHCPv6 retransmission/time-accounting changes are outside this series. Alpine's lease script and package changes are maintained separately. Based on upstream master at e3e1ad1587df43d9eaece0e29702e2089c5cbc10. The rebase changes no tested DHCP code. These patches were developed with assistance from OpenAI Codex. Validation included native aarch64 builds, packet captures, and controlled DHCPv4/DHCPv6 tests. Thanks, jake Jake Buchholz G?kt?rk (5): udhcp: export script family and IPv6 preferred lifetime udhcpc6: send proper Rebind messages udhcpc6: restart acquisition after a zero valid lifetime udhcpc6: honor IA timers and retain leases until valid udhcpc: honor T1/T2 and retain leases until expiry -- 2.55.0 From tomalok at gmail.com Fri Oct 9 19:29:48 2026 From: tomalok at gmail.com (=?UTF-8?q?Jake=20Buchholz=20G=C3=B6kt=C3=BCrk?=) Date: Fri, 9 Oct 2026 12:29:48 -0700 Subject: [PATCH 1/5] udhcp: export script family and IPv6 preferred lifetime In-Reply-To: References: Message-ID: Export ip_family for every script invocation, including calls without lease options. A shared IPv4/IPv6 script can then identify its caller even for deconfig, leasefail and stateless replies, regardless of script name. Also export the DHCPv6 preferred address lifetime alongside the existing valid lifetime in lease, so scripts can configure both kernel lifetimes. Native aarch64, GCC 15.2.0, Alpine configuration; relative to unpatched upstream master. Data and BSS are unchanged. bloat-o-meter: function old new delta option_to_env 1052 1124 +72 .rodata 69396 69447 +51 d6_run_script 248 272 +24 d4_run_script 908 924 +16 ------------------------------------------------------------------------------ (add/remove: 0/0 grow/shrink: 4/0 up/down: 163/0) Total: 163 bytes Signed-off-by: Jake Buchholz G?kt?rk --- networking/udhcp/d6_dhcpc.c | 5 +++++ networking/udhcp/dhcpc.c | 1 + 2 files changed, 6 insertions(+) diff --git a/networking/udhcp/d6_dhcpc.c b/networking/udhcp/d6_dhcpc.c index 7233c5fbe16c257391acbd123b67bfe9a1991232..42a3ea50d00e74e16729156403a06e91d478b839 100644 --- a/networking/udhcp/d6_dhcpc.c +++ b/networking/udhcp/d6_dhcpc.c @@ -312,6 +312,10 @@ static void option_to_env(const uint8_t *option, const uint8_t *option_end) sprint_nip6(ipv6str, option + 4); *new_env() = xasprintf("ipv6=%s", ipv6str); + move_from_unaligned32(v32, option + 4 + 16); + v32 = ntohl(v32); + *new_env() = xasprintf("ipv6_preferred_lifetime=%u", (unsigned)v32); + move_from_unaligned32(v32, option + 4 + 16 + 4); v32 = ntohl(v32); *new_env() = xasprintf("lease=%u", (unsigned)v32); @@ -453,6 +457,7 @@ static char **fill_envp(const uint8_t *option, const uint8_t *option_end) client6_data.env_idx = 0; *new_env() = xasprintf("interface=%s", client_data.interface); + *new_env() = xstrdup("ip_family=6"); if (option) option_to_env(option, option_end); diff --git a/networking/udhcp/dhcpc.c b/networking/udhcp/dhcpc.c index 936687e13b97bf575e02c6104cdc781ee563826d..3f91d18752c0475b5b6157629197d796bb8fca04 100644 --- a/networking/udhcp/dhcpc.c +++ b/networking/udhcp/dhcpc.c @@ -470,6 +470,7 @@ static void fill_envp(struct dhcp_packet *packet) char *new_opt; putenvp(xasprintf("interface=%s", client_data.interface)); + putenvp(xstrdup("ip_family=4")); if (!packet) return; -- 2.55.0 From tomalok at gmail.com Fri Oct 9 19:29:49 2026 From: tomalok at gmail.com (=?UTF-8?q?Jake=20Buchholz=20G=C3=B6kt=C3=BCrk?=) Date: Fri, 9 Oct 2026 12:29:49 -0700 Subject: [PATCH 2/5] udhcpc6: send proper Rebind messages In-Reply-To: References: Message-ID: <420ef4ebb6a96ae0e9d887608397ed5d90d7f3e7.1791479127.git.tomalok@gmail.com> The REBINDING state uses the Renew packet builder, so its multicast fallback sends another Renew containing the original server ID. It cannot ask a different server to extend the lease. Reuse the existing builder for both message types. Send D6_MSG_REBIND without a server ID in REBINDING, preserving the client options and IA_NA/IA_PD contents. Start a new transaction and reset elapsed time when entering that state; retransmissions retain the transaction ID. This follows RFC 9915 section 18.2.5 and resolves the TODO in the REBINDING state asking whether send_d6_renew() should use D6_MSG_REBIND instead of D6_MSG_RENEW. Keep the renewal schedule, retry limits, and multicast option behavior unchanged. Tested on aarch64 with Alpine's configuration: initial acquisition and Renew, server failover with IA_NA, IA_PD, and both, Rebind retransmissions, and stateless Information-request. The unpatched tree loses its lease in the same failover test. Packet captures verify the message type, options, transaction ID, and elapsed time. Native aarch64, GCC 15.2.0, Alpine configuration; relative to the preceding patch in this series. Data and BSS are unchanged. bloat-o-meter: function old new delta static.send_d6_renew 208 256 +48 udhcpc6_main 3000 3024 +24 .rodata 69447 69449 +2 ------------------------------------------------------------------------------ (add/remove: 0/0 grow/shrink: 3/0 up/down: 74/0) Total: 74 bytes Signed-off-by: Jake Buchholz G?kt?rk --- networking/udhcp/d6_dhcpc.c | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/networking/udhcp/d6_dhcpc.c b/networking/udhcp/d6_dhcpc.c index 42a3ea50d00e74e16729156403a06e91d478b839..6e432aca986baa52a22058c615269d6205c5e1f5 100644 --- a/networking/udhcp/d6_dhcpc.c +++ b/networking/udhcp/d6_dhcpc.c @@ -842,7 +842,7 @@ static NOINLINE int send_d6_select(void) return d6_mcast_from_client_data_ifindex(&packet, opt_ptr); } -/* Unicast or broadcast a DHCP renew message +/* Send a DHCPv6 Renew or Rebind message * * RFC 3315 18.1.3. Creation and Transmission of Renew Messages * @@ -889,16 +889,19 @@ static NOINLINE int send_d6_select(void) * about parameter values the client would like to have returned. */ /* NOINLINE: limit stack usage in caller */ -static NOINLINE int send_d6_renew(struct in6_addr *server_ipv6, struct in6_addr *our_cur_ipv6) +static NOINLINE int send_d6_renew(int msg_type, + struct in6_addr *server_ipv6, struct in6_addr *our_cur_ipv6) { struct d6_packet packet; uint8_t *opt_ptr; /* Fill in: msg type, xid, ELAPSED_TIME */ - opt_ptr = init_d6_packet(&packet, D6_MSG_RENEW); + opt_ptr = init_d6_packet(&packet, msg_type); - /* server id */ - opt_ptr = mempcpy(opt_ptr, client6_data.server_id, client6_data.server_id->len + 2+2); + /* Rebind must not contain a server ID */ + if (msg_type == D6_MSG_RENEW) { + opt_ptr = mempcpy(opt_ptr, client6_data.server_id, client6_data.server_id->len + 2+2); + } /* IA NA (contains requested IP) */ if (client6_data.ia_na) opt_ptr = mempcpy(opt_ptr, client6_data.ia_na, client6_data.ia_na->len + 2+2); @@ -911,7 +914,7 @@ static NOINLINE int send_d6_renew(struct in6_addr *server_ipv6, struct in6_addr */ opt_ptr = add_d6_client_options(opt_ptr); - bb_info_msg("sending %s", "renew"); + bb_info_msg("sending %s", msg_type == D6_MSG_RENEW ? "renew" : "rebind"); if (server_ipv6) return d6_send_kernel_packet_from_client_data_ifindex( &packet, (opt_ptr - (uint8_t*) &packet), @@ -1515,7 +1518,8 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) if (opt & OPT_l) send_d6_info_request(); else - send_d6_renew(OPT_m ? NULL : &srv6_buf, requested_ipv6); + send_d6_renew(D6_MSG_RENEW, + OPT_m ? NULL : &srv6_buf, requested_ipv6); timeout = discover_timeout; packet_num++; continue; @@ -1532,6 +1536,8 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) /* Switch to bcast receive */ change_listen_mode(LISTEN_RAW); packet_num = 0; + client_data.xid = random_xid(); + client_data.first_secs = 0; /* fall right through */ case REBINDING: /* Lease is *really* about to run out, @@ -1539,9 +1545,8 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) if (lease_remaining > 0 && packet_num < 3) { if (opt & OPT_l) send_d6_info_request(); - else /* send a broadcast renew request */ -//TODO: send_d6_renew uses D6_MSG_RENEW message, should we use D6_MSG_REBIND here instead? - send_d6_renew(/*server_ipv6:*/ NULL, requested_ipv6); + else /* send a multicast rebind request */ + send_d6_renew(D6_MSG_REBIND, /*server_ipv6:*/ NULL, requested_ipv6); timeout = discover_timeout; packet_num++; continue; -- 2.55.0 From tomalok at gmail.com Fri Oct 9 19:29:50 2026 From: tomalok at gmail.com (=?UTF-8?q?Jake=20Buchholz=20G=C3=B6kt=C3=BCrk?=) Date: Fri, 9 Oct 2026 12:29:50 -0700 Subject: [PATCH 3/5] udhcpc6: restart acquisition after a zero valid lifetime In-Reply-To: References: Message-ID: <854a8aed7233b03efb177840b53202b26692540d.1791479127.git.tomalok@gmail.com> A Reply with a zero valid lifetime revokes an address or delegated prefix. The current client instead applies its minimum lease time and keeps renewing the revoked lease. When both IA_NA and IA_PD are requested, the zero can also be replaced by the other IA's nonzero lifetime. Check the lifetimes of requested IAs before combining or clamping them. On zero, run deconfig and return to INIT_SELECTING. Clear the requested address hint, remaining lease, elapsed time and packet counter. Use the same three-second delay as the existing DHCPv6 NAK recovery path to avoid repeatedly soliciting an unusable lease. Deconfiguration happens immediately; the delay applies only to restarting acquisition. This is an existing implementation convention, not an RFC requirement. Do not report bound or renew, or honor quit-after-lease, for an unusable lease. Use option_mask32, which includes the default address request and the -r no/-l overrides. A zero preferred lifetime with a positive valid lifetime remains accepted. Stateful address and prefix acquisition still share one client state: revoking either restarts both. This addresses the existing zero-lease TODO. It does not implement independent IA renewal or processing of multiple leases per IA. Existing partial-Reply handling is unchanged: if a requested companion IA is missing, the Reply is ignored before this revocation check. Tested on aarch64: address and prefix revocation, combined leases with one or both lifetimes zero, replacement acquisition and renewal, zero first Reply, persistent zero Replies, quit-after-lease, deprecated addresses, and stateless Information-request. The unpatched baseline continues renewing revoked state. Native aarch64, GCC 15.2.0, Alpine configuration; relative to the preceding patch in this series. Data and BSS are unchanged. bloat-o-meter: function old new delta udhcpc6_main 3024 3120 +96 .rodata 69449 69484 +35 ------------------------------------------------------------------------------ (add/remove: 0/0 grow/shrink: 2/0 up/down: 131/0) Total: 131 bytes Signed-off-by: Jake Buchholz G?kt?rk --- networking/udhcp/d6_dhcpc.c | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/networking/udhcp/d6_dhcpc.c b/networking/udhcp/d6_dhcpc.c index 6e432aca986baa52a22058c615269d6205c5e1f5..3f5100ba589b5454f7e80453323a6f6e758b20ec 100644 --- a/networking/udhcp/d6_dhcpc.c +++ b/networking/udhcp/d6_dhcpc.c @@ -1841,7 +1841,6 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) requested_ipv6 = (struct in6_addr*) iaaddr->data; move_from_unaligned32(lease_seconds, iaaddr->data + 16 + 4); lease_seconds = ntohl(lease_seconds); -/// TODO: check for 0 lease time? bb_info_msg("%s obtained, lease time %u", "IPv6", /*inet_ntoa(temp_addr),*/ (unsigned)lease_seconds); address_timeout = lease_seconds; @@ -1894,6 +1893,20 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) "prefix", /*inet_ntoa(temp_addr),*/ (unsigned)lease_seconds); prefix_timeout = lease_seconds; } + /* A zero valid lifetime revokes the address or prefix. */ + if (((option_mask32 & OPT_r) && address_timeout == 0) + || ((option_mask32 & OPT_d) && prefix_timeout == 0) + ) { + bb_simple_info_msg("lease revoked, entering init state"); + d6_run_script_no_option("deconfig"); + client_data.state = INIT_SELECTING; + client_data.first_secs = 0; + requested_ipv6 = NULL; + lease_remaining = 0; + timeout = 3; /* avoid excessive network traffic */ + packet_num = 0; + continue; + } if (!address_timeout) address_timeout = prefix_timeout; if (!prefix_timeout) -- 2.55.0 From tomalok at gmail.com Fri Oct 9 19:29:51 2026 From: tomalok at gmail.com (=?UTF-8?q?Jake=20Buchholz=20G=C3=B6kt=C3=BCrk?=) Date: Fri, 9 Oct 2026 12:29:51 -0700 Subject: [PATCH 4/5] udhcpc6: honor IA timers and retain leases until valid expiry In-Reply-To: References: Message-ID: <645d70db5b289d8dda830136e675d43031a22a44.1791479127.git.tomalok@gmail.com> Renew currently starts at half the valid lifetime, ignoring the IA's T1/T2 fields. Failed renewals repeatedly halve the remaining lease, and REBINDING gives up after three packets even while the lease is still valid. SIGUSR1 also shortens the remaining lease to 30 seconds. Keep separate unsigned countdowns for renewal, rebinding, and valid expiry. Read T1/T2 from IA_NA and IA_PD, choosing the earliest deadline for the existing shared client state. Use non-immediate defaults when timers are zero and preserve DHCPv6 infinity. Reject nonzero T1 > T2 before replacing accepted lease data. Start Renew at T1 and Rebind at T2. Bound each retry wait by the next state deadline and retry until valid expiry rather than a packet limit. Do not inflate short valid lifetimes or shorten them on SIGUSR1. Account for script execution time in all three countdowns. If the script consumes the valid lifetime, deconfigure and restart discovery before honoring quit-after-lease or backgrounding. Clear the expired address hint before restarting discovery. New Renew exchanges get a fresh transaction ID; retransmissions keep it. This keeps the existing retransmission interval and one client state for both IA types. Independent per-IA expiry and multiple leases per IA remain outside this change. At the earliest valid expiry the shared state restarts discovery, deconfiguring its whole lease set. Tested on aarch64: supplied and zero timers, address/prefix/both, continued renewal, server failover, long outage, ten-second valid lease, infinite timers/lifetime, invalid timer ordering, manual renewal, slow script during continued operation (without -q), large timer arithmetic, stateless DNS, and zero-lifetime revocation regressions. With T1=8, T2=18, valid=32, Renew/Rebind start at 8/18 and deconfig occurs at 32; upstream renews at 16 and loses the lease before expiry. A separate post-script expiry regression uses a two-second valid lease and a three-second bound script with -q. The client deconfigures the expired lease and reacquires before reporting success. Native aarch64, GCC 15.2.0, Alpine configuration; relative to the preceding patch in this series. Data and BSS are unchanged. bloat-o-meter: function old new delta udhcpc6_main 3120 3792 +672 d6_update_timers - 292 +292 .rodata 69484 69520 +36 ------------------------------------------------------------------------------ (add/remove: 1/0 grow/shrink: 2/0 up/down: 1000/0) Total: 1000 bytes Signed-off-by: Jake Buchholz G?kt?rk --- networking/udhcp/d6_dhcpc.c | 178 +++++++++++++++++++++++++----------- 1 file changed, 124 insertions(+), 54 deletions(-) diff --git a/networking/udhcp/d6_dhcpc.c b/networking/udhcp/d6_dhcpc.c index 3f5100ba589b5454f7e80453323a6f6e758b20ec..4a83453560fe18a1638e79633cb1806294258ad4 100644 --- a/networking/udhcp/d6_dhcpc.c +++ b/networking/udhcp/d6_dhcpc.c @@ -1227,6 +1227,41 @@ static void client_background(void) //usage: "\n USR1 Renew lease" //usage: "\n USR2 Release lease" +/* UINT_MAX is DHCPv6 infinity, not a countdown. */ +static unsigned d6_time_left(unsigned time, unsigned elapsed) +{ + if (time == UINT_MAX) + return time; + return time > elapsed ? time - elapsed : 0; +} + +static int d6_poll_timeout(unsigned time) +{ + return MIN(time, INT_MAX / 1000); +} + +static void d6_update_timers(struct d6_option *ia, unsigned lifetime, + unsigned *renew, unsigned *rebind) +{ + uint32_t t1, t2; + unsigned zero_t1; + + move_from_unaligned32(t1, ia->data + 4); + move_from_unaligned32(t2, ia->data + 8); + t1 = ntohl(t1); + t2 = ntohl(t2); + zero_t1 = (t1 == 0); + /* Zero leaves the choice to the client. Do not renew immediately. */ + if (!t1) + t1 = lifetime == UINT_MAX ? UINT_MAX : MAX(1, lifetime / 2); + if (!t2) + t2 = lifetime == UINT_MAX ? UINT_MAX : MAX(t1, lifetime - lifetime / 5); + else if (zero_t1) + t1 = MIN(t1, t2); + *renew = MIN(*renew, t1); + *rebind = MIN(*rebind, t2); +} + int udhcpc6_main(int argc, char **argv) MAIN_EXTERNALLY_VISIBLE; int udhcpc6_main(int argc UNUSED_PARAM, char **argv) { @@ -1243,7 +1278,9 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) struct in6_addr *requested_ipv6; int packet_num; int timeout; /* must be signed */ - int lease_remaining; /* must be signed */ + unsigned lease_remaining; + unsigned renew_remaining = 0; + unsigned rebind_remaining = 0; unsigned opt; int retval; @@ -1402,9 +1439,9 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) diff = (unsigned)monotonic_sec(); retval = poll(pfds, 2, timeout * 1000); diff = (unsigned)monotonic_sec() - diff; - lease_remaining -= diff; - if (lease_remaining < 0) - lease_remaining = 0; + lease_remaining = d6_time_left(lease_remaining, diff); + renew_remaining = d6_time_left(renew_remaining, diff); + rebind_remaining = d6_time_left(rebind_remaining, diff); timeout -= diff; if (timeout < 0) timeout = 0; @@ -1496,65 +1533,59 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) client_data.state = INIT_SELECTING; goto leasefail; case BOUND: - /* 1/2 lease passed, enter renewing state */ + if (!lease_remaining) + goto lease_lost; + if (!rebind_remaining) + goto enter_rebind; + if (renew_remaining) { + timeout = d6_poll_timeout(MIN(renew_remaining, + MIN(rebind_remaining, lease_remaining))); + continue; + } client_data.state = RENEWING; - client_data.first_secs = 0; /* make secs field count from 0 */ + client_data.first_secs = 0; + client_data.xid = random_xid(); got_SIGUSR1: log1s("entering renew state"); change_listen_mode(LISTEN_KERNEL); /* fall right through */ - case RENEW_REQUESTED: /* in manual (SIGUSR1) renew */ + case RENEW_REQUESTED: case RENEWING: - if (packet_num == 0) { - /* Send an unicast renew request */ - /* Sometimes observed to fail (EADDRNOTAVAIL) to bind - * a new UDP socket for sending inside send_renew. - * I hazard to guess existing listening socket - * is somehow conflicting with it, but why is it - * not deterministic then?! Strange. - * Anyway, it does recover by eventually failing through - * into INIT_SELECTING state. - */ - if (opt & OPT_l) - send_d6_info_request(); - else - send_d6_renew(D6_MSG_RENEW, - OPT_m ? NULL : &srv6_buf, requested_ipv6); - timeout = discover_timeout; - packet_num++; - continue; - } /* else: we had sent one packet, but got no reply */ - log1s("no response to renew"); - if (lease_remaining > 30) { - /* Some lease time remains, try to renew later */ - change_listen_mode(LISTEN_NONE); - goto BOUND_for_half_lease; - } - /* Enter rebinding state */ + if (!lease_remaining) + goto lease_lost; + if (!rebind_remaining) + goto enter_rebind; + if (opt & OPT_l) + send_d6_info_request(); + else + send_d6_renew(D6_MSG_RENEW, + OPT_m ? NULL : &srv6_buf, requested_ipv6); + timeout = MIN(MAX(discover_timeout, 1), + MIN(rebind_remaining, lease_remaining)); + continue; + enter_rebind: client_data.state = REBINDING; log1s("entering rebinding state"); - /* Switch to bcast receive */ change_listen_mode(LISTEN_RAW); - packet_num = 0; client_data.xid = random_xid(); client_data.first_secs = 0; /* fall right through */ case REBINDING: - /* Lease is *really* about to run out, - * try to find DHCP server using broadcast */ - if (lease_remaining > 0 && packet_num < 3) { + /* Retry until valid expiry, not until a packet limit. */ + if (lease_remaining) { if (opt & OPT_l) send_d6_info_request(); - else /* send a multicast rebind request */ + else send_d6_renew(D6_MSG_REBIND, /*server_ipv6:*/ NULL, requested_ipv6); - timeout = discover_timeout; - packet_num++; + timeout = MIN(MAX(discover_timeout, 1), lease_remaining); continue; } + lease_lost: /* Timed out, enter init state */ change_listen_mode(LISTEN_NONE); bb_simple_info_msg("lease lost, entering init state"); d6_run_script_no_option("deconfig"); + requested_ipv6 = NULL; client_data.state = INIT_SELECTING; client_data.first_secs = 0; /* make secs field count from 0 */ timeout = 0; @@ -1581,8 +1612,6 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) /* Do not go back from rebind to renew state */ break; - if (lease_remaining > 30) /* if renew fails, do not go back to BOUND */ - lease_remaining = 30; client_data.first_secs = 0; /* make secs field count from 0 */ packet_num = 0; @@ -1592,6 +1621,7 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) /* Try to renew/rebind */ change_listen_mode(LISTEN_KERNEL); client_data.state = RENEW_REQUESTED; + client_data.xid = random_xid(); goto got_SIGUSR1; case RENEW_REQUESTED: @@ -1752,11 +1782,39 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) struct d6_option *option; unsigned address_timeout; unsigned prefix_timeout; + unsigned renew_timeout; + unsigned rebind_timeout; type_is_ok: + if (packet.d6_msg_type == D6_MSG_REPLY) { + unsigned i; + + for (i = 0; i < 2; i++) { + uint32_t t1, t2; + + if (!(option_mask32 & (i ? OPT_d : OPT_r))) + continue; + option = d6_find_option(packet.d6_options, packet_end, + i ? D6_OPT_IA_PD : D6_OPT_IA_NA); + if (!option || option->len < 12) + continue; + move_from_unaligned32(t1, option->data + 4); + move_from_unaligned32(t2, option->data + 8); + t1 = ntohl(t1); + t2 = ntohl(t2); + if (t1 && t2 && t1 > t2) + break; + } + if (i != 2) { + bb_simple_info_msg("T1 greater than T2, ignoring packet"); + continue; + } + } change_listen_mode(LISTEN_NONE); address_timeout = 0; prefix_timeout = 0; + renew_timeout = UINT_MAX; + rebind_timeout = UINT_MAX; option = d6_find_option(packet.d6_options, packet_end, D6_OPT_STATUS_CODE); if (option) { // +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ @@ -1844,6 +1902,11 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) bb_info_msg("%s obtained, lease time %u", "IPv6", /*inet_ntoa(temp_addr),*/ (unsigned)lease_seconds); address_timeout = lease_seconds; + move_from_unaligned32(lease_seconds, iaaddr->data + 16); + lease_seconds = ntohl(lease_seconds); + d6_update_timers(client6_data.ia_na, + lease_seconds ? lease_seconds : address_timeout, + &renew_timeout, &rebind_timeout); } if (option_mask32 & OPT_d) { struct d6_option *iaprefix; @@ -1892,6 +1955,11 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) bb_info_msg("%s obtained, lease time %u", "prefix", /*inet_ntoa(temp_addr),*/ (unsigned)lease_seconds); prefix_timeout = lease_seconds; + move_from_unaligned32(lease_seconds, iaprefix->data); + lease_seconds = ntohl(lease_seconds); + d6_update_timers(client6_data.ia_pd, + lease_seconds ? lease_seconds : prefix_timeout, + &renew_timeout, &rebind_timeout); } /* A zero valid lifetime revokes the address or prefix. */ if (((option_mask32 & OPT_r) && address_timeout == 0) @@ -1912,26 +1980,28 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) if (!prefix_timeout) prefix_timeout = address_timeout; lease_remaining = (prefix_timeout < address_timeout ? prefix_timeout : address_timeout); - if (lease_remaining < 0) /* signed overflow? */ - lease_remaining = INT_MAX; + renew_remaining = renew_timeout; + rebind_remaining = rebind_timeout; if (opt & OPT_l) { /* TODO: request OPTION_INFORMATION_REFRESH_TIME (32) * and use its value instead of the default 1 day. */ lease_remaining = 24 * 60 * 60; + renew_remaining = lease_remaining / 2; + rebind_remaining = lease_remaining; } - /* paranoia: must not be too small */ - if (lease_remaining < 30) - lease_remaining = 30; /* enter bound state */ start = monotonic_sec(); d6_run_script(packet.d6_options, packet_end, (client_data.state == REQUESTING ? "bound" : "renew")); - lease_remaining -= (unsigned)monotonic_sec() - start; - if (lease_remaining < 0) - lease_remaining = 0; - if (opt & OPT_q) { /* quit after lease */ + start = (unsigned)monotonic_sec() - start; + lease_remaining = d6_time_left(lease_remaining, start); + renew_remaining = d6_time_left(renew_remaining, start); + rebind_remaining = d6_time_left(rebind_remaining, start); + if (!lease_remaining) + goto lease_lost; + if (opt & OPT_q) { /* quit after lease */ goto ret0; } /* future renew failures should not exit (JM) */ @@ -1944,8 +2014,8 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) } #endif - BOUND_for_half_lease: - timeout = (unsigned)lease_remaining / 2; + timeout = d6_poll_timeout(MIN(renew_remaining, + MIN(rebind_remaining, lease_remaining))); client_data.state = BOUND; packet_num = 0; continue; /* back to main loop */ -- 2.55.0 From tomalok at gmail.com Fri Oct 9 19:29:52 2026 From: tomalok at gmail.com (=?UTF-8?q?Jake=20Buchholz=20G=C3=B6kt=C3=BCrk?=) Date: Fri, 9 Oct 2026 12:29:52 -0700 Subject: [PATCH 5/5] udhcpc: honor T1/T2 and retain leases until expiry In-Reply-To: References: Message-ID: <3abf044ad1af3f4298d929ad1108627b16c84ca5.1791479127.git.tomalok@gmail.com> The IPv4 client ignores renewal and rebinding options 58 and 59. It renews at half the lease, repeatedly halves the remaining wait, and enters REBINDING only when 30 seconds remain. Rebinding then gives up after three packets, even before expiry. SIGUSR1 also truncates the remaining lease to 30 seconds. Keep separate unsigned countdowns for T1, T2, and lease expiry. Read server-supplied timers, using the RFC 2131 defaults of half and seven eighths of the lease when absent or zero. Fall back to those defaults for invalid timer ordering. Preserve infinity and large finite leases without signed overflow; do not inflate short leases to 30 seconds. Renew by unicast at T1 and rebind by broadcast at T2. Follow RFC 2131 section 4.4.5 for retries: half the time to the next deadline, with a 60-second minimum bounded by that deadline. Retry until actual expiry rather than a packet limit, and do not shorten the lease on SIGUSR1. Clear the expired address hint before restarting discovery. Subtract lease-script execution time from all three countdowns and check expiry before honoring -q or backgrounding. A zero-length ACK cannot produce bound/renew or successful -q; deconfigure immediately and delay reacquisition by three seconds, matching NAK recovery. This is independent of the DHCPv6 timer and packet-construction fixes. It retains the existing transaction-ID and event-loop time accounting outside the timer changes. Tested on aarch64 with Alpine's configuration: supplied, absent, zero, and invalid timers; continued renewal; outage and expiry; short, infinite, and large finite leases; finite timers on an infinite lease; manual renewal; repeated zero-length ACKs; slow-script -q recovery; and DHCPNAK recovery. Thirteen patched cases and an unpatched IPv4 baseline pass. With T1=4, T2=9, lease=24, unicast/broadcast requests start at 4/9 seconds and deconfig occurs at 24. The baseline ignores T1 and abandons the lease early. Native aarch64, GCC 15.2.0, Alpine configuration; relative to the preceding patch in this series. Data and BSS are unchanged. bloat-o-meter: function old new delta udhcpc_main 2872 3332 +460 static.send_renew - 176 +176 d4_get_timer - 140 +140 static.raw_bcast_from_client_data_ifindex - 36 +36 static.bcast_or_ucast - 36 +36 raw_bcast_from_client_data_ifindex 36 - -36 bcast_or_ucast 36 - -36 send_renew 176 - -176 ------------------------------------------------------------------------------ (add/remove: 4/3 grow/shrink: 1/0 up/down: 848/-248) Total: 600 bytes Signed-off-by: Jake Buchholz G?kt?rk --- networking/udhcp/common.h | 4 +- networking/udhcp/dhcpc.c | 148 +++++++++++++++++++++++--------------- 2 files changed, 91 insertions(+), 61 deletions(-) diff --git a/networking/udhcp/common.h b/networking/udhcp/common.h index 3ef371a7c86f2a20b6d7cf6ca995fdf19fc0c199..cce44c06c2405101f81dfebf3e7da2c720119f66 100644 --- a/networking/udhcp/common.h +++ b/networking/udhcp/common.h @@ -168,8 +168,8 @@ struct dhcp_scan_state { #define DHCP_PARAM_REQ 0x37 /* 55: list of options client wants */ //#define DHCP_ERR_MESSAGE 0x38 /* 56: error message when sending NAK etc */ #define DHCP_MAX_SIZE 0x39 /* 57: 16bit big-endian */ -// 0x3a /* 58: from server: renew time, 32bit big-endian */ -// 0x3b /* 59: from server: rebind time, 32bit big-endian */ +#define DHCP_RENEWAL_TIME 0x3a /* 58: renew time, 32bit big-endian */ +#define DHCP_REBINDING_TIME 0x3b /* 59: rebind time, 32bit big-endian */ #define DHCP_VENDOR 0x3c /* 60: client's vendor (a string) */ #define DHCP_CLIENT_ID 0x3d /* 61: by default client's MAC addr, but may be arbitrarily long */ //#define DHCP_TFTP_SERVER_NAME 0x42 /* 66: same as 'sname' field */ diff --git a/networking/udhcp/dhcpc.c b/networking/udhcp/dhcpc.c index 3f91d18752c0475b5b6157629197d796bb8fca04..a226529bfeca738c345b667b393c7257d62b2233 100644 --- a/networking/udhcp/dhcpc.c +++ b/networking/udhcp/dhcpc.c @@ -1201,6 +1201,30 @@ static void client_background(void) //usage: "\n USR1 Renew lease" //usage: "\n USR2 Release lease" +/* UINT_MAX is DHCP infinity, not a countdown. */ +static unsigned d4_time_left(unsigned time, unsigned elapsed) +{ + if (time == UINT_MAX) + return time; + return time > elapsed ? time - elapsed : 0; +} + +static int d4_poll_timeout(unsigned time) +{ + return MIN(time, INT_MAX / 1000); +} + +static unsigned d4_get_timer(struct dhcp_packet *packet, unsigned code) +{ + uint8_t *option = udhcp_get_option32(packet, code); + uint32_t value; + + if (!option) + return 0; + move_from_unaligned32(value, option); + return ntohl(value); +} + int udhcpc_main(int argc, char **argv) MAIN_EXTERNALLY_VISIBLE; int udhcpc_main(int argc UNUSED_PARAM, char **argv) { @@ -1218,7 +1242,9 @@ int udhcpc_main(int argc UNUSED_PARAM, char **argv) uint32_t requested_ip = 0; int packet_num; int timeout; /* must be signed */ - int lease_remaining; /* must be signed */ + unsigned lease_remaining; + unsigned renew_remaining = 0; + unsigned rebind_remaining = 0; unsigned opt; IF_FEATURE_UDHCPC_ARPING(unsigned arpping_ms;) int retval; @@ -1400,9 +1426,9 @@ int udhcpc_main(int argc UNUSED_PARAM, char **argv) diff = (unsigned)monotonic_sec(); retval = poll(pfds, 2, timeout * 1000); diff = (unsigned)monotonic_sec() - diff; - lease_remaining -= diff; - if (lease_remaining < 0) - lease_remaining = 0; + lease_remaining = d4_time_left(lease_remaining, diff); + renew_remaining = d4_time_left(renew_remaining, diff); + rebind_remaining = d4_time_left(rebind_remaining, diff); timeout -= diff; if (timeout < 0) timeout = 0; @@ -1490,66 +1516,54 @@ int udhcpc_main(int argc UNUSED_PARAM, char **argv) client_data.state = INIT_SELECTING; goto leasefail; case BOUND: - /* 1/2 lease passed, enter renewing state */ + if (!lease_remaining) + goto lease_lost; + if (!rebind_remaining) + goto enter_rebind; + if (renew_remaining) { + timeout = d4_poll_timeout(MIN(renew_remaining, + MIN(rebind_remaining, lease_remaining))); + continue; + } client_data.state = RENEWING; - client_data.first_secs = 0; /* make secs field count from 0 */ + client_data.first_secs = 0; got_SIGUSR1: log1s("entering renew state"); change_listen_mode(LISTEN_KERNEL); /* fall right through */ case RENEW_REQUESTED: /* in manual (SIGUSR1) renew */ case RENEWING: - if (packet_num == 0) { - /* Send an unicast renew request */ - /* Sometimes observed to fail (EADDRNOTAVAIL) to bind - * a new UDP socket for sending inside send_renew. - * I hazard to guess existing listening socket - * is somehow conflicting with it, but why is it - * not deterministic then?! Strange. - * Anyway, it does recover by eventually failing through - * into INIT_SELECTING state. - */ - if (send_renew(server_id, requested_ip) >= 0) { - timeout = discover_timeout; - packet_num++; - continue; - } - /* else: error sending. - * example: ENETUNREACH seen with server - * which gave us bogus server ID 1.1.1.1 - * which wasn't reachable (and probably did not exist). - */ - } /* else: we had sent one packet, but got no reply */ - log1s("no response to renew"); - if (lease_remaining > 30) { - /* Some lease time remains, try to renew later */ - change_listen_mode(LISTEN_NONE); - goto BOUND_for_half_lease; - } - /* Enter rebinding state */ + if (!lease_remaining) + goto lease_lost; + if (!rebind_remaining) + goto enter_rebind; + send_renew(server_id, requested_ip); + /* RFC 2131 4.4.5: half the time to T2, at least 60s. */ + timeout = d4_poll_timeout(MIN(MAX(rebind_remaining / 2, 60), + MIN(rebind_remaining, lease_remaining))); + continue; + enter_rebind: client_data.state = REBINDING; log1s("entering rebinding state"); - /* Switch to bcast receive */ change_listen_mode(LISTEN_RAW); - packet_num = 0; /* fall right through */ case REBINDING: - /* Lease is *really* about to run out, - * try to find DHCP server using broadcast */ - if (lease_remaining > 0 && packet_num < 3) { - /* send a broadcast renew request */ + if (lease_remaining) { send_renew(0 /*INADDR_ANY*/, requested_ip); - timeout = discover_timeout; - packet_num++; + timeout = d4_poll_timeout(MIN(MAX(lease_remaining / 2, 60), + lease_remaining)); continue; } + lease_lost: + timeout = 0; + lease_lost_with_delay: /* Timed out, enter init state */ change_listen_mode(LISTEN_NONE); bb_simple_info_msg("lease lost, entering init state"); d4_run_script_deconfig(); + requested_ip = 0; client_data.state = INIT_SELECTING; client_data.first_secs = 0; /* make secs field count from 0 */ - timeout = 0; packet_num = 0; continue; /* case RELEASED: */ @@ -1573,8 +1587,6 @@ int udhcpc_main(int argc UNUSED_PARAM, char **argv) /* Do not go back from rebind to renew state */ break; - if (lease_remaining > 30) /* if renew fails, do not go back to BOUND */ - lease_remaining = 30; client_data.first_secs = 0; /* make secs field count from 0 */ packet_num = 0; @@ -1732,15 +1744,30 @@ int udhcpc_main(int argc UNUSED_PARAM, char **argv) inet_ntoa(temp_addr), server_str, (unsigned)lease_remaining, temp ? "" : " (default)" ); - /* paranoia: must not be too small and not prone to overflows */ - /* NB: 60s leases _are_ used in real world - * (temporary IPs while ISP modem initializes) - * do not break this case by bumping it up. - */ - if (lease_remaining < 0) /* signed overflow? */ - lease_remaining = INT_MAX; - if (lease_remaining < 30) - lease_remaining = 30; + /* RFC 2131 4.4.5: T1 < T2 < lease expiry. */ + renew_remaining = d4_get_timer(&packet, DHCP_RENEWAL_TIME); + rebind_remaining = d4_get_timer(&packet, DHCP_REBINDING_TIME); + if (!renew_remaining) + renew_remaining = lease_remaining == UINT_MAX + ? UINT_MAX : lease_remaining / 2; + if (!rebind_remaining) + rebind_remaining = lease_remaining == UINT_MAX + ? UINT_MAX : lease_remaining - lease_remaining / 8 + - (lease_remaining % 8 != 0); + if ((lease_remaining != UINT_MAX && rebind_remaining >= lease_remaining) + || (rebind_remaining != UINT_MAX && renew_remaining >= rebind_remaining) + ) { + /* Invalid supplied timers: use the standard defaults. */ + renew_remaining = lease_remaining == UINT_MAX + ? UINT_MAX : lease_remaining / 2; + rebind_remaining = lease_remaining == UINT_MAX + ? UINT_MAX : lease_remaining - lease_remaining / 8 + - (lease_remaining % 8 != 0); + } + if (!lease_remaining) { + timeout = 3; /* avoid excessive network traffic */ + goto lease_lost_with_delay; + } requested_ip = packet.yiaddr; #if ENABLE_FEATURE_UDHCPC_ARPING if (opt & OPT_a) { @@ -1779,9 +1806,12 @@ int udhcpc_main(int argc UNUSED_PARAM, char **argv) /* enter bound state */ start = monotonic_sec(); d4_run_script(&packet, client_data.state == REQUESTING ? "bound" : "renew"); - lease_remaining -= (unsigned)monotonic_sec() - start; - if (lease_remaining < 0) - lease_remaining = 0; + start = (unsigned)monotonic_sec() - start; + lease_remaining = d4_time_left(lease_remaining, start); + renew_remaining = d4_time_left(renew_remaining, start); + rebind_remaining = d4_time_left(rebind_remaining, start); + if (!lease_remaining) + goto lease_lost; if (opt & OPT_q) { /* quit after lease */ goto ret0; } @@ -1794,8 +1824,8 @@ int udhcpc_main(int argc UNUSED_PARAM, char **argv) opt = ((opt & ~OPT_b) | OPT_f); } #endif - BOUND_for_half_lease: - timeout = (unsigned)lease_remaining / 2; + timeout = d4_poll_timeout(MIN(renew_remaining, + MIN(rebind_remaining, lease_remaining))); client_data.state = BOUND; /* make future renew packets use different xid */ /* client_data.xid = random_xid(); ...but why bother? */ -- 2.55.0 From rmy at pobox.com Sat Oct 10 10:41:03 2026 From: rmy at pobox.com (Ron Yorston) Date: Sat, 10 Oct 2026 11:41:03 +0100 Subject: [PATCH] test: fix POSIX -nt and -ot when "older" is missing Message-ID: <6aca163f.9okXMabZsx4cMaN9%rmy@pobox.com> These should evaluate true if the newer file exists and the older is missing or older, but it was never true if older is missing. Note: 'test X -nt Y' is identical to 'test Y -ot X' https://pubs.opengroup.org/onlinepubs/9799919799/utilities/test.html function old new delta binop 593 634 +41 ------------------------------------------------------------------------------ (add/remove: 0/0 grow/shrink: 1/0 up/down: 41/0) Total: 41 bytes Signed-off-by: Avi Halachmi (:avih) Signed-off-by: Ron Yorston --- coreutils/test.c | 10 +++++----- testsuite/test.tests | 13 +++++++++++++ 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/coreutils/test.c b/coreutils/test.c index b63e33cc0..f2f0061fb 100644 --- a/coreutils/test.c +++ b/coreutils/test.c @@ -621,15 +621,15 @@ static int binop(void) /* if (is_file_op(op->op_num)) */ { struct stat b1, b2; + int sb1 = stat(opnd1, &b1); + int sb2 = stat(opnd2, &b2); - if (stat(opnd1, &b1) || stat(opnd2, &b2)) - return 0; /* false, since at least one stat failed */ if (op->op_num == FILNT) - return b1.st_mtime > b2.st_mtime; + return !sb1 && (sb2 || b1.st_mtime > b2.st_mtime); if (op->op_num == FILOT) - return b1.st_mtime < b2.st_mtime; + return !sb2 && (sb1 || b1.st_mtime < b2.st_mtime); /*if (op->op_num == FILEQ)*/ - return b1.st_dev == b2.st_dev && b1.st_ino == b2.st_ino; + return !sb1 && !sb2 && b1.st_dev == b2.st_dev && b1.st_ino == b2.st_ino; } /*return 1; - NOTREACHED */ } diff --git a/testsuite/test.tests b/testsuite/test.tests index 1c2edaf62..36bd37191 100755 --- a/testsuite/test.tests +++ b/testsuite/test.tests @@ -95,5 +95,18 @@ testing "test '!' '(' = '(': should be false (1)" \ "busybox test '!' '(' = '('; echo \$?" \ "1\n" \ "" "" +mkdir -p test.tempdir +touch test.tempdir/exists + +testing "test exists -nt nonexistent should be true (0)" \ + "busybox test test.tempdir/exists -nt test.tempdir/nonexistent; echo \$?" \ + "0\n" \ + "" "" + +testing "test nonexistent -ot exists should be true (0)" \ + "busybox test test.tempdir/nonexistent -ot test.tempdir/exists; echo \$?" \ + "0\n" \ + "" "" +rm -rf test.tempdir exit $FAILCOUNT -- 2.56.0 From rmy at pobox.com Sat Oct 10 10:41:27 2026 From: rmy at pobox.com (Ron Yorston) Date: Sat, 10 Oct 2026 11:41:27 +0100 Subject: [PATCH] ash: PSSYNTAX: preserve \\ in addition to \$ Message-ID: <6aca1657.wcCWob2ez65pdcKi%rmy@pobox.com> '\\t' in PS1 was incorrectly displayed as a timestamp instead of the intended '\t' because '\\' was not preserved. function old new delta readtoken1 3072 3079 +7 ------------------------------------------------------------------------------ (add/remove: 0/0 grow/shrink: 1/0 up/down: 7/0) Total: 7 bytes Signed-off-by: Avi Halachmi (:avih) Signed-off-by: Ron Yorston --- shell/ash.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/shell/ash.c b/shell/ash.c index 18b80fe4d..415765682 100644 --- a/shell/ash.c +++ b/shell/ash.c @@ -2873,7 +2873,8 @@ static const char *expandstr(const char *ps, int syntax_type); #if ENABLE_ASH_EXPAND_PRMT # define PSSYNTAX 4 /* prompt. never passed to SIT() */ #endif -/* PSSYNTAX expansion is identical to DQSYNTAX, except keeping '\$' as '\$' */ +/* PSSYNTAX expansion is identical to DQSYNTAX, except keeping '\$' as '\$' + * and '\\' as '\\' */ /* * called by editline -- any expansions to the prompt should be added here. @@ -13229,7 +13230,7 @@ readtoken1(int c, int syntax, struct heredoc *eofmark) USTPUTC('\\', out); pungetc(); } else { - if (pssyntax && c == '$') { + if (pssyntax && (c == '$' || c == '\\')) { USTPUTC(CTLESC, out); USTPUTC('\\', out); } -- 2.56.0 From vda.linux at googlemail.com Sat Oct 10 20:41:22 2026 From: vda.linux at googlemail.com (Denys Vlasenko) Date: Sat, 10 Oct 2026 22:41:22 +0200 Subject: [PATCH] test: fix POSIX -nt and -ot when "older" is missing In-Reply-To: <6aca163f.9okXMabZsx4cMaN9%rmy@pobox.com> References: <6aca163f.9okXMabZsx4cMaN9%rmy@pobox.com> Message-ID: Applied, thank you On Sat, Oct 10, 2026 at 12:46?PM Ron Yorston via busybox wrote: > > These should evaluate true if the newer file exists and the older is > missing or older, but it was never true if older is missing. > > Note: 'test X -nt Y' is identical to 'test Y -ot X' > > https://pubs.opengroup.org/onlinepubs/9799919799/utilities/test.html > > function old new delta > binop 593 634 +41 > ------------------------------------------------------------------------------ > (add/remove: 0/0 grow/shrink: 1/0 up/down: 41/0) Total: 41 bytes > > Signed-off-by: Avi Halachmi (:avih) > Signed-off-by: Ron Yorston > --- > coreutils/test.c | 10 +++++----- > testsuite/test.tests | 13 +++++++++++++ > 2 files changed, 18 insertions(+), 5 deletions(-) > > diff --git a/coreutils/test.c b/coreutils/test.c > index b63e33cc0..f2f0061fb 100644 > --- a/coreutils/test.c > +++ b/coreutils/test.c > @@ -621,15 +621,15 @@ static int binop(void) > /* if (is_file_op(op->op_num)) */ > { > struct stat b1, b2; > + int sb1 = stat(opnd1, &b1); > + int sb2 = stat(opnd2, &b2); > > - if (stat(opnd1, &b1) || stat(opnd2, &b2)) > - return 0; /* false, since at least one stat failed */ > if (op->op_num == FILNT) > - return b1.st_mtime > b2.st_mtime; > + return !sb1 && (sb2 || b1.st_mtime > b2.st_mtime); > if (op->op_num == FILOT) > - return b1.st_mtime < b2.st_mtime; > + return !sb2 && (sb1 || b1.st_mtime < b2.st_mtime); > /*if (op->op_num == FILEQ)*/ > - return b1.st_dev == b2.st_dev && b1.st_ino == b2.st_ino; > + return !sb1 && !sb2 && b1.st_dev == b2.st_dev && b1.st_ino == b2.st_ino; > } > /*return 1; - NOTREACHED */ > } > diff --git a/testsuite/test.tests b/testsuite/test.tests > index 1c2edaf62..36bd37191 100755 > --- a/testsuite/test.tests > +++ b/testsuite/test.tests > @@ -95,5 +95,18 @@ testing "test '!' '(' = '(': should be false (1)" \ > "busybox test '!' '(' = '('; echo \$?" \ > "1\n" \ > "" "" > +mkdir -p test.tempdir > +touch test.tempdir/exists > + > +testing "test exists -nt nonexistent should be true (0)" \ > + "busybox test test.tempdir/exists -nt test.tempdir/nonexistent; echo \$?" \ > + "0\n" \ > + "" "" > + > +testing "test nonexistent -ot exists should be true (0)" \ > + "busybox test test.tempdir/nonexistent -ot test.tempdir/exists; echo \$?" \ > + "0\n" \ > + "" "" > +rm -rf test.tempdir > > exit $FAILCOUNT > -- > 2.56.0 > > _______________________________________________ > busybox mailing list > busybox at busybox.net > https://lists.busybox.net/mailman/listinfo/busybox From vda.linux at googlemail.com Sat Oct 10 20:41:40 2026 From: vda.linux at googlemail.com (Denys Vlasenko) Date: Sat, 10 Oct 2026 22:41:40 +0200 Subject: [PATCH] ash: PSSYNTAX: preserve \\ in addition to \$ In-Reply-To: <6aca1657.wcCWob2ez65pdcKi%rmy@pobox.com> References: <6aca1657.wcCWob2ez65pdcKi%rmy@pobox.com> Message-ID: Applied, thank you On Sat, Oct 10, 2026 at 12:51?PM Ron Yorston via busybox wrote: > > '\\t' in PS1 was incorrectly displayed as a timestamp instead of > the intended '\t' because '\\' was not preserved. > > function old new delta > readtoken1 3072 3079 +7 > ------------------------------------------------------------------------------ > (add/remove: 0/0 grow/shrink: 1/0 up/down: 7/0) Total: 7 bytes > > Signed-off-by: Avi Halachmi (:avih) > Signed-off-by: Ron Yorston > --- > shell/ash.c | 5 +++-- > 1 file changed, 3 insertions(+), 2 deletions(-) > > diff --git a/shell/ash.c b/shell/ash.c > index 18b80fe4d..415765682 100644 > --- a/shell/ash.c > +++ b/shell/ash.c > @@ -2873,7 +2873,8 @@ static const char *expandstr(const char *ps, int syntax_type); > #if ENABLE_ASH_EXPAND_PRMT > # define PSSYNTAX 4 /* prompt. never passed to SIT() */ > #endif > -/* PSSYNTAX expansion is identical to DQSYNTAX, except keeping '\$' as '\$' */ > +/* PSSYNTAX expansion is identical to DQSYNTAX, except keeping '\$' as '\$' > + * and '\\' as '\\' */ > > /* > * called by editline -- any expansions to the prompt should be added here. > @@ -13229,7 +13230,7 @@ readtoken1(int c, int syntax, struct heredoc *eofmark) > USTPUTC('\\', out); > pungetc(); > } else { > - if (pssyntax && c == '$') { > + if (pssyntax && (c == '$' || c == '\\')) { > USTPUTC(CTLESC, out); > USTPUTC('\\', out); > } > -- > 2.56.0 > > _______________________________________________ > busybox mailing list > busybox at busybox.net > https://lists.busybox.net/mailman/listinfo/busybox From vda.linux at googlemail.com Sun Oct 11 00:40:07 2026 From: vda.linux at googlemail.com (Denys Vlasenko) Date: Sun, 11 Oct 2026 02:40:07 +0200 Subject: [PATCH 1/5] udhcp: export script family and IPv6 preferred lifetime In-Reply-To: References: Message-ID: On Fri, Oct 9, 2026 at 9:30?PM Jake Buchholz G?kt?rk wrote: > Export ip_family for every script invocation, including calls without > lease options. A shared IPv4/IPv6 script can then identify its caller even > for deconfig, leasefail and stateless replies, regardless of script name. Just use two scripts. If you want to use a common script, you can call if from tool-specific ones after you add whatever envvars / parameters you desire. > Also export the DHCPv6 preferred address lifetime alongside the existing > valid lifetime in lease, so scripts can configure both kernel lifetimes. This makes sense, applying. I think a shorter name $preferred_lifetime is better - other variables do not have "ipv6" prefix, apart from $ipv6prefix_lease - that one means "lease which is specified in IAPREFIX option 26 (not in IAADDR option 5)". Do you think the same is needed in D6_OPT_IAPREFIX too? From vda.linux at googlemail.com Sun Oct 11 00:48:53 2026 From: vda.linux at googlemail.com (Denys Vlasenko) Date: Sun, 11 Oct 2026 02:48:53 +0200 Subject: [PATCH 2/5] udhcpc6: send proper Rebind messages In-Reply-To: <420ef4ebb6a96ae0e9d887608397ed5d90d7f3e7.1791479127.git.tomalok@gmail.com> References: <420ef4ebb6a96ae0e9d887608397ed5d90d7f3e7.1791479127.git.tomalok@gmail.com> Message-ID: Applied, thank you. On Fri, Oct 9, 2026 at 9:30?PM Jake Buchholz G?kt?rk wrote: > > The REBINDING state uses the Renew packet builder, so its multicast > fallback sends another Renew containing the original server ID. It > cannot ask a different server to extend the lease. > > Reuse the existing builder for both message types. Send D6_MSG_REBIND > without a server ID in REBINDING, preserving the client options and > IA_NA/IA_PD contents. Start a new transaction and reset elapsed time > when entering that state; retransmissions retain the transaction ID. > This follows RFC 9915 section 18.2.5 and resolves the TODO in the > REBINDING state asking whether send_d6_renew() should use D6_MSG_REBIND > instead of D6_MSG_RENEW. > > Keep the renewal schedule, retry limits, and multicast option behavior > unchanged. > > Tested on aarch64 with Alpine's configuration: initial acquisition and > Renew, server failover with IA_NA, IA_PD, and both, Rebind retransmissions, > and stateless Information-request. The unpatched tree loses its lease > in the same failover test. Packet captures verify the message type, > options, transaction ID, and elapsed time. > > Native aarch64, GCC 15.2.0, Alpine configuration; relative to > the preceding patch in this series. Data and BSS are unchanged. > > bloat-o-meter: > function old new delta > static.send_d6_renew 208 256 +48 > udhcpc6_main 3000 3024 +24 > .rodata 69447 69449 +2 > ------------------------------------------------------------------------------ > (add/remove: 0/0 grow/shrink: 3/0 up/down: 74/0) Total: 74 bytes > > Signed-off-by: Jake Buchholz G?kt?rk > --- > networking/udhcp/d6_dhcpc.c | 25 +++++++++++++++---------- > 1 file changed, 15 insertions(+), 10 deletions(-) > > diff --git a/networking/udhcp/d6_dhcpc.c b/networking/udhcp/d6_dhcpc.c > index 42a3ea50d00e74e16729156403a06e91d478b839..6e432aca986baa52a22058c615269d6205c5e1f5 100644 > --- a/networking/udhcp/d6_dhcpc.c > +++ b/networking/udhcp/d6_dhcpc.c > @@ -842,7 +842,7 @@ static NOINLINE int send_d6_select(void) > return d6_mcast_from_client_data_ifindex(&packet, opt_ptr); > } > > -/* Unicast or broadcast a DHCP renew message > +/* Send a DHCPv6 Renew or Rebind message > * > * RFC 3315 18.1.3. Creation and Transmission of Renew Messages > * > @@ -889,16 +889,19 @@ static NOINLINE int send_d6_select(void) > * about parameter values the client would like to have returned. > */ > /* NOINLINE: limit stack usage in caller */ > -static NOINLINE int send_d6_renew(struct in6_addr *server_ipv6, struct in6_addr *our_cur_ipv6) > +static NOINLINE int send_d6_renew(int msg_type, > + struct in6_addr *server_ipv6, struct in6_addr *our_cur_ipv6) > { > struct d6_packet packet; > uint8_t *opt_ptr; > > /* Fill in: msg type, xid, ELAPSED_TIME */ > - opt_ptr = init_d6_packet(&packet, D6_MSG_RENEW); > + opt_ptr = init_d6_packet(&packet, msg_type); > > - /* server id */ > - opt_ptr = mempcpy(opt_ptr, client6_data.server_id, client6_data.server_id->len + 2+2); > + /* Rebind must not contain a server ID */ > + if (msg_type == D6_MSG_RENEW) { > + opt_ptr = mempcpy(opt_ptr, client6_data.server_id, client6_data.server_id->len + 2+2); > + } > /* IA NA (contains requested IP) */ > if (client6_data.ia_na) > opt_ptr = mempcpy(opt_ptr, client6_data.ia_na, client6_data.ia_na->len + 2+2); > @@ -911,7 +914,7 @@ static NOINLINE int send_d6_renew(struct in6_addr *server_ipv6, struct in6_addr > */ > opt_ptr = add_d6_client_options(opt_ptr); > > - bb_info_msg("sending %s", "renew"); > + bb_info_msg("sending %s", msg_type == D6_MSG_RENEW ? "renew" : "rebind"); > if (server_ipv6) > return d6_send_kernel_packet_from_client_data_ifindex( > &packet, (opt_ptr - (uint8_t*) &packet), > @@ -1515,7 +1518,8 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) > if (opt & OPT_l) > send_d6_info_request(); > else > - send_d6_renew(OPT_m ? NULL : &srv6_buf, requested_ipv6); > + send_d6_renew(D6_MSG_RENEW, > + OPT_m ? NULL : &srv6_buf, requested_ipv6); > timeout = discover_timeout; > packet_num++; > continue; > @@ -1532,6 +1536,8 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) > /* Switch to bcast receive */ > change_listen_mode(LISTEN_RAW); > packet_num = 0; > + client_data.xid = random_xid(); > + client_data.first_secs = 0; > /* fall right through */ > case REBINDING: > /* Lease is *really* about to run out, > @@ -1539,9 +1545,8 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) > if (lease_remaining > 0 && packet_num < 3) { > if (opt & OPT_l) > send_d6_info_request(); > - else /* send a broadcast renew request */ > -//TODO: send_d6_renew uses D6_MSG_RENEW message, should we use D6_MSG_REBIND here instead? > - send_d6_renew(/*server_ipv6:*/ NULL, requested_ipv6); > + else /* send a multicast rebind request */ > + send_d6_renew(D6_MSG_REBIND, /*server_ipv6:*/ NULL, requested_ipv6); > timeout = discover_timeout; > packet_num++; > continue; > -- > 2.55.0 > From vda.linux at googlemail.com Sun Oct 11 01:09:13 2026 From: vda.linux at googlemail.com (Denys Vlasenko) Date: Sun, 11 Oct 2026 03:09:13 +0200 Subject: [PATCH 3/5] udhcpc6: restart acquisition after a zero valid lifetime In-Reply-To: <854a8aed7233b03efb177840b53202b26692540d.1791479127.git.tomalok@gmail.com> References: <854a8aed7233b03efb177840b53202b26692540d.1791479127.git.tomalok@gmail.com> Message-ID: Applied, thank you On Fri, Oct 9, 2026 at 9:30?PM Jake Buchholz G?kt?rk wrote: > > A Reply with a zero valid lifetime revokes an address or delegated > prefix. The current client instead applies its minimum lease time and > keeps renewing the revoked lease. When both IA_NA and IA_PD are requested, > the zero can also be replaced by the other IA's nonzero lifetime. > > Check the lifetimes of requested IAs before combining or clamping them. > On zero, run deconfig and return to INIT_SELECTING. Clear the requested > address hint, remaining lease, elapsed time and packet counter. Use the > same three-second delay as the existing DHCPv6 NAK recovery path to avoid > repeatedly soliciting an unusable lease. Deconfiguration happens > immediately; the delay applies only to restarting acquisition. This is > an existing implementation convention, not an RFC requirement. Do not > report bound or renew, or honor quit-after-lease, for an unusable lease. > > Use option_mask32, which includes the default address request and the > -r no/-l overrides. A zero preferred lifetime with a positive valid > lifetime remains accepted. Stateful address and prefix acquisition > still share one client state: revoking either restarts both. > > This addresses the existing zero-lease TODO. It does not implement > independent IA renewal or processing of multiple leases per IA. > Existing partial-Reply handling is unchanged: if a requested companion > IA is missing, the Reply is ignored before this revocation check. > > Tested on aarch64: address and prefix revocation, combined leases with > one or both lifetimes zero, replacement acquisition and renewal, > zero first Reply, persistent zero Replies, quit-after-lease, deprecated > addresses, and stateless Information-request. The unpatched baseline > continues renewing revoked state. > > Native aarch64, GCC 15.2.0, Alpine configuration; relative to > the preceding patch in this series. Data and BSS are unchanged. > > bloat-o-meter: > function old new delta > udhcpc6_main 3024 3120 +96 > .rodata 69449 69484 +35 > ------------------------------------------------------------------------------ > (add/remove: 0/0 grow/shrink: 2/0 up/down: 131/0) Total: 131 bytes > > Signed-off-by: Jake Buchholz G?kt?rk > --- > networking/udhcp/d6_dhcpc.c | 15 ++++++++++++++- > 1 file changed, 14 insertions(+), 1 deletion(-) > > diff --git a/networking/udhcp/d6_dhcpc.c b/networking/udhcp/d6_dhcpc.c > index 6e432aca986baa52a22058c615269d6205c5e1f5..3f5100ba589b5454f7e80453323a6f6e758b20ec 100644 > --- a/networking/udhcp/d6_dhcpc.c > +++ b/networking/udhcp/d6_dhcpc.c > @@ -1841,7 +1841,6 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) > requested_ipv6 = (struct in6_addr*) iaaddr->data; > move_from_unaligned32(lease_seconds, iaaddr->data + 16 + 4); > lease_seconds = ntohl(lease_seconds); > -/// TODO: check for 0 lease time? > bb_info_msg("%s obtained, lease time %u", > "IPv6", /*inet_ntoa(temp_addr),*/ (unsigned)lease_seconds); > address_timeout = lease_seconds; > @@ -1894,6 +1893,20 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) > "prefix", /*inet_ntoa(temp_addr),*/ (unsigned)lease_seconds); > prefix_timeout = lease_seconds; > } > + /* A zero valid lifetime revokes the address or prefix. */ > + if (((option_mask32 & OPT_r) && address_timeout == 0) > + || ((option_mask32 & OPT_d) && prefix_timeout == 0) > + ) { > + bb_simple_info_msg("lease revoked, entering init state"); > + d6_run_script_no_option("deconfig"); > + client_data.state = INIT_SELECTING; > + client_data.first_secs = 0; > + requested_ipv6 = NULL; > + lease_remaining = 0; > + timeout = 3; /* avoid excessive network traffic */ > + packet_num = 0; > + continue; > + } > if (!address_timeout) > address_timeout = prefix_timeout; > if (!prefix_timeout) > -- > 2.55.0 > From vda.linux at googlemail.com Sun Oct 11 01:18:13 2026 From: vda.linux at googlemail.com (Denys Vlasenko) Date: Sun, 11 Oct 2026 03:18:13 +0200 Subject: [PATCH 4/5] udhcpc6: honor IA timers and retain leases until valid expiry In-Reply-To: <645d70db5b289d8dda830136e675d43031a22a44.1791479127.git.tomalok@gmail.com> References: <645d70db5b289d8dda830136e675d43031a22a44.1791479127.git.tomalok@gmail.com> Message-ID: On Fri, Oct 9, 2026 at 9:30?PM Jake Buchholz G?kt?rk wrote: > Renew currently starts at half the valid lifetime, ignoring the IA's > T1/T2 fields. Failed renewals repeatedly halve the remaining lease, and > REBINDING gives up after three packets even while the lease is still > valid. SIGUSR1 also shortens the remaining lease to 30 seconds. What specifically does not work? Why does your server does not renew the lease? > Keep separate unsigned countdowns for renewal, rebinding, and valid > expiry. Read T1/T2 from IA_NA and IA_PD, choosing the earliest deadline > for the existing shared client state. Use non-immediate defaults when > timers are zero and preserve DHCPv6 infinity. Reject nonzero T1 > T2 > before replacing accepted lease data. > > Start Renew at T1 and Rebind at T2. Bound each retry wait by the next > state deadline and retry until valid expiry rather than a packet limit. > Do not inflate short valid lifetimes or shorten them on SIGUSR1. Account > for script execution time in all three countdowns. If the script > consumes the valid lifetime, deconfigure and restart discovery before > honoring quit-after-lease or backgrounding. The script is not expected to run for seconds. It should just record its data, then kick whatever network/firewall maintenance machinery you have to do its thing. > Clear the expired address > hint before restarting discovery. New Renew exchanges get a fresh > transaction ID; retransmissions keep it. > > This keeps the existing retransmission interval and one client state for > both IA types. Independent per-IA expiry and multiple leases per IA > remain outside this change. At the earliest valid expiry the shared > state restarts discovery, deconfiguring its whole lease set. > > Tested on aarch64: supplied and zero timers, address/prefix/both, > continued renewal, server failover, long outage, ten-second valid lease, > infinite timers/lifetime, invalid timer ordering, manual renewal, slow > script during continued operation (without -q), large timer arithmetic, > stateless DNS, and zero-lifetime revocation regressions. With T1=8, > T2=18, valid=32, Renew/Rebind start at 8/18 and deconfig occurs at 32; > upstream renews at 16 and loses the lease before expiry. You have a server which refuses to renew a lease before it expires? > A separate > post-script expiry regression uses a two-second valid lease and a > three-second bound script with -q. The client deconfigures the expired > lease and reacquires before reporting success. > > Native aarch64, GCC 15.2.0, Alpine configuration; relative to > the preceding patch in this series. Data and BSS are unchanged. > > bloat-o-meter: > function old new delta > udhcpc6_main 3120 3792 +672 > d6_update_timers - 292 +292 > .rodata 69484 69520 +36 > ------------------------------------------------------------------------------ > (add/remove: 1/0 grow/shrink: 2/0 up/down: 1000/0) Total: 1000 bytes This is too costly to accept without a more detailed explanation: what scenario you are fixing? It seems most servers are rather permissive and happily renew a lease when requested ~halftime into the lease, regardless of those T1/T2 times. From tomalok at gmail.com Mon Oct 12 16:50:19 2026 From: tomalok at gmail.com (jake buchholz gokturk) Date: Mon, 12 Oct 2026 09:50:19 -0700 Subject: [PATCH 4/5] udhcpc6: honor IA timers and retain leases until valid expiry In-Reply-To: References: <645d70db5b289d8dda830136e675d43031a22a44.1791479127.git.tomalok@gmail.com> Message-ID: Hi Denys, Thanks for the feedback. We're testing with controlled Python DHCPv4 and DHCPv6 responders in isolated Linux network namespaces on an Alpine aarch64 host, using packet captures and lease-script logs to verify timing and behavior. The unanswered-renewal cases deliberately stop replying after acquisition, rather than reproducing a production server refusing renewal because of T1/T2. The main problem is that both clients can drop a still-valid lease before expiry. I've separated that fix from T1/T2 support. The expiry-only patches keep the existing renewal schedule and add 180 bytes combined. I don't consider T1/T2 support critical for moving this forward, though RFC 9915 does require clients to use the server's nonzero T1/T2 values. I've kept that as a separate follow-on and reduced its size. Including the expiry fixes, the total is now 1,168 bytes rather than 1,600 on the same build. Agreed that scripts should return promptly; the slow-script cases were defensive tests, not the main justification. I'll send the smaller patches separately with the measurements and test details. Thanks, jake > On Oct 10, 2026, at 18:18, Denys Vlasenko wrote: > > On Fri, Oct 9, 2026 at 9:30?PM Jake Buchholz G?kt?rk wrote: >> Renew currently starts at half the valid lifetime, ignoring the IA's >> T1/T2 fields. Failed renewals repeatedly halve the remaining lease, and >> REBINDING gives up after three packets even while the lease is still >> valid. SIGUSR1 also shortens the remaining lease to 30 seconds. > > What specifically does not work? Why does your server does not renew > the lease? > >> Keep separate unsigned countdowns for renewal, rebinding, and valid >> expiry. Read T1/T2 from IA_NA and IA_PD, choosing the earliest deadline >> for the existing shared client state. Use non-immediate defaults when >> timers are zero and preserve DHCPv6 infinity. Reject nonzero T1 > T2 >> before replacing accepted lease data. >> >> Start Renew at T1 and Rebind at T2. Bound each retry wait by the next >> state deadline and retry until valid expiry rather than a packet limit. >> Do not inflate short valid lifetimes or shorten them on SIGUSR1. Account >> for script execution time in all three countdowns. If the script >> consumes the valid lifetime, deconfigure and restart discovery before >> honoring quit-after-lease or backgrounding. > > The script is not expected to run for seconds. It should just record its data, > then kick whatever network/firewall maintenance machinery you have > to do its thing. > >> Clear the expired address >> hint before restarting discovery. New Renew exchanges get a fresh >> transaction ID; retransmissions keep it. >> >> This keeps the existing retransmission interval and one client state for >> both IA types. Independent per-IA expiry and multiple leases per IA >> remain outside this change. At the earliest valid expiry the shared >> state restarts discovery, deconfiguring its whole lease set. >> >> Tested on aarch64: supplied and zero timers, address/prefix/both, >> continued renewal, server failover, long outage, ten-second valid lease, >> infinite timers/lifetime, invalid timer ordering, manual renewal, slow >> script during continued operation (without -q), large timer arithmetic, >> stateless DNS, and zero-lifetime revocation regressions. With T1=8, >> T2=18, valid=32, Renew/Rebind start at 8/18 and deconfig occurs at 32; >> upstream renews at 16 and loses the lease before expiry. > > You have a server which refuses to renew a lease before it expires? > >> A separate >> post-script expiry regression uses a two-second valid lease and a >> three-second bound script with -q. The client deconfigures the expired >> lease and reacquires before reporting success. >> >> Native aarch64, GCC 15.2.0, Alpine configuration; relative to >> the preceding patch in this series. Data and BSS are unchanged. >> >> bloat-o-meter: >> function old new delta >> udhcpc6_main 3120 3792 +672 >> d6_update_timers - 292 +292 >> .rodata 69484 69520 +36 >> ------------------------------------------------------------------------------ >> (add/remove: 1/0 grow/shrink: 2/0 up/down: 1000/0) Total: 1000 bytes > > This is too costly to accept without a more detailed explanation: > what scenario you are fixing? > It seems most servers are rather permissive and happily renew > a lease when requested ~halftime into the lease, regardless > of those T1/T2 times. From tomalok at gmail.com Mon Oct 12 16:50:19 2026 From: tomalok at gmail.com (jake buchholz gokturk) Date: Mon, 12 Oct 2026 09:50:19 -0700 Subject: [PATCH 4/5] udhcpc6: honor IA timers and retain leases until valid expiry In-Reply-To: References: <645d70db5b289d8dda830136e675d43031a22a44.1791479127.git.tomalok@gmail.com> Message-ID: Hi Denys, Thanks for the feedback. We're testing with controlled Python DHCPv4 and DHCPv6 responders in isolated Linux network namespaces on an Alpine aarch64 host, using packet captures and lease-script logs to verify timing and behavior. The unanswered-renewal cases deliberately stop replying after acquisition, rather than reproducing a production server refusing renewal because of T1/T2. The main problem is that both clients can drop a still-valid lease before expiry. I've separated that fix from T1/T2 support. The expiry-only patches keep the existing renewal schedule and add 180 bytes combined. I don't consider T1/T2 support critical for moving this forward, though RFC 9915 does require clients to use the server's nonzero T1/T2 values. I've kept that as a separate follow-on and reduced its size. Including the expiry fixes, the total is now 1,168 bytes rather than 1,600 on the same build. Agreed that scripts should return promptly; the slow-script cases were defensive tests, not the main justification. I'll send the smaller patches separately with the measurements and test details. Thanks, jake > On Oct 10, 2026, at 18:18, Denys Vlasenko wrote: > > On Fri, Oct 9, 2026 at 9:30?PM Jake Buchholz G?kt?rk wrote: >> Renew currently starts at half the valid lifetime, ignoring the IA's >> T1/T2 fields. Failed renewals repeatedly halve the remaining lease, and >> REBINDING gives up after three packets even while the lease is still >> valid. SIGUSR1 also shortens the remaining lease to 30 seconds. > > What specifically does not work? Why does your server does not renew > the lease? > >> Keep separate unsigned countdowns for renewal, rebinding, and valid >> expiry. Read T1/T2 from IA_NA and IA_PD, choosing the earliest deadline >> for the existing shared client state. Use non-immediate defaults when >> timers are zero and preserve DHCPv6 infinity. Reject nonzero T1 > T2 >> before replacing accepted lease data. >> >> Start Renew at T1 and Rebind at T2. Bound each retry wait by the next >> state deadline and retry until valid expiry rather than a packet limit. >> Do not inflate short valid lifetimes or shorten them on SIGUSR1. Account >> for script execution time in all three countdowns. If the script >> consumes the valid lifetime, deconfigure and restart discovery before >> honoring quit-after-lease or backgrounding. > > The script is not expected to run for seconds. It should just record its data, > then kick whatever network/firewall maintenance machinery you have > to do its thing. > >> Clear the expired address >> hint before restarting discovery. New Renew exchanges get a fresh >> transaction ID; retransmissions keep it. >> >> This keeps the existing retransmission interval and one client state for >> both IA types. Independent per-IA expiry and multiple leases per IA >> remain outside this change. At the earliest valid expiry the shared >> state restarts discovery, deconfiguring its whole lease set. >> >> Tested on aarch64: supplied and zero timers, address/prefix/both, >> continued renewal, server failover, long outage, ten-second valid lease, >> infinite timers/lifetime, invalid timer ordering, manual renewal, slow >> script during continued operation (without -q), large timer arithmetic, >> stateless DNS, and zero-lifetime revocation regressions. With T1=8, >> T2=18, valid=32, Renew/Rebind start at 8/18 and deconfig occurs at 32; >> upstream renews at 16 and loses the lease before expiry. > > You have a server which refuses to renew a lease before it expires? > >> A separate >> post-script expiry regression uses a two-second valid lease and a >> three-second bound script with -q. The client deconfigures the expired >> lease and reacquires before reporting success. >> >> Native aarch64, GCC 15.2.0, Alpine configuration; relative to >> the preceding patch in this series. Data and BSS are unchanged. >> >> bloat-o-meter: >> function old new delta >> udhcpc6_main 3120 3792 +672 >> d6_update_timers - 292 +292 >> .rodata 69484 69520 +36 >> ------------------------------------------------------------------------------ >> (add/remove: 1/0 grow/shrink: 2/0 up/down: 1000/0) Total: 1000 bytes > > This is too costly to accept without a more detailed explanation: > what scenario you are fixing? > It seems most servers are rather permissive and happily renew > a lease when requested ~halftime into the lease, regardless > of those T1/T2 times. From tomalok at gmail.com Mon Oct 12 17:15:57 2026 From: tomalok at gmail.com (=?UTF-8?q?jake=20Buchholz=20G=C3=B6kt=C3=BCrk?=) Date: Mon, 12 Oct 2026 10:15:57 -0700 Subject: [PATCH v2 0/3] udhcp: retain valid leases and export prefix preferred lifetime In-Reply-To: References: Message-ID: Hello Denys, This replaces the original patches 4/5 and 5/5 with smaller expiry-only fixes, and adds the prefix preferred-lifetime export discussed in your reply to patch 1. The three changes you already applied are not resent. The concrete expiry issue is premature deconfiguration when renewal replies stop. With a 40-second lease, both current upstream clients give up after about 28 seconds. Patches 1 and 2 keep expiry separate from retry scheduling, continue Rebind until expiry, preserve the lease on manual renewal, and stop extending short leases. The existing half-lease renewal schedule and Rebind threshold remain; T1/T2 support is separate. Patch 3 exports prefix_preferred_lifetime separately from the address's preferred_lifetime, alongside the existing ipv6prefix_lease. Rebased onto upstream 9d961aff6f7b, including your shared deconfiguration paths. All three patches can be applied independently to that base. Native aarch64 / GCC 15.2.0 / Alpine configuration, fresh measurements: DHCPv6 expiry: +104 bytes DHCPv4 expiry: +104 bytes Prefix preferred lifetime: +83 bytes Total: +291 bytes Data and BSS are unchanged. Each patch includes bloat-o-meter output. The rebased series passed 22 cases with controlled Python DHCP responders in isolated Linux network namespaces on Alpine. Packet captures and hook logs cover outage expiry, short leases, manual/successful renewal, server failover, zero lifetimes, slow-script -q recovery, and prefix exports. The unanswered-renewal cases deliberately stop replies after acquisition; they are not a production server rejecting requests because of T1/T2. These patches were developed with assistance from OpenAI Codex. Thanks, jake jake Buchholz G?kt?rk (3): udhcpc6: retain valid leases until expiry udhcpc: retain valid leases until expiry udhcpc6: export preferred lifetime for delegated prefixes networking/udhcp/d6_dhcpc.c | 32 ++++++++++++++++++++++++-------- networking/udhcp/dhcpc.c | 26 +++++++++++++++++++------- 2 files changed, 43 insertions(+), 15 deletions(-) base-commit: 9d961aff6f7b2d39ca80dbb085c7bc599d5f2e95 -- 2.55.0 From tomalok at gmail.com Mon Oct 12 17:15:58 2026 From: tomalok at gmail.com (=?UTF-8?q?jake=20Buchholz=20G=C3=B6kt=C3=BCrk?=) Date: Mon, 12 Oct 2026 10:15:58 -0700 Subject: [PATCH v2 1/3] udhcpc6: retain valid leases until expiry In-Reply-To: References: Message-ID: <3df0a72fc44427108e8425dbf8bb41f40203675a.1791825022.git.tomalok@gmail.com> When Renew goes unanswered, REBINDING deconfigures after three packets while the lease may still be valid. SIGUSR1 also shortens the remaining lifetime to 30 seconds, and the minimum lease floor extends short leases. Keep a monotonic expiry timestamp separate from retry scheduling. Recalculate remaining lifetime before waiting, cap waits by expiry and continue Rebind until expiry. Manual renewal does not shorten the lease. Clear the expired address hint and reuse the shared deconfiguration path. Do not inflate short lifetimes. If the lease expires during the bound script, deconfigure before honoring -q or backgrounding. Reuse the three-second acquisition backoff when the first lease is already expired. Keep the existing half-lease schedule and Rebind threshold. T1/T2 parsing and existing INT_MAX lifetime clamping are unchanged. Tested with controlled Python responders in isolated Linux namespaces: unanswered renewal through expiry, short leases, manual renewal, successful renewal and slow-script -q recovery. The upstream baseline removes a 40-second outage lease after about 28 seconds; the patched client retains it until expiry within the timestamp resolution. Prefix-only/combined leases, server failover, zero-lifetime recovery and stateless operation were also tested. Native aarch64, GCC 15.2.0, Alpine configuration; relative to upstream 9d961aff6f7b. Data and BSS unchanged. function old new delta udhcpc6_main 3020 3124 +104 ------------------------------------------------------------------------------ (add/remove: 0/0 grow/shrink: 1/0 up/down: 104/0) Total: 104 bytes Signed-off-by: jake Buchholz G?kt?rk --- networking/udhcp/d6_dhcpc.c | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/networking/udhcp/d6_dhcpc.c b/networking/udhcp/d6_dhcpc.c index c014ce9..9a3eb24 100644 --- a/networking/udhcp/d6_dhcpc.c +++ b/networking/udhcp/d6_dhcpc.c @@ -1243,6 +1243,7 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) int packet_num; int timeout; /* must be signed */ int lease_remaining; /* must be signed */ + unsigned lease_end = 0; unsigned opt; int retval; @@ -1366,6 +1367,8 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) deconfig_and_set_INIT_SELECTING: d6_run_script_no_option("deconfig"); + if (client_data.state == REQUESTING) + sleep(3); /* avoid excessive traffic after an expired first lease */ set_INIT_SELECTING: client_data.state = INIT_SELECTING; packet_num = 0; @@ -1391,6 +1394,15 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) * to change_listen_mode(). Thus we open listen socket * BEFORE we send renew request (see "case BOUND:"). */ + /* Retry scheduling must not change the valid expiry time. */ + if (client_data.state >= BOUND && client_data.state <= RENEW_REQUESTED) { + lease_remaining = (int)(lease_end - (unsigned)monotonic_sec()); + if (lease_remaining <= 0) + goto lease_lost; + if (timeout > lease_remaining) + timeout = lease_remaining; + } + udhcp_sp_fd_set(pfds, client_data.sockfd); retval = 0; @@ -1527,7 +1539,7 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) continue; } /* else: we had sent one packet, but got no reply */ log1s("no response to renew"); - if (lease_remaining > 30) { + if (lease_remaining > 30 && client_data.state != RENEW_REQUESTED) { /* Some lease time remains, try to renew later */ change_listen_mode(LISTEN_NONE); goto BOUND_for_half_lease; @@ -1544,20 +1556,21 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) case REBINDING: /* Lease is *really* about to run out, * try to find DHCP server using broadcast */ - if (lease_remaining > 0 && packet_num < 3) { + if (lease_remaining > 0) { if (opt & OPT_l) send_d6_info_request(); else /* send a multicast rebind request */ send_d6_renew(D6_MSG_REBIND, /*server_ipv6:*/ NULL, requested_ipv6); timeout = discover_timeout; - packet_num++; continue; } + lease_lost: /* Timed out, enter init state */ change_listen_mode(LISTEN_NONE); bb_simple_info_msg("lease lost, entering init state"); /* will set timeout = packet_num = lease_remaining = 0 */ /* also makes secs count from 0: client_data.first_secs = 0 */ + requested_ipv6 = NULL; goto deconfig_and_set_INIT_SELECTING; /* case RELEASED: */ } @@ -1580,8 +1593,6 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) /* Do not go back from rebind to renew state */ break; - if (lease_remaining > 30) /* if renew fails, do not go back to BOUND */ - lease_remaining = 30; client_data.first_secs = 0; /* make secs field count from 0 */ packet_num = 0; @@ -1915,17 +1926,17 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) */ lease_remaining = 24 * 60 * 60; } - /* paranoia: must not be too small */ - if (lease_remaining < 30) - lease_remaining = 30; /* enter bound state */ start = monotonic_sec(); + lease_end = start + lease_remaining; d6_run_script(packet.d6_options, packet_end, (client_data.state == REQUESTING ? "bound" : "renew")); lease_remaining -= (unsigned)monotonic_sec() - start; if (lease_remaining < 0) lease_remaining = 0; + if (!lease_remaining) + goto lease_lost; if (opt & OPT_q) { /* quit after lease */ goto ret0; } -- 2.55.0 From tomalok at gmail.com Mon Oct 12 17:15:59 2026 From: tomalok at gmail.com (=?UTF-8?q?jake=20Buchholz=20G=C3=B6kt=C3=BCrk?=) Date: Mon, 12 Oct 2026 10:15:59 -0700 Subject: [PATCH v2 2/3] udhcpc: retain valid leases until expiry In-Reply-To: References: Message-ID: When Renew goes unanswered, REBINDING deconfigures after three packets while the lease may still be valid. SIGUSR1 also shortens the remaining lifetime to 30 seconds, and the minimum lease floor extends short leases. Keep a monotonic expiry timestamp separate from retry scheduling. Recalculate remaining lifetime before waiting, cap waits by expiry and continue Rebind until expiry. Manual renewal does not shorten the lease. Clear the expired address hint and reuse the shared deconfiguration path. Do not inflate short lifetimes. If the lease expires during the bound script, deconfigure before honoring -q or backgrounding. Reuse the three-second acquisition backoff when the first lease is already expired. Keep the existing half-lease schedule and Rebind threshold. T1/T2 parsing and existing INT_MAX lifetime clamping are unchanged. Tested with controlled Python responders in isolated Linux namespaces: unanswered renewal through expiry, short leases, manual renewal, successful renewal and slow-script -q recovery. The upstream baseline removes a 40-second outage lease after about 28 seconds; the patched client retains it until expiry within the timestamp resolution. Zero-lifetime ACK backoff and DHCPNAK recovery were also tested. Native aarch64, GCC 15.2.0, Alpine configuration; relative to the preceding patch. Data and BSS unchanged. function old new delta udhcpc_main 2812 2916 +104 ------------------------------------------------------------------------------ (add/remove: 0/0 grow/shrink: 1/0 up/down: 104/0) Total: 104 bytes Signed-off-by: jake Buchholz G?kt?rk --- networking/udhcp/dhcpc.c | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/networking/udhcp/dhcpc.c b/networking/udhcp/dhcpc.c index 2112edc..ee3ffcc 100644 --- a/networking/udhcp/dhcpc.c +++ b/networking/udhcp/dhcpc.c @@ -1218,6 +1218,7 @@ int udhcpc_main(int argc UNUSED_PARAM, char **argv) int packet_num; int timeout; /* must be signed */ int lease_remaining; /* must be signed */ + unsigned lease_end = 0; unsigned opt; IF_FEATURE_UDHCPC_ARPING(unsigned arpping_ms;) int retval; @@ -1365,6 +1366,8 @@ int udhcpc_main(int argc UNUSED_PARAM, char **argv) deconfig_and_set_INIT_SELECTING: d4_run_script_deconfig(); + if (client_data.state == REQUESTING) + sleep(3); /* avoid excessive traffic after an expired first lease */ set_INIT_SELECTING: client_data.state = INIT_SELECTING; packet_num = 0; @@ -1389,6 +1392,15 @@ int udhcpc_main(int argc UNUSED_PARAM, char **argv) * to change_listen_mode(). Thus we open listen socket * BEFORE we send renew request (see "case BOUND:"). */ + /* Retry scheduling must not change the valid expiry time. */ + if (client_data.state >= BOUND && client_data.state <= RENEW_REQUESTED) { + lease_remaining = (int)(lease_end - (unsigned)monotonic_sec()); + if (lease_remaining <= 0) + goto lease_lost; + if (timeout > lease_remaining) + timeout = lease_remaining; + } + udhcp_sp_fd_set(pfds, client_data.sockfd); retval = 0; @@ -1523,7 +1535,7 @@ int udhcpc_main(int argc UNUSED_PARAM, char **argv) */ } /* else: we had sent one packet, but got no reply */ log1s("no response to renew"); - if (lease_remaining > 30) { + if (lease_remaining > 30 && client_data.state != RENEW_REQUESTED) { /* Some lease time remains, try to renew later */ change_listen_mode(LISTEN_NONE); goto BOUND_for_half_lease; @@ -1538,18 +1550,19 @@ int udhcpc_main(int argc UNUSED_PARAM, char **argv) case REBINDING: /* Lease is *really* about to run out, * try to find DHCP server using broadcast */ - if (lease_remaining > 0 && packet_num < 3) { + if (lease_remaining > 0) { /* send a broadcast renew request */ send_renew(0 /*INADDR_ANY*/, requested_ip); timeout = discover_timeout; - packet_num++; continue; } + lease_lost: /* Timed out, enter init state */ change_listen_mode(LISTEN_NONE); bb_simple_info_msg("lease lost, entering init state"); /* sets packet_num = timeout = lease_remaining = 0 */ /* also makes secs count from 0: client_data.first_secs = 0 */ + requested_ip = 0; goto deconfig_and_set_INIT_SELECTING; /* case RELEASED: */ } @@ -1572,8 +1585,6 @@ int udhcpc_main(int argc UNUSED_PARAM, char **argv) /* Do not go back from rebind to renew state */ break; - if (lease_remaining > 30) /* if renew fails, do not go back to BOUND */ - lease_remaining = 30; client_data.first_secs = 0; /* make secs field count from 0 */ packet_num = 0; @@ -1738,8 +1749,6 @@ int udhcpc_main(int argc UNUSED_PARAM, char **argv) */ if (lease_remaining < 0) /* signed overflow? */ lease_remaining = INT_MAX; - if (lease_remaining < 30) - lease_remaining = 30; requested_ip = packet.yiaddr; #if ENABLE_FEATURE_UDHCPC_ARPING if (opt & OPT_a) { @@ -1776,10 +1785,13 @@ int udhcpc_main(int argc UNUSED_PARAM, char **argv) #endif /* enter bound state */ start = monotonic_sec(); + lease_end = start + lease_remaining; d4_run_script(&packet, client_data.state == REQUESTING ? "bound" : "renew"); lease_remaining -= (unsigned)monotonic_sec() - start; if (lease_remaining < 0) lease_remaining = 0; + if (!lease_remaining) + goto lease_lost; if (opt & OPT_q) { /* quit after lease */ goto ret0; } -- 2.55.0 From tomalok at gmail.com Mon Oct 12 17:16:00 2026 From: tomalok at gmail.com (=?UTF-8?q?jake=20Buchholz=20G=C3=B6kt=C3=BCrk?=) Date: Mon, 12 Oct 2026 10:16:00 -0700 Subject: [PATCH v2 3/3] udhcpc6: export preferred lifetime for delegated prefixes In-Reply-To: References: Message-ID: IAADDR exports preferred_lifetime alongside lease, but IAPREFIX only exports its valid lifetime in ipv6prefix_lease. Scripts cannot propagate the prefix's preferred lifetime, including zero with a positive valid lifetime. Export prefix_preferred_lifetime after the existing IAPREFIX length check, matching the IAADDR byte-order conversion and export style. Keep it separate from the address lifetime because both can appear with different values in the same Reply. The existing ipv6prefix_lease export is unchanged. RFC 9915 section 21.22 specifies both prefix lifetimes. Tested in isolated Linux namespaces with controlled DHCPv6 replies: prefix-only, combined distinct lifetimes, zero, infinity and renewal. Address-only and stateless replies do not export a prefix lifetime. Native aarch64, GCC 15.2.0, Alpine configuration; relative to the preceding patch. Data and BSS unchanged. function old new delta option_to_env 1124 1200 +76 .rodata 69474 69481 +7 ------------------------------------------------------------------------------ (add/remove: 0/0 grow/shrink: 2/0 up/down: 83/0) Total: 83 bytes Signed-off-by: jake Buchholz G?kt?rk --- networking/udhcp/d6_dhcpc.c | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/networking/udhcp/d6_dhcpc.c b/networking/udhcp/d6_dhcpc.c index 9a3eb24..f2fa54f 100644 --- a/networking/udhcp/d6_dhcpc.c +++ b/networking/udhcp/d6_dhcpc.c @@ -357,6 +357,11 @@ static void option_to_env(const uint8_t *option, const uint8_t *option_end) /* Make sure payload exists */ if (option[3] < (4 + 4 + 1 + 16)) break; + + move_from_unaligned32(v32, option + 4); + v32 = ntohl(v32); + *new_env() = xasprintf("prefix_preferred_lifetime=%u", (unsigned)v32); + move_from_unaligned32(v32, option + 4 + 4); v32 = ntohl(v32); *new_env() = xasprintf("ipv6prefix_lease=%u", (unsigned)v32); -- 2.55.0 From tomalok at gmail.com Mon Oct 12 17:43:40 2026 From: tomalok at gmail.com (=?UTF-8?q?jake=20Buchholz=20G=C3=B6kt=C3=BCrk?=) Date: Mon, 12 Oct 2026 10:43:40 -0700 Subject: [PATCH 0/2] udhcp: honor supplied renewal and rebinding times In-Reply-To: References: Message-ID: Hello Denys, Following the expiry fixes, here is the smaller T1/T2 implementation split into DHCPv6 and DHCPv4 patches. These apply on top of the three-patch expiry/prefix series sent earlier today (base commit below). T1/T2 support is not critical for our Alpine work. DHCPv6 does, however, require using supplied nonzero T1/T2 (RFC 9915 sections 21.4 and 21.21). This pair uses one shared unsigned monotonic countdown helper, keeps valid-expiry accounting, and schedules Renew/Rebind at the supplied times. It retains the existing shared IA handling and DHCPv6 retry algorithm. Native aarch64 builds with the Alpine configuration add 660 bytes for DHCPv6 and the helper, then 372 bytes for DHCPv4: 1,032 bytes total. Data and BSS are unchanged. The individual patches include bloat-o-meter results. Tested both clients with controlled Python DHCP responders in isolated Linux network namespaces: 34 cases covering supplied and fallback timers, short and infinite leases, lost replies, manual renewal, slow scripts, zero lifetimes, NAK recovery, and DHCPv6 address/prefix IAs and failover. Packet captures confirm renewal/rebinding deadlines and lease retention. The outage cases deliberately stop replies; they do not model a server rejecting renewal solely because it arrived before T1. These patches and tests were prepared with LLM assistance and reviewed by me. Thanks, jake jake Buchholz G?kt?rk (2): udhcpc6: honor IA renewal and rebinding times udhcpc: honor renewal and rebinding options networking/udhcp/common.c | 14 +++ networking/udhcp/common.h | 8 +- networking/udhcp/d6_dhcpc.c | 176 ++++++++++++++++++++++-------------- networking/udhcp/dhcpc.c | 162 ++++++++++++++++++--------------- 4 files changed, 213 insertions(+), 147 deletions(-) base-commit: f84c207143fe0db5e3e2b1ccd06a518a53edbcfe -- 2.55.0 From tomalok at gmail.com Mon Oct 12 17:43:41 2026 From: tomalok at gmail.com (=?UTF-8?q?jake=20Buchholz=20G=C3=B6kt=C3=BCrk?=) Date: Mon, 12 Oct 2026 10:43:41 -0700 Subject: [PATCH 1/2] udhcpc6: honor IA renewal and rebinding times In-Reply-To: References: Message-ID: <13520b413c7a2b001d27f321c91f28238a9783fb.1791826072.git.tomalok@gmail.com> The expiry-only fix retains leases, but renewal still starts halfway through the valid lifetime and Rebind follows an unanswered Renew. This ignores the server's IA T1/T2 values. Read the requested IA_NA/IA_PD timers before replacing accepted IA data. Use the earliest nonzero T1/T2 across requested IAs. RFC 9915 sections 21.4 and 21.21 require using supplied nonzero timers. Reject a Reply containing an IA with nonzero T1 > T2 before changing accepted data. Zero permits a client choice (section 14.2). Choose half and roughly seven eighths of the shared valid lifetime, bounded to avoid immediate sends. An infinite lifetime with zero timers does not require renewal. Keep all countdowns unsigned and preserve 0xffffffff as infinity. Add one shared monotonic countdown helper for lease, T1 and T2. Bound waits by the next deadline, Renew until T2, then Rebind until valid expiry. Script execution and packet processing consume the same timers. Manual Renew does not reset them. Start new Renew/Rebind exchanges with new transaction IDs. Keep the post-script expiry check before -q. This retains the client's shared IA state and required companion-IA handling: an invalid requested IA causes the whole Reply to be ignored. It does not add independent IA processing or change the existing DHCPv6 retransmission algorithm or stateless refresh policy. Tested with controlled Python responders in isolated Linux namespaces: supplied/zero/infinite/large timers, address and prefix IAs, earliest combined IA timers, invalid Renew Reply, renewal, server failover, manual renewal, short leases, revocation and stateless operation. Packet captures verify T1/T2 and continued Rebind through valid expiry. Native aarch64, GCC 15.2.0, Alpine configuration; relative to the submitted expiry/prefix series. Data and BSS unchanged. function old new delta udhcpc6_main 3124 3704 +580 udhcp_update_timers - 80 +80 ------------------------------------------------------------------------------ (add/remove: 1/0 grow/shrink: 1/0 up/down: 660/0) Total: 660 bytes Signed-off-by: jake Buchholz G?kt?rk --- networking/udhcp/common.c | 14 +++ networking/udhcp/common.h | 6 +- networking/udhcp/d6_dhcpc.c | 176 ++++++++++++++++++++++-------------- 3 files changed, 124 insertions(+), 72 deletions(-) diff --git a/networking/udhcp/common.c b/networking/udhcp/common.c index ad580f3..eb1ae6c 100644 --- a/networking/udhcp/common.c +++ b/networking/udhcp/common.c @@ -6,6 +6,20 @@ */ #include "common.h" +/* UINT_MAX means infinity. Account for time outside poll() as well. */ +void FAST_FUNC udhcp_update_timers(unsigned *timers) +{ + unsigned now = monotonic_sec(); + unsigned elapsed = now - timers[DHCP_TIMER_CLOCK]; + unsigned i; + + timers[DHCP_TIMER_CLOCK] = now; + for (i = 0; i < DHCP_TIMER_CLOCK; i++) { + if (timers[i] != UINT_MAX) + timers[i] = timers[i] > elapsed ? timers[i] - elapsed : 0; + } +} + #if defined CONFIG_UDHCP_DEBUG && CONFIG_UDHCP_DEBUG >= 1 unsigned dhcp_verbose; #endif diff --git a/networking/udhcp/common.h b/networking/udhcp/common.h index 3ef371a..7ab6a3c 100644 --- a/networking/udhcp/common.h +++ b/networking/udhcp/common.h @@ -168,8 +168,6 @@ struct dhcp_scan_state { #define DHCP_PARAM_REQ 0x37 /* 55: list of options client wants */ //#define DHCP_ERR_MESSAGE 0x38 /* 56: error message when sending NAK etc */ #define DHCP_MAX_SIZE 0x39 /* 57: 16bit big-endian */ -// 0x3a /* 58: from server: renew time, 32bit big-endian */ -// 0x3b /* 59: from server: rebind time, 32bit big-endian */ #define DHCP_VENDOR 0x3c /* 60: client's vendor (a string) */ #define DHCP_CLIENT_ID 0x3d /* 61: by default client's MAC addr, but may be arbitrarily long */ //#define DHCP_TFTP_SERVER_NAME 0x42 /* 66: same as 'sname' field */ @@ -394,6 +392,10 @@ int arpping(uint32_t test_nip, /* note: ip is a pointer to an IPv6 in network order, possibly misaliged */ int sprint_nip6(char *dest, /*const char *pre,*/ const uint8_t *ip) FAST_FUNC; +/* Three unsigned countdowns, followed by the monotonic sampling time. */ +enum { DHCP_LEASE_TIMER, DHCP_RENEW_TIMER, DHCP_REBIND_TIMER, DHCP_TIMER_CLOCK }; +void udhcp_update_timers(unsigned *timers) FAST_FUNC; + POP_SAVED_FUNCTION_VISIBILITY #endif diff --git a/networking/udhcp/d6_dhcpc.c b/networking/udhcp/d6_dhcpc.c index f2fa54f..e81b8d0 100644 --- a/networking/udhcp/d6_dhcpc.c +++ b/networking/udhcp/d6_dhcpc.c @@ -1231,6 +1231,11 @@ static void client_background(void) //usage: "\n USR1 Renew lease" //usage: "\n USR2 Release lease" +static int d6_poll_timeout(unsigned time) +{ + return MIN(time, INT_MAX / 1000); +} + int udhcpc6_main(int argc, char **argv) MAIN_EXTERNALLY_VISIBLE; int udhcpc6_main(int argc UNUSED_PARAM, char **argv) { @@ -1247,8 +1252,7 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) struct in6_addr *requested_ipv6; int packet_num; int timeout; /* must be signed */ - int lease_remaining; /* must be signed */ - unsigned lease_end = 0; + unsigned timers[4] = { 0 }; unsigned opt; int retval; @@ -1378,7 +1382,7 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) client_data.state = INIT_SELECTING; packet_num = 0; timeout = 0; - lease_remaining = 0; + timers[DHCP_LEASE_TIMER] = 0; client_data.first_secs = 0; /* Main event loop. select() waits on signal pipe and possibly @@ -1399,13 +1403,14 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) * to change_listen_mode(). Thus we open listen socket * BEFORE we send renew request (see "case BOUND:"). */ - /* Retry scheduling must not change the valid expiry time. */ + udhcp_update_timers(timers); if (client_data.state >= BOUND && client_data.state <= RENEW_REQUESTED) { - lease_remaining = (int)(lease_end - (unsigned)monotonic_sec()); - if (lease_remaining <= 0) - goto lease_lost; - if (timeout > lease_remaining) - timeout = lease_remaining; + unsigned next = timers[DHCP_LEASE_TIMER]; + + if (client_data.state != REBINDING) + next = MIN(next, timers[client_data.state == BOUND + ? DHCP_RENEW_TIMER : DHCP_REBIND_TIMER]); + timeout = MIN(timeout, d6_poll_timeout(next)); } udhcp_sp_fd_set(pfds, client_data.sockfd); @@ -1418,12 +1423,10 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) if (timeout > INT_MAX/1000) timeout = INT_MAX/1000; log1("waiting %u seconds", timeout); - diff = (unsigned)monotonic_sec(); + diff = timers[DHCP_TIMER_CLOCK]; retval = poll(pfds, 2, timeout * 1000); - diff = (unsigned)monotonic_sec() - diff; - lease_remaining -= diff; - if (lease_remaining < 0) - lease_remaining = 0; + udhcp_update_timers(timers); + diff = timers[DHCP_TIMER_CLOCK] - diff; timeout -= diff; if (timeout < 0) timeout = 0; @@ -1438,6 +1441,14 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) } } + /* Enforce deadlines even when packets or signals wake poll(). */ + if (client_data.state >= BOUND && client_data.state <= RENEW_REQUESTED) { + if (!timers[DHCP_LEASE_TIMER]) + goto lease_lost; + if (client_data.state != REBINDING && !timers[DHCP_REBIND_TIMER]) + goto enter_rebind; + } + /* If timeout dropped to zero, time to become active: * resend discover/renew/whatever */ @@ -1515,65 +1526,49 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) client_data.state = INIT_SELECTING; goto leasefail; case BOUND: - /* 1/2 lease passed, enter renewing state */ + if (timers[DHCP_RENEW_TIMER]) { + timeout = d6_poll_timeout(MIN(timers[DHCP_RENEW_TIMER], + MIN(timers[DHCP_REBIND_TIMER], timers[DHCP_LEASE_TIMER]))); + continue; + } client_data.state = RENEWING; - client_data.first_secs = 0; /* make secs field count from 0 */ + client_data.first_secs = 0; + client_data.xid = random_xid(); got_SIGUSR1: log1s("entering renew state"); change_listen_mode(LISTEN_KERNEL); /* fall right through */ - case RENEW_REQUESTED: /* in manual (SIGUSR1) renew */ + case RENEW_REQUESTED: case RENEWING: - if (packet_num == 0) { - /* Send an unicast renew request */ - /* Sometimes observed to fail (EADDRNOTAVAIL) to bind - * a new UDP socket for sending inside send_renew. - * I hazard to guess existing listening socket - * is somehow conflicting with it, but why is it - * not deterministic then?! Strange. - * Anyway, it does recover by eventually failing through - * into INIT_SELECTING state. - */ - if (opt & OPT_l) - send_d6_info_request(); - else - send_d6_renew(D6_MSG_RENEW, - OPT_m ? NULL : &srv6_buf, requested_ipv6); - timeout = discover_timeout; - packet_num++; - continue; - } /* else: we had sent one packet, but got no reply */ - log1s("no response to renew"); - if (lease_remaining > 30 && client_data.state != RENEW_REQUESTED) { - /* Some lease time remains, try to renew later */ - change_listen_mode(LISTEN_NONE); - goto BOUND_for_half_lease; - } - /* Enter rebinding state */ + if (opt & OPT_l) + send_d6_info_request(); + else + send_d6_renew(D6_MSG_RENEW, + OPT_m ? NULL : &srv6_buf, requested_ipv6); + timeout = MIN(MAX(discover_timeout, 1), + MIN(timers[DHCP_REBIND_TIMER], timers[DHCP_LEASE_TIMER])); + continue; + enter_rebind: client_data.state = REBINDING; log1s("entering rebinding state"); - /* Switch to bcast receive */ change_listen_mode(LISTEN_RAW); - packet_num = 0; client_data.xid = random_xid(); client_data.first_secs = 0; /* fall right through */ case REBINDING: - /* Lease is *really* about to run out, - * try to find DHCP server using broadcast */ - if (lease_remaining > 0) { - if (opt & OPT_l) - send_d6_info_request(); - else /* send a multicast rebind request */ - send_d6_renew(D6_MSG_REBIND, /*server_ipv6:*/ NULL, requested_ipv6); - timeout = discover_timeout; - continue; - } + /* Retry until valid expiry, not until a packet limit. */ + if (opt & OPT_l) + send_d6_info_request(); + else + send_d6_renew(D6_MSG_REBIND, /*server_ipv6:*/ NULL, requested_ipv6); + timeout = MIN(MAX(discover_timeout, 1), timers[DHCP_LEASE_TIMER]); + continue; + lease_lost: /* Timed out, enter init state */ change_listen_mode(LISTEN_NONE); bb_simple_info_msg("lease lost, entering init state"); - /* will set timeout = packet_num = lease_remaining = 0 */ + /* will set timeout = packet_num = timers[DHCP_LEASE_TIMER] = 0 */ /* also makes secs count from 0: client_data.first_secs = 0 */ requested_ipv6 = NULL; goto deconfig_and_set_INIT_SELECTING; @@ -1607,6 +1602,7 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) /* Try to renew/rebind */ change_listen_mode(LISTEN_KERNEL); client_data.state = RENEW_REQUESTED; + client_data.xid = random_xid(); goto got_SIGUSR1; case RENEW_REQUESTED: @@ -1762,12 +1758,44 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) * . . * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ */ - unsigned start; uint32_t lease_seconds; struct d6_option *option; unsigned address_timeout; unsigned prefix_timeout; + unsigned renew_timeout; + unsigned rebind_timeout; + unsigned zero_timers; type_is_ok: + renew_timeout = UINT_MAX; + rebind_timeout = UINT_MAX; + zero_timers = 0; + if (packet.d6_msg_type == D6_MSG_REPLY) { + unsigned i; + + for (i = 0; i < 2; i++) { + uint32_t t1, t2; + + if (!(option_mask32 & (i ? OPT_d : OPT_r))) + continue; + option = d6_find_option(packet.d6_options, packet_end, + i ? D6_OPT_IA_PD : D6_OPT_IA_NA); + if (!option || option->len < 12) + continue; + move_from_unaligned32(t1, option->data + 4); + move_from_unaligned32(t2, option->data + 8); + t1 = ntohl(t1); + t2 = ntohl(t2); + if (t1 && t2 && t1 > t2) + break; + zero_timers |= (!t1) | ((!t2) << 1); + if (t1) + renew_timeout = MIN(renew_timeout, t1); + if (t2) + rebind_timeout = MIN(rebind_timeout, t2); + } + if (i != 2) + continue; + } change_listen_mode(LISTEN_NONE); address_timeout = 0; @@ -1799,7 +1827,7 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) } sleep(3); /* avoid excessive network traffic */ requested_ipv6 = NULL; - /* will set timeout = packet_num = lease_remaining = 0 */ + /* will set timeout = packet_num = timers[DHCP_LEASE_TIMER] = 0 */ /* also makes secs count from 0: client_data.first_secs = 0 */ goto set_INIT_SELECTING; } @@ -1922,25 +1950,33 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) address_timeout = prefix_timeout; if (!prefix_timeout) prefix_timeout = address_timeout; - lease_remaining = (prefix_timeout < address_timeout ? prefix_timeout : address_timeout); - if (lease_remaining < 0) /* signed overflow? */ - lease_remaining = INT_MAX; + timers[DHCP_LEASE_TIMER] = (prefix_timeout < address_timeout ? prefix_timeout : address_timeout); + /* Zero leaves the choice to the client. Avoid immediate sends. */ + if (zero_timers & 1) + renew_timeout = MIN(renew_timeout, timers[DHCP_LEASE_TIMER] == UINT_MAX + ? UINT_MAX : MAX(1, timers[DHCP_LEASE_TIMER] / 2)); + if (zero_timers & 2) + rebind_timeout = MIN(rebind_timeout, timers[DHCP_LEASE_TIMER] == UINT_MAX + ? UINT_MAX : MAX(renew_timeout, + timers[DHCP_LEASE_TIMER] - timers[DHCP_LEASE_TIMER] / 8)); + renew_timeout = MIN(renew_timeout, rebind_timeout); + timers[DHCP_RENEW_TIMER] = renew_timeout; + timers[DHCP_REBIND_TIMER] = rebind_timeout; if (opt & OPT_l) { /* TODO: request OPTION_INFORMATION_REFRESH_TIME (32) * and use its value instead of the default 1 day. */ - lease_remaining = 24 * 60 * 60; + timers[DHCP_LEASE_TIMER] = 24 * 60 * 60; + timers[DHCP_RENEW_TIMER] = timers[DHCP_LEASE_TIMER] / 2; + timers[DHCP_REBIND_TIMER] = timers[DHCP_LEASE_TIMER]; } /* enter bound state */ - start = monotonic_sec(); - lease_end = start + lease_remaining; + timers[DHCP_TIMER_CLOCK] = monotonic_sec(); d6_run_script(packet.d6_options, packet_end, (client_data.state == REQUESTING ? "bound" : "renew")); - lease_remaining -= (unsigned)monotonic_sec() - start; - if (lease_remaining < 0) - lease_remaining = 0; - if (!lease_remaining) + udhcp_update_timers(timers); + if (!timers[DHCP_LEASE_TIMER]) goto lease_lost; if (opt & OPT_q) { /* quit after lease */ goto ret0; @@ -1955,8 +1991,8 @@ int udhcpc6_main(int argc UNUSED_PARAM, char **argv) } #endif - BOUND_for_half_lease: - timeout = (unsigned)lease_remaining / 2; + timeout = d6_poll_timeout(MIN(timers[DHCP_RENEW_TIMER], + MIN(timers[DHCP_REBIND_TIMER], timers[DHCP_LEASE_TIMER]))); client_data.state = BOUND; packet_num = 0; continue; /* back to main loop */ -- 2.55.0 From tomalok at gmail.com Mon Oct 12 17:43:42 2026 From: tomalok at gmail.com (=?UTF-8?q?jake=20Buchholz=20G=C3=B6kt=C3=BCrk?=) Date: Mon, 12 Oct 2026 10:43:42 -0700 Subject: [PATCH 2/2] udhcpc: honor renewal and rebinding options In-Reply-To: References: Message-ID: Read DHCP options 58 and 59 and schedule Renew/Rebind at the supplied T1/T2 rather than at the half-lease and unanswered-Renew thresholds. RFC 2131 section 4.4.5 specifies defaults of half and seven eighths of the lease. Use these for absent or zero options and fall back to both defaults when supplied values do not satisfy T1 < T2 < expiry. Use the preceding patch's shared unsigned monotonic countdowns. Preserve infinite leases and timer values beyond INT_MAX without signed clamping. Anchor the timers to the last DHCPREQUEST transmission, rather than ACK receipt. Keep expiry accounting through the bound script and before -q. Renew retries use half the time remaining to T2; Rebind retries use half the remaining valid lifetime, with the RFC's 60-second minimum bounded by the next deadline. Manual renewal preserves the original deadlines. Reject zero-lifetime ACKs without installing an address or honoring -q, retaining the existing three-second acquisition backoff. Tested with controlled Python responders in isolated Linux namespaces: supplied/default/zero/invalid/infinite/large timers, short leases, unanswered renewal through expiry, manual renewal, successful renewal, NAK recovery, zero-lifetime revocation ACKs and slow-script recovery with -q. Native aarch64, GCC 15.2.0, Alpine configuration; relative to the preceding patch. Data and BSS unchanged. Symbol renaming by the compiler has no net size effect; the only growth is udhcpc_main: function old new delta udhcpc_main 2916 3288 +372 ------------------------------------------------------------------------------ Total: 372 bytes Signed-off-by: jake Buchholz G?kt?rk --- networking/udhcp/common.h | 2 + networking/udhcp/dhcpc.c | 162 ++++++++++++++++++++------------------ 2 files changed, 89 insertions(+), 75 deletions(-) diff --git a/networking/udhcp/common.h b/networking/udhcp/common.h index 7ab6a3c..e230f48 100644 --- a/networking/udhcp/common.h +++ b/networking/udhcp/common.h @@ -168,6 +168,8 @@ struct dhcp_scan_state { #define DHCP_PARAM_REQ 0x37 /* 55: list of options client wants */ //#define DHCP_ERR_MESSAGE 0x38 /* 56: error message when sending NAK etc */ #define DHCP_MAX_SIZE 0x39 /* 57: 16bit big-endian */ +#define DHCP_RENEWAL_TIME 0x3a /* 58: renew time, 32bit big-endian */ +#define DHCP_REBINDING_TIME 0x3b /* 59: rebind time, 32bit big-endian */ #define DHCP_VENDOR 0x3c /* 60: client's vendor (a string) */ #define DHCP_CLIENT_ID 0x3d /* 61: by default client's MAC addr, but may be arbitrarily long */ //#define DHCP_TFTP_SERVER_NAME 0x42 /* 66: same as 'sname' field */ diff --git a/networking/udhcp/dhcpc.c b/networking/udhcp/dhcpc.c index ee3ffcc..09c6f0c 100644 --- a/networking/udhcp/dhcpc.c +++ b/networking/udhcp/dhcpc.c @@ -1200,6 +1200,11 @@ static void client_background(void) //usage: "\n USR1 Renew lease" //usage: "\n USR2 Release lease" +static int d4_poll_timeout(unsigned time) +{ + return MIN(time, INT_MAX / 1000); +} + int udhcpc_main(int argc, char **argv) MAIN_EXTERNALLY_VISIBLE; int udhcpc_main(int argc UNUSED_PARAM, char **argv) { @@ -1216,9 +1221,8 @@ int udhcpc_main(int argc UNUSED_PARAM, char **argv) uint32_t server_id = server_id; /* for compiler */ uint32_t requested_ip = 0; int packet_num; - int timeout; /* must be signed */ - int lease_remaining; /* must be signed */ - unsigned lease_end = 0; + int timeout = 0; /* must be signed */ + unsigned timers[4] = { 0 }; unsigned opt; IF_FEATURE_UDHCPC_ARPING(unsigned arpping_ms;) int retval; @@ -1366,13 +1370,14 @@ int udhcpc_main(int argc UNUSED_PARAM, char **argv) deconfig_and_set_INIT_SELECTING: d4_run_script_deconfig(); - if (client_data.state == REQUESTING) - sleep(3); /* avoid excessive traffic after an expired first lease */ + /* Zero-lease ACKs also request a pause via timeout. */ + if (client_data.state == REQUESTING || timeout) + sleep(3); /* avoid excessive traffic after an unusable lease */ set_INIT_SELECTING: client_data.state = INIT_SELECTING; packet_num = 0; timeout = 0; - lease_remaining = 0; + timers[DHCP_LEASE_TIMER] = 0; client_data.first_secs = 0; /* Main event loop. select() waits on signal pipe and possibly @@ -1392,13 +1397,15 @@ int udhcpc_main(int argc UNUSED_PARAM, char **argv) * to change_listen_mode(). Thus we open listen socket * BEFORE we send renew request (see "case BOUND:"). */ - /* Retry scheduling must not change the valid expiry time. */ + udhcp_update_timers(timers); if (client_data.state >= BOUND && client_data.state <= RENEW_REQUESTED) { - lease_remaining = (int)(lease_end - (unsigned)monotonic_sec()); - if (lease_remaining <= 0) - goto lease_lost; - if (timeout > lease_remaining) - timeout = lease_remaining; + unsigned next = timers[DHCP_LEASE_TIMER]; + + if (client_data.state == BOUND) + next = MIN(next, timers[DHCP_RENEW_TIMER]); + if (client_data.state != REBINDING) + next = MIN(next, timers[DHCP_REBIND_TIMER]); + timeout = MIN(timeout, d4_poll_timeout(next)); } udhcp_sp_fd_set(pfds, client_data.sockfd); @@ -1411,12 +1418,10 @@ int udhcpc_main(int argc UNUSED_PARAM, char **argv) if (timeout > INT_MAX/1000) timeout = INT_MAX/1000; log1("waiting %u seconds", timeout); - diff = (unsigned)monotonic_sec(); + diff = timers[DHCP_TIMER_CLOCK]; retval = poll(pfds, 2, timeout * 1000); - diff = (unsigned)monotonic_sec() - diff; - lease_remaining -= diff; - if (lease_remaining < 0) - lease_remaining = 0; + udhcp_update_timers(timers); + diff = timers[DHCP_TIMER_CLOCK] - diff; timeout -= diff; if (timeout < 0) timeout = 0; @@ -1504,63 +1509,51 @@ int udhcpc_main(int argc UNUSED_PARAM, char **argv) client_data.state = INIT_SELECTING; goto leasefail; case BOUND: - /* 1/2 lease passed, enter renewing state */ + if (!timers[DHCP_LEASE_TIMER]) + goto lease_lost; + if (!timers[DHCP_REBIND_TIMER]) + goto enter_rebind; + if (timers[DHCP_RENEW_TIMER]) { + timeout = d4_poll_timeout(MIN(timers[DHCP_RENEW_TIMER], + MIN(timers[DHCP_REBIND_TIMER], timers[DHCP_LEASE_TIMER]))); + continue; + } client_data.state = RENEWING; - client_data.first_secs = 0; /* make secs field count from 0 */ + client_data.first_secs = 0; got_SIGUSR1: log1s("entering renew state"); change_listen_mode(LISTEN_KERNEL); /* fall right through */ case RENEW_REQUESTED: /* in manual (SIGUSR1) renew */ case RENEWING: - if (packet_num == 0) { - /* Send an unicast renew request */ - /* Sometimes observed to fail (EADDRNOTAVAIL) to bind - * a new UDP socket for sending inside send_renew. - * I hazard to guess existing listening socket - * is somehow conflicting with it, but why is it - * not deterministic then?! Strange. - * Anyway, it does recover by eventually failing through - * into INIT_SELECTING state. - */ - if (send_renew(server_id, requested_ip) >= 0) { - timeout = discover_timeout; - packet_num++; - continue; - } - /* else: error sending. - * example: ENETUNREACH seen with server - * which gave us bogus server ID 1.1.1.1 - * which wasn't reachable (and probably did not exist). - */ - } /* else: we had sent one packet, but got no reply */ - log1s("no response to renew"); - if (lease_remaining > 30 && client_data.state != RENEW_REQUESTED) { - /* Some lease time remains, try to renew later */ - change_listen_mode(LISTEN_NONE); - goto BOUND_for_half_lease; - } - /* Enter rebinding state */ + if (!timers[DHCP_LEASE_TIMER]) + goto lease_lost; + if (!timers[DHCP_REBIND_TIMER]) + goto enter_rebind; + send_renew(server_id, requested_ip); + /* RFC 2131 4.4.5: half the time to T2, at least 60s. */ + timeout = d4_poll_timeout(MIN(MAX(timers[DHCP_REBIND_TIMER] / 2, 60), + MIN(timers[DHCP_REBIND_TIMER], timers[DHCP_LEASE_TIMER]))); + continue; + enter_rebind: client_data.state = REBINDING; log1s("entering rebinding state"); - /* Switch to bcast receive */ change_listen_mode(LISTEN_RAW); - packet_num = 0; /* fall right through */ case REBINDING: - /* Lease is *really* about to run out, - * try to find DHCP server using broadcast */ - if (lease_remaining > 0) { - /* send a broadcast renew request */ + if (timers[DHCP_LEASE_TIMER]) { send_renew(0 /*INADDR_ANY*/, requested_ip); - timeout = discover_timeout; + timeout = d4_poll_timeout(MIN(MAX(timers[DHCP_LEASE_TIMER] / 2, 60), + timers[DHCP_LEASE_TIMER])); continue; } lease_lost: + timeout = 0; + lease_lost_with_delay: /* Timed out, enter init state */ change_listen_mode(LISTEN_NONE); bb_simple_info_msg("lease lost, entering init state"); - /* sets packet_num = timeout = lease_remaining = 0 */ + /* sets packet_num = timeout = timers[DHCP_LEASE_TIMER] = 0 */ /* also makes secs count from 0: client_data.first_secs = 0 */ requested_ip = 0; goto deconfig_and_set_INIT_SELECTING; @@ -1718,7 +1711,6 @@ int udhcpc_main(int argc UNUSED_PARAM, char **argv) case RENEW_REQUESTED: case REBINDING: if (*message == DHCPACK) { - unsigned start; struct in_addr temp_addr; char server_str[sizeof("255.255.255.255")]; uint8_t *temp; @@ -1729,26 +1721,49 @@ int udhcpc_main(int argc UNUSED_PARAM, char **argv) strcpy(server_str, inet_ntoa(temp_addr)); temp_addr.s_addr = packet.yiaddr; - lease_remaining = 60 * 60; + timers[DHCP_LEASE_TIMER] = 60 * 60; temp = udhcp_get_option32(&packet, DHCP_LEASE_TIME); if (temp) { uint32_t lease; /* it IS unaligned sometimes, don't "optimize" */ move_from_unaligned32(lease, temp); - lease_remaining = ntohl(lease); + timers[DHCP_LEASE_TIMER] = ntohl(lease); } /* Log message _before_ we sanitize lease */ bb_info_msg("lease of %s obtained from %s, lease time %u%s", - inet_ntoa(temp_addr), server_str, (unsigned)lease_remaining, + inet_ntoa(temp_addr), server_str, (unsigned)timers[DHCP_LEASE_TIMER], temp ? "" : " (default)" ); - /* paranoia: must not be too small and not prone to overflows */ - /* NB: 60s leases _are_ used in real world - * (temporary IPs while ISP modem initializes) - * do not break this case by bumping it up. - */ - if (lease_remaining < 0) /* signed overflow? */ - lease_remaining = INT_MAX; + /* RFC 2131 4.4.5: T1 < T2 < lease expiry. */ + { + unsigned defaults[2]; + unsigned i; + + defaults[0] = timers[DHCP_LEASE_TIMER] / 2; + defaults[1] = (uint64_t)timers[DHCP_LEASE_TIMER] * 7 / 8; + if (timers[DHCP_LEASE_TIMER] == UINT_MAX) + defaults[0] = defaults[1] = UINT_MAX; + for (i = 0; i < 2; i++) { + uint32_t value = 0; + + temp = udhcp_get_option32(&packet, DHCP_RENEWAL_TIME + i); + if (temp) { + move_from_unaligned32(value, temp); + value = ntohl(value); + } + timers[DHCP_RENEW_TIMER + i] = value ? value : defaults[i]; + } + if ((timers[DHCP_LEASE_TIMER] != UINT_MAX && timers[DHCP_REBIND_TIMER] >= timers[DHCP_LEASE_TIMER]) + || (timers[DHCP_REBIND_TIMER] != UINT_MAX && timers[DHCP_RENEW_TIMER] >= timers[DHCP_REBIND_TIMER]) + ) { + timers[DHCP_RENEW_TIMER] = defaults[0]; + timers[DHCP_REBIND_TIMER] = defaults[1]; + } + } + if (!timers[DHCP_LEASE_TIMER]) { + timeout = 3; /* avoid excessive network traffic */ + goto lease_lost_with_delay; + } requested_ip = packet.yiaddr; #if ENABLE_FEATURE_UDHCPC_ARPING if (opt & OPT_a) { @@ -1777,20 +1792,17 @@ int udhcpc_main(int argc UNUSED_PARAM, char **argv) d4_run_script_deconfig(); sleep(3); /* avoid excessive network traffic */ requested_ip = 0; - /* sets packet_num = timeout = lease_remaining = 0 */ + /* sets packet_num = timeout = timers[DHCP_LEASE_TIMER] = 0 */ /* also makes secs count from 0: client_data.first_secs = 0 */ goto set_INIT_SELECTING; } } #endif /* enter bound state */ - start = monotonic_sec(); - lease_end = start + lease_remaining; + timers[DHCP_TIMER_CLOCK] = client_data.last_secs; d4_run_script(&packet, client_data.state == REQUESTING ? "bound" : "renew"); - lease_remaining -= (unsigned)monotonic_sec() - start; - if (lease_remaining < 0) - lease_remaining = 0; - if (!lease_remaining) + udhcp_update_timers(timers); + if (!timers[DHCP_LEASE_TIMER]) goto lease_lost; if (opt & OPT_q) { /* quit after lease */ goto ret0; @@ -1804,8 +1816,8 @@ int udhcpc_main(int argc UNUSED_PARAM, char **argv) opt = ((opt & ~OPT_b) | OPT_f); } #endif - BOUND_for_half_lease: - timeout = (unsigned)lease_remaining / 2; + timeout = d4_poll_timeout(MIN(timers[DHCP_RENEW_TIMER], + MIN(timers[DHCP_REBIND_TIMER], timers[DHCP_LEASE_TIMER]))); client_data.state = BOUND; /* make future renew packets use different xid */ /* client_data.xid = random_xid(); ...but why bother? */ -- 2.55.0