Skip to content

Commit 67014a2

Browse files
committed
Prefer IPv4 mDNS health checks while accepting IPv6-only listeners
1 parent dc422c0 commit 67014a2

4 files changed

Lines changed: 207 additions & 17 deletions

File tree

src/timecapsulesmb/assets/boot/samba4/common.d/70-smbd-service.sh

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,32 @@ tc_mdns_bound_ipv6_udp_5353() {
146146
tc_process_bound_ipv6_udp_port "$MDNS_PROC_NAME" 5353
147147
}
148148

149+
tc_mdns_health_socket_family() {
150+
families=$(tc_probe_mdns_socket_families) || return $?
151+
152+
set -- $families
153+
for family in "$@"; do
154+
if [ "$family" = "ipv4" ]; then
155+
printf '%s\n' ipv4
156+
return 0
157+
fi
158+
done
159+
for family in "$@"; do
160+
if [ "$family" = "ipv6" ]; then
161+
printf '%s\n' ipv6
162+
return 0
163+
fi
164+
done
165+
return 1
166+
}
167+
149168
tc_mdns_bound_udp_5353() {
150-
tc_mdns_bound_ipv4_udp_5353
169+
family=$(tc_mdns_health_socket_family) || return $?
170+
case "$family" in
171+
ipv4) tc_mdns_bound_ipv4_udp_5353 ;;
172+
ipv6) tc_mdns_bound_ipv6_udp_5353 ;;
173+
*) return 1 ;;
174+
esac
151175
}
152176

153177
tc_wait_for_smbd_ipv4_445() {

src/timecapsulesmb/device/probe.py

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -213,10 +213,42 @@
213213
return 0
214214
}}
215215
216+
mdns_health_socket_family() {{
217+
families=$1
218+
219+
set -- $families
220+
for family in "$@"; do
221+
if [ "$family" = "ipv4" ]; then
222+
printf '%s\n' ipv4
223+
return 0
224+
fi
225+
done
226+
for family in "$@"; do
227+
if [ "$family" = "ipv6" ]; then
228+
printf '%s\n' ipv6
229+
return 0
230+
fi
231+
done
232+
return 1
233+
}}
234+
216235
mdns_bound_5353() {{
217236
fstat_out=$1
218-
case "$fstat_out" in
219-
*mdns-advertiser*" internet dgram udp "*":5353"*) return 0 ;;
237+
family=$2
238+
239+
case "$family" in
240+
ipv4)
241+
case "$fstat_out" in
242+
*mdns-advertiser*" internet dgram udp "*":5353"*) return 0 ;;
243+
*) return 1 ;;
244+
esac
245+
;;
246+
ipv6)
247+
case "$fstat_out" in
248+
*mdns-advertiser*" internet6 dgram udp "*":5353"*) return 0 ;;
249+
*) return 1 ;;
250+
esac
251+
;;
220252
*) return 1 ;;
221253
esac
222254
}}
@@ -302,6 +334,8 @@
302334
mdns_auto_ip_state=waiting
303335
mdns_auto_ip_failure=
304336
mdns_socket_families=
337+
mdns_health_family=ipv4
338+
mdns_health_family_supported=1
305339
if [ ! -e "$RUNTIME_MDNS_BIN" ]; then
306340
mdns_auto_ip_state=failed
307341
mdns_auto_ip_failure="mdns-advertiser binary missing at $RUNTIME_MDNS_BIN"
@@ -320,6 +354,13 @@
320354
;;
321355
esac
322356
fi
357+
if [ "$mdns_auto_ip_state" = "active" ]; then
358+
if mdns_health_family=$(mdns_health_socket_family "$mdns_socket_families"); then
359+
mdns_health_family_supported=1
360+
else
361+
mdns_health_family_supported=0
362+
fi
363+
fi
323364
324365
if [ "$mdns_auto_ip_state" = "failed" ]; then
325366
echo "FAIL:$mdns_auto_ip_failure"
@@ -336,8 +377,8 @@
336377
fi
337378
status=1
338379
fi
339-
if mdns_bound_5353 "$fstat_out"; then
340-
echo "PASS:mdns-advertiser bound to IPv4 UDP 5353"
380+
if [ "$mdns_health_family_supported" -eq 1 ] && mdns_bound_5353 "$fstat_out" "$mdns_health_family"; then
381+
echo "PASS:mdns-advertiser bound to required $mdns_health_family UDP 5353 listener"
341382
if [ "$mdns_auto_ip_state" = "active" ]; then
342383
echo "PASS:mdns-advertiser bind address active"
343384
else
@@ -349,7 +390,11 @@
349390
echo "FAIL:mdns-advertiser is waiting for a usable address"
350391
status=1
351392
else
352-
echo "FAIL:mdns-advertiser is not bound to UDP 5353"
393+
if [ "$mdns_health_family_supported" -eq 1 ]; then
394+
echo "FAIL:mdns-advertiser is not bound to required UDP 5353 listener"
395+
else
396+
echo "FAIL:mdns-advertiser mDNS socket family probe returned no supported family"
397+
fi
353398
status=1
354399
fi
355400
fi

tests/test_deploy_modules.py

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6528,7 +6528,7 @@ def test_mdns_status_helper_reports_missing_binary_instead_of_network_defer(self
65286528
self.assertEqual(result.returncode, 0, result.stderr)
65296529
self.assertIn(f"FAIL:mdns-advertiser binary missing at {missing_mdns}", result.stdout)
65306530
self.assertIn("FAIL:mdns-advertiser process is not running", result.stdout)
6531-
self.assertIn("FAIL:mdns-advertiser is not bound to UDP 5353", result.stdout)
6531+
self.assertIn("FAIL:mdns-advertiser is not bound to required UDP 5353 listener", result.stdout)
65326532
self.assertIn("PASS:Apple mDNSResponder is stopped", result.stdout)
65336533
self.assertIn("status=1", result.stdout)
65346534
self.assertNotIn("mDNS startup deferred; no usable address has appeared yet", result.stdout)
@@ -6556,7 +6556,7 @@ def test_mdns_status_helper_requires_auto_ip_when_process_is_bound(self) -> None
65566556

65576557
self.assertEqual(result.returncode, 0, result.stderr)
65586558
self.assertIn("PASS:mdns-advertiser process is running", result.stdout)
6559-
self.assertIn("PASS:mdns-advertiser bound to IPv4 UDP 5353", result.stdout)
6559+
self.assertIn("PASS:mdns-advertiser bound to required ipv4 UDP 5353 listener", result.stdout)
65606560
self.assertIn("FAIL:mdns-advertiser bound to UDP 5353 but bind address is not active", result.stdout)
65616561
self.assertIn("status=1", result.stdout)
65626562
self.assertNotIn("PASS:mdns-advertiser bind address active", result.stdout)
@@ -6585,14 +6585,14 @@ def test_mdns_status_helper_reports_unexpected_auto_ip_check_failure(self) -> No
65856585
self.assertEqual(result.returncode, 0, result.stderr)
65866586
self.assertIn("FAIL:mdns-advertiser mDNS socket family probe failed with exit code 3", result.stdout)
65876587
self.assertIn("PASS:mdns-advertiser process is running", result.stdout)
6588-
self.assertIn("PASS:mdns-advertiser bound to IPv4 UDP 5353", result.stdout)
6588+
self.assertIn("PASS:mdns-advertiser bound to required ipv4 UDP 5353 listener", result.stdout)
65896589
self.assertIn("FAIL:mdns-advertiser bound to UDP 5353 but bind address is not active", result.stdout)
65906590
self.assertIn("status=1", result.stdout)
65916591

65926592
def test_mdns_status_helper_passes_only_when_bound_and_auto_ip_active(self) -> None:
65936593
with tempfile.TemporaryDirectory() as tmpdir:
65946594
mdns_bin = Path(tmpdir) / "mdns-advertiser"
6595-
mdns_bin.write_text("#!/bin/sh\nexit 0\n")
6595+
mdns_bin.write_text("#!/bin/sh\necho ipv4\n")
65966596
mdns_bin.chmod(0o755)
65976597
ps_out = "201 1 S 0:00.00 mdns-advertiser /mnt/Flash/mdns-advertiser"
65986598
fstat_out = "root mdns-advertiser 201 10 internet dgram udp 0x0 *:5353"
@@ -6612,12 +6612,12 @@ def test_mdns_status_helper_passes_only_when_bound_and_auto_ip_active(self) -> N
66126612

66136613
self.assertEqual(result.returncode, 0, result.stderr)
66146614
self.assertIn("PASS:mdns-advertiser process is running", result.stdout)
6615-
self.assertIn("PASS:mdns-advertiser bound to IPv4 UDP 5353", result.stdout)
6615+
self.assertIn("PASS:mdns-advertiser bound to required ipv4 UDP 5353 listener", result.stdout)
66166616
self.assertIn("PASS:mdns-advertiser bind address active", result.stdout)
66176617
self.assertIn("PASS:Apple mDNSResponder is stopped", result.stdout)
66186618
self.assertIn("status=0", result.stdout)
66196619

6620-
def test_mdns_status_helper_requires_ipv4_udp_5353_only(self) -> None:
6620+
def test_mdns_status_helper_prefers_ipv4_udp_5353_when_advertiser_is_dual_stack(self) -> None:
66216621
with tempfile.TemporaryDirectory() as tmpdir:
66226622
mdns_bin = Path(tmpdir) / "mdns-advertiser"
66236623
mdns_bin.write_text("#!/bin/sh\necho 'ipv4 ipv6'\n")
@@ -6640,9 +6640,62 @@ def test_mdns_status_helper_requires_ipv4_udp_5353_only(self) -> None:
66406640

66416641
self.assertEqual(result.returncode, 0, result.stderr)
66426642
self.assertIn("PASS:mdns-advertiser process is running", result.stdout)
6643-
self.assertIn("PASS:mdns-advertiser bound to IPv4 UDP 5353", result.stdout)
6643+
self.assertIn("PASS:mdns-advertiser bound to required ipv4 UDP 5353 listener", result.stdout)
66446644
self.assertIn("status=0", result.stdout)
66456645

6646+
def test_mdns_status_helper_accepts_ipv6_udp_5353_when_advertiser_is_ipv6_only(self) -> None:
6647+
with tempfile.TemporaryDirectory() as tmpdir:
6648+
mdns_bin = Path(tmpdir) / "mdns-advertiser"
6649+
mdns_bin.write_text("#!/bin/sh\necho ipv6\n")
6650+
mdns_bin.chmod(0o755)
6651+
ps_out = "201 1 S 0:00.00 mdns-advertiser /mnt/Flash/mdns-advertiser"
6652+
fstat_out = "root mdns-advertiser 201 10 internet6 dgram udp 0x0 [*]:5353"
6653+
script = f"""
6654+
RUNTIME_MDNS_BIN={shlex.quote(str(mdns_bin))}
6655+
{SMBD_STATUS_HELPERS}
6656+
ps_out={shlex.quote(ps_out)}
6657+
fstat_out={shlex.quote(fstat_out)}
6658+
if describe_managed_mdns_status "$ps_out" "$fstat_out"; then
6659+
echo status=0
6660+
else
6661+
echo status=$?
6662+
fi
6663+
"""
6664+
6665+
result = subprocess.run(["/bin/sh", "-c", script], check=False, text=True, capture_output=True)
6666+
6667+
self.assertEqual(result.returncode, 0, result.stderr)
6668+
self.assertIn("PASS:mdns-advertiser process is running", result.stdout)
6669+
self.assertIn("PASS:mdns-advertiser bound to required ipv6 UDP 5353 listener", result.stdout)
6670+
self.assertIn("PASS:mdns-advertiser bind address active", result.stdout)
6671+
self.assertIn("status=0", result.stdout)
6672+
6673+
def test_mdns_status_helper_rejects_ipv4_udp_5353_when_advertiser_is_ipv6_only(self) -> None:
6674+
with tempfile.TemporaryDirectory() as tmpdir:
6675+
mdns_bin = Path(tmpdir) / "mdns-advertiser"
6676+
mdns_bin.write_text("#!/bin/sh\necho ipv6\n")
6677+
mdns_bin.chmod(0o755)
6678+
ps_out = "201 1 S 0:00.00 mdns-advertiser /mnt/Flash/mdns-advertiser"
6679+
fstat_out = "root mdns-advertiser 201 10 internet dgram udp 0x0 *:5353"
6680+
script = f"""
6681+
RUNTIME_MDNS_BIN={shlex.quote(str(mdns_bin))}
6682+
{SMBD_STATUS_HELPERS}
6683+
ps_out={shlex.quote(ps_out)}
6684+
fstat_out={shlex.quote(fstat_out)}
6685+
if describe_managed_mdns_status "$ps_out" "$fstat_out"; then
6686+
echo status=0
6687+
else
6688+
echo status=$?
6689+
fi
6690+
"""
6691+
6692+
result = subprocess.run(["/bin/sh", "-c", script], check=False, text=True, capture_output=True)
6693+
6694+
self.assertEqual(result.returncode, 0, result.stderr)
6695+
self.assertIn("PASS:mdns-advertiser process is running", result.stdout)
6696+
self.assertIn("FAIL:mdns-advertiser is not bound to required UDP 5353 listener", result.stdout)
6697+
self.assertIn("status=1", result.stdout)
6698+
66466699
def test_probe_managed_smbd_reports_runtime_invariant_failures(self) -> None:
66476700
stdout = "\n".join(
66486701
[

tests/test_storage_runtime.py

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3174,7 +3174,7 @@ def test_common_fstat_socket_scanner_matches_process_family_and_port(self) -> No
31743174
"102\n",
31753175
)
31763176

3177-
def test_common_mdns_bound_udp_5353_requires_ipv4_only(self) -> None:
3177+
def test_common_mdns_bound_udp_5353_uses_ipv4_preferred_family_policy(self) -> None:
31783178
with tempfile.TemporaryDirectory() as tmp:
31793179
tmp_path = Path(tmp)
31803180
flash, _memory, _locks, _volumes = self.write_runtime_harness(tmp_path)
@@ -3204,19 +3204,40 @@ def test_common_mdns_bound_udp_5353_requires_ipv4_only(self) -> None:
32043204
v6_status=1
32053205
status=0
32063206
tc_mdns_bound_udp_5353 || status=$?
3207-
echo "dual_missing_v6=$status"
3207+
echo "dual_prefers_ipv4=$status"
32083208
3209+
v4_status=1
32093210
v6_status=0
32103211
status=0
32113212
tc_mdns_bound_udp_5353 || status=$?
3212-
echo "dual_bound=$status"
3213+
echo "dual_missing_ipv4=$status"
32133214
32143215
printf 'ipv4\\n' >{families_file}
32153216
v4_status=0
32163217
v6_status=1
32173218
status=0
32183219
tc_mdns_bound_udp_5353 || status=$?
32193220
echo "ipv4_only=$status"
3221+
3222+
printf 'ipv6\\n' >{families_file}
3223+
v4_status=1
3224+
v6_status=0
3225+
status=0
3226+
tc_mdns_bound_udp_5353 || status=$?
3227+
echo "ipv6_only=$status"
3228+
3229+
v4_status=0
3230+
v6_status=1
3231+
status=0
3232+
tc_mdns_bound_udp_5353 || status=$?
3233+
echo "ipv6_only_missing=$status"
3234+
3235+
printf 'ethernet\\n' >{families_file}
3236+
v4_status=0
3237+
v6_status=0
3238+
status=0
3239+
tc_mdns_bound_udp_5353 || status=$?
3240+
echo "unsupported_family=$status"
32203241
"""
32213242
)
32223243
)
@@ -3225,7 +3246,54 @@ def test_common_mdns_bound_udp_5353_requires_ipv4_only(self) -> None:
32253246
proc = subprocess.run([str(script)], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
32263247

32273248
self.assertEqual(proc.returncode, 0, proc.stderr)
3228-
self.assertEqual(proc.stdout, "dual_missing_v6=0\ndual_bound=0\nipv4_only=0\n")
3249+
self.assertEqual(
3250+
proc.stdout,
3251+
"dual_prefers_ipv4=0\n"
3252+
"dual_missing_ipv4=1\n"
3253+
"ipv4_only=0\n"
3254+
"ipv6_only=0\n"
3255+
"ipv6_only_missing=1\n"
3256+
"unsupported_family=1\n",
3257+
)
3258+
3259+
def test_common_watchdog_accepts_ipv6_udp_5353_when_advertiser_is_ipv6_only(self) -> None:
3260+
with tempfile.TemporaryDirectory() as tmp:
3261+
tmp_path = Path(tmp)
3262+
flash, memory, _locks, _volumes = self.write_runtime_harness(tmp_path)
3263+
script = tmp_path / "watchdog-mdns-ipv6-only-bound.sh"
3264+
script.write_text(
3265+
textwrap.dedent(
3266+
f"""\
3267+
#!/bin/sh
3268+
set -eu
3269+
. {flash}/common.sh
3270+
. {flash}/tcapsulesmb.conf
3271+
tc_init_runtime_env
3272+
tc_set_log "$RAM_VAR/test.log" test
3273+
mkdir -p "$RAM_VAR"
3274+
runtime_process_present_by_ucomm() {{
3275+
[ "$1" = "$MDNS_PROC_NAME" ]
3276+
}}
3277+
tc_probe_mdns_socket_families() {{ echo ipv6; }}
3278+
tc_mdns_bound_ipv4_udp_5353() {{ echo unexpected-ipv4; return 1; }}
3279+
tc_mdns_bound_ipv6_udp_5353() {{ echo ipv6-bound; return 0; }}
3280+
tc_mdns_auto_ip_available() {{ echo unexpected-auto-ip; return 0; }}
3281+
stop_runtime_process_by_ucomm() {{ echo "unexpected-stop $1"; return 1; }}
3282+
tc_restart_mdns() {{ echo unexpected-restart; return 1; }}
3283+
tc_watchdog_reconcile_mdns
3284+
"""
3285+
)
3286+
)
3287+
script.chmod(0o755)
3288+
3289+
proc = subprocess.run([str(script)], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
3290+
log_path = memory / "samba4/var/test.log"
3291+
log_text = log_path.read_text() if log_path.exists() else ""
3292+
3293+
self.assertEqual(proc.returncode, 0, proc.stderr)
3294+
self.assertEqual(proc.stdout, "ipv6-bound\n")
3295+
self.assertNotIn("watchdog recovery: mdns advertiser is running without required UDP 5353 listeners", log_text)
3296+
self.assertNotIn("unexpected", proc.stdout)
32293297

32303298
def test_common_watchdog_restarts_mdns_when_running_without_udp_5353_and_auto_ip_exists(self) -> None:
32313299
with tempfile.TemporaryDirectory() as tmp:

0 commit comments

Comments
 (0)