From 74307e57bad3e9b061e6cd4405bade761b55637e Mon Sep 17 00:00:00 2001 From: MrIron Date: Sat, 29 Aug 2026 22:03:37 +0200 Subject: [PATCH 1/2] IPcheck: fix exempt-address registry corruption, rehash, and arithmetic bugs Found by the new IPcheck unit and integration tests. - Exempt addresses (IPCheck { except ... }) were accepted without being recorded, but the client was still marked IPChecked, so IPcheck_connect_succeeded() reported a stale entry and IPcheck_disconnect() decremented a count it never incremented. With an entry present for the address (clients connected before the exemption, or remote users), the third exempt disconnect tripped "Assertion failure at IPcheck.c:665: entry->connected > 0" and aborted the server; with asserts off the count wrapped and Client-block maxlinks stopped working. Remote exempt clients were flagged the same way. IPcheck_local_connect() now returns IPCHECK_REFUSED / IPCHECK_COUNTED / IPCHECK_EXEMPT and callers mark the client IPChecked only when it was counted; ip_registry_check_remote() sets the flag only after the invalid and exempt early-outs; the post-hooks (connect_succeeded, connect_fail) are guarded by IsIPChecked() like IPcheck_disconnect() already was. - Removing the IPCheck block from the config and rehashing left the old exemptions in force: IPcheck_clear_config() was only called while parsing an IPCheck block. Call it from read_configuration_file(). - CONNECTED_SINCE() subtracted two 16-bit timestamps in int, so it went negative for up to 65536 s after CurrentTime crossed a multiple of 65536 (every 18.2 hours): the clone period never reset, entries did not expire and free-target regeneration underflowed. Reduce modulo 2^16. - With no free targets left, `CurrentTime - (TARGET_DELAY * free_targets - 1)` was computed in unsigned int, giving CurrentTime - 4294967295 on 64-bit time_t instead of CurrentTime + 1: a client returning from an address that had exhausted its targets got a full set instead of none. - The remote-connect overflow guard refused the client but left the connected counter wrapped to zero. --- include/IPcheck.h | 5 +++++ ircd/IPcheck.c | 36 ++++++++++++++++++++++++++---------- ircd/m_nick.c | 6 ++++-- ircd/s_auth.c | 21 +++++++++++++++------ ircd/s_bsd.c | 7 +++++-- ircd/s_conf.c | 1 + ircd/s_user.c | 3 ++- ircd/websocket.c | 10 ++++++++-- 8 files changed, 66 insertions(+), 23 deletions(-) diff --git a/include/IPcheck.h b/include/IPcheck.h index 9a1d50fc..f6c9f2aa 100644 --- a/include/IPcheck.h +++ b/include/IPcheck.h @@ -13,6 +13,11 @@ struct Client; struct irc_in_addr; +/** Results of IPcheck_local_connect(). */ +#define IPCHECK_REFUSED 0 /**< Too many recent connections: refuse. */ +#define IPCHECK_COUNTED 1 /**< Accepted and recorded; mark client IPChecked. */ +#define IPCHECK_EXEMPT 2 /**< Accepted, address exempt; not recorded. */ + /* * Prototypes */ diff --git a/ircd/IPcheck.c b/ircd/IPcheck.c index a81246d0..a4024973 100644 --- a/ircd/IPcheck.c +++ b/ircd/IPcheck.c @@ -69,8 +69,12 @@ struct IPRegistry48 { #define IP_REGISTRY_TABLE_SIZE 0x10000 /** Report current time for tracking in IPRegistryEntry::last_connect. */ #define NOW ((unsigned short)(CurrentTime & 0xffff)) -/** Time from \a x until now, in seconds. */ -#define CONNECTED_SINCE(x) (NOW - (x)) +/** Time from \a x until now, in seconds. Both operands are 16-bit + * timestamps, so reduce the difference modulo 2^16 as well: a plain + * subtraction goes negative once CurrentTime crosses a multiple of 65536 + * (every 18.2 hours) and the period, expiry and free-target arithmetic + * misbehave until the entry is next stamped. */ +#define CONNECTED_SINCE(x) ((unsigned short)(NOW - (x))) /** Macro for easy access to configured IPcheck clone limit. */ #define IPCHECK_CLONE_LIMIT feature_int(FEAT_IPCHECK_CLONE_LIMIT) @@ -433,7 +437,11 @@ static int ip_registry_is_exempt(const struct irc_in_addr *addr) * separated by no more than IPCHECK_CLONE_PERIOD seconds. * @param[in] addr Address of client. * @param[out] next_target_out Receives time to grant another free target. - * @return Non-zero if the connection is permitted, zero if denied. + * @return IPCHECK_REFUSED if denied, IPCHECK_COUNTED if permitted and + * recorded in the registry, IPCHECK_EXEMPT if permitted because the + * address is exempt (nothing recorded; the caller must not mark the + * client IPChecked, or its disconnect would decrement a count it never + * incremented). */ static int ip_registry_check_local(const struct irc_in_addr *addr, time_t* next_target_out) { @@ -441,7 +449,7 @@ static int ip_registry_check_local(const struct irc_in_addr *addr, time_t* next_ unsigned int free_targets = STARTTARGETS; if (ip_registry_is_exempt(addr)) { - return 1; + return IPCHECK_EXEMPT; } entry = ip_registry_find(addr); @@ -502,7 +510,9 @@ static int ip_registry_check_local(const struct irc_in_addr *addr, time_t* next_ if (entry->attempts < IPCHECK_CLONE_LIMIT) { if (next_target_out) - *next_target_out = CurrentTime - (TARGET_DELAY * free_targets - 1); + /* free_targets is unsigned: with none left, TARGET_DELAY * 0 - 1 + * must be -1 (next target in one second), not UINT_MAX. */ + *next_target_out = CurrentTime - ((time_t)TARGET_DELAY * free_targets - 1); } #ifndef NOTHROTTLE else if ((CurrentTime - cli_since(&me)) > IPCHECK_CLONE_DELAY) { @@ -535,10 +545,6 @@ static int ip_registry_check_remote(struct Client* cptr, int is_burst) { struct IPRegistryEntry* entry; - /* - * Mark that we did add/update an IPregistry entry - */ - SetIPChecked(cptr); if (!irc_in_addr_valid(&cli_ip(cptr))) { Debug((DEBUG_DNS, "IPcheck accepting remote connection from invalid %s.", ircd_ntoa(&cli_ip(cptr)))); return 1; @@ -548,6 +554,13 @@ static int ip_registry_check_remote(struct Client* cptr, int is_burst) return 1; } + /* + * Mark that we did add/update an IPregistry entry. Only now: an exempt + * or unroutable address is not counted, and IPcheck_disconnect() must not + * decrement a count that was never incremented. + */ + SetIPChecked(cptr); + if (!irc_in_addr_is_ipv4(&cli_ip(cptr))) { struct IPRegistry48* entry_48 = ip_48_find(&cli_ip(cptr)); if (CONNECTED_SINCE(entry_48->last_connect) > IPCHECK_48_CLONE_PERIOD) @@ -569,6 +582,7 @@ static int ip_registry_check_remote(struct Client* cptr, int is_burst) } /* Avoid overflowing the connection counter. */ if (0 == ++entry->connected) { + entry->connected--; Debug((DEBUG_DNS, "IPcheck refusing remote connection from %s: counter overflow.", ircd_ntoa(&entry->addr))); return 0; } @@ -732,7 +746,9 @@ static int ip_registry_count(const struct irc_in_addr *addr) /** Check whether a client is allowed to connect locally. * @param[in] a Address of client. * @param[out] next_target_out Receives time to grant another free target. - * @return Non-zero if the connection is permitted, zero if denied. + * @return IPCHECK_REFUSED (zero) if denied; IPCHECK_COUNTED if permitted + * and recorded (the caller marks the client IPChecked); IPCHECK_EXEMPT + * if permitted without being recorded (do not mark it). */ int IPcheck_local_connect(const struct irc_in_addr *a, time_t* next_target_out) { diff --git a/ircd/m_nick.c b/ircd/m_nick.c index 8b62235c..5235ba2c 100644 --- a/ircd/m_nick.c +++ b/ircd/m_nick.c @@ -248,7 +248,8 @@ int m_nick(struct Client* cptr, struct Client* sptr, int parc, char* parv[]) */ if (IsUnknown(acptr) && MyConnect(acptr)) { ++ServerStats->is_reg_collided; - IPcheck_connect_fail(acptr, 0); + if (IsIPChecked(acptr)) + IPcheck_connect_fail(acptr, 0); exit_client(cptr, acptr, &me, "Overridden by other sign on"); return set_nick_name(cptr, sptr, nick, parc, parv); } @@ -381,7 +382,8 @@ int ms_nick(struct Client* cptr, struct Client* sptr, int parc, char* parv[]) if (IsUnknown(acptr) && MyConnect(acptr)) { ++ServerStats->is_reg_collided; - IPcheck_connect_fail(acptr, 0); + if (IsIPChecked(acptr)) + IPcheck_connect_fail(acptr, 0); exit_client(cptr, acptr, &me, "Overridden by other sign on"); return set_nick_name(cptr, sptr, nick, parc, parv); } diff --git a/ircd/s_auth.c b/ircd/s_auth.c index ef283543..8a6d428d 100644 --- a/ircd/s_auth.c +++ b/ircd/s_auth.c @@ -747,7 +747,8 @@ static int preregister_user(struct Client *cptr) /* Can this ever happen? */ case ACR_BAD_SOCKET: ++ServerStats->is_bad_socket; - IPcheck_connect_fail(cptr, 0); + if (IsIPChecked(cptr)) + IPcheck_connect_fail(cptr, 0); return exit_client(cptr, cptr, &me, "Unknown error -- Try again"); } return 0; @@ -1544,11 +1545,17 @@ int auth_spoof_user(struct AuthRequest *auth, const char *username, const char * return 1; if (!ipmask_parse(ip, &cli_ip(sptr), NULL)) return 2; - if (!IPcheck_local_connect(&cli_ip(sptr), &next_target)) { + switch (IPcheck_local_connect(&cli_ip(sptr), &next_target)) { + case IPCHECK_REFUSED: ++ServerStats->is_throttled; return exit_client(sptr, sptr, &me, "Your host is trying to (re)connect too fast -- throttled"); + case IPCHECK_COUNTED: + SetIPChecked(sptr); + break; + default: /* IPCHECK_EXEMPT: accepted, not recorded */ + ClearIPChecked(sptr); + break; } - SetIPChecked(sptr); if (next_target) cli_nexttarget(sptr) = next_target; @@ -2218,9 +2225,11 @@ static int iauth_cmd_ip_address(struct IAuth *iauth, struct Client *cli, if (!irc_in_addr_valid(&auth->original)) memcpy(&auth->original, &cli_ip(cli), sizeof(auth->original)); - /* Undo original IP connection in IPcheck. */ - IPcheck_connect_fail(cli, 1); - ClearIPChecked(cli); + /* Undo original IP connection in IPcheck (unless it was exempt). */ + if (IsIPChecked(cli)) { + IPcheck_connect_fail(cli, 1); + ClearIPChecked(cli); + } /* Update the IP and charge them as a remote connect. */ memcpy(&cli_ip(cli), &addr, sizeof(cli_ip(cli))); diff --git a/ircd/s_bsd.c b/ircd/s_bsd.c index 02ffb464..f3c02241 100644 --- a/ircd/s_bsd.c +++ b/ircd/s_bsd.c @@ -549,6 +549,7 @@ void add_connection(struct Listener* listener, int fd) { struct irc_sockaddr addr; struct Client *new_client; time_t next_target = 0; + int ipcheck; void *tls; const char* const throttle_message = @@ -612,14 +613,16 @@ void add_connection(struct Listener* listener, int fd) { * known at handshake; the socket peer is a Cloudflare edge node. */ if (!(listener_websocket(listener) && listener_cloudflare(listener))) { - if (!IPcheck_local_connect(&addr.addr, &next_target)) + ipcheck = IPcheck_local_connect(&addr.addr, &next_target); + if (ipcheck == IPCHECK_REFUSED) { ++ServerStats->is_throttled; write(fd, throttle_message, strlen(throttle_message)); close(fd); return; } - SetIPChecked(new_client); + if (ipcheck == IPCHECK_COUNTED) + SetIPChecked(new_client); } } diff --git a/ircd/s_conf.c b/ircd/s_conf.c index 298455bd..d74e7463 100644 --- a/ircd/s_conf.c +++ b/ircd/s_conf.c @@ -1011,6 +1011,7 @@ int read_configuration_file(void) conf_error = 0; feature_unmark(); /* unmark all features for resetting later */ clear_nameservers(); /* clear previous list of DNS servers */ + IPcheck_clear_config(); /* IPCheck exemptions, in case the block is gone */ if (!init_lexer()) return 0; yyparse(); diff --git a/ircd/s_user.c b/ircd/s_user.c index bcce0032..fcb9e370 100644 --- a/ircd/s_user.c +++ b/ircd/s_user.c @@ -394,7 +394,8 @@ int register_user(struct Client *cptr, struct Client *sptr) cli_sock_ip(sptr), get_client_class(sptr), cli_info(sptr), NumNick(cptr) /* two %s's */); - IPcheck_connect_succeeded(sptr); + if (IsIPChecked(sptr)) + IPcheck_connect_succeeded(sptr); } else { struct Client *acptr = user->server; diff --git a/ircd/websocket.c b/ircd/websocket.c index 16e73f4c..bd1536c0 100644 --- a/ircd/websocket.c +++ b/ircd/websocket.c @@ -174,11 +174,17 @@ static int websocket_apply_client_ip(struct Client *cptr, const char *ip) if (IsIPChecked(cptr)) IPcheck_connect_fail(cptr, 0); - if (!IPcheck_local_connect(&addr, &next_target)) { + switch (IPcheck_local_connect(&addr, &next_target)) { + case IPCHECK_REFUSED: ++ServerStats->is_throttled; return 0; + case IPCHECK_COUNTED: + SetIPChecked(cptr); + break; + default: /* IPCHECK_EXEMPT: accepted, not recorded */ + ClearIPChecked(cptr); + break; } - SetIPChecked(cptr); memcpy(&cli_ip(cptr), &addr, sizeof(cli_ip(cptr))); ircd_ntoa_r(cli_sock_ip(cptr), &cli_ip(cptr)); From 2d5777924874354a26ef3abc0bd4a0a1687247ed Mon Sep 17 00:00:00 2001 From: MrIron Date: Sat, 29 Aug 2026 22:03:37 +0200 Subject: [PATCH 2/2] Add IPcheck unit and integration tests ircd/test/ipcheck_t.c (make check) drives ircd/IPcheck.c through its public API with a fake clock and feature values: clone limit and period (including refused attempts restarting the period), address independence, boot grace (IPCHECK_CLONE_DELAY), connect_fail() undo, disconnect accounting and the reset on last disconnect, exemptions (result code, no flag, no interference with an existing entry, cleared config), remote vs. burst introductions, IPv6 /64 keying and the /48 limit, IPv4 canonical form, free-target inheritance / pinning / regeneration / long-connection bonus, the expiry pass, the 16-bit clock wrap and the connected-counter overflow guard. tests/ipcheck/ (docker): - test_ipcheck_limits.py on the limits server, driving the limits with SET (PRIV_SET granted by rehash): registry notice contents, throttle ERROR before any input, recovery after the period, refused attempts restarting the period, slot release on disconnect, boot grace, IPCheck except block via rehash (and its removal), Client-block maxlinks enforced from the registry. - test_ipcheck_remote.py on the hub: a P10-introduced user with the test host's address counts toward (and is released from) the address, one with another address does not. P10Server.introduce_user() gains an ip parameter for this. --- ircd/test/Makefile.am | 6 +- ircd/test/ipcheck_t.c | 729 +++++++++++++++++++++++++++ tests/ipcheck/__init__.py | 0 tests/ipcheck/helpers.py | 134 +++++ tests/ipcheck/test_ipcheck_limits.py | 317 ++++++++++++ tests/ipcheck/test_ipcheck_remote.py | 90 ++++ tests/p10_server.py | 5 +- 7 files changed, 1279 insertions(+), 2 deletions(-) create mode 100644 ircd/test/ipcheck_t.c create mode 100644 tests/ipcheck/__init__.py create mode 100644 tests/ipcheck/helpers.py create mode 100644 tests/ipcheck/test_ipcheck_limits.py create mode 100644 tests/ipcheck/test_ipcheck_remote.py diff --git a/ircd/test/Makefile.am b/ircd/test/Makefile.am index 0f883961..d5095046 100644 --- a/ircd/test/Makefile.am +++ b/ircd/test/Makefile.am @@ -1,7 +1,7 @@ AM_CPPFLAGS = -I$(top_srcdir)/include -I../.. AM_CFLAGS = -g -Wall -check_PROGRAMS = cidr_lookups_t ircd_chattr_t ircd_in_addr_t ircd_match_t ircd_string_t msgq_excise_t +check_PROGRAMS = cidr_lookups_t ipcheck_t ircd_chattr_t ircd_in_addr_t ircd_match_t ircd_string_t msgq_excise_t TESTS = $(check_PROGRAMS) @@ -9,6 +9,10 @@ cidr_lookups_t_CPPFLAGS = $(AM_CPPFLAGS) -DIRCU2_BUILD cidr_lookups_t_SOURCES = cidr_lookups_t.c test_stub.c cidr_lookups_t_LDADD = ../cidr_lookups.o ../ircd_alloc.o ../ircd_string.o +ipcheck_t_CPPFLAGS = $(AM_CPPFLAGS) -DIRCU2_BUILD +ipcheck_t_SOURCES = ipcheck_t.c test_stub.c +ipcheck_t_LDADD = ../IPcheck.o ../ircd_alloc.o ../ircd_string.o ../match.o + msgq_excise_t_CPPFLAGS = $(AM_CPPFLAGS) -DIRCU2_BUILD msgq_excise_t_SOURCES = msgq_excise_t.c test_stub.c msgq_excise_t_LDADD = ../msgq.o ../ircd_snprintf.o ../ircd_alloc.o ../ircd_string.o diff --git a/ircd/test/ipcheck_t.c b/ircd/test/ipcheck_t.c new file mode 100644 index 00000000..b8f80576 --- /dev/null +++ b/ircd/test/ipcheck_t.c @@ -0,0 +1,729 @@ +/* ipcheck_t.c - unit tests for the IPcheck connection-rate registry. + * + * Exercises ircd/IPcheck.c through its public API with a fake clock and + * fake feature values: per-address clone limit and period, the boot grace + * period (IPCHECK_CLONE_DELAY), connect_fail() undo, disconnect() and + * IPcheck_nr() accounting, exemption netblocks, remote vs. burst + * introductions, the IPv6 /48 limit, address canonicalisation (IPv4 /32, + * IPv6 /64), free-target bookkeeping, registry expiry, the 16-bit + * last_connect clock wrap, and the "connected" counter overflow guard. + */ + +#include "IPcheck.h" +#include "client.h" +#include "ircd.h" +#include "ircd_defs.h" +#include "ircd_events.h" +#include "ircd_features.h" +#include "ircd_log.h" +#include "ircd_string.h" +#include "s_user.h" + +#include +#include +#include +#include + +/* --- globals IPcheck.o expects from the rest of ircd --------------------- */ + +time_t CurrentTime; +extern struct Client me; + +static int f_clone_limit = 4; +static int f_clone_period = 40; +static int f_48_limit = 50; +static int f_48_period = 10; +static int f_clone_delay = 600; + +int feature_int(enum Feature feat) +{ + switch (feat) { + case FEAT_IPCHECK_CLONE_LIMIT: return f_clone_limit; + case FEAT_IPCHECK_CLONE_PERIOD: return f_clone_period; + case FEAT_IPCHECK_48_CLONE_LIMIT: return f_48_limit; + case FEAT_IPCHECK_48_CLONE_PERIOD: return f_48_period; + case FEAT_IPCHECK_CLONE_DELAY: return f_clone_delay; + default: return 0; + } +} + +/* IPcheck_init() registers a periodic expiry timer; capture the callback so + * the tests can run an expiry pass on demand. */ +static EventCallBack expire_cb; + +struct Timer *timer_init(struct Timer *timer) { return timer; } +void timer_add(struct Timer *timer, EventCallBack call, void *data, + enum TimerType type, time_t value) +{ + (void)timer; (void)data; (void)type; (void)value; + expire_cb = call; +} + +/* IPcheck_connect_succeeded() sends "on %u ca %u(%u) ft %u(%u)%s"; capture + * the values so tests can assert on the registry state it reports. */ +static struct { + int calls; + unsigned int connected, attempts, limit, free_targets, start_targets; + int has_tr; +} notice; + +void sendcmdto_one(struct Client *from, const char *cmd, const char *tok, + struct Client *to, const char *pattern, ...) +{ + va_list ap; + (void)from; (void)cmd; (void)tok; (void)to; + + if (strcmp(pattern, "%C :on %u ca %u(%u) ft %u(%u)%s")) { + fprintf(stderr, "unexpected sendcmdto_one pattern: %s\n", pattern); + abort(); + } + va_start(ap, pattern); + (void)va_arg(ap, struct Client *); + notice.connected = va_arg(ap, unsigned int); + notice.attempts = va_arg(ap, unsigned int); + notice.limit = va_arg(ap, unsigned int); + notice.free_targets = va_arg(ap, unsigned int); + notice.start_targets = va_arg(ap, unsigned int); + notice.has_tr = !!strcmp(va_arg(ap, const char *), ""); + va_end(ap); + notice.calls++; +} + +/* --- tiny test framework ------------------------------------------------ */ + +static int failures; +static const char *current; + +#define CHECK(cond) do { \ + if (!(cond)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d [%s]: %s\n", __FILE__, __LINE__, current, \ + #cond); \ + } \ +} while (0) + +#define CHECK_EQ(a, b) do { \ + long _a = (long)(a), _b = (long)(b); \ + if (_a != _b) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d [%s]: %s == %ld, expected %s == %ld\n", \ + __FILE__, __LINE__, current, #a, _a, #b, _b); \ + } \ +} while (0) + +/* --- helpers ------------------------------------------------------------ */ + +static struct Connection me_con; + +/* Pool of fake clients; each gets its own Connection so cli_* accessors + * work and MyConnect() can be controlled. */ +#define NCLIENTS 64 +static struct Client clients[NCLIENTS]; +static struct Connection conns[NCLIENTS]; +static int nclients; + +static struct Client *mk_client(const char *ip, int local) +{ + struct Client *cptr; + struct Connection *con; + + if (nclients >= NCLIENTS) { + fprintf(stderr, "client pool exhausted\n"); + abort(); + } + cptr = &clients[nclients]; + con = &conns[nclients]; + nclients++; + memset(cptr, 0, sizeof(*cptr)); + memset(con, 0, sizeof(*con)); + cli_connect(cptr) = con; + /* MyConnect(cptr) is cli_from(cptr) == cptr; a remote client's "from" is + * the server it came in through. */ + con_client(con) = local ? cptr : &me; + cli_firsttime(cptr) = CurrentTime; + cli_nexttarget(cptr) = CurrentTime - TARGET_DELAY * STARTTARGETS; + if (!ircd_aton(&cli_ip(cptr), ip)) { + fprintf(stderr, "bad test address %s\n", ip); + abort(); + } + return cptr; +} + +static struct irc_in_addr addr_of(const char *ip) +{ + struct irc_in_addr a; + if (!ircd_aton(&a, ip)) { + fprintf(stderr, "bad test address %s\n", ip); + abort(); + } + return a; +} + +/* Simulate the accept path: IPcheck_local_connect() and, when the address + * was recorded, the flag add_connection() sets. Returns the client + * (accepted) or NULL (refused). */ +static struct Client *local_connect(const char *ip, time_t *next_target) +{ + struct irc_in_addr a = addr_of(ip); + time_t nt; + struct Client *cptr; + int res = IPcheck_local_connect(&a, next_target ? next_target : &nt); + + if (res == IPCHECK_REFUSED) + return NULL; + cptr = mk_client(ip, 1); + if (res == IPCHECK_COUNTED) + SetIPChecked(cptr); + return cptr; +} + +/* Disconnect the way s_misc.c does: only counted clients touch the + * registry. */ +static void disconnect(struct Client *cptr) +{ + if (IsIPChecked(cptr)) + IPcheck_disconnect(cptr); +} + +/* Count of clients IPcheck knows for \a ip. */ +static unsigned int nr(const char *ip) +{ + struct Client probe; + memset(&probe, 0, sizeof(probe)); + cli_ip(&probe) = addr_of(ip); + return IPcheck_nr(&probe); +} + +/* Fresh registry state between tests: distinct address per test avoids + * cross-talk, and the fake clock keeps moving forward. */ +static void begin(const char *name) +{ + current = name; + nclients = 0; + memset(¬ice, 0, sizeof(notice)); + f_clone_limit = 4; + f_clone_period = 40; + f_48_limit = 50; + f_48_period = 10; + f_clone_delay = 600; + CurrentTime += 10000; /* well past every period and expiry */ + cli_since(&me) = CurrentTime - 100000; /* "booted long ago" by default */ + IPcheck_clear_config(); +} + +/* --- tests -------------------------------------------------------------- */ + +/* Up to IPCHECK_CLONE_LIMIT-1 connects within a period are accepted, the + * next is refused and does not count as connected, and the counter resets + * once the address has been idle for more than the period. */ +static void test_clone_limit_and_period(void) +{ + int i; + begin("clone_limit_and_period"); + + for (i = 1; i < f_clone_limit; i++) + CHECK(local_connect("10.1.0.1", NULL) != NULL); + CHECK_EQ(nr("10.1.0.1"), f_clone_limit - 1); + + CHECK(local_connect("10.1.0.1", NULL) == NULL); /* attempt == limit */ + CHECK_EQ(nr("10.1.0.1"), f_clone_limit - 1); /* refusal not counted */ + CHECK(local_connect("10.1.0.1", NULL) == NULL); /* still refused */ + + /* A refused attempt restarts the period too: exactly one period after + * the last refusal is still inside it, one second more is not. */ + CurrentTime += f_clone_period; + CHECK(local_connect("10.1.0.1", NULL) == NULL); + CurrentTime += f_clone_period; + CHECK(local_connect("10.1.0.1", NULL) == NULL); + CurrentTime += f_clone_period + 1; + CHECK(local_connect("10.1.0.1", NULL) != NULL); + CHECK_EQ(nr("10.1.0.1"), f_clone_limit); +} + +/* Other addresses are unaffected by one address hitting its limit. */ +static void test_addresses_are_independent(void) +{ + int i; + begin("addresses_are_independent"); + + for (i = 1; i < f_clone_limit; i++) + CHECK(local_connect("10.2.0.1", NULL) != NULL); + CHECK(local_connect("10.2.0.1", NULL) == NULL); + CHECK(local_connect("10.2.0.2", NULL) != NULL); + CHECK_EQ(nr("10.2.0.2"), 1); + CHECK_EQ(nr("10.2.0.1"), f_clone_limit - 1); +} + +/* IPCHECK_CLONE_DELAY: nothing is refused until the server has been up + * for longer than the delay (attempts are still counted). */ +static void test_boot_grace(void) +{ + int i; + begin("boot_grace"); + f_clone_delay = 30; /* shorter than the clone period */ + cli_since(&me) = CurrentTime; /* just booted */ + + for (i = 0; i < 3 * f_clone_limit; i++) + CHECK(local_connect("10.3.0.1", NULL) != NULL); + CHECK_EQ(nr("10.3.0.1"), 3 * f_clone_limit); + + /* Grace over, still inside the clone period: the accumulated attempts + * now bite. */ + CurrentTime += f_clone_delay + 1; + CHECK(local_connect("10.3.0.1", NULL) == NULL); +} + +/* IPcheck_connect_fail() gives back the attempt (and optionally the + * connected slot) for a rejection that was not the client's fault. */ +static void test_connect_fail_undo(void) +{ + struct Client *c1, *c2, *c3; + begin("connect_fail_undo"); + + c1 = local_connect("10.4.0.1", NULL); + c2 = local_connect("10.4.0.1", NULL); + c3 = local_connect("10.4.0.1", NULL); + CHECK(c1 && c2 && c3); /* attempts == limit - 1 */ + + /* Registration of c3 failed through no fault of its own, but it is + * still connected: one attempt returned, still 3 connected, so one more + * connect fits before the limit. */ + IPcheck_connect_fail(c3, 0); + CHECK_EQ(nr("10.4.0.1"), 3); + CHECK(local_connect("10.4.0.1", NULL) != NULL); + CHECK(local_connect("10.4.0.1", NULL) == NULL); + + /* disconnect=1 also releases the connected slot. */ + IPcheck_connect_fail(c2, 1); + CHECK_EQ(nr("10.4.0.1"), 3); +} + +/* IPcheck_disconnect() decrements the connected count; when the last + * client leaves after a long connection the attempt counter is cleared so + * an immediate reconnect is not penalised for ancient history. */ +static void test_disconnect_accounting(void) +{ + struct Client *c1, *c2; + begin("disconnect_accounting"); + + c1 = local_connect("10.5.0.1", NULL); + c2 = local_connect("10.5.0.1", NULL); + CHECK(c1 && c2); + CHECK_EQ(nr("10.5.0.1"), 2); + + IPcheck_disconnect(c1); + CHECK_EQ(nr("10.5.0.1"), 1); + + /* Reconnecting right away continues the attempt count (2 -> 3). */ + c1 = local_connect("10.5.0.1", NULL); + CHECK(c1 != NULL); + CHECK(local_connect("10.5.0.1", NULL) == NULL); + + /* Everyone leaves after being connected longer than limit*period: the + * last disconnect resets the attempts (and stamps last_connect), so an + * immediate reconnect burst is allowed again. Without that reset the + * three old attempts would still count and the first reconnect would be + * refused, since no period has elapsed since the disconnect. */ + CurrentTime += f_clone_limit * f_clone_period + 1; + IPcheck_disconnect(c1); + IPcheck_disconnect(c2); + CHECK_EQ(nr("10.5.0.1"), 0); + CHECK(local_connect("10.5.0.1", NULL) != NULL); + CHECK(local_connect("10.5.0.1", NULL) != NULL); + CHECK(local_connect("10.5.0.1", NULL) != NULL); + CHECK(local_connect("10.5.0.1", NULL) == NULL); +} + +/* IPCheck { except ... } netblocks bypass the limit entirely, for local + * and remote clients, until the config is cleared. */ +static void test_exemptions(void) +{ + int i; + struct Client *r; + begin("exemptions"); + + CHECK_EQ(IPcheck_except("10.6.0.0/16"), 0); + CHECK_EQ(IPcheck_except("2001:db8:6::/48"), 0); + CHECK(IPcheck_except("not an address") != 0); + + for (i = 0; i < 5 * f_clone_limit; i++) { + struct Client *c = local_connect("10.6.1.1", NULL); + CHECK(c != NULL); + CHECK(!IsIPChecked(c)); /* accepted but not recorded */ + } + { + struct irc_in_addr a = addr_of("10.6.1.1"); + time_t nt = 0; + CHECK_EQ(IPcheck_local_connect(&a, &nt), IPCHECK_EXEMPT); + } + for (i = 0; i < 5 * f_clone_limit; i++) + CHECK(local_connect("2001:db8:6:1::1", NULL) != NULL); + /* Exempt addresses are never entered in the registry. */ + CHECK_EQ(nr("10.6.1.1"), 0); + + /* Remote exempt clients are accepted but not recorded either. */ + r = mk_client("10.6.2.2", 0); + CHECK(IPcheck_remote_connect(r, 0) != 0); + CHECK(!IsIPChecked(r)); + CHECK_EQ(nr("10.6.2.2"), 0); + + /* Outside the block: normal limit. */ + for (i = 1; i < f_clone_limit; i++) + CHECK(local_connect("10.7.0.1", NULL) != NULL); + CHECK(local_connect("10.7.0.1", NULL) == NULL); + + /* Rehash without the block: exemption gone. */ + IPcheck_clear_config(); + for (i = 1; i < f_clone_limit; i++) + CHECK(local_connect("10.6.1.1", NULL) != NULL); + CHECK(local_connect("10.6.1.1", NULL) == NULL); +} + +/* An address that becomes exempt while it already has a registry entry + * (clients connected before the rehash, or remote users) must not have that + * entry disturbed by exempt clients coming and going; and clients that were + * counted before the exemption still release their slot afterwards. */ +static void test_exempt_does_not_touch_existing_entry(void) +{ + struct Client *counted, *exempt1, *exempt2, *remote; + begin("exempt_does_not_touch_existing_entry"); + + counted = local_connect("10.14.0.1", NULL); + remote = mk_client("10.14.0.1", 0); + CHECK(counted && IsIPChecked(counted)); + CHECK(IPcheck_remote_connect(remote, 0) != 0); + CHECK_EQ(nr("10.14.0.1"), 2); + + CHECK_EQ(IPcheck_except("10.14.0.0/24"), 0); + exempt1 = local_connect("10.14.0.1", NULL); + exempt2 = local_connect("10.14.0.1", NULL); + CHECK(exempt1 && exempt2); + CHECK(!IsIPChecked(exempt1) && !IsIPChecked(exempt2)); + CHECK_EQ(nr("10.14.0.1"), 2); /* untouched */ + + disconnect(exempt1); + disconnect(exempt2); + CHECK_EQ(nr("10.14.0.1"), 2); /* still untouched, no underflow */ + + IPcheck_clear_config(); /* exemption removed again */ + disconnect(counted); + CHECK_EQ(nr("10.14.0.1"), 1); + disconnect(remote); + CHECK_EQ(nr("10.14.0.1"), 0); +} + +/* Remote clients share the per-address entry: non-burst introductions + * count as attempts (so they can exhaust the local limit) but are never + * refused themselves; burst introductions count only as connected. */ +static void test_remote_and_burst(void) +{ + int i; + struct Client *r[8]; + begin("remote_and_burst"); + + for (i = 0; i < 3; i++) { + r[i] = mk_client("10.8.0.1", 0); + CHECK(IPcheck_remote_connect(r[i], 0) != 0); + CHECK(IsIPChecked(r[i])); + } + CHECK_EQ(nr("10.8.0.1"), 3); + /* Three remote attempts + this one == limit. */ + CHECK(local_connect("10.8.0.1", NULL) == NULL); + /* Remote is never rate-limited. */ + for (i = 3; i < 8; i++) { + r[i] = mk_client("10.8.0.1", 0); + CHECK(IPcheck_remote_connect(r[i], 0) != 0); + } + CHECK_EQ(nr("10.8.0.1"), 8); + + /* Burst: connected counts, attempts do not. */ + for (i = 0; i < 6; i++) { + struct Client *b = mk_client("10.9.0.1", 0); + CHECK(IPcheck_remote_connect(b, 1) != 0); + } + CHECK_EQ(nr("10.9.0.1"), 6); + for (i = 1; i < f_clone_limit; i++) + CHECK(local_connect("10.9.0.1", NULL) != NULL); + CHECK(local_connect("10.9.0.1", NULL) == NULL); + CHECK_EQ(nr("10.9.0.1"), 6 + f_clone_limit - 1); + + /* Remote clients disconnecting release their slots too. */ + for (i = 0; i < 8; i++) + IPcheck_disconnect(r[i]); + CHECK_EQ(nr("10.8.0.1"), 0); +} + +/* IPv6 addresses are keyed on their /64, and a /48 has its own, separate + * attempt limit across all of its /64s. */ +static void test_ipv6_64_and_48(void) +{ + int i; + begin("ipv6_64_and_48"); + + /* Same /64, different host bits: one entry. */ + for (i = 1; i < f_clone_limit; i++) + CHECK(local_connect("2001:db8:1:1::1", NULL) != NULL); + CHECK(local_connect("2001:db8:1:1:ffff::2", NULL) == NULL); + CHECK_EQ(nr("2001:db8:1:1::abcd"), f_clone_limit - 1); + + /* Different /64 in the same /48: separate entry, accepted. */ + CHECK(local_connect("2001:db8:1:2::1", NULL) != NULL); + CHECK_EQ(nr("2001:db8:1:2::1"), 1); + + /* /48 limit: with limit 3, the third connect from any /64 of the /48 + * within the /48 period is refused, even for a never-seen /64. */ + f_48_limit = 3; + CHECK(local_connect("2001:db8:2:1::1", NULL) != NULL); + CHECK(local_connect("2001:db8:2:2::1", NULL) != NULL); + CHECK(local_connect("2001:db8:2:3::1", NULL) == NULL); + CHECK_EQ(nr("2001:db8:2:3::1"), 0); + /* A /48 refusal for an existing /64 must not leak a connected slot. */ + CHECK(local_connect("2001:db8:2:1::1", NULL) == NULL); + CHECK_EQ(nr("2001:db8:2:1::1"), 1); + + /* After the /48 period the /48 counter resets. */ + CurrentTime += f_48_period + 1; + CHECK(local_connect("2001:db8:2:3::1", NULL) != NULL); + + /* Remote IPv6 clients count against the /48 too. */ + { + struct Client *r = mk_client("2001:db8:3:1::1", 0); + CHECK(IPcheck_remote_connect(r, 0) != 0); + r = mk_client("2001:db8:3:2::1", 0); + CHECK(IPcheck_remote_connect(r, 0) != 0); + CHECK(local_connect("2001:db8:3:3::1", NULL) == NULL); + } +} + +/* IPv4 addresses are keyed on the full /32 (6to4 form, /48). */ +static void test_ipv4_canonical_form(void) +{ + int i; + begin("ipv4_canonical_form"); + + for (i = 1; i < f_clone_limit; i++) + CHECK(local_connect("192.0.2.10", NULL) != NULL); + CHECK(local_connect("192.0.2.10", NULL) == NULL); + /* Adjacent addresses are distinct entries. */ + CHECK(local_connect("192.0.2.11", NULL) != NULL); + CHECK(local_connect("192.0.2.9", NULL) != NULL); + CHECK_EQ(nr("192.0.2.11"), 1); +} + +/* Free-target bookkeeping: a new address starts with STARTTARGETS, a + * departing client leaves behind the smallest free-target count seen, the + * next client from that address inherits it (and its target hashes), and + * the count regenerates at one per TARGET_DELAY seconds of idle time. */ +static void test_free_targets(void) +{ + struct Client *c; + time_t nt = 0; + begin("free_targets"); + + c = local_connect("10.10.0.1", &nt); + CHECK(c != NULL); + CHECK_EQ(nt, 0); /* new address: caller keeps the client default */ + IPcheck_connect_succeeded(c); + CHECK_EQ(notice.calls, 1); + CHECK_EQ(notice.connected, 1); + CHECK_EQ(notice.attempts, 1); + CHECK_EQ(notice.limit, f_clone_limit); + CHECK_EQ(notice.free_targets, STARTTARGETS); + CHECK_EQ(notice.start_targets, STARTTARGETS); + CHECK(!notice.has_tr); + + /* The client used its targets: nexttarget 2 delays in the past means + * 3 free targets at disconnect. */ + cli_nexttarget(c) = CurrentTime - 2 * TARGET_DELAY; + memset(cli_targets(c), 0x5a, MAXTARGETS); + IPcheck_disconnect(c); + + /* Next client inherits 3 free targets and the target hashes. */ + c = local_connect("10.10.0.1", &nt); + CHECK(c != NULL); + CHECK_EQ(nt, CurrentTime - (TARGET_DELAY * 3 - 1)); + IPcheck_connect_succeeded(c); + CHECK_EQ(notice.free_targets, 3); + CHECK(notice.has_tr); + CHECK_EQ(cli_targets(c)[0], 0x5a); + CHECK_EQ(cli_targets(c)[MAXTARGETS - 1], 0x5a); + + /* A client with no free targets left pins the count at 0. */ + cli_nexttarget(c) = CurrentTime + 5 * TARGET_DELAY; + IPcheck_disconnect(c); + c = local_connect("10.10.0.1", &nt); + CHECK(c != NULL); + CHECK_EQ(nt, CurrentTime + 1); + IPcheck_connect_succeeded(c); + CHECK_EQ(notice.free_targets, 0); + IPcheck_disconnect(c); + + /* Regeneration: two delays of idle time give two targets back. */ + CurrentTime += 2 * TARGET_DELAY; + c = local_connect("10.10.0.1", &nt); + CHECK(c != NULL); + CHECK_EQ(nt, CurrentTime - (TARGET_DELAY * 2 - 1)); + IPcheck_connect_succeeded(c); + CHECK_EQ(notice.free_targets, 2); + + /* Long-lived clients earn a bonus: 10 minutes plus 4 delays online. */ + cli_firsttime(c) = CurrentTime - 600 - 4 * TARGET_DELAY; + cli_nexttarget(c) = CurrentTime - 1; /* 1 free target itself */ + IPcheck_disconnect(c); + c = local_connect("10.10.0.1", &nt); + CHECK(c != NULL); + /* min(previous 2 regenerated, 1 + 4 bonus) == 2, capped by history. */ + IPcheck_connect_succeeded(c); + CHECK_EQ(notice.free_targets, 2); + + /* Remote clients leave no target history. */ + { + struct Client *r = mk_client("10.10.0.2", 0); + CHECK(IPcheck_remote_connect(r, 0) != 0); + cli_nexttarget(r) = CurrentTime + 5 * TARGET_DELAY; + IPcheck_disconnect(r); + c = local_connect("10.10.0.2", &nt); + CHECK(c != NULL); + CHECK_EQ(nt, CurrentTime - (TARGET_DELAY * STARTTARGETS - 1)); + } +} + +/* The expiry pass drops target history after 120 s idle and the whole + * entry after 600 s idle, but only for addresses with nothing connected. */ +static void test_expiry(void) +{ + struct Client *c; + time_t nt = 0; + begin("expiry"); + CHECK(expire_cb != NULL); + + /* Leave zero free targets behind. */ + c = local_connect("10.11.0.1", &nt); + CHECK(c != NULL); + cli_nexttarget(c) = CurrentTime + 5 * TARGET_DELAY; + IPcheck_disconnect(c); + + /* 100 s idle: history kept (0 targets, nt in the future). */ + { + struct Event ev; + struct Timer tim; + memset(&ev, 0, sizeof(ev)); + ev.ev_type = ET_EXPIRE; + ev.ev_gen.gen_timer = &tim; + + CurrentTime += 100; + expire_cb(&ev); + c = local_connect("10.11.0.1", &nt); + CHECK(c != NULL); + CHECK_EQ(nt, CurrentTime + 1); + IPcheck_disconnect(c); + + /* > 120 s idle: target history expired, back to STARTTARGETS. */ + CurrentTime += 121; + expire_cb(&ev); + c = local_connect("10.11.0.1", &nt); + CHECK(c != NULL); + CHECK_EQ(nt, CurrentTime - (TARGET_DELAY * STARTTARGETS - 1)); + + /* An address with a client still connected is never expired. */ + CurrentTime += 601; + expire_cb(&ev); + CHECK_EQ(nr("10.11.0.1"), 1); + IPcheck_disconnect(c); + + /* > 600 s idle: entry dropped and the address behaves like a new + * one (the attempt counter alone cannot tell this apart from the + * period reset; this mainly checks the pass does not crash or drop + * live entries). */ + c = local_connect("10.11.0.1", &nt); + CHECK(c != NULL); + IPcheck_disconnect(c); + CHECK_EQ(nr("10.11.0.1"), 0); + CurrentTime += 601; + expire_cb(&ev); + CHECK(local_connect("10.11.0.1", NULL) != NULL); + CHECK(local_connect("10.11.0.1", NULL) != NULL); + CHECK(local_connect("10.11.0.1", NULL) != NULL); + CHECK(local_connect("10.11.0.1", NULL) == NULL); + } +} + +/* last_connect is stored in 16 bits; the "seconds since" arithmetic must + * survive CurrentTime crossing a multiple of 65536. */ +static void test_clock_wrap(void) +{ + begin("clock_wrap"); + CurrentTime = (CurrentTime | 0xffff) - 5; /* NOW == 65530 */ + cli_since(&me) = CurrentTime - 100000; + + CHECK(local_connect("10.12.0.1", NULL) != NULL); /* attempt 1 @65530 */ + CurrentTime += 11; /* NOW == 5, 11 s later */ + CHECK(local_connect("10.12.0.1", NULL) != NULL); /* attempt 2 */ + CurrentTime += 1; + CHECK(local_connect("10.12.0.1", NULL) != NULL); /* attempt 3 */ + CurrentTime += 1; + /* If the wrap were mishandled the entry would look ancient, the + * attempts would have been reset and this would be accepted. */ + CHECK(local_connect("10.12.0.1", NULL) == NULL); + + /* And a genuinely idle entry across the wrap does reset. */ + CurrentTime = (CurrentTime | 0xffff) - 5; + CHECK(local_connect("10.12.0.2", NULL) != NULL); + CHECK(local_connect("10.12.0.2", NULL) != NULL); + CHECK(local_connect("10.12.0.2", NULL) != NULL); + CurrentTime += f_clone_period + 6; + CHECK(local_connect("10.12.0.2", NULL) != NULL); +} + +/* The 16-bit connected counter refuses rather than wrapping. */ +static void test_connected_overflow(void) +{ + struct irc_in_addr a = addr_of("10.13.0.1"); + struct Client r; + time_t nt; + long i; + begin("connected_overflow"); + cli_since(&me) = CurrentTime; /* grace: nothing refused for rate */ + + for (i = 0; i < 65535; i++) + CHECK(IPcheck_local_connect(&a, &nt) != 0); + CHECK_EQ(nr("10.13.0.1"), 65535); + CHECK(IPcheck_local_connect(&a, &nt) == 0); + CHECK_EQ(nr("10.13.0.1"), 65535); + + memset(&r, 0, sizeof(r)); + cli_ip(&r) = a; + CHECK(IPcheck_remote_connect(&r, 1) == 0); + CHECK_EQ(nr("10.13.0.1"), 65535); +} + +int main(void) +{ + memset(&me_con, 0, sizeof(me_con)); + cli_connect(&me) = &me_con; + con_client(&me_con) = &me; + CurrentTime = 1000000; + IPcheck_init(); + + test_clone_limit_and_period(); + test_addresses_are_independent(); + test_boot_grace(); + test_connect_fail_undo(); + test_disconnect_accounting(); + test_exemptions(); + test_exempt_does_not_touch_existing_entry(); + test_remote_and_burst(); + test_ipv6_64_and_48(); + test_ipv4_canonical_form(); + test_free_targets(); + test_expiry(); + test_clock_wrap(); + test_connected_overflow(); + + if (failures) { + fprintf(stderr, "ipcheck_t: %d failure(s)\n", failures); + return 1; + } + printf("ipcheck_t: all tests passed\n"); + return 0; +} diff --git a/tests/ipcheck/__init__.py b/tests/ipcheck/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/ipcheck/helpers.py b/tests/ipcheck/helpers.py new file mode 100644 index 00000000..38cde614 --- /dev/null +++ b/tests/ipcheck/helpers.py @@ -0,0 +1,134 @@ +"""Shared helpers for the IPcheck integration tests.""" + +from __future__ import annotations + +import asyncio +import re +from dataclasses import dataclass + +from irc_client import IRCClient, Message + +# IPcheck_connect_succeeded() tells every local client the registry state +# for its address: "on ca () ft ()" +# with a trailing " tr" when target history was inherited. +IPCHECK_NOTICE = re.compile( + r"^on (?P\d+) ca (?P\d+)\((?P\d+)\) " + r"ft (?P\d+)\((?P\d+)\)(?P tr)?$" +) + +THROTTLE_ERROR = "ERROR :Your host is trying to (re)connect too fast -- throttled" + + +@dataclass +class IPcheckNotice: + connected: int + attempts: int + limit: int + free_targets: int + start_targets: int + inherited: bool + + @classmethod + def parse(cls, msg: Message) -> "IPcheckNotice | None": + if msg.command != "NOTICE" or not msg.params: + return None + m = IPCHECK_NOTICE.match(msg.params[-1]) + if not m: + return None + return cls( + connected=int(m["on"]), + attempts=int(m["ca"]), + limit=int(m["limit"]), + free_targets=int(m["ft"]), + start_targets=int(m["start"]), + inherited=bool(m["tr"]), + ) + + +def find_notice(msgs: list[Message]) -> IPcheckNotice | None: + for msg in msgs: + parsed = IPcheckNotice.parse(msg) + if parsed: + return parsed + return None + + +async def register_with_notice( + host: str, port: int, nick: str, timeout: float = 5.0 +) -> tuple[IRCClient, IPcheckNotice]: + """Connect + register and return the client with its IPcheck notice.""" + client = IRCClient() + await client.connect(host, port) + msgs = await client.register(nick, "ipcuser", "IPcheck test") + notice = find_notice(msgs) + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while notice is None and loop.time() < deadline: + msg = await client.recv(timeout=deadline - loop.time()) + notice = IPcheckNotice.parse(msg) + assert notice is not None, f"{nick}: no IPcheck notice after registration" + return client, notice + + +async def register_expect_no_notice( + host: str, port: int, nick: str, quiet: float = 1.0 +) -> IRCClient: + """Connect + register and assert no IPcheck notice arrives (exempt IP).""" + client = IRCClient() + await client.connect(host, port) + msgs = await client.register(nick, "ipcuser", "IPcheck test") + assert find_notice(msgs) is None, f"{nick}: exempt address got an IPcheck notice" + try: + while True: + msg = await client.recv(timeout=quiet) + assert IPcheckNotice.parse(msg) is None, ( + f"{nick}: exempt address got an IPcheck notice: {msg.raw}" + ) + except asyncio.TimeoutError: + pass + return client + + +async def raw_connect_first_line(host: str, port: int, timeout: float = 5.0) -> str: + """Open a TCP connection and return the first line the server sends. + + A throttled connection gets the ERROR line and EOF before any command + is read, so nothing needs to be sent. + """ + reader, writer = await asyncio.open_connection(host, port) + try: + data = await asyncio.wait_for(reader.readline(), timeout) + finally: + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + return data.decode(errors="replace").rstrip("\r\n") + + +async def register_expect_throttled(host: str, port: int) -> None: + line = await raw_connect_first_line(host, port) + assert line == THROTTLE_ERROR, f"expected throttle ERROR, got {line!r}" + + +async def userip(oper: IRCClient, nick: str) -> str: + """Return the IP the server recorded for \\a nick (RPL_USERIP).""" + await oper.send(f"USERIP {nick}") + msg = await oper.wait_for("340", timeout=5.0) + # "[*]=[+-]@" + return msg.params[-1].strip().rsplit("@", 1)[1] + + +async def disconnect_all(*clients: IRCClient | None) -> None: + for c in clients: + if c is None: + continue + try: + await c.send("QUIT :ipcheck test cleanup") + except Exception: + pass + try: + await c.disconnect() + except Exception: + pass diff --git a/tests/ipcheck/test_ipcheck_limits.py b/tests/ipcheck/test_ipcheck_limits.py new file mode 100644 index 00000000..e717d105 --- /dev/null +++ b/tests/ipcheck/test_ipcheck_limits.py @@ -0,0 +1,317 @@ +"""IPcheck connection-rate limiting on the dedicated limits server. + +IPcheck (ircd/IPcheck.c) keeps a per-address registry of connected clients +and recent connection attempts. A local connection is refused with + + ERROR :Your host is trying to (re)connect too fast -- throttled + +when the address has made IPCHECK_CLONE_LIMIT attempts each less than +IPCHECK_CLONE_PERIOD seconds apart -- unless the server booted less than +IPCHECK_CLONE_DELAY seconds ago, or the address is listed in an IPCheck +except block. Every registered local client is told the registry state for +its address ("on N ca M(L) ft F(S)"), which these tests use to observe the +counters. The Client-block maxlinks limit is enforced from the same +registry via IPcheck_nr(). + +All test connections come from one address (the docker bridge gateway), so +every connection made by a test -- including the opers -- is an attempt +against that address. Each test therefore sleeps out the period before its +measured burst. Features changed with SET are reverted with RESET, and +REHASH (which resets SET features to the config values) always precedes +the SET calls. +""" + +from __future__ import annotations + +import asyncio + +import pytest +import pytest_asyncio + +from class_limits.helpers import rehash_config, restore_config +from ipcheck.helpers import ( + disconnect_all, + raw_connect_first_line, + register_expect_no_notice, + register_expect_throttled, + register_with_notice, + userip, + THROTTLE_ERROR, +) +from irc_client import IRCClient +from tls.helpers import oper_up + +pytestmark = pytest.mark.limits + +CLONE_LIMIT = 3 # attempts within the period; the LIMIT-th is refused +CLONE_PERIOD = 4 # seconds between attempts that keep the counter alive +STARTTARGETS = 10 # ircd_defs.h + + +def _grant_set(snapshot: str) -> str: + """Config with PRIV_SET granted to the test oper (denied by default).""" + return snapshot.replace( + 'name = "testoper";', + 'name = "testoper";\n set = yes;', + ) + + +async def _apply(setoper: IRCClient, **features: int | str) -> None: + for name, value in features.items(): + await setoper.send(f"SET {name} {value}") + await asyncio.sleep(0.3) + + +async def _reset(setoper: IRCClient, *names: str) -> None: + for name in names: + try: + await setoper.send(f"RESET {name}") + except Exception: + pass + await asyncio.sleep(0.3) + + +async def _idle_out_period() -> None: + """Let the address go idle for longer than the clone period.""" + await asyncio.sleep(CLONE_PERIOD + 1.5) + + +@pytest_asyncio.fixture +async def ipcheck_env(ircd_limits, limits_oper, limits_config_snapshot, make_limits_client): + """PRIV_SET-capable oper with the clone limit/period/delay applied. + + Yields (server, setoper). Restores features and config afterwards. + """ + restore_config(_grant_set(limits_config_snapshot)) + await rehash_config(limits_oper) + setoper = await make_limits_client("ipcsetop") + await oper_up(setoper) + await _apply( + setoper, + IPCHECK_CLONE_DELAY=0, + IPCHECK_CLONE_LIMIT=CLONE_LIMIT, + IPCHECK_CLONE_PERIOD=CLONE_PERIOD, + ) + try: + yield ircd_limits, setoper + finally: + await _reset( + setoper, + "IPCHECK_CLONE_DELAY", "IPCHECK_CLONE_LIMIT", "IPCHECK_CLONE_PERIOD", + ) + restore_config(limits_config_snapshot) + await rehash_config(limits_oper) + + +async def test_registration_notice_reports_registry_state(ipcheck_env): + """Each local client is told connected/attempt counts and the limit.""" + srv, _ = ipcheck_env + await _idle_out_period() + c1 = c2 = None + try: + c1, n1 = await register_with_notice(srv["host"], srv["port"], "ipcnot1") + assert n1.limit == CLONE_LIMIT + assert n1.attempts == 1, n1 # idle period reset the counter + assert n1.start_targets == STARTTARGETS + assert 0 <= n1.free_targets <= STARTTARGETS + base = n1.connected # opers + c1 + + c2, n2 = await register_with_notice(srv["host"], srv["port"], "ipcnot2") + assert n2.connected == base + 1, (n1, n2) + assert n2.attempts == 2, n2 + finally: + await disconnect_all(c1, c2) + + +async def test_clone_limit_throttles_and_recovers(ipcheck_env): + """LIMIT-1 quick connects succeed; the LIMIT-th is refused before it + can send anything; a refused attempt does not count as connected; after + the period the address is accepted again.""" + srv, _ = ipcheck_env + await _idle_out_period() + clients = [] + try: + for i in range(CLONE_LIMIT - 1): + c, n = await register_with_notice(srv["host"], srv["port"], f"ipclim{i}") + clients.append(c) + assert n.attempts == i + 1, n + connected_before = n.connected + + await register_expect_throttled(srv["host"], srv["port"]) + # Still refused while the counter is alive. + await register_expect_throttled(srv["host"], srv["port"]) + + await _idle_out_period() + c, n = await register_with_notice(srv["host"], srv["port"], "ipclimok") + clients.append(c) + assert n.attempts == 1, n # counter reset + assert n.connected == connected_before + 1, n # refusals not counted + finally: + await disconnect_all(*clients) + + +async def test_refused_attempt_restarts_period(ipcheck_env): + """A throttled attempt is itself an attempt: reconnecting just inside + the period after a refusal is refused again.""" + srv, _ = ipcheck_env + await _idle_out_period() + clients = [] + try: + for i in range(CLONE_LIMIT - 1): + c, _ = await register_with_notice(srv["host"], srv["port"], f"ipcref{i}") + clients.append(c) + await register_expect_throttled(srv["host"], srv["port"]) + await asyncio.sleep(CLONE_PERIOD - 1) + await register_expect_throttled(srv["host"], srv["port"]) + await _idle_out_period() + c, _ = await register_with_notice(srv["host"], srv["port"], "ipcrefok") + clients.append(c) + finally: + await disconnect_all(*clients) + + +async def test_disconnected_clients_release_connected_slots(ipcheck_env): + """The connected count drops when clients leave (attempts do not).""" + srv, _ = ipcheck_env + await _idle_out_period() + keep = None + try: + a, na = await register_with_notice(srv["host"], srv["port"], "ipcrel1") + await disconnect_all(a) + await asyncio.sleep(0.3) + keep, nb = await register_with_notice(srv["host"], srv["port"], "ipcrel2") + assert nb.connected == na.connected, (na, nb) # a's slot released + assert nb.attempts == na.attempts + 1, (na, nb) # but still an attempt + finally: + await disconnect_all(keep) + + +async def test_boot_grace_disables_throttling(ipcheck_env): + """With IPCHECK_CLONE_DELAY larger than the uptime nothing is refused, + although attempts keep being counted past the limit.""" + srv, setoper = ipcheck_env + await _apply(setoper, IPCHECK_CLONE_DELAY=10_000_000) + await _idle_out_period() + clients = [] + try: + for i in range(2 * CLONE_LIMIT): + c, n = await register_with_notice(srv["host"], srv["port"], f"ipcgrc{i}") + clients.append(c) + assert n.attempts == i + 1, n + assert n.attempts > n.limit + finally: + await disconnect_all(*clients) + + +async def test_except_block_exempts_address(ipcheck_env, limits_oper, limits_config_snapshot): + """An address in IPCheck { except ...; } is never counted or refused, + and gets no registry notice; removing the block restores the limit.""" + srv, setoper = ipcheck_env + ip = await userip(setoper, "ipcsetop") + + text = _grant_set(limits_config_snapshot) + f'\nIPCheck {{ except "{ip}"; }};\n' + restore_config(text) + await rehash_config(limits_oper) + # REHASH reverts SET features to the config values; reapply. + await _apply( + setoper, + IPCHECK_CLONE_DELAY=0, + IPCHECK_CLONE_LIMIT=CLONE_LIMIT, + IPCHECK_CLONE_PERIOD=CLONE_PERIOD, + ) + clients = [] + try: + for i in range(2 * CLONE_LIMIT): + clients.append( + await register_expect_no_notice(srv["host"], srv["port"], f"ipcexm{i}") + ) + finally: + await disconnect_all(*clients) + clients = [] + + # Drop the exemption again. + restore_config(_grant_set(limits_config_snapshot)) + await rehash_config(limits_oper) + await _apply( + setoper, + IPCHECK_CLONE_DELAY=0, + IPCHECK_CLONE_LIMIT=CLONE_LIMIT, + IPCHECK_CLONE_PERIOD=CLONE_PERIOD, + ) + await _idle_out_period() + try: + for i in range(CLONE_LIMIT - 1): + c, n = await register_with_notice(srv["host"], srv["port"], f"ipcexn{i}") + clients.append(c) + await register_expect_throttled(srv["host"], srv["port"]) + finally: + await disconnect_all(*clients) + + +async def test_client_block_maxlinks_uses_ip_registry(ipcheck_env, limits_oper, limits_config_snapshot): + """Client { maxlinks = N } refuses the N+1th client from one address, + counted from the IPcheck registry (IPcheck_nr), and admits again once a + client leaves.""" + srv, setoper = ipcheck_env + probe = extra = None + # No rate limiting in this test: it is about the per-IP maximum. + await _apply(setoper, IPCHECK_CLONE_LIMIT=1000) + try: + # Measure how many clients from our address are connected right now. + probe, n = await register_with_notice(srv["host"], srv["port"], "ipcmaxp") + limit = n.connected + + text = _grant_set(limits_config_snapshot).replace( + 'Client { ip = "*"; class = "Local"; };', + f'Client {{ ip = "*"; class = "Local"; maxlinks = {limit}; }};', + ) + assert text != limits_config_snapshot + restore_config(text) + await rehash_config(limits_oper) + await _apply(setoper, IPCHECK_CLONE_LIMIT=1000) # REHASH reset it + + # limit + 1 > maxlinks: refused at registration. + line = await _register_first_error(srv, "ipcmaxx") + assert "Too many connections from your host" in line, line + + # Once a slot frees up the next client is admitted. + await disconnect_all(probe) + probe = None + await asyncio.sleep(0.3) + extra, n2 = await register_with_notice(srv["host"], srv["port"], "ipcmaxy") + assert n2.connected == limit, (limit, n2) + finally: + await disconnect_all(probe, extra) + + +async def _register_first_error(srv, nick: str) -> str: + """Register and return the ERROR line the server closes with.""" + client = IRCClient() + await client.connect(srv["host"], srv["port"]) + await client.send(f"NICK {nick}") + await client.send(f"USER {nick} 0 * :IPcheck test") + try: + while True: + msg = await client.recv(timeout=5.0) + if msg.command == "ERROR": + return msg.raw + if msg.command == "001": + raise AssertionError(f"{nick}: registered but should have been refused") + finally: + await client.disconnect() + + +async def test_throttle_error_is_sent_before_any_input(ipcheck_env): + """The throttle decision is made at accept time: the ERROR arrives + without the client sending a byte, then the connection is closed.""" + srv, _ = ipcheck_env + await _idle_out_period() + clients = [] + try: + for i in range(CLONE_LIMIT - 1): + c, _ = await register_with_notice(srv["host"], srv["port"], f"ipcerr{i}") + clients.append(c) + line = await raw_connect_first_line(srv["host"], srv["port"]) + assert line == THROTTLE_ERROR, line + finally: + await disconnect_all(*clients) diff --git a/tests/ipcheck/test_ipcheck_remote.py b/tests/ipcheck/test_ipcheck_remote.py new file mode 100644 index 00000000..fb7d684e --- /dev/null +++ b/tests/ipcheck/test_ipcheck_remote.py @@ -0,0 +1,90 @@ +"""IPcheck accounting for remote (server-introduced) clients. + +IPcheck_remote_connect() shares the per-address registry with local +connections: a user introduced by another server with the same IP as a +local client raises that address's connected count (visible in the "on N" +registry notice given to local clients, and used by Client-block maxlinks), +and IPcheck_disconnect() releases it again when the user goes away with its +server. Remote clients are never rate-limited themselves. + +Uses the plain hub (IPCHECK_CLONE_LIMIT is set high there, so local +connects in this test are never throttled) and a P10 pseudo-server that +introduces a user carrying the test host's own address. +""" + +from __future__ import annotations + +import pytest + +from ipcheck.helpers import disconnect_all, register_with_notice, userip +from irc_client import IRCClient +from p10_server import P10Server +from tls.helpers import oper_up + +pytestmark = pytest.mark.multi_server + + +async def test_remote_user_with_same_ip_counts_and_releases(ircd_network): + hub = ircd_network["hub"] + oper = local_a = local_b = local_c = None + srv = None + try: + oper = IRCClient() + await oper.connect(hub["host"], hub["port"]) + await oper.register("ipcrop", "oper", "IPcheck oper") + await oper_up(oper) + my_ip = await userip(oper, "ipcrop") + + local_a, na = await register_with_notice(hub["host"], hub["port"], "ipcra") + + srv = P10Server(name="services.test.net", numeric=4, password="testpass") + await srv.connect(hub["host"], hub["server_port"]) + await srv.handshake() + await srv.introduce_user("ipcremote", ip=my_ip) + # Wait until the hub knows the remote user before measuring. + await oper.send("WHOIS ipcremote") + await oper.wait_for("311", timeout=5.0) + + local_b, nb = await register_with_notice(hub["host"], hub["port"], "ipcrb") + assert nb.connected == na.connected + 2, (na, nb) # local_b + remote + + # Take the pseudo-server down: its user leaves with it. + await srv.disconnect() + srv = None + await oper.send("WHOIS ipcremote") + await oper.wait_for("401", timeout=5.0) + + local_c, nc = await register_with_notice(hub["host"], hub["port"], "ipcrc") + assert nc.connected == na.connected + 2, (na, nb, nc) # a, b, c: remote gone + finally: + if srv is not None: + try: + await srv.disconnect() + except Exception: + pass + await disconnect_all(local_a, local_b, local_c, oper) + + +async def test_remote_user_with_other_ip_does_not_count(ircd_network): + hub = ircd_network["hub"] + local_a = local_b = None + srv = None + try: + local_a, na = await register_with_notice(hub["host"], hub["port"], "ipcoa") + + srv = P10Server(name="notulined.test.net", numeric=5, password="testpass") + await srv.connect(hub["host"], hub["server_port"]) + await srv.handshake() + await srv.introduce_user("ipcother", ip="192.0.2.77") + await local_a.send("WHOIS ipcother") + await local_a.wait_for("311", timeout=5.0) + + local_b, nb = await register_with_notice(hub["host"], hub["port"], "ipcob") + assert nb.connected == na.connected + 1, (na, nb) + finally: + if srv is not None: + try: + await srv.disconnect() + except Exception: + pass + await disconnect_all(local_a, local_b) diff --git a/tests/p10_server.py b/tests/p10_server.py index 7814a458..9f9189f7 100644 --- a/tests/p10_server.py +++ b/tests/p10_server.py @@ -6,6 +6,7 @@ """ import asyncio +import ipaddress import logging import ssl import time @@ -434,12 +435,14 @@ async def introduce_user( host: str = "fake.test.net", modes: str = "+i", realname: str = "Fake User", + ip: str = "127.0.0.1", ) -> str: """Introduce a user originating from this server via a P10 N message. Format: N <+modes> : ``modes`` may include a following account token for +r, e.g. ``+ir AcctName``. + ``ip`` is the user's IPv4 address as the hub should record it. Returns the new user's numnick. """ @@ -447,7 +450,7 @@ async def introduce_user( self._next_client_num += 1 numnick = self._num + int_to_b64(client_num, 3) ts = int(time.time()) - ip64 = int_to_b64(0x7F000001, 6) # 127.0.0.1 + ip64 = int_to_b64(int(ipaddress.IPv4Address(ip)), 6) await self._send( f"{self._num} N {nick} 1 {ts} {username} {host} {modes} " f"{ip64} {numnick} :{realname}"