diff --git a/src/dcc.c b/src/dcc.c index f65a5b360..cb6e686a6 100644 --- a/src/dcc.c +++ b/src/dcc.c @@ -1335,11 +1335,18 @@ static void dcc_telnet(int idx, char *buf, int i) */ void dcc_telnet_hostresolved2(int i, int idx) { int sock, j; - + char userhost[7 + UHOSTLEN]; /* telnet@ */ + /* Read EGGDROP_TEST once on first call; lets the integration test + * harness redirect the ident lookup to an unprivileged port. */ + static int ident_target_port = 0; + if (!ident_target_port) + ident_target_port = getenv("EGGDROP_TEST") ? 1113 : 113; + + snprintf(userhost, sizeof userhost, "telnet@%s", dcc[i].host); /* Skip ident lookup if disabled */ if (identtimeout <= 0) { dcc[i].u.ident_sock = dcc[idx].sock; - dcc_telnet_got_ident(i, dcc[idx].host); + dcc_telnet_got_ident(i, userhost); return; } @@ -1362,7 +1369,7 @@ void dcc_telnet_hostresolved2(int i, int idx) { setsnport(name, 0); if (bind(dcc[j].sock, &name.addr.sa, name.addrlen) < 0) debug2("dcc: dcc_telnet_hostresolved(): bind() socket %ld error %s", dcc[j].sock, strerror(errno)); - setsnport(dcc[j].sockname, 113); + setsnport(dcc[j].sockname, ident_target_port); if ((sock = connect_nonblock(dcc[j].sock, &dcc[j].sockname, 0)) < 0) { putlog(LOG_MISC, "*", DCC_IDENTFAIL, dcc[i].host, strerror(errno)); killsock(dcc[j].sock); @@ -1372,11 +1379,11 @@ void dcc_telnet_hostresolved2(int i, int idx) { } } if (j < 0) { - dcc_telnet_got_ident(i, dcc[idx].host); + dcc_telnet_got_ident(i, userhost); return; } dcc[j].sock = sock; - dcc[j].port = 113; + dcc[j].port = ident_target_port; dcc[j].addr = dcc[i].addr; strcpy(dcc[j].host, dcc[i].host); strcpy(dcc[j].nick, "*"); @@ -2320,7 +2327,7 @@ void dcc_ident(int idx, char *buf, int len) void eof_timeout_dcc_ident(int idx, const char *s) { - char buf[7 + UHOSTLEN]; + char buf[7 + UHOSTLEN]; /* telnet@ */ int i; for (i = 0; i < dcc_total; i++) diff --git a/tests/README.md b/tests/README.md index e23d4f064..680bdb013 100644 --- a/tests/README.md +++ b/tests/README.md @@ -149,11 +149,17 @@ What this exercises: asynchronously from the stdin write; `wait_for` has an explicit timeout instead of `time.sleep()`. -## Helpers (`support/irc_helpers.py`) +## Helpers + +Shared utilities for things tests would otherwise hand-roll. Each lives in +its own module under `support/`; add a new module here (and a new +subsection below) when something doesn't fit an existing one. + +### IRC dialogue (`support/irc_helpers.py`) Shared multi-step IRC interactions so tests don't repeat boilerplate. -### `drive_registration(mock_ircd, nick="TestBot", isupport_tokens=None)` +#### `drive_registration(mock_ircd, nick="TestBot", isupport_tokens=None)` Drives Eggdrop through IRC registration: @@ -200,7 +206,7 @@ is opt-in via `set account-tag 1` in the rendered eggdrop.conf — pass After this returns, Eggdrop has processed 005 and is about to JOIN configured channels. -### `drive_join_with_names(mock_ircd, members_with_prefix, nick="TestBot", server="mock.test", member_accounts=None) -> str` +#### `drive_join_with_names(mock_ircd, members_with_prefix, nick="TestBot", server="mock.test", member_accounts=None) -> str` Mimics a real IRCd's full post-JOIN dance for the bot: @@ -239,7 +245,7 @@ chan = drive_join_with_names( # op's account is now "op" via 354 → got354 → setaccount ``` -### `wait_for_isupport(bridge, key, expected, timeout=5.0)` +#### `wait_for_isupport(bridge, key, expected, timeout=5.0)` Polls `isupport get ` over the bridge until it returns `expected`. Useful right after `drive_registration(..., isupport_tokens=...)` to @@ -249,12 +255,45 @@ ensure Eggdrop has finished processing 005 before assertions run. wait_for_isupport(tcl_bridge, "PREFIX", "(qaohv)~&@%+") ``` -### `split_member_prefix(token) -> (nick, prefix_symbols)` +#### `split_member_prefix(token) -> (nick, prefix_symbols)` Tiny helper used internally by `drive_join_with_names`; exposed for test code that needs to do the same parsing. `"@alice"` → `("alice", "@")`, `"~&boss"` → `("boss", "~&")`, `"plain"` → `("plain", "")`. +### Ident responder (`support/identd.py`) + +Test-time RFC 1413 ident server, bound to `127.0.0.1:1113`. Used by tests +that exercise the inbound DCC/telnet ident lookup path in +`src/dcc.c:dcc_telnet_hostresolved2`. Eggdrop normally queries TCP/113, +which is privileged; when the spawn env has `EGGDROP_TEST=1` the bot +connects to `1113` instead (see the lazy `getenv` in +`dcc_telnet_hostresolved2`), so the test process doesn't need root or +`CAP_NET_BIND_SERVICE`. + +```python +from support.identd import IdentServer + +# Reply with USERID — exercises the happy-path parse in dcc_ident. +with IdentServer("respond", user="alice"): + ... # open inbound DCC; bot resolves host as alice@ + +# Accept then hold; eggdrop's identtimeout eventually fires. +with IdentServer("timeout"): + ... # bot resolves host as telnet@ after identtimeout +``` + +To exercise the **connection-refused** path, just *don't* construct one +— with nothing listening on 1113 the kernel returns RST, and the bot +resolves the host as `telnet@` immediately. See +`tests/test_ident_scenarios.py` for the full three-modes × two-timeouts +matrix. + +Caveat: eggdrop's `check_expired_dcc` only runs every 10s +(`src/main.c:556`), so the effective wait for the timeout case is +`identtimeout + up-to-10s`, not exactly `identtimeout`. Tests waiting on +the ident timeout need a poll window of roughly `identtimeout + 12s`. + ## Fixtures | Fixture | Scope | What you get | @@ -392,16 +431,20 @@ tests/ │ ├── bridge_client.py # Python client → eval_ok("...") │ ├── mock_ircd.py # asyncio IRCd, sync facade │ ├── irc_helpers.py # drive_registration, drive_join_with_names, ... +│ ├── identd.py # IdentServer — RFC 1413 responder on 127.0.0.1:1113 +│ ├── userfile_helpers.py # format_userfile_ban (escaping per src/misc.c:str_escape) │ ├── eggdrop_proc.py # subprocess wrapper, stdout drain, terminate │ └── waiters.py # wait_for / wait_for_file / wait_for_log_match ├── templates/ │ ├── eggdrop.conf.j2 -│ └── userfile.j2 +│ ├── userfile.j2 +│ └── chanfile.j2 └── tests/ ├── test_framing.py ├── test_smoke_connect.py ├── test_partyline_chan.py ├── test_isupport_modes.py + ├── test_ident_scenarios.py # ident lookup refused / timeout / success ├── test_tcl_passwdok.py # ported from eggdrop_tcl_passwdok.bats ├── test_tcl_iscmds.py # ported from eggdrop_tcl_iscmds.bats ├── test_tcl_matchattr.py # ported from eggdrop_tcl_matchattr.bats diff --git a/tests/support/identd.py b/tests/support/identd.py new file mode 100644 index 000000000..a6e42b3f7 --- /dev/null +++ b/tests/support/identd.py @@ -0,0 +1,115 @@ +"""Minimal ident (RFC 1413) responder for Eggdrop integration tests. + +Listens on `127.0.0.1:1113` to match the `EGGDROP_TEST` override in +`src/dcc.c:dcc_telnet_hostresolved2` — when that env var is set, eggdrop +connects to TCP/1113 (unprivileged) instead of the wire-standard 113. + +Two modes: + + "respond" — accept, read the bot's request line, reply with + `, : USERID : UNIX : \\r\\n`. + "timeout" — accept, never write; eggdrop's `identtimeout` fires. + +The third ident scenario, "connection refused", is exercised by simply +*not* constructing an `IdentServer` so port 1113 stays unbound and the +kernel returns RST. +""" + +from __future__ import annotations + +import contextlib +import socket +import threading +from typing import Literal + + +class IdentServer: + """Threaded ident responder bound to 127.0.0.1: (default 1113).""" + + def __init__( + self, + mode: Literal["respond", "timeout"], + user: str = "alice", + host: str = "127.0.0.1", + port: int = 1113, + ) -> None: + self._mode = mode + self._user = user + self._host = host + self._port = port + self._sock: socket.socket | None = None + self._thread: threading.Thread | None = None + self._stop = threading.Event() + + def start(self) -> IdentServer: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind((self._host, self._port)) + s.listen(8) + s.settimeout(0.2) + self._sock = s + self._thread = threading.Thread( + target=self._serve, name=f"identd-{self._mode}", daemon=True + ) + self._thread.start() + return self + + def _serve(self) -> None: + assert self._sock is not None + while not self._stop.is_set(): + try: + conn, _ = self._sock.accept() + except TimeoutError: + continue + except OSError: + return + t = threading.Thread( + target=self._handle, args=(conn,), daemon=True + ) + t.start() + + def _handle(self, conn: socket.socket) -> None: + try: + if self._mode == "timeout": + # Accept and hold; eggdrop's DCC_IDENT timeout + # (identtimeout seconds) fires and closes from its side. + conn.settimeout(15.0) + try: + while not self._stop.is_set(): + chunk = conn.recv(4096) + if not chunk: + return + except OSError: + return + else: # "respond" + conn.settimeout(5.0) + data = b"" + while b"\n" not in data and len(data) < 256: + chunk = conn.recv(64) + if not chunk: + break + data += chunk + line = data.decode("ascii", "replace").rstrip("\r\n") + reply = f"{line} : USERID : UNIX : {self._user}\r\n" + with contextlib.suppress(OSError): + conn.sendall(reply.encode("ascii")) + finally: + with contextlib.suppress(OSError): + conn.shutdown(socket.SHUT_RDWR) + conn.close() + + def stop(self) -> None: + self._stop.set() + if self._sock is not None: + with contextlib.suppress(OSError): + self._sock.close() + self._sock = None + if self._thread is not None: + self._thread.join(timeout=2) + self._thread = None + + def __enter__(self) -> IdentServer: + return self.start() + + def __exit__(self, *args: object) -> None: + self.stop() diff --git a/tests/tests/test_ident_scenarios.py b/tests/tests/test_ident_scenarios.py new file mode 100644 index 000000000..d9235b06d --- /dev/null +++ b/tests/tests/test_ident_scenarios.py @@ -0,0 +1,255 @@ +"""Ident lookup scenarios for inbound DCC/telnet connections. + +Exercises `dcc_telnet_hostresolved2` (src/dcc.c) which is responsible for +performing an RFC 1413 ident query back to the originator of an inbound +DCC connection. Three real-world outcomes are covered: + + * "refused" — nothing listening on the ident port; kernel RSTs the + SYN. `connect_nonblock` returns -4 and the host falls + back to `telnet@`. + * "timeout" — ident port accepts but never writes a reply. After + `ident-timeout` seconds, `timeout_dcc_ident` fires and + the host falls back to `telnet@`. + * "respond" — ident port replies with a valid `USERID : UNIX : ` + line; the host is set to `@`. + +Each scenario is run twice — once with `ident-timeout 0` (eggdrop skips +the lookup entirely and always falls back to `telnet@`) and once +with `ident-timeout 3` (eggdrop actually does the lookup). + +The bot connects to TCP/1113 instead of the wire-standard 113 when +`EGGDROP_TEST` is set in the env (see src/dcc.c:dcc_telnet_hostresolved2) +so the test process doesn't need privileged-port access. +""" + +from __future__ import annotations + +import contextlib +import re +import socket +from collections.abc import Iterator +from typing import Any + +import pytest + +from support.bridge_client import BridgeClient +from support.eggdrop_proc import EggdropProc # noqa: F401 (fixture name) +from support.identd import IdentServer +from support.waiters import wait_for + +# Owner record matches whatever reverse-DNS returns for 127.0.0.1 +# (`localhost`, `127.0.0.1`, or anything in /etc/hosts) so `protect-telnet` +# accepts the inbound connection regardless of the resolver. The user has +# 'n' flag (owner), which implies USER_OP for the `protect_telnet` check +# in dcc_telnet_got_ident. +OWNER_HOSTMASK = "*!*@*" + + +def _pick_free_port() -> int: + """Bind to :0, read the assigned port, release. Best-effort — race window + between close and the eggdrop child binding the same port, but on Linux + the kernel doesn't immediately reissue closed ports so this is fine.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +@contextlib.contextmanager +def _telnet_client(host: str, port: int) -> Iterator[socket.socket]: + """Open a TCP connection to the bot's listen port and keep it open for + the lifetime of the `with` block. The dcc entry lives only as long as + the socket — closing it ends the test scenario from the bot's side.""" + s = socket.create_connection((host, port), timeout=3.0) + try: + yield s + finally: + with contextlib.suppress(OSError): + s.shutdown(socket.SHUT_RDWR) + s.close() + + +def _wait_for_telnet_id_host(bridge: BridgeClient, timeout: float) -> str: + """Poll `dcclist TELNET_ID` until one entry exists; return its host. + + The dcc entry transitions DCC_TELNET → DCC_DNSWAIT → DCC_IDENTWAIT → + DCC_TELNET_ID once host resolution + ident finish. We watch the final + state. The host field is `@` per + `dcc_telnet_got_ident` (src/dcc.c).""" + cmd = ( + "if {[llength [set l [dcclist TELNET_ID]]]} " + "{lindex [lindex $l 0] 2}" + ) + host: dict[str, str] = {} + + def _check() -> bool: + result = bridge.eval_ok(cmd) + if result: + host["v"] = result + return True + return False + + wait_for(_check, timeout=timeout, description="dcc TELNET_ID entry to appear") + return host["v"] + + +# ---------- ident-timeout 0: lookup is skipped, host always telnet@... ---------- + + +def test_ident_disabled_with_no_identd( + eggdrop_config: Any, + request: pytest.FixtureRequest, +) -> None: + """ident-timeout=0 + no ident responder → host is `telnet@`. + + Verifies the early-return path in dcc_telnet_hostresolved2: identtimeout + is checked *before* the connect to 1113, so the identd presence is + irrelevant. The userhost should be the fallback `telnet@...` form. + """ + listen_port = _pick_free_port() + eggdrop_config.render( + owner_hostmask=OWNER_HOSTMASK, + extra_tcl=f"set ident-timeout 0\nlisten {listen_port} all\n", + ) + bridge: BridgeClient = request.getfixturevalue("tcl_bridge") + + with _telnet_client("127.0.0.1", listen_port): + host = _wait_for_telnet_id_host(bridge, timeout=5.0) + + assert re.fullmatch(r"telnet@(127\.0\.0\.1|localhost|localhost\.\S+)", host), ( + f"expected telnet@, got {host!r}" + ) + + +def test_ident_disabled_with_responder_running( + eggdrop_config: Any, + request: pytest.FixtureRequest, +) -> None: + """ident-timeout=0 + identd running → identd is NOT contacted; host stays + `telnet@`. Confirms the skip path doesn't accidentally fire + the lookup. + """ + listen_port = _pick_free_port() + eggdrop_config.render( + owner_hostmask=OWNER_HOSTMASK, + extra_tcl=f"set ident-timeout 0\nlisten {listen_port} all\n", + ) + bridge: BridgeClient = request.getfixturevalue("tcl_bridge") + + with ( + IdentServer("respond", user="alice"), + _telnet_client("127.0.0.1", listen_port), + ): + host = _wait_for_telnet_id_host(bridge, timeout=5.0) + + assert re.fullmatch(r"telnet@(127\.0\.0\.1|localhost|localhost\.\S+)", host), ( + f"expected telnet@ (identd ignored), got {host!r}" + ) + + +def test_ident_disabled_with_hung_responder( + eggdrop_config: Any, + request: pytest.FixtureRequest, +) -> None: + """ident-timeout=0 + identd that hangs → host is `telnet@` + immediately (no waiting on the hung server).""" + listen_port = _pick_free_port() + eggdrop_config.render( + owner_hostmask=OWNER_HOSTMASK, + extra_tcl=f"set ident-timeout 0\nlisten {listen_port} all\n", + ) + bridge: BridgeClient = request.getfixturevalue("tcl_bridge") + + with ( + IdentServer("timeout"), + _telnet_client("127.0.0.1", listen_port), + ): + # Wait window deliberately tight: with ident-timeout=0 the + # lookup is skipped, so the entry should appear in <1s. + host = _wait_for_telnet_id_host(bridge, timeout=2.0) + + assert re.fullmatch(r"telnet@(127\.0\.0\.1|localhost|localhost\.\S+)", host), ( + f"expected telnet@ (identd ignored), got {host!r}" + ) + + +# ---------- ident-timeout 3: lookup actually runs ---------- + + +def test_ident_lookup_refused( + eggdrop_config: Any, + request: pytest.FixtureRequest, +) -> None: + """ident-timeout=3 + nothing on TCP/1113 → kernel RST → host falls back + to `telnet@`. connect_nonblock detects ECONNREFUSED via its + own 500ms select, so this resolves quickly regardless of ident-timeout.""" + listen_port = _pick_free_port() + eggdrop_config.render( + owner_hostmask=OWNER_HOSTMASK, + extra_tcl=f"set ident-timeout 3\nlisten {listen_port} all\n", + ) + bridge: BridgeClient = request.getfixturevalue("tcl_bridge") + + with _telnet_client("127.0.0.1", listen_port): + host = _wait_for_telnet_id_host(bridge, timeout=5.0) + + assert re.fullmatch(r"telnet@(127\.0\.0\.1|localhost|localhost\.\S+)", host), ( + f"expected telnet@ (ident refused), got {host!r}" + ) + + +@pytest.mark.slow +def test_ident_lookup_timeout( + eggdrop_config: Any, + request: pytest.FixtureRequest, +) -> None: + """ident-timeout=3 + identd accepts but never replies → DCC_IDENT timeout + eventually fires; host falls back to `telnet@`. + + This is the case that *cannot* be exercised by leaving 1113 unbound — + we need the TCP handshake to succeed so the dcc enters DCC_IDENT state + and the app-level identtimeout actually counts down. + + Eggdrop's `check_expired_dcc` only runs every 10s (src/main.c:556) so + the effective timeout latency is `identtimeout + (up to 10s)`. The 15s + wait below covers the worst case. + """ + listen_port = _pick_free_port() + eggdrop_config.render( + owner_hostmask=OWNER_HOSTMASK, + extra_tcl=f"set ident-timeout 3\nlisten {listen_port} all\n", + ) + bridge: BridgeClient = request.getfixturevalue("tcl_bridge") + + with ( + IdentServer("timeout"), + _telnet_client("127.0.0.1", listen_port), + ): + host = _wait_for_telnet_id_host(bridge, timeout=15.0) + + assert re.fullmatch(r"telnet@(127\.0\.0\.1|localhost|localhost\.\S+)", host), ( + f"expected telnet@ (ident timed out), got {host!r}" + ) + + +def test_ident_lookup_success( + eggdrop_config: Any, + request: pytest.FixtureRequest, +) -> None: + """ident-timeout=3 + identd replies `USERID : UNIX : alice` → host is + `alice@`. Verifies the full happy-path parse in `dcc_ident`.""" + listen_port = _pick_free_port() + eggdrop_config.render( + owner_hostmask=OWNER_HOSTMASK, + extra_tcl=f"set ident-timeout 3\nlisten {listen_port} all\n", + ) + bridge: BridgeClient = request.getfixturevalue("tcl_bridge") + + with ( + IdentServer("respond", user="alice"), + _telnet_client("127.0.0.1", listen_port), + ): + host = _wait_for_telnet_id_host(bridge, timeout=5.0) + + assert re.fullmatch(r"alice@(127\.0\.0\.1|localhost|localhost\.\S+)", host), ( + f"expected alice@, got {host!r}" + )