Skip to content
Merged
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
19 changes: 13 additions & 6 deletions src/dcc.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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);
Expand All @@ -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, "*");
Expand Down Expand Up @@ -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++)
Expand Down
55 changes: 49 additions & 6 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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 <key>` over the bridge until it returns `expected`.
Useful right after `drive_registration(..., isupport_tokens=...)` to
Expand All @@ -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@<peer>

# Accept then hold; eggdrop's identtimeout eventually fires.
with IdentServer("timeout"):
... # bot resolves host as telnet@<peer> 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@<peer>` 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 |
Expand Down Expand Up @@ -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
Expand Down
115 changes: 115 additions & 0 deletions tests/support/identd.py
Original file line number Diff line number Diff line change
@@ -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
`<lport>, <rport> : USERID : UNIX : <user>\\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:<port> (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()
Loading
Loading