Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions doc/example.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 7 additions & 3 deletions tests/cidr_glines/test_family_ambiguous_masks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
36 changes: 0 additions & 36 deletions tests/docker/iauth-tilded.pl

This file was deleted.

48 changes: 48 additions & 0 deletions tests/docker/iauth-trust-username.pl
Original file line number Diff line number Diff line change
@@ -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);
}
}
14 changes: 6 additions & 8 deletions tests/docker/ircd-hub.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 0 additions & 2 deletions tests/docker/ircd-leaf1.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 0 additions & 2 deletions tests/docker/ircd-leaf2.conf
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,6 @@ Class {

Client { ip = "*"; class = "Local"; };

IAuth { program = "/opt/ircu/bin/iauth-tilded.pl"; };

Operator {
local = no;
class = "Local";
Expand Down
144 changes: 105 additions & 39 deletions tests/pr_websocket/test_websocket_cloudflare.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions tests/strict_username/test_strict_username.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,15 @@ 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:
return False, "timeout"
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 (
Expand Down
Loading