diff --git a/Dockerfile b/Dockerfile index e1f6105e..34a6ea78 100644 --- a/Dockerfile +++ b/Dockerfile @@ -112,8 +112,9 @@ RUN chown ircu:ircu /opt/ircu/lib/iauth-dns-stub.pl # Create empty motd file RUN touch /opt/ircu/lib/ircd.motd && chown ircu:ircu /opt/ircu/lib/ircd.motd -COPY tests/docker/iauth-tilded.pl /opt/ircu/bin/iauth-tilded.pl -RUN chmod +x /opt/ircu/bin/iauth-tilded.pl && chown ircu:ircu /opt/ircu/bin/iauth-tilded.pl +# IAuth stub for CF/websocket trusted-username tests (not in shared hub conf). +COPY tests/docker/iauth-trust-username.pl /opt/ircu/bin/iauth-trust-username.pl +RUN chmod +x /opt/ircu/bin/iauth-trust-username.pl && chown ircu:ircu /opt/ircu/bin/iauth-trust-username.pl COPY tests/docker/ircd-entrypoint.sh /opt/ircu/lib/ircd-entrypoint.sh RUN chmod 755 /opt/ircu/lib/ircd-entrypoint.sh diff --git a/doc/example.conf b/doc/example.conf index 2f857dbf..8dad3650 100644 --- a/doc/example.conf +++ b/doc/example.conf @@ -192,9 +192,11 @@ Class { # limits the number of matching clients allowed from a particular IP # address. # -# If any Client block contains a non-empty username, IDENT lookups are -# performed for all clients except those on a websocket cloudflare port, -# and clients for whom an IDENT lookup fails and clients on websocket cloudflare ports +# If any Client block contains a non-empty username component (from +# username =, or host = "user@host", or ip = "user@host"), IDENT lookups are +# performed for all clients except those on a websocket cloudflare port. +# Typical production configs use host = "*@*" (username mask "*"). Clients +# for whom an IDENT lookup fails and clients on websocket cloudflare ports # are given the username they claim in the USER command with a ~ prefix. # # Take the following class blocks only as a guide. diff --git a/tests/cidr_glines/test_family_ambiguous_masks.py b/tests/cidr_glines/test_family_ambiguous_masks.py index fd6ade81..4dd7b1b2 100644 --- a/tests/cidr_glines/test_family_ambiguous_masks.py +++ b/tests/cidr_glines/test_family_ambiguous_masks.py @@ -199,13 +199,17 @@ async def test_ambiguous_mask_blocks_reconnect(ircd_hub, oper): try: await victim.send("NICK vict5c") await victim.send("USER victim 0 * :Test Victim") - deadline = asyncio.get_running_loop().time() + 5.0 + # Ident lookup must time out before find_kill runs; allow headroom. + deadline = asyncio.get_running_loop().time() + 20.0 while True: remaining = deadline - asyncio.get_running_loop().time() if remaining <= 0: break - msg = await victim.recv(timeout=remaining) - if msg.command == "ERROR": + try: + msg = await victim.recv(timeout=remaining) + except (asyncio.TimeoutError, TimeoutError): + break + if msg.command in ("ERROR", "465"): refused = True break if msg.command == "001": diff --git a/tests/docker/iauth-tilded.pl b/tests/docker/iauth-tilded.pl deleted file mode 100644 index 001597e4..00000000 --- a/tests/docker/iauth-tilded.pl +++ /dev/null @@ -1,36 +0,0 @@ -#! /usr/bin/perl -# Minimal IAuth helper for trust-username tests: force ~ prefix on usernames. -use strict; -use warnings; -use FileHandle; - -my %pending; - -sub reply { - my ($msg, $client) = @_; - return unless defined $msg; - if (ref $msg eq '') { - $msg =~ s/^(.) ?/$1 $client->{id} $client->{ip} $client->{port} / if $client; - print "$msg\n"; - } -} - -autoflush STDOUT 1; -print "O ARU\n"; - -while (<>) { - s/\r?\n?\r?$//; - my $client = $pending{my $id = $1} if s/^(\d+) //; - - if (/^C (\S+) (\S+) (.+)$/) { - $pending{$id} = { id => $id, ip => $1, port => $2 }; - } elsif (/^([DT])/ and $client) { - delete $pending{$id}; - } elsif (/^u (\S+)/ and $client) { - my $user = $1; - $user = "~$user" unless $user =~ /^~/; - reply("u $user", $client); - } elsif (/^n (.+)$/ and $client) { - reply("D", $client); - } -} diff --git a/tests/docker/iauth-trust-username.pl b/tests/docker/iauth-trust-username.pl new file mode 100644 index 00000000..b3272902 --- /dev/null +++ b/tests/docker/iauth-trust-username.pl @@ -0,0 +1,48 @@ +#! /usr/bin/perl +# IAuth helper: assert a trusted username (iauth "U") with no leading ~. +# +# Used by the Cloudflare/websocket tests to prove that when iauth sets a +# username during auth, ircu does not prepend ~ — even on ports that skip +# the ident query. This is the opposite of forcing a tilde. +use strict; +use warnings; +use FileHandle; + +my %pending; + +sub reply { + my ($msg, $client) = @_; + return unless defined $msg; + if (ref $msg eq '') { + $msg =~ s/^(.) ?/$1 $client->{id} $client->{ip} $client->{port} / if $client; + print "$msg\n"; + } +} + +# Strip an interim ~ that ircu may have already prepended before notifying +# iauth; the trusted name we assert must not keep that marker. +sub trust_user { + my ($user) = @_; + $user =~ s/^~//; + return $user; +} + +autoflush STDOUT 1; +# A: get USER (U) from the client; R: require approval; U: Undernet n/u/H. +print "O ARU\n"; + +while (<>) { + s/\r?\n?\r?$//; + my $client = $pending{my $id = $1} if s/^(\d+) //; + + if (/^C (\S+) (\S+) (.+)$/) { + $pending{$id} = { id => $id, ip => $1, port => $2 }; + } elsif (/^([DT])/ and $client) { + delete $pending{$id}; + } elsif (/^[Uu] (\S+)/ and $client) { + # Trusted Username (capital U): GotId, no tilde on registration. + reply("U " . trust_user($1), $client); + } elsif (/^n (.+)$/ and $client) { + reply("D", $client); + } +} diff --git a/tests/docker/ircd-hub.conf b/tests/docker/ircd-hub.conf index 9fdfd5a5..365399de 100644 --- a/tests/docker/ircd-hub.conf +++ b/tests/docker/ircd-hub.conf @@ -85,20 +85,18 @@ Class { maxlinks = 100; }; -# username = "ident" enables DoIdentLookups globally (any non-empty Client -# username mask does). Failed/skipped ident then gets a leading ~, including -# on cloudflare = yes ports that never query ident. The companion Client -# without username still accepts clients after a failed lookup. +# DoIdentLookups is set when any Client block has a non-empty username +# component: dedicated username =, or host = "user@host", or ip = "user@host". +# Production configs usually use host = "*@*" (username mask "*"), not a +# separate username line. Here username = "ident" enables lookups and defines +# a line for successful ident; the companion Client without username accepts +# clients after a failed lookup. Client { ip = "*"; class = "Local"; username = "ident"; maxlinks = 50; }; Client { ip = "*"; class = "Local"; maxlinks = 50; }; # Connections on the exempt WebSocket port (7002) are flood-exempt. Defined # last so it is matched first (Client blocks are checked in reverse order). Client { ip = "*"; port = 7002; class = "Exempt"; maxlinks = 50; }; -# Forces ~ on USER names for trust-username (+x display) tests. Independent -# of DoIdentLookups; disable both when testing "ident off => no tilde". -IAuth { program = "/opt/ircu/bin/iauth-tilded.pl"; }; - Operator { local = no; class = "Local"; diff --git a/tests/docker/ircd-leaf1.conf b/tests/docker/ircd-leaf1.conf index c69228eb..5d9d679e 100644 --- a/tests/docker/ircd-leaf1.conf +++ b/tests/docker/ircd-leaf1.conf @@ -54,8 +54,6 @@ Client { ip = "*"; class = "Local"; }; # Route clients that connect on the dedicated exempt port (6690) into theExempt class. Client { ip = "*"; port = 6690; class = "Exempt"; }; -IAuth { program = "/opt/ircu/bin/iauth-tilded.pl"; }; - Operator { local = no; class = "Local"; diff --git a/tests/docker/ircd-leaf2.conf b/tests/docker/ircd-leaf2.conf index 48844301..38feb443 100644 --- a/tests/docker/ircd-leaf2.conf +++ b/tests/docker/ircd-leaf2.conf @@ -42,8 +42,6 @@ Class { Client { ip = "*"; class = "Local"; }; -IAuth { program = "/opt/ircu/bin/iauth-tilded.pl"; }; - Operator { local = no; class = "Local"; diff --git a/tests/pr_websocket/test_websocket_cloudflare.py b/tests/pr_websocket/test_websocket_cloudflare.py index 0f4697b2..5d7ef30a 100644 --- a/tests/pr_websocket/test_websocket_cloudflare.py +++ b/tests/pr_websocket/test_websocket_cloudflare.py @@ -39,12 +39,13 @@ HUB_CONTAINER = "ircu-hub" HUB_CONF_CONTAINER = "/opt/ircu/lib/ircd.conf" -# Exact lines from tests/docker/ircd-hub.conf (DoIdentLookups + tilde harness). +# Hub test harness: explicit username = "ident" Client line enables DoIdentLookups. _IDENT_CLIENT_LINE = ( 'Client { ip = "*"; class = "Local"; username = "ident"; maxlinks = 50; };\n' ) -_IAUTH_TILDED_LINE = 'IAuth { program = "/opt/ircu/bin/iauth-tilded.pl"; };\n' - +_IAUTH_TRUST_LINE = ( + 'IAuth { program = "/opt/ircu/bin/iauth-trust-username.pl"; };\n' +) def _raw_ws_handshake(*extra_header_lines: bytes) -> bytes: lines = [ @@ -268,32 +269,103 @@ def _notice_blob(notices: list[str]) -> str: @pytest.mark.asyncio -async def test_cloudflare_websocket_keeps_tilde_without_ident(ircd_hub, make_client): - """Skipping ident on cloudflare ports must not trust the USER username. +async def test_cloudflare_websocket_keeps_tilde_without_ident( + ircd_hub, make_client, request +): + """CF ports skip ident but still get ~ from ircu when lookups are enabled.""" + baseline = HUB_CONF_HOST.read_text() + assert _IDENT_CLIENT_LINE in baseline, "hub conf missing username=ident Client line" - Hub config enables DoIdentLookups via ``Client { username = "ident"; }``. - Clients still get a leading ~ unless iauth/WEBIRC explicitly trusts the - name. Successful ident cannot run here; the query is skipped on CF ports. + oper = await make_client("cftilop") + await _oper_up(oper) + try: + observer = await make_client("cftilob") + nick = f"cftu{random.randint(0, 999_999)}" + headers = (b"CF-Connecting-IP: " + CF_CLIENT_IP.encode() + b"\r\n",) + notices, _, ws_writer = await _ws_register_collect_notices( + CF_WS_PORT, nick, extra_headers=headers, keep_open=True + ) + assert ws_writer is not None + try: + blob = _notice_blob(notices) + assert "Checking Ident" not in blob, ( + f"cloudflare WS should skip ident, got notices: {blob!r}" + ) + username, host = await _whois_userhost(observer, nick) + assert host == CF_CLIENT_IP, f"expected CF IP host, got {host!r}" + assert username == "~wsuser", ( + "with DoIdentLookups on, cloudflare WS must keep tilde, " + f"got {username!r}" + ) + finally: + ws_writer.write(_masked_text_frame("QUIT :done")) + await ws_writer.drain() + ws_writer.close() + await observer.send("QUIT :done") + await observer.disconnect() + finally: + await oper.disconnect() + + +@pytest.mark.asyncio +async def test_cloudflare_iauth_trusted_username_no_tilde( + ircd_hub, make_client, request +): + """When iauth asserts a trusted username (U), CF WS must not get a tilde. + + Ident is skipped on cloudflare ports, so without iauth the client would + get ~ from DoIdentLookups. Enabling iauth-trust-username.pl temporarily + proves the trust path: iauth sets the username, and ircu must not prepend ~. """ - observer = await make_client("cftil") - nick = f"cftu{random.randint(0, 999_999)}" - headers = (b"CF-Connecting-IP: " + CF_CLIENT_IP.encode() + b"\r\n",) - _, _, ws_writer = await _ws_register_collect_notices( - CF_WS_PORT, nick, extra_headers=headers, keep_open=True - ) - assert ws_writer is not None + baseline = HUB_CONF_HOST.read_text() + assert _IDENT_CLIENT_LINE in baseline, "hub conf missing username=ident Client line" + assert "IAuth {" not in baseline, "shared hub must not run a permanent IAuth" + + request.addfinalizer(lambda: _restore_hub_baseline(baseline)) + + patched = baseline.rstrip() + "\n" + _IAUTH_TRUST_LINE + assert _IAUTH_TRUST_LINE in patched + + oper = await make_client("cfiauthop") + await _oper_up(oper) try: - username, host = await _whois_userhost(observer, nick) - assert host == CF_CLIENT_IP, f"expected CF IP host, got {host!r}" - assert username.startswith("~"), ( - f"cloudflare WS without trusted username must keep tilde, got {username!r}" + _write_hub_config(patched) + await _rehash_hub(oper) + + observer = await make_client("cfiauthob") + nick = f"cfiu{random.randint(0, 999_999)}" + headers = (b"CF-Connecting-IP: " + CF_CLIENT_IP.encode() + b"\r\n",) + notices, _, ws_writer = await _ws_register_collect_notices( + CF_WS_PORT, nick, extra_headers=headers, keep_open=True ) - assert username == "~wsuser", f"unexpected username {username!r}" + assert ws_writer is not None + try: + blob = _notice_blob(notices) + assert "Checking Ident" not in blob, ( + f"cloudflare WS should skip ident, got notices: {blob!r}" + ) + username, host = await _whois_userhost(observer, nick) + assert host == CF_CLIENT_IP, f"expected CF IP host, got {host!r}" + assert username == "wsuser", ( + "iauth trusted username must register without tilde on CF WS, " + f"got {username!r}" + ) + finally: + ws_writer.write(_masked_text_frame("QUIT :done")) + await ws_writer.drain() + ws_writer.close() + await observer.send("QUIT :done") + await observer.disconnect() finally: - ws_writer.write(_masked_text_frame("QUIT :done")) - await ws_writer.drain() - ws_writer.close() - await observer.send("QUIT :done") + _restore_hub_baseline(baseline) + conf = _hub_conf_text() + assert _IDENT_CLIENT_LINE in conf, "hub conf restore missing username=ident" + assert "IAuth {" not in conf, "hub conf restore left IAuth enabled" + try: + await _rehash_hub(oper) + except Exception: + pass + await oper.disconnect() @pytest.mark.asyncio @@ -400,10 +472,11 @@ def _sighup_hub_ircd() -> None: def _restore_hub_baseline(baseline: str) -> None: """Always rewrite the baked hub conf and SIGHUP so later tests see tildes again. - The patched config removes ``username = "ident"`` and iauth-tilded; if a - run is interrupted before REHASH restore, subsequent trust_username tests - register without ``~`` and fail in cascade. File write + SIGHUP works even - when the test's oper client is already dead. + The patched config removes ``username = "ident"`` or adds a temporary + IAuth trust stub; if a run is interrupted before REHASH restore, subsequent + trust_username tests register without ``~`` (or hang on required iauth) + and fail in cascade. File write + SIGHUP works even when the test's oper + client is already dead. """ _write_hub_config(baseline) _sighup_hub_ircd() @@ -439,24 +512,18 @@ async def test_cloudflare_no_tilde_when_ident_lookups_disabled( ): """CF ports must not force ~ when DoIdentLookups is off. - DoIdentLookups is enabled by any Client block with a non-empty username - mask (``username = "ident"`` in the hub test config). Removing that line - turns lookups off. The hub's iauth-tilded.pl harness also forces ~ for - trust-username tests, so IAuth is disabled for this check; iauth U/o is a - separate trust path unrelated to the Client/DoIdentLookups policy. + DoIdentLookups is enabled when any Client block has a non-empty username + component (dedicated username =, or host/ip = "user@host"). This test + removes the hub's username = "ident" line to turn lookups off. IAuth is + disabled for this check; iauth U/o is a separate trust path. """ baseline = HUB_CONF_HOST.read_text() assert _IDENT_CLIENT_LINE in baseline, "hub conf missing username=ident Client line" - assert _IAUTH_TILDED_LINE in baseline, "hub conf missing iauth-tilded IAuth line" - # Register before patching so KeyboardInterrupt / teardown still restores. request.addfinalizer(lambda: _restore_hub_baseline(baseline)) patched = baseline.replace(_IDENT_CLIENT_LINE, "", 1) - patched = patched.replace(_IAUTH_TILDED_LINE, "", 1) assert _IDENT_CLIENT_LINE not in patched - assert _IAUTH_TILDED_LINE not in patched - assert "IAuth {" not in patched oper = await make_client("cfnoidop") await _oper_up(oper) @@ -494,7 +561,6 @@ async def test_cloudflare_no_tilde_when_ident_lookups_disabled( _restore_hub_baseline(baseline) conf = _hub_conf_text() assert _IDENT_CLIENT_LINE in conf, "hub conf restore missing username=ident" - assert _IAUTH_TILDED_LINE in conf, "hub conf restore missing IAuth" try: await _rehash_hub(oper) except Exception: diff --git a/tests/strict_username/test_strict_username.py b/tests/strict_username/test_strict_username.py index 74c82650..f756c4ea 100644 --- a/tests/strict_username/test_strict_username.py +++ b/tests/strict_username/test_strict_username.py @@ -23,7 +23,7 @@ async def _try_register(host, port, nick, username) -> tuple[bool, str]: try: await client.send(f"NICK {nick}") await client.send(f"USER {username} 0 * :User {nick}") - deadline = asyncio.get_running_loop().time() + 8.0 + deadline = asyncio.get_running_loop().time() + 20.0 while True: remaining = deadline - asyncio.get_running_loop().time() if remaining <= 0: @@ -31,7 +31,7 @@ async def _try_register(host, port, nick, username) -> tuple[bool, str]: try: msg = await client.recv(timeout=remaining) except (asyncio.TimeoutError, ConnectionError) as exc: - return False, f"disconnect:{exc}" + return False, f"disconnect:{exc!s}" if msg.command in ("376", "422"): return True, "registered" if (