/* * Unreal Internet Relay Chat Daemon, src/modules/maxperip.c * (C) 2025 Bram Matthys and the UnrealIRCd Team * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 1, or (at your option) * any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. */ #include "unrealircd.h" ModuleHeader MOD_HEADER = { "maxperip", "2.0.0", "Limit user connections based on ip address", "UnrealIRCd Team", "unrealircd-6", }; /* Defines and macros */ #define IPUSERS_HASH_TABLE_SIZE 8192 /* Structs */ typedef struct IpUsersBucket IpUsersBucket; struct IpUsersBucket { IpUsersBucket *prev, *next; char rawip[16]; int local_clients; int global_clients; }; /* Variables */ IpUsersBucket **IpUsersHash_ipv4 = NULL; IpUsersBucket **IpUsersHash_ipv6 = NULL; char *siphashkey_ipusers = NULL; /** set::known-cloud-services (enabled by default) */ static int known_cloud_services = 1; /* default: enabled */ /** IRCCloud gateway CIDRs. * See https://www.irccloud.com/networks and https://www.irccloud.com/static/hosts.json */ static const char *irccloud_cidrs[] = { "5.254.36.56/29", "5.254.36.104/29", "2a03:5180:f::/62", "2a03:5180:f:4::/63", "2a03:5180:f:6::/64", NULL, }; /* Forward declarations */ int maxperip_config_test_allow(ConfigFile *cf, ConfigEntry *ce, int type, int *errs); int maxperip_config_run_allow(ConfigFile *cf, ConfigEntry *ce, int type, void *ptr); int maxperip_config_test_set(ConfigFile *cf, ConfigEntry *ce, int type, int *errs); int maxperip_config_run_set(ConfigFile *cf, ConfigEntry *ce, int type); void maxperip_postconf(void); int exceeds_maxperip(Client *client, ConfigItem_allow *aconf); IpUsersBucket *find_ipusers_bucket(Client *client); IpUsersBucket *add_ipusers_bucket(Client *client); void decrease_ipusers_bucket(Client *client); int decrease_ipusers_bucket_wrapper(Client *client); static void rebuild_ipusers_buckets(void); static void free_ipusers_buckets(void); static void add_known_cloud_services_exempts(void); int stats_maxperip(Client *client, const char *para); int maxperip_remote_connect(Client *client); const char *maxperip_allow_client(Client *client, ConfigItem_allow *aconf); int _get_connections_from_ip(Client *client); MOD_TEST() { MARK_AS_OFFICIAL_MODULE(modinfo); HookAdd(modinfo->handle, HOOKTYPE_CONFIGTEST, 0, maxperip_config_test_allow); HookAdd(modinfo->handle, HOOKTYPE_CONFIGTEST, 0, maxperip_config_test_set); EfunctionAdd(modinfo->handle, EFUNC_GET_CONNECTIONS_FROM_IP, _get_connections_from_ip); return MOD_SUCCESS; } MOD_INIT() { MARK_AS_OFFICIAL_MODULE(modinfo); siphashkey_ipusers = safe_alloc(SIPHASH_KEY_LENGTH); siphash_generate_key(siphashkey_ipusers); IpUsersHash_ipv4 = safe_alloc(sizeof(IpUsersBucket *) * IPUSERS_HASH_TABLE_SIZE); IpUsersHash_ipv6 = safe_alloc(sizeof(IpUsersBucket *) * IPUSERS_HASH_TABLE_SIZE); known_cloud_services = 1; /* reset to default before CONFIGRUN may change it */ HookAdd(modinfo->handle, HOOKTYPE_CONFIGRUN_EX, 0, maxperip_config_run_allow); HookAdd(modinfo->handle, HOOKTYPE_CONFIGRUN, 0, maxperip_config_run_set); HookAdd(modinfo->handle, HOOKTYPE_FREE_USER, 0, decrease_ipusers_bucket_wrapper); HookAdd(modinfo->handle, HOOKTYPE_STATS, 0, stats_maxperip); HookAdd(modinfo->handle, HOOKTYPE_REMOTE_CONNECT, 0, maxperip_remote_connect); HookAddConstString(modinfo->handle, HOOKTYPE_ALLOW_CLIENT, 0, maxperip_allow_client); return MOD_SUCCESS; } MOD_LOAD() { maxperip_postconf(); rebuild_ipusers_buckets(); add_known_cloud_services_exempts(); return MOD_SUCCESS; } MOD_UNLOAD() { free_ipusers_buckets(); safe_free(siphashkey_ipusers); return MOD_SUCCESS; } int maxperip_config_test_allow(ConfigFile *cf, ConfigEntry *ce, int type, int *errs) { int errors = 0; int ext = 0; if ((type != CONFIG_ALLOW_BLOCK) || !ce || !ce->name) return 0; if (!strcmp(ce->name, "maxperip") || !strcmp(ce->name, "global-maxperip")) { if (!ce->value) { config_error("%s:%i: missing parameter", ce->file->filename, ce->line_number); errors++; } else { int v = atoi(ce->value); if ((v <= 0) || (v > 1000000)) { config_error("%s:%i: allow::%s with illegal value (must be 1-1000000)", ce->file->filename, ce->line_number, ce->name); errors++; } } } else { return 0; /* Unknown option for us */ } *errs = errors; return errors ? -1 : 1; } int maxperip_config_run_allow(ConfigFile *cf, ConfigEntry *ce, int type, void *ptr) { ConfigEntry *cep, *cepp; ConfigItem_allow *allow = (ConfigItem_allow *)ptr; if ((type != CONFIG_ALLOW_BLOCK) || !ce || !ce->name) return 0; if (!strcmp(ce->name, "maxperip")) { allow->maxperip = atoi(ce->value); } else if (!strcmp(ce->name, "global-maxperip")) { allow->global_maxperip = atoi(ce->value); } else { return 0; /* Unknown option for us */ } return 1; /* Handled */ } void maxperip_postconf(void) { ConfigItem_allow *allow; for (allow = conf_allow; allow; allow = allow->next) { /* Default: global-maxperip = maxperip+1 */ if (allow->global_maxperip == 0) allow->global_maxperip = allow->maxperip + 1; /* global-maxperip < maxperip makes no sense */ if (allow->global_maxperip < allow->maxperip) allow->global_maxperip = allow->maxperip; } } /** Build the rawip used to identify this client's ipusers bucket. * * For IPv4: copies the 4 raw bytes of client->rawip. * For IPv6: copies and masks client->rawip according to * iConf.default_ipv6_clone_mask, so all addresses within the * same /N share one bucket. * * The 'rawip' buffer must be at least 16 bytes (only first 4 used for IPv4). */ static void make_ipusers_rawip(Client *client, char *rawip) { if (IsIPV6(client)) mask_ipv6_rawip(client->rawip, iConf.default_ipv6_clone_mask, rawip); else memcpy(rawip, client->rawip, 4); } uint64_t hash_ipusers(Client *client, const char *rawip) { if (IsIPV6(client)) return siphash_raw(rawip, 16, siphashkey_ipusers) % IPUSERS_HASH_TABLE_SIZE; else return siphash_raw(rawip, 4, siphashkey_ipusers) % IPUSERS_HASH_TABLE_SIZE; } IpUsersBucket *find_ipusers_bucket(Client *client) { int hash; IpUsersBucket *p; char rawip[16]; make_ipusers_rawip(client, rawip); hash = hash_ipusers(client, rawip); if (IsIPV6(client)) { for (p = IpUsersHash_ipv6[hash]; p; p = p->next) if (memcmp(p->rawip, rawip, 16) == 0) return p; } else { for (p = IpUsersHash_ipv4[hash]; p; p = p->next) if (memcmp(p->rawip, rawip, 4) == 0) return p; } return NULL; } /* (wrapper needed because hook has return type 'int' and function is 'void' */ int decrease_ipusers_bucket_wrapper(Client *client) { decrease_ipusers_bucket(client); return 0; } IpUsersBucket *add_ipusers_bucket(Client *client) { int hash; IpUsersBucket *n; char rawip[16]; make_ipusers_rawip(client, rawip); hash = hash_ipusers(client, rawip); n = safe_alloc(sizeof(IpUsersBucket)); if (IsIPV6(client)) { memcpy(n->rawip, rawip, 16); AddListItem(n, IpUsersHash_ipv6[hash]); } else { memcpy(n->rawip, rawip, 4); AddListItem(n, IpUsersHash_ipv4[hash]); } return n; } void decrease_ipusers_bucket(Client *client) { int hash; IpUsersBucket *p; char rawip[16]; if (!(client->flags & CLIENT_FLAG_IPUSERS_BUMPED)) return; /* nothing to do */ client->flags &= ~CLIENT_FLAG_IPUSERS_BUMPED; make_ipusers_rawip(client, rawip); hash = hash_ipusers(client, rawip); if (IsIPV6(client)) { for (p = IpUsersHash_ipv6[hash]; p; p = p->next) if (memcmp(p->rawip, rawip, 16) == 0) break; } else { for (p = IpUsersHash_ipv4[hash]; p; p = p->next) if (memcmp(p->rawip, rawip, 4) == 0) break; } if (!p) { unreal_log(ULOG_INFO, "user", "BUG_DECREASE_IPUSERS_BUCKET", client, "[BUG] decrease_ipusers_bucket() called but bucket is gone for client $client.details"); return; } p->global_clients--; if (MyConnect(client)) p->local_clients--; if ((p->global_clients == 0) && (p->local_clients == 0)) { if (IsIPV6(client)) DelListItem(p, IpUsersHash_ipv6[hash]); else DelListItem(p, IpUsersHash_ipv4[hash]); safe_free(p); } } /* Restore the buckets by walking current clients with the bumped flag. * Cost is negligible (a few tens of milliseconds even for ~10k clients). */ static void rebuild_ipusers_buckets(void) { Client *client; IpUsersBucket *bucket; list_for_each_entry(client, &client_list, client_node) { if (!(client->flags & CLIENT_FLAG_IPUSERS_BUMPED)) continue; if (!client->ip) continue; /* defensive */ bucket = find_ipusers_bucket(client); if (!bucket) bucket = add_ipusers_bucket(client); bucket->global_clients++; if (MyConnect(client)) bucket->local_clients++; } list_for_each_entry(client, &unknown_list, lclient_node) { if (!(client->flags & CLIENT_FLAG_IPUSERS_BUMPED)) continue; if (!client->ip) continue; bucket = find_ipusers_bucket(client); if (!bucket) bucket = add_ipusers_bucket(client); bucket->global_clients++; if (MyConnect(client)) bucket->local_clients++; } } /* Free every bucket in both hash tables, then free the tables themselves. */ static void free_ipusers_buckets(void) { int i; IpUsersBucket *p, *next; for (i = 0; i < IPUSERS_HASH_TABLE_SIZE; i++) { for (p = IpUsersHash_ipv4[i]; p; p = next) { next = p->next; safe_free(p); } IpUsersHash_ipv4[i] = NULL; for (p = IpUsersHash_ipv6[i]; p; p = next) { next = p->next; safe_free(p); } IpUsersHash_ipv6[i] = NULL; } safe_free(IpUsersHash_ipv4); safe_free(IpUsersHash_ipv6); } int maxperip_config_test_set(ConfigFile *cf, ConfigEntry *ce, int type, int *errs) { int errors = 0; if (type != CONFIG_SET) return 0; if (!strcmp(ce->name, "known-cloud-services")) { if (!ce->value) { config_error("%s:%i: set::known-cloud-services: no value specified", ce->file->filename, ce->line_number); errors++; } *errs = errors; return errors ? -1 : 1; } return 0; } int maxperip_config_run_set(ConfigFile *cf, ConfigEntry *ce, int type) { if (type != CONFIG_SET) return 0; if (!strcmp(ce->name, "known-cloud-services")) { known_cloud_services = config_checkval(ce->value, CFG_YESNO); return 1; } return 0; } /* Install default maxperip/connect-flood exception for IRC platforms * that are so big that they are known to trip default maxperip restrictions * (per IPv4 IP or per IPv6 /64: 3 local users, 4 network-wide users) * on dozens of networks and that publish a stable list of IP ranges. * Currently only IRCCloud qualifies for this. * IRCCloud is in example conf since May 2023 (commit 82dbc4a29716) as: * except ban { mask *.irccloud.com; type { maxperip; connect-flood; } }. * Unfortunately DNS sometimes fails to resolve. We have seen this happen * during an outage or server restart. People then mass-connect, but DNS is * not fully working (yet), leading to unresolved hostnames. * In May 2026 we added stricter maxperip treatment for /64 IPv6, and in * connthrottle we added /56, /48 and /32 restrictions. Without these IP * exceptions this would cause unwanted rejections. */ static void add_known_cloud_services_exempts(void) { int i; if (!known_cloud_services) return; for (i = 0; irccloud_cidrs[i]; i++) { tkl_add_banexception(TKL_EXCEPTION, "*", irccloud_cidrs[i], NULL, "IRCCloud default maxperip/connect-flood exemption", "-default-", 0, TStime(), 0, "mc", TKL_FLAG_CONFIG); } } int stats_maxperip(Client *client, const char *para) { int i; IpUsersBucket *e; char ipbuf[256]; const char *ip; /* '/STATS 8' or '/STATS maxperip' is for us... */ if (strcmp(para, "8") && strcasecmp(para, "maxperip")) return 0; if (!ValidatePermissionsForPath("server:info:stats", client, NULL, NULL, NULL)) { sendnumeric(client, ERR_NOPRIVILEGES); return 0; } sendtxtnumeric(client, "MaxPerIp IPv4 hash table:"); for (i = 0; i < IPUSERS_HASH_TABLE_SIZE; i++) { for (e = IpUsersHash_ipv4[i]; e; e = e->next) { ip = inetntop(AF_INET, e->rawip, ipbuf, sizeof(ipbuf)); if (!ip) ip = ""; sendtxtnumeric(client, "IPv4 #%d %s: %d local / %d global", i, ip, e->local_clients, e->global_clients); } } sendtxtnumeric(client, "MaxPerIp IPv6 hash table:"); for (i = 0; i < IPUSERS_HASH_TABLE_SIZE; i++) { for (e = IpUsersHash_ipv6[i]; e; e = e->next) { ip = inet_ntop(AF_INET6, e->rawip, ipbuf, sizeof(ipbuf)); if (!ip) ip = ""; sendtxtnumeric(client, "IPv6 #%d %s/%d: %d local / %d global", i, ip, iConf.default_ipv6_clone_mask, e->local_clients, e->global_clients); } } return 1; } /** Returns 1 if allow::maxperip is exceeded by 'client' */ int exceeds_maxperip(Client *client, ConfigItem_allow *aconf) { Client *acptr; IpUsersBucket *bucket; if (!client->ip) return 0; /* eg. services */ bucket = find_ipusers_bucket(client); if (!bucket) { client->flags |= CLIENT_FLAG_IPUSERS_BUMPED; bucket = add_ipusers_bucket(client); bucket->global_clients = 1; if (MyConnect(client)) bucket->local_clients = 1; return 0; } /* Bump if we haven't done so yet * (Actually not sure if this can ever be false, but... * who knows with some 3rd party or some future change) */ if (!(client->flags & CLIENT_FLAG_IPUSERS_BUMPED)) { bucket->global_clients++; if (MyConnect(client)) bucket->local_clients++; client->flags |= CLIENT_FLAG_IPUSERS_BUMPED; } if (find_tkl_exception(TKL_MAXPERIP, client)) return 0; /* exempt */ if (aconf) { if ((bucket->local_clients > aconf->maxperip) || (bucket->global_clients > aconf->global_maxperip)) { return 1; } } return 0; } /** Called for remote connects, to track global max restrictions */ int maxperip_remote_connect(Client *client) { exceeds_maxperip(client, NULL); return 0; } /** Called from AllowClient(), to deal with restrictions */ const char *maxperip_allow_client(Client *client, ConfigItem_allow *aconf) { if (exceeds_maxperip(client, aconf)) { IpUsersBucket *bucket = find_ipusers_bucket(client); if (IsIPV6(client) && iConf.default_ipv6_clone_mask < 128) { char masked[16]; mask_ipv6_rawip(client->rawip, iConf.default_ipv6_clone_mask, masked); if (bucket && bucket->local_clients > aconf->maxperip) unreal_log(ULOG_INFO, "maxperip", "MAXPERIP_LIMIT", client, "Client $client.name with IP $client.ip rejected: maxperip limit exceeded for $prefix_addr/$prefix_len ($count local, max $max)", log_data_string("prefix_addr", format_ipv6_addr(masked)), log_data_integer("prefix_len", iConf.default_ipv6_clone_mask), log_data_integer("count", bucket->local_clients), log_data_integer("max", aconf->maxperip)); else unreal_log(ULOG_INFO, "maxperip", "MAXPERIP_LIMIT", client, "Client $client.name with IP $client.ip rejected: maxperip limit exceeded for $prefix_addr/$prefix_len ($count global, max $max)", log_data_string("prefix_addr", format_ipv6_addr(masked)), log_data_integer("prefix_len", iConf.default_ipv6_clone_mask), log_data_integer("count", bucket ? bucket->global_clients : 0), log_data_integer("max", aconf->global_maxperip)); return format_ipv6_prefix_reject_message( iConf.reject_message_too_many_connections_ipv6_range, masked, iConf.default_ipv6_clone_mask); } if (bucket && bucket->local_clients > aconf->maxperip) unreal_log(ULOG_INFO, "maxperip", "MAXPERIP_LIMIT", client, "Client $client.name with IP $client.ip rejected: maxperip limit exceeded ($count local, max $max)", log_data_integer("count", bucket->local_clients), log_data_integer("max", aconf->maxperip)); else unreal_log(ULOG_INFO, "maxperip", "MAXPERIP_LIMIT", client, "Client $client.name with IP $client.ip rejected: maxperip limit exceeded ($count global, max $max)", log_data_integer("count", bucket ? bucket->global_clients : 0), log_data_integer("max", aconf->global_maxperip)); return iConf.reject_message_too_many_connections; } return NULL; } /** Return the number of connections from the same IP as 'client' */ int _get_connections_from_ip(Client *client) { IpUsersBucket *bucket; if (!client->ip) return 0; bucket = find_ipusers_bucket(client); if (!bucket) return 0; return bucket->global_clients; }