Files
rsync/testsuite/daemon-access-hostname_test.py
Tomáš ChvátalandZen Dodd 7472f8dad0 access: match hosts allow/deny hostname tokens against IPv6 peers (#1140)
* 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: 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: tighten IPv6 hostname matching portability

---------

Co-authored-by: Zen Dodd <mail@steadytao.com>
2026-10-26 20:51:39 +10:00

85 lines
3.0 KiB
Python
Executable File

#!/usr/bin/env python3
"""Daemon forward-DNS hostname rules against IPv4 and IPv6 peers."""
import socket
import subprocess
from rsyncfns import (
FROMDIR, SCRATCHDIR, RSYNC,
make_tree, require_tcp, rmtree, rsync_argv, start_test_daemon,
test_fail, test_skipped,
)
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")
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 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")