From 51091fcd3f61b7af94e6257befdb3bd601648e15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Chv=C3=A1tal?= Date: Sun, 25 Oct 2026 16:01:00 +0000 Subject: [PATCH 1/3] access: match hosts allow/deny hostname tokens against IPv6 peers match_hostname() resolved hostname tokens with gethostbyname(), which only returns IPv4 addresses, so a hostname token never matched an IPv6 peer: allow entries locked such clients out and deny entries silently failed open. Resolve with getaddrinfo(PF_UNSPEC) instead, mirroring match_address() in the same file. Fixes #563. --- access.c | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/access.c b/access.c index a9b6149c8..24c6771fc 100644 --- a/access.c +++ b/access.c @@ -30,8 +30,7 @@ extern const char undetermined_hostname[]; static int match_hostname(const char **host_ptr, const char *addr, const char *tok, int deny) { - struct hostent *hp; - unsigned int i; + struct addrinfo hints, *res, *res0; const char *host = *host_ptr; #ifdef SUPPORT_IDN char idn_tok[1024]; @@ -65,7 +64,10 @@ static int match_hostname(const char **host_ptr, const char *addr, const char *t return 0; /* Now try forward-DNS on the token (config-specified hostname) and see if the IP matches. */ - if (!(hp = gethostbyname(tok))) { + memset(&hints, 0, sizeof hints); + hints.ai_family = PF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + if (getaddrinfo(tok, NULL, &hints, &res0) != 0) { /* A deny-list hostname token we cannot resolve must fail CLOSED: * we can't prove the peer isn't the denied host, so treat the * unresolvable token as a match (deny). Allow-list tokens keep @@ -74,16 +76,33 @@ static int match_hostname(const char **host_ptr, const char *addr, const char *t return deny; } - for (i = 0; hp->h_addr_list[i] != NULL; i++) { - if (strcmp(addr, inet_ntoa(*(struct in_addr*)(hp->h_addr_list[i]))) == 0) { + for (res = res0; res; res = res->ai_next) { + char res_addr[128]; + const void *src_addr; + + switch (res->ai_family) { + case AF_INET: + src_addr = &((struct sockaddr_in *)res->ai_addr)->sin_addr; + break; + case AF_INET6: + src_addr = &((struct sockaddr_in6 *)res->ai_addr)->sin6_addr; + break; + default: + continue; + } + if (!inet_ntop(res->ai_family, src_addr, res_addr, sizeof res_addr)) + continue; + if (strcmp(addr, res_addr) == 0) { /* If reverse lookups are off, we'll use the conf-specified * hostname in preference to UNDETERMINED. */ if (host == undetermined_hostname) *host_ptr = strdup(tok); + freeaddrinfo(res0); return 1; } } + freeaddrinfo(res0); return 0; } From 84c92926714091f72fdaca1dc451b1eed412a165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Chv=C3=A1tal?= Date: Mon, 26 Oct 2026 09:32:28 +0000 Subject: [PATCH 2/3] access: compare forward-DNS hostname matches in binary form Address review feedback on the getaddrinfo conversion: parse the peer address once and compare each resolved address against it byte-wise within the same family, as match_address() does, instead of comparing inet_ntop() strings; keep the IPv6 cases under #ifdef INET6 so a --disable-ipv6 build compiles the IPv4 path only. Add daemon-access-hostname regressions: hostname-token allow and deny over IPv4 and IPv6 (with reverse lookup off, so only the forward path can match), forward lookup disabled, and unresolvable allow/deny tokens. --- access.c | 73 ++++++--- testsuite/daemon-access-hostname_test.py | 189 +++++++++++++++++++++++ 2 files changed, 242 insertions(+), 20 deletions(-) create mode 100755 testsuite/daemon-access-hostname_test.py diff --git a/access.c b/access.c index 24c6771fc..ecaf462d7 100644 --- a/access.c +++ b/access.c @@ -30,7 +30,9 @@ extern const char undetermined_hostname[]; static int match_hostname(const char **host_ptr, const char *addr, const char *tok, int deny) { - struct addrinfo hints, *res, *res0; + struct addrinfo hints, *res, *res0, *resa = NULL; + const char *a = NULL; + int addrlen = 0; const char *host = *host_ptr; #ifdef SUPPORT_IDN char idn_tok[1024]; @@ -76,30 +78,61 @@ static int match_hostname(const char **host_ptr, const char *addr, const char *t return deny; } - for (res = res0; res; res = res->ai_next) { - char res_addr[128]; - const void *src_addr; - - switch (res->ai_family) { - case AF_INET: - src_addr = &((struct sockaddr_in *)res->ai_addr)->sin_addr; + /* Parse the peer address once so the resolved addresses below can be + * compared against it in binary form by family, as match_address() + * does, rather than by comparing formatted strings. */ + memset(&hints, 0, sizeof hints); + hints.ai_family = PF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; +#ifdef AI_NUMERICHOST + hints.ai_flags = AI_NUMERICHOST; +#endif + if (getaddrinfo(addr, NULL, &hints, &resa) == 0) { + switch (resa->ai_family) { + case PF_INET: + a = (char *)&((struct sockaddr_in *)resa->ai_addr)->sin_addr; + addrlen = 4; break; - case AF_INET6: - src_addr = &((struct sockaddr_in6 *)res->ai_addr)->sin6_addr; +#ifdef INET6 + case PF_INET6: + a = (char *)&((struct sockaddr_in6 *)resa->ai_addr)->sin6_addr; + addrlen = 16; break; +#endif default: - continue; + break; } - if (!inet_ntop(res->ai_family, src_addr, res_addr, sizeof res_addr)) - continue; - if (strcmp(addr, res_addr) == 0) { - /* If reverse lookups are off, we'll use the conf-specified - * hostname in preference to UNDETERMINED. */ - if (host == undetermined_hostname) - *host_ptr = strdup(tok); - freeaddrinfo(res0); - return 1; + } + + if (a) { + for (res = res0; res; res = res->ai_next) { + const char *t = NULL; + + if (res->ai_family != resa->ai_family) + continue; + switch (res->ai_family) { + case PF_INET: + t = (char *)&((struct sockaddr_in *)res->ai_addr)->sin_addr; + break; +#ifdef INET6 + case PF_INET6: + t = (char *)&((struct sockaddr_in6 *)res->ai_addr)->sin6_addr; + break; +#endif + default: + continue; + } + if (memcmp(a, t, addrlen) == 0) { + /* If reverse lookups are off, we'll use the conf-specified + * hostname in preference to UNDETERMINED. */ + if (host == undetermined_hostname) + *host_ptr = strdup(tok); + freeaddrinfo(resa); + freeaddrinfo(res0); + return 1; + } } + freeaddrinfo(resa); } freeaddrinfo(res0); diff --git a/testsuite/daemon-access-hostname_test.py b/testsuite/daemon-access-hostname_test.py new file mode 100755 index 000000000..8ca04fa86 --- /dev/null +++ b/testsuite/daemon-access-hostname_test.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Daemon coverage: hosts allow / hosts deny hostname tokens matched by +forward DNS (access.c match_hostname). + +"reverse lookup" is off in every config below, so the daemon has no +reverse-DNS name for the peer ("UNDETERMINED") and the reverse-name match +in match_hostname() can never fire: only the forward-lookup path -- resolve +the config's hostname token and compare the result against the peer's +numeric address -- can match. The token is "localhost", which resolves to +the loopback address in both families, so the same token exercises the +IPv4 path against a 127.0.0.1 peer and the IPv6 path against a ::1 peer. + +Also covered: "forward lookup = no" must disable the forward match, and a +token that fails to resolve must fail CLOSED in a deny list (the peer is +denied) while simply not matching in an allow list. + +Like daemon-access-ip_test.py this needs a real TCP peer (--use-tcp), and +the configs set no global hosts allow so each module's own patterns are +the sole decider. The IPv6 daemon is spawned directly (the shared +start_test_daemon helper binds 127.0.0.1 only) and its section is skipped +with a note when this host or build has no IPv6 loopback. +""" + +import atexit +import socket +import subprocess +import time + +from rsyncfns import ( + FROMDIR, RSYNC_PEER, SCRATCHDIR, + claim_ports, make_tree, require_tcp, rmtree, rsync_argv, + split_rsync_cmd, start_test_daemon, test_fail, +) + +DAEMON_PORT = 12914 +V6_PORT = 12917 +UNRESOLVABLE = 'rsync-test-nonexistent.invalid' + +require_tcp("hosts allow/deny forward-DNS matching needs a real TCP peer") + +src = FROMDIR +rmtree(src) +make_tree(src, depth=2) + + +def resolves_to(name, family, addr): + """True if `name` forward-resolves to the binary address `addr`.""" + try: + infos = socket.getaddrinfo(name, None, family, socket.SOCK_STREAM) + except socket.gaierror: + return False + return any(info[4][0] == addr for info in infos) + + +def write_conf(path, modules, log, pidfile): + lines = [ + '# autogenerated by daemon-access-hostname_test.py', + f'pid file = {pidfile}', + 'use chroot = no', + # No reverse-DNS name for the peer: only forward matching can fire. + 'reverse lookup = no', + f'log file = {log}', + '', + ] + for mod, params in modules: + lines.append(f'[{mod}]') + lines.append(f'\tpath = {src}') + lines.append('\tread only = yes') + lines += [f'\t{k} = {v}' for k, v in params.items()] + lines.append('') + path.write_text('\n'.join(lines) + '\n', encoding='utf-8') + return path + + +def connect(url, mod): + """Return rsync's exit code for listing the module over the daemon.""" + return subprocess.run(rsync_argv('-r', f'{url}{mod}/'), + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, + text=True).returncode + + +def allowed(url, mod, why): + if connect(url, mod) != 0: + test_fail(f"connection to {mod} should be ALLOWED ({why}) but was refused") + + +def denied(url, mod, why): + if connect(url, mod) == 0: + test_fail(f"connection to {mod} should be DENIED ({why}) but succeeded") + + +# --- IPv4 peer (127.0.0.1) ------------------------------------------------- + +if not resolves_to('localhost', socket.AF_INET, '127.0.0.1'): + test_fail("this host does not resolve 'localhost' to 127.0.0.1; " + "the forward-DNS cases cannot be constructed") + +modules = [ + ('plain', {}), + ('fwd-allow4', {'hosts allow': 'localhost'}), + ('fwd-deny4', {'hosts deny': 'localhost'}), + ('fwd-off', {'hosts allow': 'localhost', 'forward lookup': 'no'}), +] +# The unresolvable-token cases are only meaningful if the name really does +# not resolve here (a wildcard resolver would defeat them). +try: + socket.getaddrinfo(UNRESOLVABLE, None, socket.AF_UNSPEC, + socket.SOCK_STREAM) + print(f"note: {UNRESOLVABLE} unexpectedly resolves here; " + "unresolvable-token cases skipped") +except socket.gaierror: + modules += [ + ('deny-unresolvable', {'hosts deny': UNRESOLVABLE}), + ('allow-unresolvable', {'hosts allow': UNRESOLVABLE}), + ] + +conf = write_conf(SCRATCHDIR / 'access-hostname.conf', modules, + SCRATCHDIR / 'rsyncd.log', SCRATCHDIR / 'rsyncd.pid') +url = start_test_daemon(conf, DAEMON_PORT) + +allowed(url, 'plain', "no hosts patterns on the module") +allowed(url, 'fwd-allow4', "localhost forward-resolves to the 127.0.0.1 peer") +denied(url, 'fwd-deny4', "localhost forward-resolves to the 127.0.0.1 peer") +denied(url, 'fwd-off', "forward lookup is disabled for the module") +if any(mod == 'deny-unresolvable' for mod, _ in modules): + denied(url, 'deny-unresolvable', + "an unresolvable deny token fails closed") + denied(url, 'allow-unresolvable', + "an unresolvable allow token does not match") + +# --- IPv6 peer (::1) -------------------------------------------------------- + +v6_ok = resolves_to('localhost', socket.AF_INET6, '::1') +if v6_ok: + try: + with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as s: + s.bind(('::1', 0)) + except OSError: + v6_ok = False + +if not v6_ok: + print("note: no IPv6 loopback for 'localhost' here; IPv6 cases skipped") +else: + modules6 = [ + ('plain6', {}), + ('fwd-allow6', {'hosts allow': 'localhost'}), + ('fwd-deny6', {'hosts deny': 'localhost'}), + ] + conf6 = write_conf(SCRATCHDIR / 'access-hostname6.conf', modules6, + SCRATCHDIR / 'rsyncd6.log', SCRATCHDIR / 'rsyncd6.pid') + claim_ports(V6_PORT) + # start_test_daemon binds 127.0.0.1 only, so spawn the IPv6 daemon + # directly, with the same flags start_rsyncd uses apart from --address. + proc = subprocess.Popen( + split_rsync_cmd(RSYNC_PEER) + [ + '--daemon', '--no-detach', '--address=::1', + f'--port={V6_PORT}', f'--config={conf6}', + ], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + atexit.register(proc.terminate) + deadline = time.monotonic() + 10 + up = False + while time.monotonic() < deadline: + if proc.poll() is not None: + break + try: + with socket.create_connection(('::1', V6_PORT), timeout=0.5): + up = True + break + except OSError: + time.sleep(0.05) + if not up: + proc.terminate() + print("note: rsyncd did not listen on ::1 (built without INET6?); " + "IPv6 cases skipped") + else: + try: + url6 = f'rsync://[::1]:{V6_PORT}/' + allowed(url6, 'plain6', "no hosts patterns on the module") + allowed(url6, 'fwd-allow6', + "localhost forward-resolves to the ::1 peer") + denied(url6, 'fwd-deny6', + "localhost forward-resolves to the ::1 peer") + finally: + proc.terminate() + proc.wait(timeout=5) + +print("daemon-access-hostname: forward-DNS hosts allow/deny matching verified") From 36268e30eeb05aa90c4c7269b9ceea31e0684066 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Mon, 26 Oct 2026 20:19:55 +1000 Subject: [PATCH 3/3] access: tighten IPv6 hostname matching portability --- Makefile.in | 6 +- access.c | 18 +- testsuite/daemon-access-hostname_test.py | 245 +++++++---------------- testsuite/rsyncfns.py | 32 +-- testsuite/skiplist/common.txt | 1 + 5 files changed, 109 insertions(+), 193 deletions(-) diff --git a/Makefile.in b/Makefile.in index 716171db1..fedf2f44a 100644 --- a/Makefile.in +++ b/Makefile.in @@ -68,7 +68,7 @@ CHECK_SYMLINKS = testsuite/chown-fake_test.py testsuite/devices-fake_test.py \ # Objects for CHECK_PROGS to clean CHECK_OBJS=tls.o testrun.o getgroups.o getfsdev.o t_stub.o t_unsafe.o t_chmod_secure.o t_rename_secure.o t_symlink_secure.o t_secure_relpath.o t_acl.o t_hashtable_overflow.o t_iwildmatch.o t_clean_fname.o t_safe_arg.o trimslash.o wildtest.o # Compile-only feature-shape checks. -CHECK_COMPILE_OBJS=syscall-no-at-fdcwd.o +CHECK_COMPILE_OBJS=syscall-no-at-fdcwd.o access-no-inet6.o # note that the -I. is needed to handle config.h when using VPATH .c.o: @@ -85,6 +85,10 @@ syscall-no-at-fdcwd.o: syscall.c $(HEADERS) $(CC) -I. -I$(srcdir) $(CFLAGS) $(CPPFLAGS) \ -DRSYNC_TEST_NO_AT_FDCWD -c $(srcdir)/syscall.c -o $@ +access-no-inet6.o: access.c $(HEADERS) + $(CC) -I. -I$(srcdir) $(CFLAGS) $(CPPFLAGS) \ + -DRSYNC_TEST_NO_INET6 -c $(srcdir)/access.c -o $@ + .PHONY: install install: all -$(MKDIR_P) $(DESTDIR)$(bindir) diff --git a/access.c b/access.c index ecaf462d7..8031feb72 100644 --- a/access.c +++ b/access.c @@ -20,6 +20,9 @@ #include "rsync.h" #include "ifuncs.h" +#ifdef RSYNC_TEST_NO_INET6 +#undef INET6 +#endif #ifdef HAVE_NETGROUP_H #include #endif @@ -115,9 +118,17 @@ static int match_hostname(const char **host_ptr, const char *addr, const char *t t = (char *)&((struct sockaddr_in *)res->ai_addr)->sin_addr; break; #ifdef INET6 - case PF_INET6: - t = (char *)&((struct sockaddr_in6 *)res->ai_addr)->sin6_addr; + case PF_INET6: { + const struct sockaddr_in6 *peer6 = (const struct sockaddr_in6 *)resa->ai_addr; + const struct sockaddr_in6 *resolved6 = (const struct sockaddr_in6 *)res->ai_addr; + +#ifdef HAVE_SOCKADDR_IN6_SCOPE_ID + if (resolved6->sin6_scope_id && peer6->sin6_scope_id != resolved6->sin6_scope_id) + continue; +#endif + t = (char *)&resolved6->sin6_addr; break; + } #endif default: continue; @@ -132,9 +143,10 @@ static int match_hostname(const char **host_ptr, const char *addr, const char *t return 1; } } - freeaddrinfo(resa); } + if (resa) + freeaddrinfo(resa); freeaddrinfo(res0); return 0; } diff --git a/testsuite/daemon-access-hostname_test.py b/testsuite/daemon-access-hostname_test.py index 8ca04fa86..6c15da1bc 100755 --- a/testsuite/daemon-access-hostname_test.py +++ b/testsuite/daemon-access-hostname_test.py @@ -1,189 +1,84 @@ #!/usr/bin/env python3 -"""Daemon coverage: hosts allow / hosts deny hostname tokens matched by -forward DNS (access.c match_hostname). - -"reverse lookup" is off in every config below, so the daemon has no -reverse-DNS name for the peer ("UNDETERMINED") and the reverse-name match -in match_hostname() can never fire: only the forward-lookup path -- resolve -the config's hostname token and compare the result against the peer's -numeric address -- can match. The token is "localhost", which resolves to -the loopback address in both families, so the same token exercises the -IPv4 path against a 127.0.0.1 peer and the IPv6 path against a ::1 peer. - -Also covered: "forward lookup = no" must disable the forward match, and a -token that fails to resolve must fail CLOSED in a deny list (the peer is -denied) while simply not matching in an allow list. - -Like daemon-access-ip_test.py this needs a real TCP peer (--use-tcp), and -the configs set no global hosts allow so each module's own patterns are -the sole decider. The IPv6 daemon is spawned directly (the shared -start_test_daemon helper binds 127.0.0.1 only) and its section is skipped -with a note when this host or build has no IPv6 loopback. -""" - -import atexit +"""Daemon forward-DNS hostname rules against IPv4 and IPv6 peers.""" + import socket import subprocess -import time from rsyncfns import ( - FROMDIR, RSYNC_PEER, SCRATCHDIR, - claim_ports, make_tree, require_tcp, rmtree, rsync_argv, - split_rsync_cmd, start_test_daemon, test_fail, + FROMDIR, SCRATCHDIR, RSYNC, + make_tree, require_tcp, rmtree, rsync_argv, start_test_daemon, + test_fail, test_skipped, ) -DAEMON_PORT = 12914 -V6_PORT = 12917 -UNRESOLVABLE = 'rsync-test-nonexistent.invalid' +IPV4_PORT = 12895 +IPV6_PORT = 12897 +require_tcp("hostname access matching needs a real TCP peer") + +try: + for family, address in ((socket.AF_INET, '127.0.0.1'), (socket.AF_INET6, '::1')): + with socket.socket(family, socket.SOCK_STREAM) as probe: + probe.bind((address, 0)) +except OSError: + test_skipped("IPv4 or IPv6 loopback is unavailable") -require_tcp("hosts allow/deny forward-DNS matching needs a real TCP peer") + +def loopback_name(family, address): + for candidate in ('localhost', 'ip6-localhost', 'ip6-loopback', socket.gethostname()): + try: + answers = socket.getaddrinfo(candidate, None, socket.AF_UNSPEC, socket.SOCK_STREAM) + except socket.gaierror: + continue + if any(item[0] == family and item[4][0] == address for item in answers): + return candidate + test_skipped(f"no local hostname resolves to {address}") + + +host4 = loopback_name(socket.AF_INET, '127.0.0.1') +host6 = loopback_name(socket.AF_INET6, '::1') src = FROMDIR rmtree(src) make_tree(src, depth=2) -def resolves_to(name, family, addr): - """True if `name` forward-resolves to the binary address `addr`.""" - try: - infos = socket.getaddrinfo(name, None, family, socket.SOCK_STREAM) - except socket.gaierror: - return False - return any(info[4][0] == addr for info in infos) - - -def write_conf(path, modules, log, pidfile): - lines = [ - '# autogenerated by daemon-access-hostname_test.py', - f'pid file = {pidfile}', - 'use chroot = no', - # No reverse-DNS name for the peer: only forward matching can fire. - 'reverse lookup = no', - f'log file = {log}', - '', - ] - for mod, params in modules: - lines.append(f'[{mod}]') - lines.append(f'\tpath = {src}') - lines.append('\tread only = yes') - lines += [f'\t{k} = {v}' for k, v in params.items()] - lines.append('') - path.write_text('\n'.join(lines) + '\n', encoding='utf-8') - return path - - -def connect(url, mod): - """Return rsync's exit code for listing the module over the daemon.""" - return subprocess.run(rsync_argv('-r', f'{url}{mod}/'), - stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, - text=True).returncode - - -def allowed(url, mod, why): - if connect(url, mod) != 0: - test_fail(f"connection to {mod} should be ALLOWED ({why}) but was refused") - - -def denied(url, mod, why): - if connect(url, mod) == 0: - test_fail(f"connection to {mod} should be DENIED ({why}) but succeeded") - - -# --- IPv4 peer (127.0.0.1) ------------------------------------------------- - -if not resolves_to('localhost', socket.AF_INET, '127.0.0.1'): - test_fail("this host does not resolve 'localhost' to 127.0.0.1; " - "the forward-DNS cases cannot be constructed") - -modules = [ - ('plain', {}), - ('fwd-allow4', {'hosts allow': 'localhost'}), - ('fwd-deny4', {'hosts deny': 'localhost'}), - ('fwd-off', {'hosts allow': 'localhost', 'forward lookup': 'no'}), -] -# The unresolvable-token cases are only meaningful if the name really does -# not resolve here (a wildcard resolver would defeat them). -try: - socket.getaddrinfo(UNRESOLVABLE, None, socket.AF_UNSPEC, - socket.SOCK_STREAM) - print(f"note: {UNRESOLVABLE} unexpectedly resolves here; " - "unresolvable-token cases skipped") -except socket.gaierror: - modules += [ - ('deny-unresolvable', {'hosts deny': UNRESOLVABLE}), - ('allow-unresolvable', {'hosts allow': UNRESOLVABLE}), - ] - -conf = write_conf(SCRATCHDIR / 'access-hostname.conf', modules, - SCRATCHDIR / 'rsyncd.log', SCRATCHDIR / 'rsyncd.pid') -url = start_test_daemon(conf, DAEMON_PORT) - -allowed(url, 'plain', "no hosts patterns on the module") -allowed(url, 'fwd-allow4', "localhost forward-resolves to the 127.0.0.1 peer") -denied(url, 'fwd-deny4', "localhost forward-resolves to the 127.0.0.1 peer") -denied(url, 'fwd-off', "forward lookup is disabled for the module") -if any(mod == 'deny-unresolvable' for mod, _ in modules): - denied(url, 'deny-unresolvable', - "an unresolvable deny token fails closed") - denied(url, 'allow-unresolvable', - "an unresolvable allow token does not match") - -# --- IPv6 peer (::1) -------------------------------------------------------- - -v6_ok = resolves_to('localhost', socket.AF_INET6, '::1') -if v6_ok: - try: - with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as s: - s.bind(('::1', 0)) - except OSError: - v6_ok = False - -if not v6_ok: - print("note: no IPv6 loopback for 'localhost' here; IPv6 cases skipped") -else: - modules6 = [ - ('plain6', {}), - ('fwd-allow6', {'hosts allow': 'localhost'}), - ('fwd-deny6', {'hosts deny': 'localhost'}), - ] - conf6 = write_conf(SCRATCHDIR / 'access-hostname6.conf', modules6, - SCRATCHDIR / 'rsyncd6.log', SCRATCHDIR / 'rsyncd6.pid') - claim_ports(V6_PORT) - # start_test_daemon binds 127.0.0.1 only, so spawn the IPv6 daemon - # directly, with the same flags start_rsyncd uses apart from --address. - proc = subprocess.Popen( - split_rsync_cmd(RSYNC_PEER) + [ - '--daemon', '--no-detach', '--address=::1', - f'--port={V6_PORT}', f'--config={conf6}', - ], - stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL) - atexit.register(proc.terminate) - deadline = time.monotonic() + 10 - up = False - while time.monotonic() < deadline: - if proc.poll() is not None: - break - try: - with socket.create_connection(('::1', V6_PORT), timeout=0.5): - up = True - break - except OSError: - time.sleep(0.05) - if not up: - proc.terminate() - print("note: rsyncd did not listen on ::1 (built without INET6?); " - "IPv6 cases skipped") - else: - try: - url6 = f'rsync://[::1]:{V6_PORT}/' - allowed(url6, 'plain6', "no hosts patterns on the module") - allowed(url6, 'fwd-allow6', - "localhost forward-resolves to the ::1 peer") - denied(url6, 'fwd-deny6', - "localhost forward-resolves to the ::1 peer") - finally: - proc.terminate() - proc.wait(timeout=5) - -print("daemon-access-hostname: forward-DNS hosts allow/deny matching verified") +def start(family, address, host, port): + conf = SCRATCHDIR / f'access-hostname-{family}.conf' + conf.write_text( + f"pid file = {SCRATCHDIR}/rsyncd-{family}.pid\n" + "use chroot = no\n" + "reverse lookup = no\n" + "forward lookup = yes\n" + f"log file = {SCRATCHDIR}/{'rsyncd.log' if family == 'ipv6' else 'rsyncd-ipv4.log'}\n" + f"\n[plain]\n\tpath = {src}\n\tread only = yes\n" + f"\n[allow-host]\n\tpath = {src}\n\tread only = yes\n\thosts allow = {host}\n" + f"\n[deny-host]\n\tpath = {src}\n\tread only = yes\n\thosts deny = {host}\n" + f"\n[allow-unresolved]\n\tpath = {src}\n\tread only = yes\n\thosts allow = nope.nonexistent.invalid\n" + f"\n[deny-unresolved]\n\tpath = {src}\n\tread only = yes\n\thosts deny = nope.nonexistent.invalid\n" + f"\n[forward-off]\n\tpath = {src}\n\tread only = yes\n\tforward lookup = no\n\thosts allow = {host}\n" + ) + return start_test_daemon(conf, port, rsync_cmd=RSYNC, address=address) + + +urls = ( + ('IPv4', start('ipv4', '127.0.0.1', host4, IPV4_PORT)), + ('IPv6', start('ipv6', '::1', host6, IPV6_PORT)), +) + + +def connect(url, module): + return subprocess.run( + rsync_argv('-r', f'{url}{module}/'), + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True, + ).returncode + + +for family, url in urls: + for module in ('plain', 'allow-host'): + if connect(url, module) != 0: + test_fail(f"{family} connection to {module} should be allowed") + + for module in ('deny-host', 'allow-unresolved', 'deny-unresolved', 'forward-off'): + if connect(url, module) == 0: + test_fail(f"{family} connection to {module} should be denied") + +print("daemon-access-hostname: IPv4 and IPv6 hostname rules verified") diff --git a/testsuite/rsyncfns.py b/testsuite/rsyncfns.py index 7524cfff0..0cb473e59 100644 --- a/testsuite/rsyncfns.py +++ b/testsuite/rsyncfns.py @@ -768,12 +768,12 @@ def _cleanup_rsyncd(proc, port: int) -> 'None': def start_rsyncd(conf_path, port: int, rsync_cmd: str = None, - stdin=subprocess.DEVNULL) -> 'subprocess.Popen': - """Spawn `rsync --daemon --no-detach --address=127.0.0.1 --port=N + stdin=subprocess.DEVNULL, address: str = '127.0.0.1') -> 'subprocess.Popen': + """Spawn `rsync --daemon --no-detach --address=ADDRESS --port=N --config=conf` and return the Popen handle after the port is accepting connections. - The daemon is bound to LOOPBACK ONLY (--address=127.0.0.1): without it, + The daemon is bound to LOOPBACK ONLY: without an explicit address, rsync --daemon binds 0.0.0.0 and the test modules would be reachable from the whole LAN. The daemon is killed automatically when this Python process exits (atexit). On Linux, the kernel also signals SIGTERM to the @@ -791,9 +791,11 @@ def start_rsyncd(conf_path, port: int, rsync_cmd: str = None, This is only ever reached from start_test_daemon() in --use-tcp mode; the default (pipe) mode never starts a listening daemon. """ + if address not in ('127.0.0.1', '::1'): + test_fail(f"test daemon address is not loopback: {address}") argv = shlex.split(rsync_cmd or RSYNC_PEER) + [ '--daemon', '--no-detach', - '--address=127.0.0.1', + f'--address={address}', f'--port={port}', f'--config={conf_path}', ] @@ -820,17 +822,18 @@ def start_rsyncd(conf_path, port: int, rsync_cmd: str = None, f"(status={proc.returncode})" ) try: - with _socket.create_connection(('127.0.0.1', port), timeout=0.5): + with _socket.create_connection((address, port), timeout=0.5): return proc except OSError as e: last_err = e time.sleep(0.05) _stop_rsyncd(proc) - test_fail(f"rsyncd never listened on 127.0.0.1:{port}: {last_err}") + test_fail(f"rsyncd never listened on {address}:{port}: {last_err}") -def start_test_daemon(conf_path, port: int, rsync_cmd: str = None) -> str: +def start_test_daemon(conf_path, port: int, rsync_cmd: str = None, + address: str = '127.0.0.1') -> str: """Bring up the test daemon and return a URL prefix for client commands. rsync_cmd selects the daemon-side binary (default RSYNC_PEER); pass @@ -844,11 +847,11 @@ def start_test_daemon(conf_path, port: int, rsync_cmd: str = None) -> str: 'rsync://localhost/'. Another local user can't reach it; nothing is listening. - * --use-tcp -- starts a real rsyncd bound to 127.0.0.1 on the given - claim_ports()-reserved port. Returns 'rsync://localhost:PORT/'. Bound - to loopback so off-host/LAN access is impossible; a same-host user - could still connect during the test window, which is the documented, - accepted cost of explicitly opting into TCP. + * --use-tcp -- starts a real rsyncd bound to IPv4 loopback by default, + or to the selected loopback address, on the claim_ports()-reserved + port. Off-host/LAN access is impossible; a same-host user could still + connect during the test window, which is the documented, accepted cost + of explicitly opting into TCP. Build URLs as f"{prefix}module/path". `port` is only used (and claimed) in --use-tcp mode. @@ -856,8 +859,9 @@ def start_test_daemon(conf_path, port: int, rsync_cmd: str = None) -> str: daemon_cmd = rsync_cmd or RSYNC_PEER if USE_TCP: port = claim_free_port(port) - start_rsyncd(conf_path, port, daemon_cmd) - return f'rsync://localhost:{port}/' + start_rsyncd(conf_path, port, daemon_cmd, address=address) + host = 'localhost' if address == '127.0.0.1' else f'[{address}]' + return f'rsync://{host}:{port}/' # RSYNC_CONNECT_PROG is run by a shell, so every word has to survive # re-parsing: a build path with a space would otherwise exec its prefix. os.environ['RSYNC_CONNECT_PROG'] = ( diff --git a/testsuite/skiplist/common.txt b/testsuite/skiplist/common.txt index cb09e33e8..9517cf18b 100644 --- a/testsuite/skiplist/common.txt +++ b/testsuite/skiplist/common.txt @@ -11,6 +11,7 @@ checksum-zero-blocklen # the pure-Python receiver needs a real TCP socket; run with --use-tcp chroot-basis-forge-inner-module # the pure-Python sender needs a real TCP daemon; run with --use-tcp +daemon-access-hostname # IPv6 hostname access matching needs a real TCP peer daemon-access-idn # hosts allow/deny hostname matching needs a real TCP peer daemon-access-ip # hosts allow/deny address matching needs a real TCP peer daemon-argv-limit # raw malicious daemon client needs a real TCP daemon; run with --use-tcp