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/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/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)); 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}"