From f0c0feff4f559f4631d87e3c797537a2a049cef9 Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Fri, 5 Jun 2026 09:43:22 +0200 Subject: [PATCH 01/44] Set PCRE2 limits explicitly (to more sensible defaults), reported by Link420. --- doc/RELEASE-NOTES.md | 8 +++++++- include/config.h | 10 ++++++++++ include/h.h | 4 +++- src/aliases.c | 2 +- src/conf.c | 1 + src/ircd.c | 1 + src/match.c | 47 ++++++++++++++++++++++++++++++++++++-------- src/modules/tkl.c | 42 +++++++++++++++++++++++++++++++++++---- 8 files changed, 100 insertions(+), 15 deletions(-) diff --git a/doc/RELEASE-NOTES.md b/doc/RELEASE-NOTES.md index c18e209b2..fa175efc7 100644 --- a/doc/RELEASE-NOTES.md +++ b/doc/RELEASE-NOTES.md @@ -7,15 +7,21 @@ This is work in progress and may not always be a stable version. ### Enhancements: ### Changes: +* Spamfilter regexes now use more sensible defaults in terms of "max effort", + similar to what PHP has been using for years. This means very slow regexes + will now raise a `SPAMFILTER_REGEX_ERROR` warning during execution if + this happens (should be extremely rare). ### Fixes: -* Harden the built-in HTTPS client +* Hardening of the built-in HTTPS client ### Developers and protocol: * URL API: The OutgoingWebRequest `max_size` (introduced last release) now also caps file-backed downloads. Default for file-backed when left at 0 is 50MB (`DOWNLOAD_MAX_SIZE_FILE_BACKED`). For memory-backed, it stays at 1MB like in 6.2.5 (`DOWNLOAD_MAX_SIZE_MEMORY_BACKED`). +* The `unreal_match()` function now has a 3rd argument `const char **error` + for communicating regex errors back. Just set to `NULL` if you don't care. * If you do something to a user that would (potentially) move the user from `unknown-users` to `known-users` (or vice versa) then you should call `update_known_user_cache(client);` to update the known users cache. diff --git a/include/config.h b/include/config.h index 36b888917..ae259e791 100644 --- a/include/config.h +++ b/include/config.h @@ -238,6 +238,16 @@ #define SPAMFILTER_DETECTSLOW #endif +/* Limits for PCRE2 regex matching (eg. spamfilter, badwords). A regex that + * exceeds these is aborted and treated as no match, instead of running + * unbounded. The match limit is honoured by JIT. The depth limit only applies + * to the non-JIT interpreter, since PCRE2 ignores it under JIT. + * We use the same defaults that PHP has been using for a long time (which is + * actually 10 times lower than PCRE2 defaults, as of 2026). + */ +#define UNREAL_PCRE2_MATCH_LIMIT 1000000 +#define UNREAL_PCRE2_DEPTH_LIMIT 100000 + /* Maximum number of ModData objects that may be attached to an object */ /* UnrealIRCd 4.0.0: 8, 8, 4, 4 * UnrealIRCd 4.0.14: 12, 8, 4, 4 diff --git a/include/h.h b/include/h.h index 233707863..85c074631 100644 --- a/include/h.h +++ b/include/h.h @@ -1091,9 +1091,11 @@ extern void read_packet(int fd, int revents, void *data); extern int process_packet(Client *cptr, char *readbuf, int length, int killsafely); extern int parse_chanmode(ParseMode *pm, const char *modebuf_in, const char *parabuf_in); extern int dead_socket(Client *to, const char *notice); +extern MODVAR pcre2_match_context *unreal_pcre2_match_ctx; +extern void init_match(void); extern Match *unreal_create_match(MatchType type, const char *str, char **error); extern void unreal_delete_match(Match *m); -extern int unreal_match(Match *m, const char *str); +extern int unreal_match(Match *m, const char *str, const char **error); extern int unreal_match_method_strtoval(const char *str); extern char *unreal_match_method_valtostr(int val); #ifdef _WIN32 diff --git a/src/aliases.c b/src/aliases.c index 6b6390498..b66154f6f 100644 --- a/src/aliases.c +++ b/src/aliases.c @@ -143,7 +143,7 @@ void cmd_alias(ClientContext *clictx, Client *client, MessageTag *mtags, int par for (format = alias->format; format; format = format->next) { - if (unreal_match(format->expr, ptr)) + if (unreal_match(format->expr, ptr, NULL)) { /* Parse the parameters */ int i = 0, j = 0, k = 1; diff --git a/src/conf.c b/src/conf.c index 0e51e0867..0c91ec64e 100644 --- a/src/conf.c +++ b/src/conf.c @@ -1906,6 +1906,7 @@ void config_setdefaultsettings(Configuration *i) add_log_throttle_config(&i->log_throttle, "BUG_CT_BUCKET_MISSING", 5, 60, 0); add_log_throttle_config(&i->log_throttle, "BUG_CT_NEGATIVE_COUNTER", 5, 60, 0); add_log_throttle_config(&i->log_throttle, "BUG_DECREASE_IPUSERS_BUCKET", 5, 60, 0); + add_log_throttle_config(&i->log_throttle, "SPAMFILTER_REGEX_ERROR", 5, 60, 0); /* TLS options */ i->tls_options = safe_alloc(sizeof(TLSOptions)); diff --git a/src/ircd.c b/src/ircd.c index 74b9b27ba..b40ec8606 100644 --- a/src/ircd.c +++ b/src/ircd.c @@ -547,6 +547,7 @@ int InitUnrealIRCd(int argc, char *argv[]) init_hash(); log_throttle_init(); + init_match(); SetupEvents(); diff --git a/src/match.c b/src/match.c index 7b6e16440..36e55c4b4 100644 --- a/src/match.c +++ b/src/match.c @@ -35,6 +35,8 @@ u_char touppertab[], tolowertab[]; #define tolowertab2 tolowertab #define lc(x) tolowertab2[x] +pcre2_match_context *unreal_pcre2_match_ctx = NULL; + /* Match routine for special cases where escaping is needed in a normal fashion. * Checks a string ('name') against a globbing(+more) pattern ('mask'). * Original by Douglas A Lewis (dalewis@acsu.buffalo.edu). @@ -369,6 +371,17 @@ u_char char_atribs[] = { /* f0-ff */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; +/* Set up global match state. Called once at startup. */ +void init_match(void) +{ + unreal_pcre2_match_ctx = pcre2_match_context_create(NULL); + if (unreal_pcre2_match_ctx) + { + pcre2_set_match_limit(unreal_pcre2_match_ctx, UNREAL_PCRE2_MATCH_LIMIT); + pcre2_set_depth_limit(unreal_pcre2_match_ctx, UNREAL_PCRE2_DEPTH_LIMIT); + } +} + /** Free up all resources of an Match entry (including the struct itself). * NOTE: this function may (also) be called for Match structs that have only been * setup half-way, so use special care when accessing members (NULL checks!) @@ -441,28 +454,46 @@ Match *unreal_create_match(MatchType type, const char *str, char **error) } /** Try to match an Match entry ('m') against a string ('str'). + * @param error If non-NULL, set to an error string when the regex could not + * complete (eg. a resource limit was hit), or to NULL otherwise. + * Points to a static buffer, valid until the next unreal_match(). * @returns 1 if matched, 0 if not. * @note These (more logical) return values are opposite to the match_simple() function. */ -int unreal_match(Match *m, const char *str) +int unreal_match(Match *m, const char *str, const char **error) { + static char errbuf[256]; + + if (error) + *error = NULL; + if (m->type == MATCH_SIMPLE) { if (match_simple(m->str, str)) return 1; return 0; } - + if (m->type == MATCH_PCRE_REGEX) { pcre2_match_data *md = pcre2_match_data_create(9, NULL); int ret; - - ret = pcre2_match(m->ext.pcre2_expr, str, PCRE2_ZERO_TERMINATED, 0, 0, md, NULL); /* run the regex */ + + ret = pcre2_match(m->ext.pcre2_expr, str, PCRE2_ZERO_TERMINATED, 0, 0, md, unreal_pcre2_match_ctx); /* run the regex */ pcre2_match_data_free(md); /* yeah, we never use it. unfortunately argument must be non-NULL for pcre2_match() */ - + if (ret > 0) - return 1; /* MATCH */ + return 1; /* MATCH */ + + if (error && (ret < 0) && (ret != PCRE2_ERROR_NOMATCH) && (ret != PCRE2_ERROR_PARTIAL)) + { + /* Regex did not finish (eg. hit the match, depth or JIT stack limit). + * Report it so the caller can warn. We still return no-match. + */ + *errbuf = '\0'; + pcre2_get_error_message(ret, errbuf, sizeof(errbuf)); + *error = errbuf; + } return 0; /* NO MATCH */ } @@ -681,7 +712,7 @@ const char *stripbadwords(const char *str, ConfigItem_badword *start_bw, int *bl pcre2_match_data *md = pcre2_match_data_create(9, NULL); int ret; - ret = pcre2_match(this_word->pcre2_expr, cleanstr, PCRE2_ZERO_TERMINATED, 0, 0, md, NULL); /* run the regex */ + ret = pcre2_match(this_word->pcre2_expr, cleanstr, PCRE2_ZERO_TERMINATED, 0, 0, md, unreal_pcre2_match_ctx); /* run the regex */ pcre2_match_data_free(md); /* yeah, we never use it. unfortunately argument must be non-NULL for pcre2_match() */ if (ret > 0) { @@ -702,7 +733,7 @@ const char *stripbadwords(const char *str, ConfigItem_badword *start_bw, int *bl /* ^^ we need to free 'md' in ALL circumstances. * remember this if you break or continue in this loop! */ - ret = pcre2_match(this_word->pcre2_expr, ptr, PCRE2_ZERO_TERMINATED, 0, 0, md, NULL); /* run the regex */ + ret = pcre2_match(this_word->pcre2_expr, ptr, PCRE2_ZERO_TERMINATED, 0, 0, md, unreal_pcre2_match_ctx); /* run the regex */ if (ret > 0) { dd = pcre2_get_ovector_pointer(md); diff --git a/src/modules/tkl.c b/src/modules/tkl.c index 38e7a7059..d144aba15 100644 --- a/src/modules/tkl.c +++ b/src/modules/tkl.c @@ -1281,6 +1281,29 @@ char *spamfilter_id(TKL *tk) return buf; } +/* Warn opers when a spamfilter regex could not finish (eg. it hit the + * PCRE2 match or depth limit). The match is treated as no-match, so we + * only warn and do not remove the spamfilter. + */ +static void spamfilter_regex_error(TKL *tkl, const char *regex_error) +{ + if (tkl->type & TKL_GLOBAL) + { + unreal_log(ULOG_WARNING, "tkl", "SPAMFILTER_REGEX_ERROR", NULL, + "[Spamfilter] Regex aborted ($regex_error) for '$tkl'. Possibly too complex regex? " + "To delete, use: /SPAMFILTER del $spamfilter_id", + log_data_string("regex_error", regex_error), + log_data_string("spamfilter_id", spamfilter_id(tkl)), + log_data_tkl("tkl", tkl)); + } else { + unreal_log(ULOG_WARNING, "tkl", "SPAMFILTER_REGEX_ERROR", NULL, + "[Spamfilter] Regex aborted ($regex_error) for '$tkl'. Possibly too complex regex? " + "To remove it, edit your config file", + log_data_string("regex_error", regex_error), + log_data_tkl("tkl", tkl)); + } +} + int tkl_ip_change(Client *client, const char *oldip) { TKL *tkl; @@ -3774,9 +3797,15 @@ int spamfilter_check_users(TKL *tkl) { if (MyUser(client)) { + const char *regex_error = NULL; + spamfilter_build_user_string(spamfilter_user, client->name, client); - if (!unreal_match(tkl->ptr.spamfilter->match, spamfilter_user)) + if (!unreal_match(tkl->ptr.spamfilter->match, spamfilter_user, ®ex_error)) + { + if (regex_error) + spamfilter_regex_error(tkl, regex_error); continue; /* No match */ + } /* matched! */ unreal_log(ULOG_INFO, "tkl", "SPAMFILTER_MATCH", client, @@ -5589,6 +5618,8 @@ int _match_spamfilter(Client *client, const char *str_in, int target, const char if (tkl->ptr.spamfilter->match && (tkl->ptr.spamfilter->match->type != MATCH_NONE)) { + const char *regex_error = NULL; + #ifdef SPAMFILTER_DETECTSLOW if (tkl->ptr.spamfilter->match->type == MATCH_PCRE_REGEX) { @@ -5600,11 +5631,11 @@ int _match_spamfilter(Client *client, const char *str_in, int target, const char #endif if (tkl->ptr.spamfilter->input_conversion == INPUT_CONVERSION_STRIP_CONTROL_CODES) - ret = unreal_match(tkl->ptr.spamfilter->match, str); /* StripControlCodes() */ + ret = unreal_match(tkl->ptr.spamfilter->match, str, ®ex_error); /* StripControlCodes() */ else if (tkl->ptr.spamfilter->input_conversion == INPUT_CONVERSION_CONFUSABLES) - ret = unreal_match(tkl->ptr.spamfilter->match, str_deconfused ? str_deconfused : str); /* utf8_convert_confusables(), with fallback */ + ret = unreal_match(tkl->ptr.spamfilter->match, str_deconfused ? str_deconfused : str, ®ex_error); /* utf8_convert_confusables(), with fallback */ else - ret = unreal_match(tkl->ptr.spamfilter->match, str_in); /* raw */ + ret = unreal_match(tkl->ptr.spamfilter->match, str_in, ®ex_error); /* raw */ #ifdef SPAMFILTER_DETECTSLOW if (tkl->ptr.spamfilter->match->type == MATCH_PCRE_REGEX) @@ -5633,6 +5664,9 @@ int _match_spamfilter(Client *client, const char *str_in, int target, const char } } #endif + + if (regex_error) + spamfilter_regex_error(tkl, regex_error); } else { /* There is no ::match but there was a ::rule, and that is enough for a match.. */ if (tkl->ptr.spamfilter->rule) From dee26e2e1272a9d9eef3934bcfb5b80ac4686c7e Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Fri, 5 Jun 2026 09:58:16 +0200 Subject: [PATCH 02/44] Add const to third argument of unreal_create_match() --- doc/RELEASE-NOTES.md | 3 +++ include/h.h | 2 +- src/conf.c | 2 +- src/match.c | 2 +- src/modules/rpc/spamfilter.c | 2 +- src/modules/tkl.c | 10 +++++----- src/modules/tkldb.c | 2 +- 7 files changed, 13 insertions(+), 10 deletions(-) diff --git a/doc/RELEASE-NOTES.md b/doc/RELEASE-NOTES.md index fa175efc7..3d66a1c20 100644 --- a/doc/RELEASE-NOTES.md +++ b/doc/RELEASE-NOTES.md @@ -22,6 +22,9 @@ This is work in progress and may not always be a stable version. at 1MB like in 6.2.5 (`DOWNLOAD_MAX_SIZE_MEMORY_BACKED`). * The `unreal_match()` function now has a 3rd argument `const char **error` for communicating regex errors back. Just set to `NULL` if you don't care. +* Similarly, `unreal_create_match()` last argument is now `const char **error` + (const was added). So when callers use that, their variable `char *err` + needs to become `const char *err`. * If you do something to a user that would (potentially) move the user from `unknown-users` to `known-users` (or vice versa) then you should call `update_known_user_cache(client);` to update the known users cache. diff --git a/include/h.h b/include/h.h index 85c074631..d24ff3b39 100644 --- a/include/h.h +++ b/include/h.h @@ -1093,7 +1093,7 @@ extern int parse_chanmode(ParseMode *pm, const char *modebuf_in, const char *par extern int dead_socket(Client *to, const char *notice); extern MODVAR pcre2_match_context *unreal_pcre2_match_ctx; extern void init_match(void); -extern Match *unreal_create_match(MatchType type, const char *str, char **error); +extern Match *unreal_create_match(MatchType type, const char *str, const char **error); extern void unreal_delete_match(Match *m); extern int unreal_match(Match *m, const char *str, const char **error); extern int unreal_match_method_strtoval(const char *str); diff --git a/src/conf.c b/src/conf.c index 0c91ec64e..6c51a2718 100644 --- a/src/conf.c +++ b/src/conf.c @@ -10624,7 +10624,7 @@ int _test_alias(ConfigFile *conf, ConfigEntry *ce) { continue; } if (!strcmp(cep->name, "format")) { - char *err = NULL; + const char *err = NULL; Match *expr; char has_type = 0, has_target = 0, has_parameters = 0; diff --git a/src/match.c b/src/match.c index 36e55c4b4..2c27e58f9 100644 --- a/src/match.c +++ b/src/match.c @@ -397,7 +397,7 @@ void unreal_delete_match(Match *m) safe_free(m); } -Match *unreal_create_match(MatchType type, const char *str, char **error) +Match *unreal_create_match(MatchType type, const char *str, const char **error) { Match *m = safe_alloc(sizeof(Match)); static char errorbuf[512]; diff --git a/src/modules/rpc/spamfilter.c b/src/modules/rpc/spamfilter.c index a10bf73ab..bae953101 100644 --- a/src/modules/rpc/spamfilter.c +++ b/src/modules/rpc/spamfilter.c @@ -210,7 +210,7 @@ RPC_CALL_FUNC(rpc_spamfilter_add) char targetbuf[64]; char actionbuf[2]; char reasonbuf[512]; - char *err = NULL; + const char *err = NULL; if (!spamfilter_select_criteria(client, request, params, &name, &match_type, &targets, targetbuf, sizeof(targetbuf), &action, actionbuf)) return; /* Error already communicated to client */ diff --git a/src/modules/tkl.c b/src/modules/tkl.c index d144aba15..d268cf008 100644 --- a/src/modules/tkl.c +++ b/src/modules/tkl.c @@ -533,7 +533,7 @@ int tkl_config_test_spamfilter(ConfigFile *cf, ConfigEntry *ce, int type, int *e if (match && match_type) { Match *m; - char *err; + const char *err; m = unreal_create_match(match_type, match, &err); if (!m) @@ -733,7 +733,7 @@ int tkl_config_run_spamfilter(ConfigFile *cf, ConfigEntry *ce, int type) if (match) { - char *err; + const char *err; m = unreal_create_match(match_type, match, &err); if (!m) { @@ -1227,7 +1227,7 @@ void recompile_spamfilters(void) { TKL *tkl; Match *m; - char *err; + const char *err; int converted = 0; int index; @@ -2517,7 +2517,7 @@ CMD_FUNC(cmd_spamfilter) int n; Match *m; int match_type = 0; - char *err = NULL; + const char *err = NULL; if (IsServer(client)) return; @@ -4741,7 +4741,7 @@ CMD_FUNC(cmd_tkl_add) BanActionValue action; unsigned short target; /* helper variables */ - char *err; + const char *err; if (parc < 12) { diff --git a/src/modules/tkldb.c b/src/modules/tkldb.c index f18efe4d5..21f9baa7a 100644 --- a/src/modules/tkldb.c +++ b/src/modules/tkldb.c @@ -678,7 +678,7 @@ int read_tkldb(void) if (TKLIsSpamfilter(tkl)) { int match_method; - char *err = NULL; + const char *err = NULL; tkl->ptr.spamfilter = safe_alloc(sizeof(Spamfilter)); From 425a9b978a9089b4d73dae5ce6972fb74501d46a Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Fri, 5 Jun 2026 10:06:33 +0200 Subject: [PATCH 03/44] Fix deny channel::mask not working if security group. Reported by PeGaSuS. --- src/conf.c | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/conf.c b/src/conf.c index 6c51a2718..cfd6ac2cd 100644 --- a/src/conf.c +++ b/src/conf.c @@ -10874,12 +10874,22 @@ int _test_deny(ConfigFile *conf, ConfigEntry *ce) char has_mask = 0, has_match = 0; for (cep = ce->items; cep; cep = cep->next) { - if (config_is_blankorempty(cep, "deny channel")) + if (!strcmp(cep->name, "match")) + { + has_match = 1; + test_match_block(conf, cep, &errors); + } + else if (!strcmp(cep->name, "mask")) + { + has_mask = 1; + test_match_block(conf, cep, &errors); + } + else if (config_is_blankorempty(cep, "deny channel")) { errors++; continue; } - if (!strcmp(cep->name, "channel")) + else if (!strcmp(cep->name, "channel")) { if (has_channel) { @@ -10929,16 +10939,6 @@ int _test_deny(ConfigFile *conf, ConfigEntry *ce) } has_class = 1; } - else if (!strcmp(cep->name, "match")) - { - has_match = 1; - test_match_block(conf, cep, &errors); - } - else if (!strcmp(cep->name, "mask")) - { - has_mask = 1; - test_match_block(conf, cep, &errors); - } else { config_error_unknown(cep->file->filename, From cbc9213d5ed11611244788516524c0bfa609f21a Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Fri, 5 Jun 2026 10:36:46 +0200 Subject: [PATCH 04/44] Similarly to previous, fix allow channel::except and spamfilter::except so they actually work. --- src/conf.c | 25 ++++++++++++------------- src/modules/tkl.c | 8 ++++---- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/conf.c b/src/conf.c index cfd6ac2cd..3afb3d8c8 100644 --- a/src/conf.c +++ b/src/conf.c @@ -6419,13 +6419,22 @@ int _test_allow_channel(ConfigFile *conf, ConfigEntry *ce) for (cep = ce->items; cep; cep = cep->next) { - if (config_is_blankorempty(cep, "allow channel")) + if (!strcmp(cep->name, "match")) + { + has_match = 1; + test_match_block(conf, cep, &errors); + } + else if (!strcmp(cep->name, "mask")) + { + has_mask = 1; + test_match_block(conf, cep, &errors); + } + else if (config_is_blankorempty(cep, "allow channel")) { errors++; continue; } - - if (!strcmp(cep->name, "channel")) + else if (!strcmp(cep->name, "channel")) { has_channel = 1; } @@ -6440,16 +6449,6 @@ int _test_allow_channel(ConfigFile *conf, ConfigEntry *ce) } has_class = 1; } - else if (!strcmp(cep->name, "match")) - { - has_match = 1; - test_match_block(conf, cep, &errors); - } - else if (!strcmp(cep->name, "mask")) - { - has_mask = 1; - test_match_block(conf, cep, &errors); - } else { config_error_unknown(cep->file->filename, cep->line_number, diff --git a/src/modules/tkl.c b/src/modules/tkl.c index d268cf008..d03e76af0 100644 --- a/src/modules/tkl.c +++ b/src/modules/tkl.c @@ -413,6 +413,10 @@ int tkl_config_test_spamfilter(ConfigFile *cf, ConfigEntry *ce, int type, int *e has_action = 1; errors += test_ban_action_config(cep); } + else if (!strcmp(cep->name, "except")) + { + test_match_block(cf, cep, &errors); + } else if (!cep->value) { config_error_empty(cep->file->filename, cep->line_number, @@ -517,10 +521,6 @@ int tkl_config_test_spamfilter(ConfigFile *cf, ConfigEntry *ce, int type, int *e errors++; } } - else if (!strcmp(cep->name, "except")) - { - test_match_block(cf, cep, &errors); - } else { config_error_unknown(cep->file->filename, cep->line_number, From 982325fc825aa9a8cdb8a07c16c723024c651448 Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Fri, 5 Jun 2026 16:06:47 +0200 Subject: [PATCH 05/44] Move "make pem" to "./unrealircd makecert" and make tools use this and refer to this as well. Suggested by PeGaSuS in https://bugs.unrealircd.org/view.php?id=6610 This also moves extras/tls.cnf to doc/conf/tls/tls.cnf which also gets installed in ~/unrealircd/conf/tls/ (or whatever CONFDIR is) And just to be clear: this means you can run "./unrealircd makecert" without needing to go into BUILDDIR (or even having it at all). At the same time, the generation commands have been modified slightly so two warnings during certificate generation are no longer there. --- Config | 2 +- Makefile.in | 22 ++------- {extras => doc/conf/tls}/tls.cnf | 4 -- src/tls.c | 6 +-- src/windows/makecert.bat | 3 +- src/windows/unrealinst.iss | 2 +- unrealircd.in | 76 +++++++++++++++++++++++++++++++- 7 files changed, 85 insertions(+), 30 deletions(-) rename {extras => doc/conf/tls}/tls.cnf (92%) diff --git a/Config b/Config index b0ab1887c..a8ba37d5e 100755 --- a/Config +++ b/Config @@ -188,7 +188,7 @@ if [ "$QUICK" != "1" ] ; then echo "*******************************************************************************" echo "Press ENTER to continue" read cc - $MAKE pem + ./unrealircd makecert echo "Certificate created successfully." sleep 1 else diff --git a/Makefile.in b/Makefile.in index 25083ef0b..5316f0b18 100644 --- a/Makefile.in +++ b/Makefile.in @@ -224,6 +224,7 @@ install: all fi $(INSTALL) -m 0700 -d $(DESTDIR)@CONFDIR@/tls $(INSTALL) -m 0600 doc/conf/tls/curl-ca-bundle.crt $(DESTDIR)@CONFDIR@/tls + $(INSTALL) -m 0600 doc/conf/tls/tls.cnf $(DESTDIR)@CONFDIR@/tls @# delete modules/cap directory, to avoid confusing with U4 to U5 upgrades: rm -rf $(DESTDIR)@MODULESDIR@/cap $(INSTALL) -m 0700 -d $(DESTDIR)@MODULESDIR@/third @@ -234,11 +235,6 @@ install: all $(INSTALL) -m 0700 -d $(DESTDIR)@CACHEDIR@ $(INSTALL) -m 0700 -d $(DESTDIR)@PERMDATADIR@ $(INSTALL) -m 0700 -d $(DESTDIR)@LOGDIR@ - -@if [ ! -f "$(DESTDIR)@CONFDIR@/tls/server.cert.pem" ] ; then \ - $(INSTALL) -m 0600 server.req.pem $(DESTDIR)@CONFDIR@/tls ; \ - $(INSTALL) -m 0600 server.key.pem $(DESTDIR)@CONFDIR@/tls ; \ - $(INSTALL) -m 0600 server.cert.pem $(DESTDIR)@CONFDIR@/tls ; \ - fi @rm -f $(DESTDIR)@SCRIPTDIR@/source ln -s @BUILDDIR@ $(DESTDIR)@SCRIPTDIR@/source @echo '' @@ -268,19 +264,9 @@ install: all echo 'Again, be sure to change to the @SCRIPTDIR@ directory!' ; \ fi -pem: extras/tls.cnf - @echo "Generating server key..." - $(OPENSSLPATH) ecparam -out server.key.pem -name secp384r1 -genkey - @echo "Generating certificate request..." - $(OPENSSLPATH) req -new \ - -config extras/tls.cnf -sha256 -out server.req.pem \ - -key server.key.pem -nodes - @echo "Generating self-signed certificate..." - $(OPENSSLPATH) req -x509 -days 3650 -sha256 -nodes -in server.req.pem \ - -key server.key.pem -out server.cert.pem - @echo "Setting permissions on server.*.pem files..." - chmod o-rwx server.req.pem server.key.pem server.cert.pem - chmod g-rwx server.req.pem server.key.pem server.cert.pem +pem: + @echo "The command 'make pem' is no longer used to generate the TLS certificate." + @echo "Please run './unrealircd makecert' instead." Makefile: config.status Makefile.in ./config.status diff --git a/extras/tls.cnf b/doc/conf/tls/tls.cnf similarity index 92% rename from extras/tls.cnf rename to doc/conf/tls/tls.cnf index 46a961159..d768febe8 100644 --- a/extras/tls.cnf +++ b/doc/conf/tls/tls.cnf @@ -4,7 +4,6 @@ # Note: RSA bits is ignored, as we use ECC now default_bits = 2048 distinguished_name = req_dn -x509_extensions = cert_type [ req_dn ] countryName = Country Name @@ -26,6 +25,3 @@ organizationalUnitName_default = IRCd 0.commonName = Common Name (Full domain of your server) 1.commonName_value = localhost -[ cert_type ] -nsCertType = server - diff --git a/src/tls.c b/src/tls.c index a6812900d..6d409c338 100644 --- a/src/tls.c +++ b/src/tls.c @@ -358,7 +358,7 @@ SSL_CTX *init_ctx(TLSOptions *tlsoptions, int server) { unreal_log(ULOG_ERROR, "config", "TLS_LOAD_FAILED_DEFAULT_CERT", NULL, "It seems the default certificate is missing. " - "Run 'make pem && make install' in the UnrealIRCd source directory " + "Run './unrealircd makecert' " "to generate a self-signed cert."); } goto fail; @@ -457,8 +457,8 @@ SSL_CTX *init_ctx(TLSOptions *tlsoptions, int server) { unreal_log(ULOG_ERROR, "config", "TLS_CERTIFICATE_CHECK_FAILED", NULL, "There is a problem with your TLS certificate: $quality_check_error\n" - "If you use the standard UnrealIRCd certificates then you can simply run 'make pem' and 'make install' " - "from your UnrealIRCd source directory (eg: ~/unrealircd-6.X.Y/) to create and install new certificates", + "If you use the standard UnrealIRCd certificates then you can simply run './unrealircd makecert' " + "to create and install new certificates", log_data_string("quality_check_error", errstr)); goto fail; } diff --git a/src/windows/makecert.bat b/src/windows/makecert.bat index 572626267..64ff6ca18 100755 --- a/src/windows/makecert.bat +++ b/src/windows/makecert.bat @@ -1,6 +1,5 @@ @title Certificate Generation SET OPENSSL_CONF=tls.cnf openssl ecparam -out ../conf/tls/server.key.pem -name secp384r1 -genkey -openssl req -new -config tls.cnf -out ../conf/tls/server.req.pem -key ../conf/tls/server.key.pem -nodes -openssl req -x509 -config tls.cnf -days 3650 -sha256 -in ../conf/tls/server.req.pem -key ../conf/tls/server.key.pem -out ../conf/tls/server.cert.pem +openssl req -new -x509 -config tls.cnf -key ../conf/tls/server.key.pem -days 3650 -sha256 -out ../conf/tls/server.cert.pem diff --git a/src/windows/unrealinst.iss b/src/windows/unrealinst.iss index 3b3e38ab2..a941220ee 100755 --- a/src/windows/unrealinst.iss +++ b/src/windows/unrealinst.iss @@ -50,7 +50,7 @@ Source: "unrealsvc.exe"; DestDir: "{app}\bin"; Flags: ignoreversion signonce ; TLS certificate generation helpers Source: "src\windows\makecert.bat"; DestDir: "{app}\bin"; Flags: ignoreversion -Source: "extras\tls.cnf"; DestDir: "{app}\bin"; Flags: ignoreversion +Source: "doc\conf\tls\tls.cnf"; DestDir: "{app}\bin"; Flags: ignoreversion ; UnrealIRCd modules Source: "src\modules\*.dll"; DestDir: "{app}\modules"; Flags: ignoreversion signonce diff --git a/unrealircd.in b/unrealircd.in index 76b1e54dd..32ec8cd6d 100644 --- a/unrealircd.in +++ b/unrealircd.in @@ -11,12 +11,13 @@ TMPDIR="@TMPDIR@" SCRIPTDIR="@SCRIPTDIR@" MODULESDIR="@MODULESDIR@" DOCDIR="@DOCDIR@" +OPENSSLPATH="@OPENSSLPATH@" # When built with --with-asan, ASan does not dump core by default because # older gcc/clang might dump a 16TB core file. We explicitly enable it here. export ASAN_OPTIONS="abort_on_error=1:disable_coredump=0:unmap_shadow_on_exit=1:log_path=$TMPDIR/unrealircd_asan:detect_leaks=0" -if [ ! -f $IRCD ]; then +if [ "$1" != "makecert" ] && [ ! -f $IRCD ]; then echo "ERROR: Could not find the IRCd binary ($IRCD)" echo "This could mean two things:" echo "1) You forgot to run 'make install' after running 'make'" @@ -268,6 +269,78 @@ __EOF__ echo "Thanks!" elif [ "$1" = "spki" -o "$1" = "spkifp" ] ; then $UNREALIRCDCTL $* +elif [ "$1" = "makecert" ] ; then + TLSDIR="$CONFDIR/tls" + KEY="$TLSDIR/server.key.pem" + CERT="$TLSDIR/server.cert.pem" + + # Locate the OpenSSL configuration template. After 'make install' it + # lives in the TLS directory. During initial setup (./Config), before + # 'make install' has run, we use the copy in the source directory. + if [ -f "$TLSDIR/tls.cnf" ]; then + CNF="$TLSDIR/tls.cnf" + elif [ -f "$BUILDDIR/doc/conf/tls/tls.cnf" ]; then + CNF="$BUILDDIR/doc/conf/tls/tls.cnf" + else + echo "ERROR: Could not find the OpenSSL template tls.cnf" + echo "(Neither $TLSDIR/tls.cnf nor $BUILDDIR/doc/conf/tls/tls.cnf exists)" + exit 1 + fi + + if [ ! -d "$TLSDIR" ]; then + mkdir -p "$TLSDIR" || exit 1 + chmod 0700 "$TLSDIR" + fi + + REPLACED=0 + if [ -f "$CERT" ] || [ -f "$KEY" ]; then + echo "This command will replace your existing server certificate and key." + echo "(in $TLSDIR)" + echo -n "Do you wish to proceed? [Y|N] " + read answer + case "$answer" in + [Yy]*) + ;; + *) + echo "Aborted." + exit 1 + ;; + esac + REPLACED=1 + fi + + # Keep a backup of the previous certificate and key, so it can be + # restored if the newly generated one turns out to be unsuitable. + if [ "$REPLACED" = 1 ]; then + for f in "$KEY" "$CERT"; do + if [ -f "$f" ]; then + cp -p "$f" "$f.old" + fi + done + fi + + # Make sure the private key is not briefly world/group readable while + # it is being generated. + umask 077 + + echo "Generating server key..." + "$OPENSSLPATH" ecparam -out "$KEY" -name secp384r1 -genkey || exit 1 + echo "Generating self-signed certificate..." + "$OPENSSLPATH" req -new -x509 -key "$KEY" -config "$CNF" -days 3650 -sha256 -out "$CERT" || exit 1 + + echo "Setting permissions on server.*.pem files..." + chmod o-rwx "$KEY" "$CERT" + chmod g-rwx "$KEY" "$CERT" + + echo "" + echo "A new self-signed certificate and key have been generated in $TLSDIR" + if [ "$REPLACED" = 1 ]; then + echo "Your previous certificate and key were backed up with a .old suffix." + fi + echo "Note: the SPKI fingerprint has changed. If other servers link to you and" + echo " verify a fingerprint, you need to update the link { } block on their side." + echo "If UnrealIRCd is currently running, load the new certificate with:" + echo " $0 reloadtls" elif [ "$1" = "hot-patch" -o "$1" = "cold-patch" ] ; then if [ ! -d "$BUILDDIR" ]; then echo "UnrealIRCd source not found. Sorry, it is not possible to patch." @@ -434,6 +507,7 @@ else echo "unrealircd stop Stop (kill) the IRC Server" echo "unrealircd rehash Reload the configuration file" echo "unrealircd reloadtls Reload the SSL/TLS certificates" + echo "unrealircd makecert Create or replace the self-signed TLS certificate" echo "unrealircd restart Restart the IRC Server (stop+start)" echo "unrealircd status Show current status of the IRC Server" echo "unrealircd module-status Show all currently loaded modules" From be08bc2e33380c9dd6c36dec8cf1ecc5428599b7 Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Fri, 5 Jun 2026 16:41:33 +0200 Subject: [PATCH 06/44] Let's call it "./unrealircd mkcert" instead (like mkpasswd). Fix test suite. --- Config | 2 +- Makefile.in | 2 +- extras/build-tests/nix/build | 2 +- src/tls.c | 4 ++-- unrealircd.in | 6 +++--- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Config b/Config index a8ba37d5e..b0d552926 100755 --- a/Config +++ b/Config @@ -188,7 +188,7 @@ if [ "$QUICK" != "1" ] ; then echo "*******************************************************************************" echo "Press ENTER to continue" read cc - ./unrealircd makecert + ./unrealircd mkcert echo "Certificate created successfully." sleep 1 else diff --git a/Makefile.in b/Makefile.in index 5316f0b18..11106f7ad 100644 --- a/Makefile.in +++ b/Makefile.in @@ -266,7 +266,7 @@ install: all pem: @echo "The command 'make pem' is no longer used to generate the TLS certificate." - @echo "Please run './unrealircd makecert' instead." + @echo "Please run './unrealircd mkcert' instead." Makefile: config.status Makefile.in ./config.status diff --git a/extras/build-tests/nix/build b/extras/build-tests/nix/build index 33f845fec..4f8dbb3cc 100755 --- a/extras/build-tests/nix/build +++ b/extras/build-tests/nix/build @@ -41,7 +41,7 @@ if [ "$SSLDIR" != "" ]; then fi ./Config -quick || (tail -n 5000 config.log; exit 1) $MAKE -yes ''|$MAKE pem +(echo 'Y'; yes '')|./unrealircd mkcert $MAKE || exit 1 $MAKE install || exit 1 ./unrealircd module install third/dumpcmds || exit 1 diff --git a/src/tls.c b/src/tls.c index 6d409c338..ea46588b5 100644 --- a/src/tls.c +++ b/src/tls.c @@ -358,7 +358,7 @@ SSL_CTX *init_ctx(TLSOptions *tlsoptions, int server) { unreal_log(ULOG_ERROR, "config", "TLS_LOAD_FAILED_DEFAULT_CERT", NULL, "It seems the default certificate is missing. " - "Run './unrealircd makecert' " + "Run './unrealircd mkcert' " "to generate a self-signed cert."); } goto fail; @@ -457,7 +457,7 @@ SSL_CTX *init_ctx(TLSOptions *tlsoptions, int server) { unreal_log(ULOG_ERROR, "config", "TLS_CERTIFICATE_CHECK_FAILED", NULL, "There is a problem with your TLS certificate: $quality_check_error\n" - "If you use the standard UnrealIRCd certificates then you can simply run './unrealircd makecert' " + "If you use the standard UnrealIRCd certificates then you can simply run './unrealircd mkcert' " "to create and install new certificates", log_data_string("quality_check_error", errstr)); goto fail; diff --git a/unrealircd.in b/unrealircd.in index 32ec8cd6d..79bd8ed5a 100644 --- a/unrealircd.in +++ b/unrealircd.in @@ -17,7 +17,7 @@ OPENSSLPATH="@OPENSSLPATH@" # older gcc/clang might dump a 16TB core file. We explicitly enable it here. export ASAN_OPTIONS="abort_on_error=1:disable_coredump=0:unmap_shadow_on_exit=1:log_path=$TMPDIR/unrealircd_asan:detect_leaks=0" -if [ "$1" != "makecert" ] && [ ! -f $IRCD ]; then +if [ "$1" != "mkcert" ] && [ ! -f $IRCD ]; then echo "ERROR: Could not find the IRCd binary ($IRCD)" echo "This could mean two things:" echo "1) You forgot to run 'make install' after running 'make'" @@ -269,7 +269,7 @@ __EOF__ echo "Thanks!" elif [ "$1" = "spki" -o "$1" = "spkifp" ] ; then $UNREALIRCDCTL $* -elif [ "$1" = "makecert" ] ; then +elif [ "$1" = "mkcert" ] ; then TLSDIR="$CONFDIR/tls" KEY="$TLSDIR/server.key.pem" CERT="$TLSDIR/server.cert.pem" @@ -507,13 +507,13 @@ else echo "unrealircd stop Stop (kill) the IRC Server" echo "unrealircd rehash Reload the configuration file" echo "unrealircd reloadtls Reload the SSL/TLS certificates" - echo "unrealircd makecert Create or replace the self-signed TLS certificate" echo "unrealircd restart Restart the IRC Server (stop+start)" echo "unrealircd status Show current status of the IRC Server" echo "unrealircd module-status Show all currently loaded modules" echo "unrealircd upgrade Upgrade UnrealIRCd to the latest version (or the next available" echo " release candidate if the --rc argument is used)" echo "unrealircd mkpasswd Hash a password" + echo "unrealircd mkcert Create or replace the self-signed TLS certificate" echo "unrealircd version Display the UnrealIRCd version" echo "unrealircd module Install and uninstall 3rd party modules" echo "unrealircd croncheck For use in crontab: this checks if the server" From 3571c9e75b5d46bf657e6422319b71c4e1276ab3 Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Fri, 5 Jun 2026 17:03:33 +0200 Subject: [PATCH 07/44] Create BASEDIR with 0700. Just like we already did for almost all subdirs. Only for ~/unrealircd/lib/ we had this ommision, and for ~/unrealircd itself. I doubt this means a change for users, as all subdirs were already 0700 so then tightening of ~/unrealircd is not very important. And only upsides... making things safer.. --- Config | 5 ++++- configure | 7 ++++++- configure.ac | 7 ++++++- doc/RELEASE-NOTES.md | 2 ++ 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/Config b/Config index b0d552926..e1b9a2a4e 100755 --- a/Config +++ b/Config @@ -50,9 +50,12 @@ if [ -z "$BINDIR" -o -z "$DATADIR" -o -z "$CONFDIR" -o -z "$MODULESDIR" -o -z "$ fi +mkdir -p $BASEPATH mkdir -p $TMPDIR -mkdir -p $PRIVATELIBDIR mkdir -p $CONFDIR +chmod 0700 $BASEPATH +chmod 0700 $TMPDIR +chmod 0700 $CONFDIR # Do this even if we're not in advanced mode if [ "$ADVANCED" = "1" ] ; then diff --git a/configure b/configure index 1c413ab9a..91b5c7be2 100755 --- a/configure +++ b/configure @@ -7582,6 +7582,11 @@ else case e in #( e) printf "%s\n" "#define PRIVATELIBDIR \"$PRIVATELIBDIR\"" >>confdefs.h + # Create the private library directory now with restrictive permissions. + # This must happen here rather than in the Makefile because the bundled + # libraries are installed into it during configure. It must also work when + # configure is run directly without ./Config having created it first. + mkdir -p "$PRIVATELIBDIR" && chmod 0700 "$PRIVATELIBDIR" LDFLAGS_PRIVATELIBS="-Wl,-rpath,$PRIVATELIBDIR" LDFLAGS="$LDFLAGS $LDFLAGS_PRIVATELIBS" export LDFLAGS ;; @@ -8893,7 +8898,7 @@ $ac_cv_prog_MAKER install PREFIX=$cur_dir/extras/argon2 || exit 1 # lead to a crash of the currently running IRCd. rm -f "$PRIVATELIBDIR/"libargon2* # Now copy the new library files: -cp -av $cur_dir/extras/argon2/lib/* $PRIVATELIBDIR/ +cp -av $cur_dir/extras/argon2/lib/* $PRIVATELIBDIR/ || exit 1 CFLAGS="$save_cflags" LDFLAGS="$save_ldflags" ARGON2_CFLAGS="-I$cur_dir/extras/argon2/include" diff --git a/configure.ac b/configure.ac index 701539d0a..2236d1552 100644 --- a/configure.ac +++ b/configure.ac @@ -558,6 +558,11 @@ AS_IF([test "x$with_privatelibdir" = "xno"], AS_IF([test "x$PRIVATELIBDIR" = "x"], [LDFLAGS_PRIVATELIBS=""], [AC_DEFINE_UNQUOTED([PRIVATELIBDIR], ["$PRIVATELIBDIR"], [Define the location of private libraries]) + # Create the private library directory now with restrictive permissions. + # This must happen here rather than in the Makefile because the bundled + # libraries are installed into it during configure. It must also work when + # configure is run directly without ./Config having created it first. + mkdir -p "$PRIVATELIBDIR" && chmod 0700 "$PRIVATELIBDIR" LDFLAGS_PRIVATELIBS="-Wl,-rpath,$PRIVATELIBDIR" LDFLAGS="$LDFLAGS $LDFLAGS_PRIVATELIBS" export LDFLAGS]) @@ -728,7 +733,7 @@ $ac_cv_prog_MAKER install PREFIX=$cur_dir/extras/argon2 || exit 1 # lead to a crash of the currently running IRCd. rm -f "$PRIVATELIBDIR/"libargon2* # Now copy the new library files: -cp -av $cur_dir/extras/argon2/lib/* $PRIVATELIBDIR/ +cp -av $cur_dir/extras/argon2/lib/* $PRIVATELIBDIR/ || exit 1 CFLAGS="$save_cflags" LDFLAGS="$save_ldflags" ARGON2_CFLAGS="-I$cur_dir/extras/argon2/include" diff --git a/doc/RELEASE-NOTES.md b/doc/RELEASE-NOTES.md index 3d66a1c20..2c2ac3ff2 100644 --- a/doc/RELEASE-NOTES.md +++ b/doc/RELEASE-NOTES.md @@ -11,6 +11,8 @@ This is work in progress and may not always be a stable version. similar to what PHP has been using for years. This means very slow regexes will now raise a `SPAMFILTER_REGEX_ERROR` warning during execution if this happens (should be extremely rare). +* The UnrealIRCd base directory (eg `~/unrealircd/`) is now created with + 0700 permissions, just like most subdirectories were. ### Fixes: * Hardening of the built-in HTTPS client From b19573d562859e812b92cba82fd42d3763f732cf Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Fri, 5 Jun 2026 18:29:57 +0200 Subject: [PATCH 08/44] Update release notes [skip ci] --- doc/RELEASE-NOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/RELEASE-NOTES.md b/doc/RELEASE-NOTES.md index 2c2ac3ff2..7de58af5b 100644 --- a/doc/RELEASE-NOTES.md +++ b/doc/RELEASE-NOTES.md @@ -15,6 +15,8 @@ This is work in progress and may not always be a stable version. 0700 permissions, just like most subdirectories were. ### Fixes: +* The following config items previously raised a config error: + allow channel::except, deny channel::except and spamfilter::except. * Hardening of the built-in HTTPS client ### Developers and protocol: From 27a086b03a7adf7ec9407d7a70728492f0b05a92 Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Sun, 7 Jun 2026 16:54:34 +0200 Subject: [PATCH 09/44] Add TKL IDs via message tags in S2S. By default - assuming you don't set set::reject-message things by yourself - the *LINE id is appended at the end of the rejection that is shown to the user, like: [ID: G7K2MP9WQX3]. Also new is spamfilter to *LINE mapping, so you can see which *LINE was set by which SPAMFILTER. For this STATS gline and friends were enhanced. In fact, multiple fields were added there, including some that are 0 (zero) placeholders at the moment. These will be set in a future commit. Some things were combined here so we only have to break STATS and tkldb database format once (unless i made a mistake, then the follow up commit will correct that i guess :D). This was requested by Hero in https://bugs.unrealircd.org/view.php?id=4397 in 2015. Again by musk in https://bugs.unrealircd.org/view.php?id=4397 in 2022. And on IRC by Chris and others. As you can see it was not SUPER easy and a lot of thought went into this (and in terms of S2S traffic it is part of something bigger too) --- doc/RELEASE-NOTES.md | 47 +++++ include/h.h | 2 +- include/numeric.h | 8 +- include/struct.h | 5 +- src/api-efunctions.c | 2 +- src/conf.c | 4 +- src/json.c | 4 + src/misc.c | 2 +- src/modules/chgname.c | 2 +- src/modules/nick.c | 2 +- src/modules/quit.c | 27 ++- src/modules/setname.c | 2 +- src/modules/tkl.c | 403 +++++++++++++++++++++++++++++++++++++----- src/modules/tkldb.c | 54 +++++- 14 files changed, 495 insertions(+), 69 deletions(-) diff --git a/doc/RELEASE-NOTES.md b/doc/RELEASE-NOTES.md index 7de58af5b..0dd4b0d29 100644 --- a/doc/RELEASE-NOTES.md +++ b/doc/RELEASE-NOTES.md @@ -5,6 +5,28 @@ This is the git version (development version) for future UnrealIRCd 6.2.6. This is work in progress and may not always be a stable version. ### Enhancements: +* Server bans and Spamfilters now have a unique ID, like `G7K2MP9WQX3`: + * The first letter denotes the type: `G` for gline, `K` for kline, + `Z` for (g)zline. + * The ID is shown to the affected user, so they can paste the ID back to + network staff. It is `$banid` in + [set::reject-message](https://www.unrealircd.org/docs/Set_block#set::reject-message) + and included by default. If you have a custom set::reject-message in + your config file then you will have to add it in manually. + * IRCOps see the ID in `STATS gline` and similar and can search for it + with e.g. `STATS gline +i G7K2MP9WQX3`. + * Spamfilters also have an ID, and we already had something similar that + could be used for `SPAMFILTER del ` that was only local-server. + For new spamfilters this is now a network wide ID as well. + * When a server ban was placed by a spamfilter, `STATS gline` shows the + originating spamfilter's ID, so it can be traced back. + * The `STATS gline` and other TKL stats changed format, see + *Developers and protocol* if you use scripts or your client depends + on the exact numeric format. + * For TKL IDs to reach all servers, all servers need to be on 6.2.6 + or later, especially the hubs. If there is one server in-between + that is older, then TKL IDs don't propagate properly and the ID + will be empty. ### Changes: * Spamfilter regexes now use more sensible defaults in terms of "max effort", @@ -34,6 +56,31 @@ This is work in progress and may not always be a stable version. `update_known_user_cache(client);` to update the known users cache. For example, if you have some authentication module that marks a user as IsLoggedIn. +* TKL entries can now use message tags to carry extra data, similar to how + `s2s-md` worked for early moddata, we have `s2s-tkl`. We have two at + the moment: + * `s2s-tkl/id`: network-wide unique ID for the TKL entry + * `s2s-tkl/spamfilter_id`: for server bans, if they were set by spamfilter, + the spamfilter TKL ID. + * Example: `@s2s-tkl/id=xxxx;s2s-tkl/spamfilter_id=yyyy :server TKL .....` + * Older servers will not pass these along, so for this functionality to + work all servers need to be on latest version, especially your hub. +* The TKL `STATS` numerics has more fields now, they are added right before + last (so before the `:reason` or `:regex`): + * `RPL_STATSGLINE` (223) ends with `hits lasthit spamfilter_id id :reason` + * `RPL_STATSQLINE` (217) ends with `hits lasthit id :reason` + * `RPL_STATSSPAMF` (229) ends with `lasthit lasthit_except id :regex` + (which comes right after `hits hits_except`, which was already there) + * `RPL_STATSEXCEPTTKL` (230) ends with `id :reason` + * An absent id or spamfilter_id is sent as `-`. The hits/lasthit/lasthit_except + fields are reserved and currently always `0`; FIXME in later commit. +* `banned_client()` has an extra parameter `const char *tklid` for the + TKL ID (or NULL if none). +* The tkldb database version is now 6260 and stores the id and spamfilter_id. + FIXME: also prepared for hit stats, so update this release note line in later commit. + Older databases still load. Downside: you cannot downgrade UnrealIRCd. +* JSON for TKL entries (server logs and JSON-RPC) now includes `id`, and + `spamfilter_id` for spamfilter-created server bans. UnrealIRCd 6.2.5 ----------------- diff --git a/include/h.h b/include/h.h index d24ff3b39..6cad8cd4e 100644 --- a/include/h.h +++ b/include/h.h @@ -953,7 +953,7 @@ extern MODVAR int (*decode_authenticate_plain)(const char *param, char **authori extern MODVAR void (*exit_client)(Client *client, MessageTag *recv_mtags, const char *comment); extern MODVAR void (*exit_client_fmt)(Client *client, MessageTag *recv_mtags, FORMAT_STRING(const char *pattern), ...) __attribute__((format(printf, 3, 4))); extern MODVAR void (*exit_client_ex)(Client *client, Client *origin, MessageTag *recv_mtags, const char *comment); -extern MODVAR void (*banned_client)(Client *client, const char *bantype, const char *reason, int global, int noexit); +extern MODVAR void (*banned_client)(Client *client, const char *bantype, const char *reason, const char *tklid, int global, int noexit); extern MODVAR char *(*unreal_expand_string)(const char *str, char *buf, size_t buflen, NameValuePrioList *nvp, int buildvarstring_options, Client *client); extern MODVAR char *(*utf8_convert_confusables)(const char *i, char *obuf, int olen); extern MODVAR const char *(*utf8_get_block_name)(int i); diff --git a/include/numeric.h b/include/numeric.h index 928525915..dd8b42baa 100644 --- a/include/numeric.h +++ b/include/numeric.h @@ -372,17 +372,17 @@ #define STR_RPL_STATSCOMMANDS /* 212 */ "%s %u %lu" #define STR_RPL_STATSCLINE /* 213 */ "%c %s * %s %d %d %s" #define STR_RPL_STATSILINE /* 215 */ "I %s %s %d %d %s %s %d" -#define STR_RPL_STATSQLINE /* 217 */ "%c %s %lld %lld %s :%s" +#define STR_RPL_STATSQLINE /* 217 */ "%c %s %lld %lld %s %lld %lld %s :%s" #define STR_RPL_STATSYLINE /* 218 */ "Y %s %d %d %d %d %d" #define STR_RPL_ENDOFSTATS /* 219 */ "%c :End of /STATS report" #define STR_RPL_UMODEIS /* 221 */ "%s" -#define STR_RPL_STATSGLINE /* 223 */ "%c %s %lld %lld %s :%s" +#define STR_RPL_STATSGLINE /* 223 */ "%c %s %lld %lld %s %lld %lld %s %s :%s" #define STR_RPL_STATSTLINE /* 224 */ "T %s %s %s" #define STR_RPL_STATSNLINE /* 226 */ "n %s %s" #define STR_RPL_STATSVLINE /* 227 */ "v %s %s %s" #define STR_RPL_STATSBANVER /* 228 */ "%s %s" -#define STR_RPL_STATSSPAMF /* 229 */ "%c %s %s %s %lld %lld %lld %s %s %lld %lld :%s" -#define STR_RPL_STATSEXCEPTTKL /* 230 */ "%s %s %lld %lld %s :%s" +#define STR_RPL_STATSSPAMF /* 229 */ "%c %s %s %s %lld %lld %lld %s %s %lld %lld %lld %lld %s :%s" +#define STR_RPL_STATSEXCEPTTKL /* 230 */ "%s %s %lld %lld %s %s :%s" #define STR_RPL_RULES /* 232 */ ":- %s" #define STR_RPL_STATSLLINE /* 241 */ "%c %s * %s %d %d" #define STR_RPL_STATSUPTIME /* 242 */ ":Server Up %lld days, %lld:%02lld:%02lld" diff --git a/include/struct.h b/include/struct.h index 5ec08e1b1..2f8e617bd 100644 --- a/include/struct.h +++ b/include/struct.h @@ -204,7 +204,6 @@ typedef OperPermission (*OperClassEntryEvalCallback)(OperClassACLEntryVar* varia #define MAXCCUSERS 20 /* Maximum for set::anti-flood::max-concurrent-conversations */ #define BATCHLEN 22 #define MAXBATCHREFLEN 48 /* Max length of client-sent batch reference tags */ -#define MAXSPAMFILTERIDLEN 24 /* * Watch it - Don't change this unless you also change the ERR_TOOMANYWATCH @@ -1319,6 +1318,8 @@ struct BanException { #define TKL_FLAG_CONFIG 0x0001 /* Entry from configuration file. Cannot be removed by using commands. */ #define TKL_FLAG_CENTRAL_SPAMFILTER 0x0002 /* Entry from central spamfilter. */ +#define TKLIDLEN 16 /**< TKL id buffer size: native 'prefix+body' or an external id. Max 15 chars + NUL. */ + /** A TKL entry, such as a KLINE, GLINE, Spamfilter, QLINE, Exception, .. */ struct TKL { TKL *prev, *next; @@ -1327,6 +1328,8 @@ struct TKL { char *set_by; /**< By who was this entry added */ time_t set_at; /**< When this entry was added */ time_t expire_at; /**< When this entry will expire */ + char id[TKLIDLEN]; /**< Unique ID: assigned (random, generated by originating server) or external (eg from services), or empty string if none */ + char spamfilter_id[TKLIDLEN]; /**< For server bans created by a spamfilter: that spamfilter's id. Empty otherwise. */ union { Spamfilter *spamfilter; ServerBan *serverban; diff --git a/src/api-efunctions.c b/src/api-efunctions.c index 359a775f4..31e205f17 100644 --- a/src/api-efunctions.c +++ b/src/api-efunctions.c @@ -184,7 +184,7 @@ int (*decode_authenticate_plain)(const char *param, char **authorization_id, cha void (*exit_client)(Client *client, MessageTag *recv_mtags, const char *comment); void (*exit_client_fmt)(Client *client, MessageTag *recv_mtags, FORMAT_STRING(const char *pattern), ...); void (*exit_client_ex)(Client *client, Client *origin, MessageTag *recv_mtags, const char *comment); -void (*banned_client)(Client *client, const char *bantype, const char *reason, int global, int noexit); +void (*banned_client)(Client *client, const char *bantype, const char *reason, const char *tklid, int global, int noexit); char *(*unreal_expand_string)(const char *str, char *buf, size_t buflen, NameValuePrioList *nvp, int buildvarstring_options, Client *client); char *(*utf8_convert_confusables)(const char *i, char *obuf, int olen); const char *(*utf8_get_block_name)(int i); diff --git a/src/conf.c b/src/conf.c index 3afb3d8c8..0e8e7c968 100644 --- a/src/conf.c +++ b/src/conf.c @@ -1935,8 +1935,8 @@ void config_setdefaultsettings(Configuration *i) safe_strdup(i->reject_message_too_many_new_connections_ipv6_range, "Too many new connections from this IPv6 range ($prefix_addr/$prefix_len) [connthrottle]"); safe_strdup(i->reject_message_server_full, "This server is full"); safe_strdup(i->reject_message_unauthorized, "You are not authorized to connect to this server"); - safe_strdup(i->reject_message_kline, "You are not welcome on this server. $bantype: $banreason. Email $klineaddr for more information."); - safe_strdup(i->reject_message_gline, "You are not welcome on this network. $bantype: $banreason. Email $glineaddr for more information."); + safe_strdup(i->reject_message_kline, "You are not welcome on this server. $bantype: $banreason. Email $klineaddr for more information.$banid"); + safe_strdup(i->reject_message_gline, "You are not welcome on this network. $bantype: $banreason. Email $glineaddr for more information.$banid"); i->topic_setter = SETTER_NICK_USER_HOST; i->ban_setter = SETTER_NICK_USER_HOST; diff --git a/src/json.c b/src/json.c index a586567b1..61ac6f476 100644 --- a/src/json.c +++ b/src/json.c @@ -565,6 +565,8 @@ void json_expand_tkl(json_t *root, const char *key, TKL *tkl, int detail) json_object_set_new(j, "type", json_string_unreal(tkl_type_config_string(tkl))); // Eg 'kline' json_object_set_new(j, "type_string", json_string_unreal(tkl_type_string(tkl))); // Eg 'Soft K-Line' json_object_set_new(j, "set_by", json_string_unreal(tkl->set_by)); + if (tkl->id[0]) + json_object_set_new(j, "id", json_string_unreal(tkl->id)); json_object_set_new(j, "set_at", json_timestamp(tkl->set_at)); json_object_set_new(j, "expire_at", json_timestamp(tkl->expire_at)); *buf = '\0'; @@ -592,6 +594,8 @@ void json_expand_tkl(json_t *root, const char *key, TKL *tkl, int detail) else json_object_set_new(j, "name", json_string_unreal(tkl_uhost(tkl, buf, sizeof(buf), 0))); json_object_set_new(j, "reason", json_string_unreal(tkl->ptr.serverban->reason)); + if (tkl->spamfilter_id[0]) + json_object_set_new(j, "spamfilter_id", json_string_unreal(tkl->spamfilter_id)); } else if (TKLIsNameBan(tkl)) { diff --git a/src/misc.c b/src/misc.c index a6c93585f..af3362486 100644 --- a/src/misc.c +++ b/src/misc.c @@ -2967,7 +2967,7 @@ const char *command_issued_by_rpc(MessageTag *mtags) /** Is 's' a valid spamfilter id? A-Z, 0-9 and _ and with a max length. */ int valid_spamfilter_id(const char *s) { - if (strlen(s) > MAXSPAMFILTERIDLEN) + if (strlen(s) >= TKLIDLEN) return 0; return 1; } diff --git a/src/modules/chgname.c b/src/modules/chgname.c index bac4fcd1d..b4b628f9b 100644 --- a/src/modules/chgname.c +++ b/src/modules/chgname.c @@ -124,7 +124,7 @@ CMD_FUNC(cmd_chgname) if (!ValidatePermissionsForPath("immune:server-ban:ban-realname",target,NULL,NULL,NULL) && ((bconf = find_ban(NULL, target->info, CONF_BAN_REALNAME)))) { - banned_client(target, "realname", bconf->reason?bconf->reason:"", 0, 0); + banned_client(target, "realname", bconf->reason?bconf->reason:"", NULL, 0, 0); return; } } diff --git a/src/modules/nick.c b/src/modules/nick.c index cc252cafd..75dee86e4 100644 --- a/src/modules/nick.c +++ b/src/modules/nick.c @@ -1097,7 +1097,7 @@ int _register_user(Client *client) if ((bconf = find_ban(NULL, client->info, CONF_BAN_REALNAME))) { ircstats.is_ref++; - banned_client(client, "realname", bconf->reason?bconf->reason:"", 0, 0); + banned_client(client, "realname", bconf->reason?bconf->reason:"", NULL, 0, 0); return 0; } /* Check G/Z lines before shuns -- kill before quite -- codemastr */ diff --git a/src/modules/quit.c b/src/modules/quit.c index 54e62232b..c4767e07c 100644 --- a/src/modules/quit.c +++ b/src/modules/quit.c @@ -38,7 +38,7 @@ CMD_FUNC(cmd_quit); void _exit_client(Client *client, MessageTag *recv_mtags, const char *comment); void _exit_client_fmt(Client *client, MessageTag *recv_mtags, FORMAT_STRING(const char *pattern), ...) __attribute__((format(printf, 3, 4))); void _exit_client_ex(Client *client, Client *origin, MessageTag *recv_mtags, const char *comment); -void _banned_client(Client *client, const char *bantype, const char *reason, int global, int noexit); +void _banned_client(Client *client, const char *bantype, const char *reason, const char *tklid, int global, int noexit); static void remove_dependents(Client *client, Client *from, MessageTag *mtags, const char *comment, const char *splitstr); static void exit_one_client(Client *, MessageTag *mtags_i, const char *); static int should_hide_ban_reason(Client *client, const char *reason); @@ -491,11 +491,12 @@ static void exit_one_client(Client *client, MessageTag *mtags_i, const char *com * * @note This function will call exit_client() appropriately. */ -void _banned_client(Client *client, const char *bantype, const char *reason, int global, int noexit) +void _banned_client(Client *client, const char *bantype, const char *reason, const char *tklid, int global, int noexit) { char buf[512]; + char idbuf[64]; char *fmt = global ? iConf.reject_message_gline : iConf.reject_message_kline; - const char *vars[6], *values[6]; + const char *vars[7], *values[7]; MessageTag *mtags = NULL; if (!MyConnect(client)) @@ -503,6 +504,14 @@ void _banned_client(Client *client, const char *bantype, const char *reason, int RunHook(HOOKTYPE_BANNED_CLIENT, client, bantype, reason, global); + /* The " [ID: xxx]" fragment, empty when there is no id. Used both as the $banid + * reject-message variable and appended to the quit reason / real-quit-reason mtag. + */ + if (!BadPtr(tklid)) + snprintf(idbuf, sizeof(idbuf), " [ID: %s]", tklid); + else + idbuf[0] = '\0'; + /* This was: "You are not welcome on this %s. %s: %s. %s" but is now dynamic: */ vars[0] = "bantype"; values[0] = bantype; @@ -514,8 +523,10 @@ void _banned_client(Client *client, const char *bantype, const char *reason, int values[3] = GLINE_ADDRESS ? GLINE_ADDRESS : KLINE_ADDRESS; /* fallback to klineaddr */ vars[4] = "ip"; values[4] = GetIP(client); - vars[5] = NULL; - values[5] = NULL; + vars[5] = "banid"; + values[5] = idbuf; + vars[6] = NULL; + values[6] = NULL; buildvarstring(fmt, buf, sizeof(buf), vars, values); /* This is a bit extensive but we will send both a YOUAREBANNEDCREEP @@ -537,13 +548,13 @@ void _banned_client(Client *client, const char *bantype, const char *reason, int /* Hide the ban reason, but put the real reason in unrealircd.org/real-quit-reason */ MessageTag *m = safe_alloc(sizeof(MessageTag)); safe_strdup(m->name, "unrealircd.org/real-quit-reason"); - snprintf(buf, sizeof(buf), "Banned (%s): %s", bantype, reason); + snprintf(buf, sizeof(buf), "Banned (%s): %s%s", bantype, reason, idbuf); safe_strdup(m->value, buf); AddListItem(m, mtags); /* And the quit reason for anyone else, goes here.. */ - snprintf(buf, sizeof(buf), "Banned (%s)", bantype); + snprintf(buf, sizeof(buf), "Banned (%s)%s", bantype, idbuf); } else { - snprintf(buf, sizeof(buf), "Banned (%s): %s", bantype, reason); + snprintf(buf, sizeof(buf), "Banned (%s): %s%s", bantype, reason, idbuf); } if (noexit != NO_EXIT_CLIENT) diff --git a/src/modules/setname.c b/src/modules/setname.c index 6fa190e82..426ad6832 100644 --- a/src/modules/setname.c +++ b/src/modules/setname.c @@ -145,7 +145,7 @@ CMD_FUNC(cmd_setname) if (!ValidatePermissionsForPath("immune:server-ban:ban-realname",client,NULL,NULL,NULL) && ((bconf = find_ban(NULL, client->info, CONF_BAN_REALNAME)))) { - banned_client(client, "realname", bconf->reason?bconf->reason:"", 0, 0); + banned_client(client, "realname", bconf->reason?bconf->reason:"", NULL, 0, 0); return; } } else { diff --git a/src/modules/tkl.c b/src/modules/tkl.c index d03e76af0..543d25f2c 100644 --- a/src/modules/tkl.c +++ b/src/modules/tkl.c @@ -175,6 +175,25 @@ int confusables_spamfilters_present = 0; /**< Are any spamfilters with input-con long previous_spamfilter_utf8 = 0; static int firstboot = 0; +/* s2s-tkl/: per-TKL fields carried over S2S as @s2s-tkl/= on the + * TKL command (modeled on s2s-md/). Old servers ignore unknown tags. To add a field, + * add a row here plus its serialize/unserialize pair (defined further down). + */ +typedef struct { + const char *name; + const char *(*serialize)(TKL *tkl); /**< value to send, or NULL to omit the tag */ + void (*unserialize)(TKL *tkl, const char *value); +} TKLS2SField; +static const char *tkl_s2s_get_id(TKL *tkl); +static void tkl_s2s_set_id(TKL *tkl, const char *value); +static const char *tkl_s2s_get_spamfilter_id(TKL *tkl); +static void tkl_s2s_set_spamfilter_id(TKL *tkl, const char *value); +static const TKLS2SField tkl_s2s_fields[] = { + { "id", tkl_s2s_get_id, tkl_s2s_set_id }, + { "spamfilter_id", tkl_s2s_get_spamfilter_id, tkl_s2s_set_spamfilter_id }, + { NULL, NULL, NULL }, +}; + MOD_TEST() { MARK_AS_OFFICIAL_MODULE(modinfo); @@ -320,7 +339,7 @@ int tkl_config_test_spamfilter(ConfigFile *cf, ConfigEntry *ce, int type, int *e { config_error("%s:%i: spamfilter::id invalid: maximum size (%d chars) exceeded " "or forbidden characters encountered: only A-Z, 0-9 and _ are permitted.", - cep->file->filename, cep->line_number, MAXSPAMFILTERIDLEN); + cep->file->filename, cep->line_number, TKLIDLEN-1); errors++; } } else @@ -1272,12 +1291,209 @@ void check_set_spamfilter_utf8_setting_changed(void) previous_spamfilter_utf8 = iConf.spamfilter_utf8; } -/** Return unique spamfilter id for TKL */ -char *spamfilter_id(TKL *tk) +/* === TKL unique IDs === + * Every TKL has a unique id (struct TKL.id): either assigned (a random id generated + * by the server that originates the entry, or an id supplied by an external setter + * such as services), or an empty string if none is known. A native (assigned-here) + * id is <10 x base32>, eg "G7K2MP9WQX3". It is carried over S2S in the + * s2s-tkl/id message tag and persisted by tkldb; there is no derivation. + */ + +/** Single-letter prefix for a native TKL id, derived from the TKL type. + * Uppercase, with global/local collapsed (eg gzline and zline both 'Z', spamfilter + * and local-spamfilter both 'F'). gline stays 'G' and kline 'K' as their letters differ. + */ +static char tkl_id_prefix(int type) { - static char buf[128]; + return toupper(_tkl_typetochar(type)); +} + +/** Find a TKL by its exact id (case-insensitive). Returns NULL on no match or empty id. */ +static TKL *find_tkl_by_id(const char *id) +{ + TKL *tkl; + int index, index2; + + if (BadPtr(id)) + return NULL; + + for (index = 0; index < TKLIPHASHLEN1; index++) + for (index2 = 0; index2 < TKLIPHASHLEN2; index2++) + for (tkl = tklines_ip_hash[index][index2]; tkl; tkl = tkl->next) + if (!strcasecmp(tkl->id, id)) + return tkl; + + for (index = 0; index < TKLISTLEN; index++) + for (tkl = tklines[index]; tkl; tkl = tkl->next) + if (!strcasecmp(tkl->id, id)) + return tkl; + + return NULL; +} + +/** Assign a fresh, locally-unique random id to 'tkl' (overwrites tkl->id). + * Called when this server is the origin of the entry. + */ +static void tkl_generate_id(TKL *tkl) +{ + /* Crockford base32 (no I/L/O/U) so the id survives being read aloud */ + static const char b32[] = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + char prefix = tkl_id_prefix(tkl->type); + TKL *other; + int attempt, i; + + for (attempt = 0; attempt < 16; attempt++) + { + tkl->id[0] = prefix; + for (i = 1; i <= 10; i++) + tkl->id[i] = b32[getrandom8() % 32]; + tkl->id[i] = '\0'; + other = find_tkl_by_id(tkl->id); + if (!other || (other == tkl)) + return; /* unique (or only matches ourselves, if already linked) */ + } + /* 16 collisions in a row is astronomically unlikely; keep the last candidate. */ +} + +/* s2s-tkl/xxx message-tag field handlers */ + +static const char *tkl_s2s_get_id(TKL *tkl) +{ + return tkl->id[0] ? tkl->id : NULL; +} + +static void tkl_s2s_set_id(TKL *tkl, const char *value) +{ + if (!BadPtr(value) && (strlen(value) < sizeof(tkl->id))) + strlcpy(tkl->id, value, sizeof(tkl->id)); +} + +static const char *tkl_s2s_get_spamfilter_id(TKL *tkl) +{ + return tkl->spamfilter_id[0] ? tkl->spamfilter_id : NULL; +} + +static void tkl_s2s_set_spamfilter_id(TKL *tkl, const char *value) +{ + if (!BadPtr(value) && (strlen(value) < sizeof(tkl->spamfilter_id))) + strlcpy(tkl->spamfilter_id, value, sizeof(tkl->spamfilter_id)); +} + +/** Append s2s-tkl/ message tags describing 'tkl' onto '*mtags' (for sending over S2S). */ +static void tkl_add_s2s_mtags(TKL *tkl, MessageTag **mtags) +{ + const TKLS2SField *f; + const char *value; + MessageTag *m; + char name[64]; + + for (f = tkl_s2s_fields; f->name; f++) + { + value = f->serialize(tkl); + if (!value) + continue; + snprintf(name, sizeof(name), "s2s-tkl/%s", f->name); + m = safe_alloc(sizeof(MessageTag)); + safe_strdup(m->name, name); + safe_strdup(m->value, value); + AddListItem(m, *mtags); + } +} + +/** Build a one-element s2s-tkl/spamfilter_id message tag from 'spamfilter_id', or NULL if it is empty. + * Lets a locally-created server ban carry its spamfilter id through the same path as a remote one. + */ +static MessageTag *tkl_spamfilter_id_mtag(const char *spamfilter_id) +{ + MessageTag *m; + + if (BadPtr(spamfilter_id)) + return NULL; + m = safe_alloc(sizeof(MessageTag)); + safe_strdup(m->name, "s2s-tkl/spamfilter_id"); + safe_strdup(m->value, spamfilter_id); + return m; +} - snprintf(buf, sizeof(buf), "%p", (void *)tk); +/** Read s2s-tkl/ message tags from 'mtags' into 'tkl' (on receiving an S2S TKL command). */ +static void tkl_extract_s2s_mtags(TKL *tkl, MessageTag *mtags) +{ + MessageTag *m; + const TKLS2SField *f; + + for (m = mtags; m; m = m->next) + { + if (strncmp(m->name, "s2s-tkl/", 8)) + continue; + for (f = tkl_s2s_fields; f->name; f++) + { + if (!strcmp(f->name, m->name + 8)) + { + f->unserialize(tkl, m->value); + break; + } + } + } +} + +/** Return the value of the s2s-tkl/ message tag in 'mtags', or NULL if absent. + * Used by the merge logic to peek at an incoming field without overwriting the existing entry. + */ +static const char *tkl_s2s_mtag_value(MessageTag *mtags, const char *field) +{ + MessageTag *m; + char name[64]; + + snprintf(name, sizeof(name), "s2s-tkl/%s", field); + for (m = mtags; m; m = m->next) + if (!strcmp(m->name, name)) + return m->value; + return NULL; +} + +/** A fallback spamfilter id (not the normal randomly generated ID). + * This ID is a hash based on (target, action, match method+string). So when those + * properties are the same, it will create the same ID. Used for: + * - Config spamfilter or Central Spamfilter without an explicit id. + * For these it behaves very much like a regular tkl->id, is shown in STATS etc. + * These entries never propagate servers, as they are local, but it is still nice + * for the ID to be unique on each server if you use the exact same spamfilter + * (think remote includes or central spamfilter). + * - Global spamfilter with a blank id (eg from an older server). + * In this case it is ONLY used for '/SPAMFILTER del' and not in regular STATS + * or used as a true id. + * And to be clear: for both cases they never appear in S2S and not on disk either + * (it is computed, each time). + */ +static const char *spamfilter_fallback_id(TKL *tkl) +{ + static const char b32[] = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + /* Fixed, non-secret key: the fallback id is opers-only and not security-sensitive, the + * key just makes the hash deterministic and identical across servers. */ + static const char key[16] = "UnrealIRCd rocks"; + static char buf[16]; + char content[8192]; + char actbuf[2]; + uint64_t h; + int i; + + actbuf[0] = banact_valtochar(tkl->ptr.spamfilter->action->action); + actbuf[1] = '\0'; + snprintf(content, sizeof(content), "%s\t%s\t%s\t%s", + spamfilter_target_inttostring(tkl->ptr.spamfilter->target), + actbuf, + unreal_match_method_valtostr(tkl->ptr.spamfilter->match->type), + tkl->ptr.spamfilter->match->str); + + h = siphash(content, key); + + buf[0] = tkl_id_prefix(tkl->type); + for (i = 1; i <= 10; i++) + { + buf[i] = b32[h & 31]; + h >>= 5; + } + buf[i] = '\0'; return buf; } @@ -1291,9 +1507,8 @@ static void spamfilter_regex_error(TKL *tkl, const char *regex_error) { unreal_log(ULOG_WARNING, "tkl", "SPAMFILTER_REGEX_ERROR", NULL, "[Spamfilter] Regex aborted ($regex_error) for '$tkl'. Possibly too complex regex? " - "To delete, use: /SPAMFILTER del $spamfilter_id", + "To delete, use: /SPAMFILTER del $tkl.id", log_data_string("regex_error", regex_error), - log_data_string("spamfilter_id", spamfilter_id(tkl)), log_data_tkl("tkl", tkl)); } else { unreal_log(ULOG_WARNING, "tkl", "SPAMFILTER_REGEX_ERROR", NULL, @@ -1308,7 +1523,7 @@ int tkl_ip_change(Client *client, const char *oldip) { TKL *tkl; if ((tkl = find_tkline_match_zap(client))) - banned_client(client, "Z-Lined", tkl->ptr.serverban->reason, (tkl->type & TKL_GLOBAL)?1:0, NO_EXIT_CLIENT); + banned_client(client, "Z-Lined", tkl->ptr.serverban->reason, tkl->id, (tkl->type & TKL_GLOBAL)?1:0, NO_EXIT_CLIENT); return 0; } @@ -1317,7 +1532,7 @@ int tkl_accept(Client *client) TKL *tkl; if ((tkl = find_tkline_match_zap(client))) { - banned_client(client, "Z-Lined", tkl->ptr.serverban->reason, (tkl->type & TKL_GLOBAL)?1:0, NO_EXIT_CLIENT); + banned_client(client, "Z-Lined", tkl->ptr.serverban->reason, tkl->id, (tkl->type & TKL_GLOBAL)?1:0, NO_EXIT_CLIENT); return 2; // TODO: HOOK_DENY_ALWAYS; } return 0; @@ -2431,7 +2646,8 @@ void spamfilter_del_by_id(Client *client, const char *id) { int index; TKL *tk; - int found = 0; + int matches = 0; + TKL *match = NULL; char mo[32], mo2[32]; const char *tkllayer[13] = { me.name, /* 0 server.name */ @@ -2453,21 +2669,30 @@ void spamfilter_del_by_id(Client *client, const char *id) { for (tk = tklines[index]; tk; tk = tk->next) { - if (((tk->type & (TKL_GLOBAL|TKL_SPAMF)) == (TKL_GLOBAL|TKL_SPAMF)) && !strcmp(spamfilter_id(tk), id)) + if ((tk->type & (TKL_GLOBAL|TKL_SPAMF)) != (TKL_GLOBAL|TKL_SPAMF)) + continue; + /* Match the real id, or for an id-less spamfilter (eg from an old server) + * its locally-computed fallback handle. + */ + if (tk->id[0] ? !strcmp(tk->id, id) : !strcmp(spamfilter_fallback_id(tk), id)) { - found = 1; - break; + match = tk; + matches++; } } - if (found) - break; /* break outer loop */ } - if (!tk) + if (matches == 0) { sendnotice(client, "Sorry, no spamfilter found with that ID. Did you run '/spamfilter del' to get the appropriate id?"); return; } + if (matches > 1) + { + sendnotice(client, "That ID is ambiguous (it matches multiple spamfilters). Please remove the spamfilter by its full definition instead."); + return; + } + tk = match; /* Spamfilter found. Now fill the tkllayer */ tkllayer[1] = "-"; @@ -3000,6 +3225,19 @@ TKL *_tkl_add_spamfilter(int type, const char *id, unsigned short target, BanAct safe_strdup(tkl->ptr.spamfilter->id, id); tkl->ptr.spamfilter->show_message_content_on_hit = show_message_content_on_hit; + /* Config (and central) spamfilters are local per server, so they never receive + * a random network id. Give them a deterministic id instead, identical on every + * server: the config/central id label if set, otherwise a content hash. This is + * what lets a central spamfilter be referenced by the same id on every server. + */ + if (flags & (TKL_FLAG_CONFIG | TKL_FLAG_CENTRAL_SPAMFILTER)) + { + if (!BadPtr(id)) + strlcpy(tkl->id, id, sizeof(tkl->id)); + else + strlcpy(tkl->id, spamfilter_fallback_id(tkl), sizeof(tkl->id)); + } + if (tkl->ptr.spamfilter->target & SPAMF_USER) loop.do_bancheck_spamf_user = 1; if (tkl->ptr.spamfilter->target & SPAMF_AWAY) @@ -3679,15 +3917,15 @@ int _find_tkline_match(Client *client, int skip_soft) { ircstats.is_ref++; if (tkl->type & TKL_GLOBAL) - banned_client(client, "G-Lined", tkl->ptr.serverban->reason, 1, 0); + banned_client(client, "G-Lined", tkl->ptr.serverban->reason, tkl->id, 1, 0); else - banned_client(client, "K-Lined", tkl->ptr.serverban->reason, 0, 0); + banned_client(client, "K-Lined", tkl->ptr.serverban->reason, tkl->id, 0, 0); return 1; /* killed */ } else if (tkl->type & TKL_ZAP) { ircstats.is_ref++; - banned_client(client, "Z-Lined", tkl->ptr.serverban->reason, (tkl->type & TKL_GLOBAL)?1:0, 0); + banned_client(client, "Z-Lined", tkl->ptr.serverban->reason, tkl->id, (tkl->type & TKL_GLOBAL)?1:0, 0); return 1; /* killed */ } @@ -3929,12 +4167,15 @@ TKL *_find_tkline_match_zap(Client *client) #define NOT_BY_REASON 0x8 #define BY_SETBY 0x10 #define NOT_BY_SETBY 0x20 +#define BY_ID 0x40 +#define NOT_BY_ID 0x80 typedef struct { int flags; const char *mask; const char *reason; const char *set_by; + const char *id; } TKLFlag; /** Parse STATS tkl parameters. @@ -3989,6 +4230,15 @@ static void parse_stats_params(const char *para, TKLFlag *flag) flag->flags |= NOT_BY_SETBY; flag->set_by = tmp; break; + case 'i': + if (flag->id || !(tmp = strtok(NULL, " "))) + continue; + if (what == '+') + flag->flags |= BY_ID; + else + flag->flags |= NOT_BY_ID; + flag->id = tmp; + break; } } } @@ -3998,6 +4248,9 @@ static void parse_stats_params(const char *para, TKLFlag *flag) */ int tkl_stats_matcher(Client *client, int type, const char *para, TKLFlag *tklflags, TKL *tkl) { + const char *id_str = tkl->id[0] ? tkl->id : "-"; + const char *spamfilter_id_str = tkl->spamfilter_id[0] ? tkl->spamfilter_id : "-"; + /***** First, handle the selection ******/ if (!BadPtr(para)) @@ -4008,6 +4261,12 @@ int tkl_stats_matcher(Client *client, int type, const char *para, TKLFlag *tklfl if (tklflags->flags & NOT_BY_SETBY) if (match_simple(tklflags->set_by, tkl->set_by)) return 0; + if (tklflags->flags & BY_ID) + if (!match_simple(tklflags->id, tkl->id)) + return 0; + if (tklflags->flags & NOT_BY_ID) + if (match_simple(tklflags->id, tkl->id)) + return 0; if (TKLIsServerBan(tkl)) { if (tklflags->flags & BY_MASK) @@ -4078,7 +4337,7 @@ int tkl_stats_matcher(Client *client, int type, const char *para, TKLFlag *tklfl { sendnumeric(client, RPL_STATSGLINE, 'K', namevalue_nospaces(m), (tkl->expire_at != 0) ? (long long)(tkl->expire_at - TStime()) : 0, - (long long)(TStime() - tkl->set_at), tkl->set_by, tkl->ptr.serverban->reason); + (long long)(TStime() - tkl->set_at), tkl->set_by, (long long)0, (long long)0, spamfilter_id_str, id_str, tkl->ptr.serverban->reason); } } else { @@ -4088,31 +4347,31 @@ int tkl_stats_matcher(Client *client, int type, const char *para, TKLFlag *tklfl { sendnumeric(client, RPL_STATSGLINE, 'G', uhost, (tkl->expire_at != 0) ? (long long)(tkl->expire_at - TStime()) : 0, - (long long)(TStime() - tkl->set_at), tkl->set_by, tkl->ptr.serverban->reason); + (long long)(TStime() - tkl->set_at), tkl->set_by, (long long)0, (long long)0, spamfilter_id_str, id_str, tkl->ptr.serverban->reason); } else if (tkl->type == (TKL_ZAP | TKL_GLOBAL)) { sendnumeric(client, RPL_STATSGLINE, 'Z', uhost, (tkl->expire_at != 0) ? (long long)(tkl->expire_at - TStime()) : 0, - (long long)(TStime() - tkl->set_at), tkl->set_by, tkl->ptr.serverban->reason); + (long long)(TStime() - tkl->set_at), tkl->set_by, (long long)0, (long long)0, spamfilter_id_str, id_str, tkl->ptr.serverban->reason); } else if (tkl->type == (TKL_SHUN | TKL_GLOBAL)) { sendnumeric(client, RPL_STATSGLINE, 's', uhost, (tkl->expire_at != 0) ? (long long)(tkl->expire_at - TStime()) : 0, - (long long)(TStime() - tkl->set_at), tkl->set_by, tkl->ptr.serverban->reason); + (long long)(TStime() - tkl->set_at), tkl->set_by, (long long)0, (long long)0, spamfilter_id_str, id_str, tkl->ptr.serverban->reason); } else if (tkl->type == (TKL_KILL)) { sendnumeric(client, RPL_STATSGLINE, 'K', uhost, (tkl->expire_at != 0) ? (long long)(tkl->expire_at - TStime()) : 0, - (long long)(TStime() - tkl->set_at), tkl->set_by, tkl->ptr.serverban->reason); + (long long)(TStime() - tkl->set_at), tkl->set_by, (long long)0, (long long)0, spamfilter_id_str, id_str, tkl->ptr.serverban->reason); } else if (tkl->type == (TKL_ZAP)) { sendnumeric(client, RPL_STATSGLINE, 'z', uhost, (tkl->expire_at != 0) ? (long long)(tkl->expire_at - TStime()) : 0, - (long long)(TStime() - tkl->set_at), tkl->set_by, tkl->ptr.serverban->reason); + (long long)(TStime() - tkl->set_at), tkl->set_by, (long long)0, (long long)0, spamfilter_id_str, id_str, tkl->ptr.serverban->reason); } } } else @@ -4130,18 +4389,17 @@ int tkl_stats_matcher(Client *client, int type, const char *para, TKLFlag *tklfl tkl->set_by, tkl->ptr.spamfilter->hits, tkl->ptr.spamfilter->hits_except, + (long long)0, + (long long)0, + id_str, tkl->ptr.spamfilter->match->str); if (para && !strcasecmp(para, "del")) { - char *hash = spamfilter_id(tkl); - if (tkl->type & TKL_GLOBAL) - { - sendtxtnumeric(client, "To delete this spamfilter, use /SPAMFILTER del %s", hash); - sendtxtnumeric(client, "-"); - } else { + if (!(tkl->type & TKL_GLOBAL)) sendtxtnumeric(client, "This spamfilter is stored in the configuration file and cannot be removed with /SPAMFILTER del"); - sendtxtnumeric(client, "-"); - } + else + sendtxtnumeric(client, "To delete this spamfilter, use /SPAMFILTER del %s", tkl->id[0] ? tkl->id : spamfilter_fallback_id(tkl)); + sendtxtnumeric(client, "-"); } } else if (TKLIsNameBan(tkl)) @@ -4152,6 +4410,9 @@ int tkl_stats_matcher(Client *client, int type, const char *para, TKLFlag *tklfl (tkl->expire_at != 0) ? (long long)(tkl->expire_at - TStime()) : 0, (long long)(TStime() - tkl->set_at), tkl->set_by, + (long long)0, + (long long)0, + id_str, tkl->ptr.nameban->reason); } else if (TKLIsBanException(tkl)) @@ -4165,7 +4426,7 @@ int tkl_stats_matcher(Client *client, int type, const char *para, TKLFlag *tklfl sendnumeric(client, RPL_STATSEXCEPTTKL, namevalue_nospaces(m), tkl->ptr.banexception->bantypes, (tkl->expire_at != 0) ? (long long)(tkl->expire_at - TStime()) : 0, - (long long)(TStime() - tkl->set_at), tkl->set_by, tkl->ptr.banexception->reason); + (long long)(TStime() - tkl->set_at), tkl->set_by, id_str, tkl->ptr.banexception->reason); } } else { /* IRC-added: uses simple user/host mask */ @@ -4174,7 +4435,7 @@ int tkl_stats_matcher(Client *client, int type, const char *para, TKLFlag *tklfl sendnumeric(client, RPL_STATSEXCEPTTKL, uhost, tkl->ptr.banexception->bantypes, (tkl->expire_at != 0) ? (long long)(tkl->expire_at - TStime()) : 0, - (long long)(TStime() - tkl->set_at), tkl->set_by, tkl->ptr.banexception->reason); + (long long)(TStime() - tkl->set_at), tkl->set_by, id_str, tkl->ptr.banexception->reason); } } else { @@ -4255,6 +4516,7 @@ void _tkl_stats(Client *client, int type, const char *para, int *cnt) */ void tkl_sync_send_entry(int add, Client *sender, Client *to, TKL *tkl) { + MessageTag *mtags = NULL; char typ; if (!(tkl->type & TKL_GLOBAL)) @@ -4262,9 +4524,13 @@ void tkl_sync_send_entry(int add, Client *sender, Client *to, TKL *tkl) typ = tkl_typetochar(tkl->type); + /* Carry the s2s-tkl/ fields (id, spamfilter_id) on add; del matches by mask so needs no tags. */ + if (add) + tkl_add_s2s_mtags(tkl, &mtags); + if (TKLIsServerBan(tkl)) { - sendto_one(to, NULL, ":%s TKL %c %c %s%s %s %s %lld %lld :%s", sender->name, + sendto_one(to, mtags, ":%s TKL %c %c %s%s %s %s %lld %lld :%s", sender->name, add ? '+' : '-', typ, (tkl->ptr.serverban->subtype & TKL_SUBTYPE_SOFT) ? "%" : "", @@ -4275,7 +4541,7 @@ void tkl_sync_send_entry(int add, Client *sender, Client *to, TKL *tkl) } else if (TKLIsNameBan(tkl)) { - sendto_one(to, NULL, ":%s TKL %c %c %c %s %s %lld %lld :%s", sender->name, + sendto_one(to, mtags, ":%s TKL %c %c %c %s %s %lld %lld :%s", sender->name, add ? '+' : '-', typ, tkl->ptr.nameban->hold ? 'H' : '*', @@ -4286,7 +4552,7 @@ void tkl_sync_send_entry(int add, Client *sender, Client *to, TKL *tkl) } else if (TKLIsSpamfilter(tkl)) { - sendto_one(to, NULL, ":%s TKL %c %c %s %c %s %lld %lld %lld %s %s :%s", sender->name, + sendto_one(to, mtags, ":%s TKL %c %c %s %c %s %lld %lld %lld %s %s :%s", sender->name, add ? '+' : '-', typ, spamfilter_target_inttostring(tkl->ptr.spamfilter->target), @@ -4299,7 +4565,7 @@ void tkl_sync_send_entry(int add, Client *sender, Client *to, TKL *tkl) } else if (TKLIsBanException(tkl)) { - sendto_one(to, NULL, ":%s TKL %c %c %s%s %s %s %lld %lld %s :%s", sender->name, + sendto_one(to, mtags, ":%s TKL %c %c %s%s %s %s %lld %lld %s :%s", sender->name, add ? '+' : '-', typ, (tkl->ptr.banexception->subtype & TKL_SUBTYPE_SOFT) ? "%" : "", @@ -4316,6 +4582,8 @@ void tkl_sync_send_entry(int add, Client *sender, Client *to, TKL *tkl) log_data_integer("tkl_type_int", typ)); abort(); } + + safe_free_message_tags(mtags); } /** Broadcast a TKL entry. @@ -4822,6 +5090,30 @@ CMD_FUNC(cmd_tkl_add) * Note that we only update common fields, * which is acceptable to me. -- Syzop */ + const char *incoming_id = tkl_s2s_mtag_value(recv_mtags, "id"); + const char *incoming_spamfilter_id = NULL; + int changed = 0; + + /* id: an assigned id beats a blank one; two assigned ids tie-break by older + * set_at, then lexicographically. The "spamfilter_id" travels with the winning "id". + * This is done before set_at is lowered below, so the tie-break sees the original set_at. + */ + if (!BadPtr(incoming_id) && (strlen(incoming_id) < sizeof(tkl->id)) && strcmp(tkl->id, incoming_id)) + { + if (!tkl->id[0] || + (set_at < tkl->set_at) || + ((set_at == tkl->set_at) && (strcmp(incoming_id, tkl->id) < 0))) + { + strlcpy(tkl->id, incoming_id, sizeof(tkl->id)); + incoming_spamfilter_id = tkl_s2s_mtag_value(recv_mtags, "spamfilter_id"); + if (!BadPtr(incoming_spamfilter_id) && (strlen(incoming_spamfilter_id) < sizeof(tkl->spamfilter_id))) + strlcpy(tkl->spamfilter_id, incoming_spamfilter_id, sizeof(tkl->spamfilter_id)); + else + tkl->spamfilter_id[0] = '\0'; + changed = 1; + } + } + if ((set_at < tkl->set_at) || (expire_at != tkl->expire_at) || strcmp(tkl->set_by, parv[5])) { /* here's how it goes: @@ -4844,12 +5136,22 @@ CMD_FUNC(cmd_tkl_add) if (strcmp(tkl->set_by, parv[5]) < 0) safe_strdup(tkl->set_by, parv[5]); - if (type & TKL_GLOBAL) - tkl_broadcast_entry(1, client, client, tkl); + changed = 1; } + + if (changed && (type & TKL_GLOBAL)) + tkl_broadcast_entry(1, client, client, tkl); return; } + /* New entry: assign its unique id. Honor a supplied s2s-tkl/id (and spamfilter_id) from a + * remote server or services; if none and we are the originating server, generate a random one; + * otherwise leave it blank (eg the tag was dropped by an old server in the path). + */ + tkl_extract_s2s_mtags(tkl, recv_mtags); + if (!tkl->id[0] && IsMe(client)) + tkl_generate_id(tkl); + tkl_added(client, tkl); } @@ -5169,7 +5471,18 @@ void ban_action_run_all_sets_and_stops(Client *client, BanAction *action, int *s * @note Be sure to check IsDead(client) if return value is 1 and you are * considering to continue processing. */ +static int take_action_ex(Client *client, BanAction *actions, const char *reason, long duration, int take_action_flags, int *stopped, const char *spamfilter_id); + int _take_action(Client *client, BanAction *actions, const char *reason, long duration, int take_action_flags, int *stopped) +{ + return take_action_ex(client, actions, reason, duration, take_action_flags, stopped, NULL); +} + +/** Internal variant of take_action() that records the spamfilter id on any + * *LINE/SHUN it creates, for the gline->spamfilter trace. Kept internal so the public + * take_action() efunc stays free of this spamfilter-specific concept. + */ +static int take_action_ex(Client *client, BanAction *actions, const char *reason, long duration, int take_action_flags, int *stopped, const char *spamfilter_id) { BanAction *action; int previous_highest = 0; @@ -5244,7 +5557,11 @@ int _take_action(Client *client, BanAction *actions, const char *reason, long du tkllayer[6] = mo; tkllayer[7] = mo2; tkllayer[8] = reason; - cmd_tkl(NULL, &me, NULL, 9, tkllayer); + { + MessageTag *m = tkl_spamfilter_id_mtag(spamfilter_id); /* NULL unless set by a spamfilter */ + cmd_tkl(NULL, &me, m, 9, tkllayer); + safe_free_message_tags(m); + } RunHookReturnInt(HOOKTYPE_TAKE_ACTION, !=99, client, action->action, reason, duration); if ((action->action == BAN_ACT_SHUN) || (action->action == BAN_ACT_SOFT_SHUN)) { @@ -5754,7 +6071,7 @@ int _match_spamfilter(Client *client, const char *str_in, int target, const char /* Spamfilter matched */ reason = unreal_decodespace(tkl->ptr.spamfilter->tkl_reason); - ret = take_action(client, tkl->ptr.spamfilter->action, reason, tkl->ptr.spamfilter->tkl_duration, TAKE_ACTION_SKIP_SET, NULL); + ret = take_action_ex(client, tkl->ptr.spamfilter->action, reason, tkl->ptr.spamfilter->tkl_duration, TAKE_ACTION_SKIP_SET, NULL, tkl->id); if (!IsDead(client)) { if ((ret == BAN_ACT_BLOCK) || (ret == BAN_ACT_SOFT_BLOCK)) diff --git a/src/modules/tkldb.c b/src/modules/tkldb.c index 21f9baa7a..9a65ede79 100644 --- a/src/modules/tkldb.c +++ b/src/modules/tkldb.c @@ -29,7 +29,7 @@ ModuleHeader MOD_HEADER = { #define TKLDB_MAGIC 0x10101010 /* Database version */ -#define TKLDB_VERSION 4999 +#define TKLDB_VERSION 6260 /* Save tkls to file every seconds */ #define TKLDB_SAVE_EVERY 300 /* The very first save after boot, apply this delta, this @@ -401,6 +401,15 @@ int write_tkline(UnrealDB *db, const char *tmpfname, TKL *tkl) W_SAFE(unrealdb_write_str(db, tkl->set_by)); W_SAFE(unrealdb_write_int64(db, tkl->set_at)); W_SAFE(unrealdb_write_int64(db, tkl->expire_at)); + W_SAFE(unrealdb_write_str(db, tkl->id)); /* since TKLDB_VERSION 6260 */ + W_SAFE(unrealdb_write_str(db, tkl->spamfilter_id)); /* since TKLDB_VERSION 6260 */ + + /* Reserved hit-stat fields for all TKL types (since TKLDB_VERSION 6260): + * hits, lasthit. Written as 0 for now; a later release will populate them + * (for non-config TKLs). + */ + W_SAFE(unrealdb_write_int64(db, 0)); + W_SAFE(unrealdb_write_int64(db, 0)); if (TKLIsServerBan(tkl)) { @@ -446,6 +455,11 @@ int write_tkline(UnrealDB *db, const char *tmpfname, TKL *tkl) W_SAFE(unrealdb_write_char(db, action)); W_SAFE(unrealdb_write_str(db, tkl->ptr.spamfilter->tkl_reason)); W_SAFE(unrealdb_write_int64(db, tkl->ptr.spamfilter->tkl_duration)); + /* Reserved spamfilter-only hit-stat fields (since TKLDB_VERSION 6260): + * hits_except, lasthit_except. Written as 0 for now. + */ + W_SAFE(unrealdb_write_int64(db, 0)); + W_SAFE(unrealdb_write_int64(db, 0)); } return 1; @@ -530,6 +544,7 @@ int read_tkldb(void) for (cnt = 0; cnt < tklcount; cnt++) { int do_not_add = 0; + TKL *added = NULL; tkl = safe_alloc(sizeof(TKL)); @@ -556,6 +571,22 @@ int read_tkldb(void) R_SAFE(unrealdb_read_int64(db, &v)); tkl->expire_at = v; + /* id and spamfilter_id were added in TKLDB_VERSION 6260 */ + if (version >= 6260) + { + R_SAFE(unrealdb_read_str(db, &str)); + strlcpy(tkl->id, str, sizeof(tkl->id)); + safe_free(str); + R_SAFE(unrealdb_read_str(db, &str)); + strlcpy(tkl->spamfilter_id, str, sizeof(tkl->spamfilter_id)); + safe_free(str); + /* Reserved hit-stat fields for all TKL types (hits, lasthit). + * Read and discarded for now; a later release will use them. + */ + R_SAFE(unrealdb_read_int64(db, &v)); + R_SAFE(unrealdb_read_int64(db, &v)); + } + /* Save some CPU... if it's already expired then don't bother adding */ if (tkl->expire_at != 0 && tkl->expire_at <= TStime()) do_not_add = 1; @@ -599,7 +630,7 @@ int read_tkldb(void) if (!do_not_add) { - tkl_add_serverban(tkl->type, tkl->ptr.serverban->usermask, + added = tkl_add_serverban(tkl->type, tkl->ptr.serverban->usermask, tkl->ptr.serverban->hostmask, NULL, tkl->ptr.serverban->reason, @@ -639,7 +670,7 @@ int read_tkldb(void) if (!do_not_add) { - tkl_add_banexception(tkl->type, tkl->ptr.banexception->usermask, + added = tkl_add_banexception(tkl->type, tkl->ptr.banexception->usermask, tkl->ptr.banexception->hostmask, NULL, tkl->ptr.banexception->reason, @@ -668,7 +699,7 @@ int read_tkldb(void) if (!do_not_add) { - tkl_add_nameban(tkl->type, tkl->ptr.nameban->name, + added = tkl_add_nameban(tkl->type, tkl->ptr.nameban->name, tkl->ptr.nameban->hold, tkl->ptr.nameban->reason, tkl->set_by, tkl->expire_at, @@ -726,6 +757,13 @@ int read_tkldb(void) R_SAFE(unrealdb_read_str(db, &tkl->ptr.spamfilter->tkl_reason)); R_SAFE(unrealdb_read_int64(db, &v)); tkl->ptr.spamfilter->tkl_duration = v; + /* Reserved spamfilter-only hit-stat fields (hits_except, + * lasthit_except). Read and discarded for now. */ + if (version >= 6260) + { + R_SAFE(unrealdb_read_int64(db, &v)); + R_SAFE(unrealdb_read_int64(db, &v)); + } if (!do_not_add && find_tkl_spamfilter(tkl->type, tkl->ptr.spamfilter->match->str, @@ -744,7 +782,7 @@ int read_tkldb(void) if (!do_not_add) { - tkl_add_spamfilter(tkl->type, NULL, tkl->ptr.spamfilter->target, + added = tkl_add_spamfilter(tkl->type, NULL, tkl->ptr.spamfilter->target, tkl->ptr.spamfilter->action, tkl->ptr.spamfilter->match, NULL, @@ -771,6 +809,12 @@ int read_tkldb(void) break; /* we MUST stop reading */ } + if (added) + { + strlcpy(added->id, tkl->id, sizeof(added->id)); + strlcpy(added->spamfilter_id, tkl->spamfilter_id, sizeof(added->spamfilter_id)); + } + if (!do_not_add) added_cnt++; From 74557f2378922a9a1758e4ae01ea6cf2051fcd13 Mon Sep 17 00:00:00 2001 From: LeCoyote Date: Mon, 8 Jun 2026 12:21:35 +0200 Subject: [PATCH 10/44] help.fr.conf: translation update, include eline, tline, new snomasks (#342) --- doc/conf/help/help.fr.conf | 87 ++++++++++++++++++++++++++++++-------- 1 file changed, 70 insertions(+), 17 deletions(-) diff --git a/doc/conf/help/help.fr.conf b/doc/conf/help/help.fr.conf index ccca5982f..7ceeb9cfc 100644 --- a/doc/conf/help/help.fr.conf +++ b/doc/conf/help/help.fr.conf @@ -3,7 +3,7 @@ * Révisé par CC (07/2002) * Ancien traducteur français : babass * Tradution française : Alef Burzmali - http://www.burzmali.com - * Dernière mise à jour : 2010-09-11 + * Dernière mise à jour : 2025-06-05 par Le_Coyote * $Id$ * * Ceci est une mise à jour pratique du système /HELPOP @@ -128,21 +128,27 @@ help Snomasks { "-"; " Ci-dessous, une liste des snomasks disponibles :"; " ==-------------------------oOo-----------------------=="; - " b = Voir les hits sur les blacklists"; - " c = Voir les connexions/déconnexions sur le serveur local"; - " e = Voir les messages 'Eyes' du serveur (OperOverride, utilisation de /CHG* et /SET*, ...)"; - " f = Voir les alertes de flood"; - " F = Voir les connexions/déconnexions sur les serveurs distants (exceptés les U-lines)"; - " G = Voir les notices TKL (Gline, GZline, Shun, etc)"; - " j = Voir les notices Junk (non recommendé pour un usage normal)"; - " k = Voir les notices KILL"; - " n = Voir les changements de pseudo sur le serveur local"; - " N = Voir les changements de pseudo sur les serveurs distants"; - " o = Voir les notices d'identification des opérateurs"; - " q = Voir les rejets de changements de pseudo dus aux Q:lines"; - " s = Voir les notices générales"; - " S = Voir les correspondances au spamfilter"; - " v = Voir l'usage de la commande /VHOST"; + " b = Bans serveurs (Gline, GZline, Shun, etc)"; + " B = Messages du module DNS Blacklist"; + " c = Connexions/déconnexions sur le serveur local"; + " C = Connexions/déconnexions sur les serveurs distants (exceptés les U-lines)"; + " d = DCCs rejetés par les blocs Deny dcc"; + " D = Notices de debugging / junk (NON recommandé)"; + " f = Notices de flood"; + " j = Join, parts et kicks"; + " k = Notices de KILL"; + " n = Changements de pseudo sur le serveur local"; + " N = Changements de pseudo sur les serveurs distants"; + " q = Pseudos rejetés par les Q:lines"; + " s = Notices générales qui ne sont pas dans les autres snomasks"; + " (inclut des messages très importants, donc fortement recommandé)"; + " S = Correspondances aux spamfilters"; + " o = IRCOp utilisant OperOverride sur un canal"; + " O = IRCOp faisant un changement de propriété (/CGHNAME, /CHGIDENT, /CHGHOST, ...)"; + " ou forçant une commande (/SAJOIN, /SAPART) sur un utilisateur"; + " R = Utilisation de JSON-RPC"; + " v = Utilisation de la commande /VHOST"; + " x = Connexions rejetées (maxperip, connthrottle)"; " ==-------------------------oOo------------------------=="; } @@ -894,6 +900,53 @@ help Gzline { " NOTE: requiert le flag oper can_gzline"; } +help Eline { + " Ajoute une exception à un ban, pour que certains utilisateurs ne soient"; + " pas affectés par des K-Lines, G-Lines et autres types de bans."; + " Syntaxe: ELINE (Ajoute une E-Line)"; + " ELINE - (Enlève une E-Line)"; + " Exemple: ELINE *@unrealircd.org kGF 0 Cet utilisateur est exempté"; + " Les valides sont :"; + " ==-Type--------Nom----------------------------Explication-----------------------=="; + " k | K-Line | Exception de K-Lines "; + " G | G-Line | Exception de G-Lines "; + " z | Z-Line | Exception de Z-Lines "; + " Z | GZ-Line | Exception de Z-Lines globales "; + " Q | Q-Line | Exception de Q-Lines "; + " s | shun | Exception de Shuns "; + " F | spamfilter | Exception de spamfilter "; + " b | blacklist | Exception de blacklist "; + " c | connect flood | Exception sur set::anti-flood::connect-flood "; + " d | handshake flood | Exception de test de flood sur les données handhsake "; + " | | (pas de ZLINE si flood avant enregistrement) "; + " m | maxperip | Exeptmion de restricion allow::maxperip restriction "; + " r | antirandom | Exception sur le module antirandom "; + " 8 | antimixedutf8 | Exception sur le module antimixedutf8 "; + " v | version | Exception des blocs ban version { } "; + " ==------------------------------------------------------------------------------=="; + " -"; + " Bans serveurs étendus :"; + " Permet une correspondance sur des critères autres que user/host/ip."; + " Syntaxe: ELINE ~: "; + " Exemple: ELINE ~certfp:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef kGF 0 Utilisateur de confiance avec cette empreinte"; + " Voir /HELPOP EXTSERVERBANS pour les critères, ou la doc en ligne à"; + " https://www.unrealircd.org/docs/Extended_server_bans"; +} + +help Tline { + " Indique le nombre de clients correspondant à un masque de ban serveur"; + " Commande réservée aux IRC Opérateurs."; + " Syntaxe: TLINE "; + " Exemple: TLINE *!*@127.0.0.0/8"; + " -"; + " Bans serveurs étendus :"; + " Permet une correspondance sur des critères autres que user/host/ip."; + " Syntaxe: TLINE ~:"; + " Exemple: TLINE ~realname:*Stupid_bot_script*"; + " Voir /HELPOP EXTSERVERBANS pour les critères, ou la doc en ligne à"; + " https://www.unrealircd.org/docs/Extended_server_bans"; +} + help Rehash { " Fait relire les fichiers de configuration au serveur."; " Commande réservée aux IRC Operateurs."; @@ -1239,7 +1292,7 @@ help Svsmotd { help Svsnline { " Ajoute un ban global sur un realname."; " Doit être envoyé à travers un serveur avec U:Line."; - " La raison doit être un seul paramètre c'est pour quoi"; + " La raison doit être un seul paramètre, c'est pourquoi"; " les espaces sont indiqués par des _, UnrealIRCd les traduira"; " en interne par des espaces"; " -"; From d5b799d3de7e5caa50bacf7d21e060777e36cd67 Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Mon, 8 Jun 2026 13:27:00 +0200 Subject: [PATCH 11/44] Server bans and Spamfilters now track how often they are hit and the time of the last hit, eg in `STATS gline` for GLINEs. These counts happen on each individual server and are not network-wide. This allows IRCOps to see which entries never get any hits and can potentially be removed. * Important exception: config-based spamfilters/bans lose their counters on `REHASH` and restart atm. * For non-config TKLs, the hit count and last hit timestamp are preserved across reboots (via tkldb). * Again, see *Developers and protocol* for the exact STATS field. The spamfilter hits already existed but all the rest is new. Suggested by BlackBishop in https://bugs.unrealircd.org/view.php?id=6304 (in particular, time of the last hit) --- doc/RELEASE-NOTES.md | 19 ++++++++++++++---- include/h.h | 1 + include/modules.h | 1 + include/struct.h | 4 +++- src/api-efunctions.c | 2 ++ src/json.c | 8 +++++++- src/modules/join.c | 1 + src/modules/nick.c | 1 + src/modules/rpc/user.c | 4 +++- src/modules/tkl.c | 44 ++++++++++++++++++++++++++++++------------ src/modules/tkldb.c | 35 ++++++++++++++++++--------------- 11 files changed, 85 insertions(+), 35 deletions(-) diff --git a/doc/RELEASE-NOTES.md b/doc/RELEASE-NOTES.md index 0dd4b0d29..3fed163e0 100644 --- a/doc/RELEASE-NOTES.md +++ b/doc/RELEASE-NOTES.md @@ -27,6 +27,15 @@ This is work in progress and may not always be a stable version. or later, especially the hubs. If there is one server in-between that is older, then TKL IDs don't propagate properly and the ID will be empty. +* Server bans and Spamfilters now track how often they are hit and the time + of the last hit, eg in `STATS gline` for GLINEs. These counts happen on + each individual server and are not network-wide. This allows IRCOps to see + which entries never get any hits and can potentially be removed. + * Important exception: config-based spamfilters/bans lose their counters + on `REHASH` and restart. + * For non-config TKLs, the hit count and last hit timestamp are preserved + across reboots (via tkldb). + * Again, see *Developers and protocol* for the exact STATS field. ### Changes: * Spamfilter regexes now use more sensible defaults in terms of "max effort", @@ -72,12 +81,14 @@ This is work in progress and may not always be a stable version. * `RPL_STATSSPAMF` (229) ends with `lasthit lasthit_except id :regex` (which comes right after `hits hits_except`, which was already there) * `RPL_STATSEXCEPTTKL` (230) ends with `id :reason` - * An absent id or spamfilter_id is sent as `-`. The hits/lasthit/lasthit_except - fields are reserved and currently always `0`; FIXME in later commit. + * An absent id or spamfilter_id is sent as `-` + * The hits/lasthit/lasthit_except show how often the TKL was hit and + the timestamp of the last hit (the usual, unix time), or 0 for never. + These counts are local to each server. * `banned_client()` has an extra parameter `const char *tklid` for the TKL ID (or NULL if none). -* The tkldb database version is now 6260 and stores the id and spamfilter_id. - FIXME: also prepared for hit stats, so update this release note line in later commit. +* The tkldb database version is now 6260 and stores the id, spamfilter_id and + the hit statistics (hit count and last-hit time per ban). Older databases still load. Downside: you cannot downgrade UnrealIRCd. * JSON for TKL entries (server logs and JSON-RPC) now includes `id`, and `spamfilter_id` for spamfilter-created server bans. diff --git a/include/h.h b/include/h.h index 6cad8cd4e..1e5ddf9df 100644 --- a/include/h.h +++ b/include/h.h @@ -847,6 +847,7 @@ extern MODVAR void (*free_tkl)(TKL *tkl); extern MODVAR void (*tkl_del_line)(TKL *tkl); extern MODVAR void (*tkl_check_local_remove_shun)(TKL *tmp); extern MODVAR int (*find_tkline_match)(Client *cptr, int skip_soft); +extern MODVAR void (*tkl_hit)(Client *client, TKL *tkl); extern MODVAR int (*find_shun)(Client *cptr); extern MODVAR int (*find_spamfilter_user)(Client *client, int flags); extern MODVAR TKL *(*find_qline)(Client *cptr, const char *nick, int *ishold); diff --git a/include/modules.h b/include/modules.h index 864f333a4..d378839c6 100644 --- a/include/modules.h +++ b/include/modules.h @@ -3055,6 +3055,7 @@ enum EfunctionType { EFUNC_GET_CONNECTIONS_FROM_IP, EFUNC_GET_FLOODPROT_CHANNEL_MAX_LINES, EFUNC_FLOODPROT_CHECK_MULTILINE_BATCH, + EFUNC_TKL_HIT, }; /* Module flags */ diff --git a/include/struct.h b/include/struct.h index 2f8e617bd..9cb8be391 100644 --- a/include/struct.h +++ b/include/struct.h @@ -1291,8 +1291,8 @@ struct Spamfilter { char *tkl_reason; /**< Reason to use for bans placed by this spamfilter, escaped by unreal_encodespace(). */ time_t tkl_duration; /**< Duration of bans placed by this spamfilter */ char *id; /**< ID */ - long long hits; /**< Spamfilter hits (except exempts) */ long long hits_except; /**< Spamfilter hits by exempt clients */ + time_t lasthit_except; /**< When an exempt client last hit this spamfilter, or 0 if never */ SecurityGroup *except; /**< Don't run this spamfilter at all for these users (not counting towards hits_except btw) */ int input_conversion; /**< How we should handle the input */ /** For overriding set::spamfilter::show-message-content-on-hit @@ -1330,6 +1330,8 @@ struct TKL { time_t expire_at; /**< When this entry will expire */ char id[TKLIDLEN]; /**< Unique ID: assigned (random, generated by originating server) or external (eg from services), or empty string if none */ char spamfilter_id[TKLIDLEN]; /**< For server bans created by a spamfilter: that spamfilter's id. Empty otherwise. */ + long long hits; /**< Number of times this TKL was enforced (counted locally on this server) */ + time_t lasthit; /**< When this TKL was last enforced, or 0 if never */ union { Spamfilter *spamfilter; ServerBan *serverban; diff --git a/src/api-efunctions.c b/src/api-efunctions.c index 31e205f17..0ce3ffcef 100644 --- a/src/api-efunctions.c +++ b/src/api-efunctions.c @@ -73,6 +73,7 @@ TKL *(*tkl_add_banexception)(int type, const char *usermask, const char *hostmas void (*tkl_del_line)(TKL *tkl); void (*tkl_check_local_remove_shun)(TKL *tmp); int (*find_tkline_match)(Client *client, int skip_soft); +void (*tkl_hit)(Client *client, TKL *tkl); int (*find_shun)(Client *client); int(*find_spamfilter_user)(Client *client, int flags); TKL *(*find_qline)(Client *client, const char *nick, int *ishold); @@ -422,6 +423,7 @@ void efunctions_init(void) efunc_init_function(EFUNC_TKL_DEL_LINE, tkl_del_line, NULL, 0); efunc_init_function(EFUNC_TKL_CHECK_LOCAL_REMOVE_SHUN, tkl_check_local_remove_shun, NULL, 0); efunc_init_function(EFUNC_FIND_TKLINE_MATCH, find_tkline_match, NULL, 0); + efunc_init_function(EFUNC_TKL_HIT, tkl_hit, NULL, 0); efunc_init_function(EFUNC_FIND_SHUN, find_shun, NULL, 0); efunc_init_function(EFUNC_FIND_SPAMFILTER_USER, find_spamfilter_user, NULL, 0); efunc_init_function(EFUNC_FIND_QLINE, find_qline, NULL, 0); diff --git a/src/json.c b/src/json.c index 61ac6f476..c7fdf55fb 100644 --- a/src/json.c +++ b/src/json.c @@ -596,11 +596,15 @@ void json_expand_tkl(json_t *root, const char *key, TKL *tkl, int detail) json_object_set_new(j, "reason", json_string_unreal(tkl->ptr.serverban->reason)); if (tkl->spamfilter_id[0]) json_object_set_new(j, "spamfilter_id", json_string_unreal(tkl->spamfilter_id)); + json_object_set_new(j, "hits", json_integer(tkl->hits)); + json_object_set_new(j, "last_hit_at", json_timestamp(tkl->lasthit)); } else if (TKLIsNameBan(tkl)) { json_object_set_new(j, "name", json_string_unreal(tkl->ptr.nameban->name)); json_object_set_new(j, "reason", json_string_unreal(tkl->ptr.nameban->reason)); + json_object_set_new(j, "hits", json_integer(tkl->hits)); + json_object_set_new(j, "last_hit_at", json_timestamp(tkl->lasthit)); } else if (TKLIsBanException(tkl)) { @@ -627,8 +631,10 @@ void json_expand_tkl(json_t *root, const char *key, TKL *tkl, int detail) json_object_set_new(j, "ban_duration_string", json_string_unreal(pretty_time_val_r(buf, sizeof(buf), tkl->ptr.spamfilter->tkl_duration))); json_object_set_new(j, "spamfilter_targets", json_string_unreal(spamfilter_target_inttostring(tkl->ptr.spamfilter->target))); json_object_set_new(j, "reason", json_string_unreal(unreal_decodespace(tkl->ptr.spamfilter->tkl_reason))); - json_object_set_new(j, "hits", json_integer(tkl->ptr.spamfilter->hits)); + json_object_set_new(j, "hits", json_integer(tkl->hits)); + json_object_set_new(j, "last_hit_at", json_timestamp(tkl->lasthit)); json_object_set_new(j, "hits_except", json_integer(tkl->ptr.spamfilter->hits_except)); + json_object_set_new(j, "last_hit_except_at", json_timestamp(tkl->ptr.spamfilter->lasthit_except)); } } diff --git a/src/modules/join.c b/src/modules/join.c index 2da220bfb..110e0a935 100644 --- a/src/modules/join.c +++ b/src/modules/join.c @@ -486,6 +486,7 @@ void _do_join(Client *client, int parc, const char *parv[]) } if (!ValidatePermissionsForPath("immune:server-ban:deny-channel",client,NULL,NULL,NULL) && (tklban = find_qline(client, name, &ishold))) { + tkl_hit(client, tklban); sendnumeric(client, ERR_FORBIDDENCHANNEL, name, tklban->ptr.nameban->reason); continue; } diff --git a/src/modules/nick.c b/src/modules/nick.c index 75dee86e4..e541a9ad0 100644 --- a/src/modules/nick.c +++ b/src/modules/nick.c @@ -314,6 +314,7 @@ CMD_FUNC(cmd_nick_local) } if (!ValidatePermissionsForPath("immune:server-ban:ban-nick",client,NULL,NULL,nick)) { + tkl_hit(client, tklban); add_fake_lag(client, 4000); /* lag them up */ sendnumeric(client, ERR_ERRONEUSNICKNAME, nick, tklban->ptr.nameban->reason); unreal_log(ULOG_INFO, "nick", "QLINE_NICK_LOCAL_ATTEMPT", client, diff --git a/src/modules/rpc/user.c b/src/modules/rpc/user.c index 41e53f7e3..07598d3b8 100644 --- a/src/modules/rpc/user.c +++ b/src/modules/rpc/user.c @@ -254,6 +254,7 @@ RPC_CALL_FUNC(rpc_user_set_nick) /* Check other restrictions */ Client *check = find_user(newnick, NULL); int ishold = 0; + TKL *tklban; /* Check if in use by someone else (do allow case-changing) */ if (check && (acptr != check)) @@ -265,8 +266,9 @@ RPC_CALL_FUNC(rpc_user_set_nick) // Can't really check for spamfilter here, since it assumes user is local // But we can check q-lines... - if (find_qline(acptr, newnick, &ishold)) + if ((tklban = find_qline(acptr, newnick, &ishold))) { + tkl_hit(acptr, tklban); rpc_error(client, request, JSON_RPC_ERROR_INVALID_NAME, "New nickname is forbidden by q-line"); return; } diff --git a/src/modules/tkl.c b/src/modules/tkl.c index 543d25f2c..fa65d08b5 100644 --- a/src/modules/tkl.c +++ b/src/modules/tkl.c @@ -55,6 +55,7 @@ CMD_FUNC(cmd_eline); CMD_FUNC(cmd_spaminfo); void cmd_tkl_line(Client *client, int parc, const char *parv[], char *type); int _tkl_hash(unsigned int c); +void _tkl_hit(Client *client, TKL *tkl); char _tkl_typetochar(int type); int _tkl_chartotype(char c); char _tkl_configtypetochar(const char *name); @@ -222,6 +223,7 @@ MOD_TEST() EfunctionAddVoid(modinfo->handle, EFUNC_FREE_TKL, _free_tkl); EfunctionAddVoid(modinfo->handle, EFUNC_TKL_CHECK_LOCAL_REMOVE_SHUN, _tkl_check_local_remove_shun); EfunctionAdd(modinfo->handle, EFUNC_FIND_TKLINE_MATCH, _find_tkline_match); + EfunctionAddVoid(modinfo->handle, EFUNC_TKL_HIT, _tkl_hit); EfunctionAdd(modinfo->handle, EFUNC_FIND_SHUN, _find_shun); EfunctionAdd(modinfo->handle, EFUNC_FIND_SPAMFILTER_USER, _find_spamfilter_user); EfunctionAddPVoid(modinfo->handle, EFUNC_FIND_QLINE, TO_PVOIDFUNC(_find_qline)); @@ -1497,6 +1499,16 @@ static const char *spamfilter_fallback_id(TKL *tkl) return buf; } +/** Called when a TKL is enforced against a client: bump its hit counter and + * remember when. Call this while 'client' is still valid (before the client is + * exited), so it stays safe if we later add a hook here. + */ +void _tkl_hit(Client *client, TKL *tkl) +{ + tkl->hits++; + tkl->lasthit = TStime(); +} + /* Warn opers when a spamfilter regex could not finish (eg. it hit the * PCRE2 match or depth limit). The match is treated as no-match, so we * only warn and do not remove the spamfilter. @@ -1523,7 +1535,10 @@ int tkl_ip_change(Client *client, const char *oldip) { TKL *tkl; if ((tkl = find_tkline_match_zap(client))) + { + tkl_hit(client, tkl); banned_client(client, "Z-Lined", tkl->ptr.serverban->reason, tkl->id, (tkl->type & TKL_GLOBAL)?1:0, NO_EXIT_CLIENT); + } return 0; } @@ -1532,6 +1547,7 @@ int tkl_accept(Client *client) TKL *tkl; if ((tkl = find_tkline_match_zap(client))) { + tkl_hit(client, tkl); banned_client(client, "Z-Lined", tkl->ptr.serverban->reason, tkl->id, (tkl->type & TKL_GLOBAL)?1:0, NO_EXIT_CLIENT); return 2; // TODO: HOOK_DENY_ALWAYS; } @@ -3916,6 +3932,7 @@ int _find_tkline_match(Client *client, int skip_soft) if (tkl->type & TKL_KILL) { ircstats.is_ref++; + tkl_hit(client, tkl); if (tkl->type & TKL_GLOBAL) banned_client(client, "G-Lined", tkl->ptr.serverban->reason, tkl->id, 1, 0); else @@ -3925,6 +3942,7 @@ int _find_tkline_match(Client *client, int skip_soft) if (tkl->type & TKL_ZAP) { ircstats.is_ref++; + tkl_hit(client, tkl); banned_client(client, "Z-Lined", tkl->ptr.serverban->reason, tkl->id, (tkl->type & TKL_GLOBAL)?1:0, 0); return 1; /* killed */ } @@ -3967,6 +3985,7 @@ int _find_shun(Client *client) /* Found match. Now check for exception... */ if (find_tkl_exception(TKL_SHUN, client)) return 0; + tkl_hit(client, tkl); SetShunned(client); return 1; } @@ -4337,7 +4356,7 @@ int tkl_stats_matcher(Client *client, int type, const char *para, TKLFlag *tklfl { sendnumeric(client, RPL_STATSGLINE, 'K', namevalue_nospaces(m), (tkl->expire_at != 0) ? (long long)(tkl->expire_at - TStime()) : 0, - (long long)(TStime() - tkl->set_at), tkl->set_by, (long long)0, (long long)0, spamfilter_id_str, id_str, tkl->ptr.serverban->reason); + (long long)(TStime() - tkl->set_at), tkl->set_by, tkl->hits, (long long)tkl->lasthit, spamfilter_id_str, id_str, tkl->ptr.serverban->reason); } } else { @@ -4347,31 +4366,31 @@ int tkl_stats_matcher(Client *client, int type, const char *para, TKLFlag *tklfl { sendnumeric(client, RPL_STATSGLINE, 'G', uhost, (tkl->expire_at != 0) ? (long long)(tkl->expire_at - TStime()) : 0, - (long long)(TStime() - tkl->set_at), tkl->set_by, (long long)0, (long long)0, spamfilter_id_str, id_str, tkl->ptr.serverban->reason); + (long long)(TStime() - tkl->set_at), tkl->set_by, tkl->hits, (long long)tkl->lasthit, spamfilter_id_str, id_str, tkl->ptr.serverban->reason); } else if (tkl->type == (TKL_ZAP | TKL_GLOBAL)) { sendnumeric(client, RPL_STATSGLINE, 'Z', uhost, (tkl->expire_at != 0) ? (long long)(tkl->expire_at - TStime()) : 0, - (long long)(TStime() - tkl->set_at), tkl->set_by, (long long)0, (long long)0, spamfilter_id_str, id_str, tkl->ptr.serverban->reason); + (long long)(TStime() - tkl->set_at), tkl->set_by, tkl->hits, (long long)tkl->lasthit, spamfilter_id_str, id_str, tkl->ptr.serverban->reason); } else if (tkl->type == (TKL_SHUN | TKL_GLOBAL)) { sendnumeric(client, RPL_STATSGLINE, 's', uhost, (tkl->expire_at != 0) ? (long long)(tkl->expire_at - TStime()) : 0, - (long long)(TStime() - tkl->set_at), tkl->set_by, (long long)0, (long long)0, spamfilter_id_str, id_str, tkl->ptr.serverban->reason); + (long long)(TStime() - tkl->set_at), tkl->set_by, tkl->hits, (long long)tkl->lasthit, spamfilter_id_str, id_str, tkl->ptr.serverban->reason); } else if (tkl->type == (TKL_KILL)) { sendnumeric(client, RPL_STATSGLINE, 'K', uhost, (tkl->expire_at != 0) ? (long long)(tkl->expire_at - TStime()) : 0, - (long long)(TStime() - tkl->set_at), tkl->set_by, (long long)0, (long long)0, spamfilter_id_str, id_str, tkl->ptr.serverban->reason); + (long long)(TStime() - tkl->set_at), tkl->set_by, tkl->hits, (long long)tkl->lasthit, spamfilter_id_str, id_str, tkl->ptr.serverban->reason); } else if (tkl->type == (TKL_ZAP)) { sendnumeric(client, RPL_STATSGLINE, 'z', uhost, (tkl->expire_at != 0) ? (long long)(tkl->expire_at - TStime()) : 0, - (long long)(TStime() - tkl->set_at), tkl->set_by, (long long)0, (long long)0, spamfilter_id_str, id_str, tkl->ptr.serverban->reason); + (long long)(TStime() - tkl->set_at), tkl->set_by, tkl->hits, (long long)tkl->lasthit, spamfilter_id_str, id_str, tkl->ptr.serverban->reason); } } } else @@ -4387,10 +4406,10 @@ int tkl_stats_matcher(Client *client, int type, const char *para, TKLFlag *tklfl (long long)tkl->ptr.spamfilter->tkl_duration, tkl->ptr.spamfilter->tkl_reason, tkl->set_by, - tkl->ptr.spamfilter->hits, + tkl->hits, tkl->ptr.spamfilter->hits_except, - (long long)0, - (long long)0, + (long long)tkl->lasthit, + (long long)tkl->ptr.spamfilter->lasthit_except, id_str, tkl->ptr.spamfilter->match->str); if (para && !strcasecmp(para, "del")) @@ -4410,8 +4429,8 @@ int tkl_stats_matcher(Client *client, int type, const char *para, TKLFlag *tklfl (tkl->expire_at != 0) ? (long long)(tkl->expire_at - TStime()) : 0, (long long)(TStime() - tkl->set_at), tkl->set_by, - (long long)0, - (long long)0, + tkl->hits, + (long long)tkl->lasthit, id_str, tkl->ptr.nameban->reason); } else @@ -5760,9 +5779,10 @@ static void match_spamfilter_hit(Client *client, const char *str_in, const char if (match_spamfilter_exempt(tkl, user_is_exempt_general, user_is_exempt_central)) { tkl->ptr.spamfilter->hits_except++; + tkl->ptr.spamfilter->lasthit_except = TStime(); } else { - tkl->ptr.spamfilter->hits++; + tkl_hit(client, tkl); highest_action = highest_ban_action(tkl->ptr.spamfilter->action); if (highest_action > BAN_ACT_SET) { diff --git a/src/modules/tkldb.c b/src/modules/tkldb.c index 9a65ede79..aadfda947 100644 --- a/src/modules/tkldb.c +++ b/src/modules/tkldb.c @@ -404,12 +404,9 @@ int write_tkline(UnrealDB *db, const char *tmpfname, TKL *tkl) W_SAFE(unrealdb_write_str(db, tkl->id)); /* since TKLDB_VERSION 6260 */ W_SAFE(unrealdb_write_str(db, tkl->spamfilter_id)); /* since TKLDB_VERSION 6260 */ - /* Reserved hit-stat fields for all TKL types (since TKLDB_VERSION 6260): - * hits, lasthit. Written as 0 for now; a later release will populate them - * (for non-config TKLs). - */ - W_SAFE(unrealdb_write_int64(db, 0)); - W_SAFE(unrealdb_write_int64(db, 0)); + /* Hit-stat fields for all TKL types (since TKLDB_VERSION 6260): hits, lasthit. */ + W_SAFE(unrealdb_write_int64(db, tkl->hits)); + W_SAFE(unrealdb_write_int64(db, tkl->lasthit)); if (TKLIsServerBan(tkl)) { @@ -455,11 +452,9 @@ int write_tkline(UnrealDB *db, const char *tmpfname, TKL *tkl) W_SAFE(unrealdb_write_char(db, action)); W_SAFE(unrealdb_write_str(db, tkl->ptr.spamfilter->tkl_reason)); W_SAFE(unrealdb_write_int64(db, tkl->ptr.spamfilter->tkl_duration)); - /* Reserved spamfilter-only hit-stat fields (since TKLDB_VERSION 6260): - * hits_except, lasthit_except. Written as 0 for now. - */ - W_SAFE(unrealdb_write_int64(db, 0)); - W_SAFE(unrealdb_write_int64(db, 0)); + /* Spamfilter-only hit-stat fields (since TKLDB_VERSION 6260): hits_except, lasthit_except. */ + W_SAFE(unrealdb_write_int64(db, tkl->ptr.spamfilter->hits_except)); + W_SAFE(unrealdb_write_int64(db, tkl->ptr.spamfilter->lasthit_except)); } return 1; @@ -580,11 +575,11 @@ int read_tkldb(void) R_SAFE(unrealdb_read_str(db, &str)); strlcpy(tkl->spamfilter_id, str, sizeof(tkl->spamfilter_id)); safe_free(str); - /* Reserved hit-stat fields for all TKL types (hits, lasthit). - * Read and discarded for now; a later release will use them. - */ + /* Hit-stat fields for all TKL types (hits, lasthit). */ R_SAFE(unrealdb_read_int64(db, &v)); + tkl->hits = v; R_SAFE(unrealdb_read_int64(db, &v)); + tkl->lasthit = v; } /* Save some CPU... if it's already expired then don't bother adding */ @@ -757,12 +752,13 @@ int read_tkldb(void) R_SAFE(unrealdb_read_str(db, &tkl->ptr.spamfilter->tkl_reason)); R_SAFE(unrealdb_read_int64(db, &v)); tkl->ptr.spamfilter->tkl_duration = v; - /* Reserved spamfilter-only hit-stat fields (hits_except, - * lasthit_except). Read and discarded for now. */ + /* Spamfilter-only hit-stat fields (hits_except, lasthit_except). */ if (version >= 6260) { R_SAFE(unrealdb_read_int64(db, &v)); + tkl->ptr.spamfilter->hits_except = v; R_SAFE(unrealdb_read_int64(db, &v)); + tkl->ptr.spamfilter->lasthit_except = v; } if (!do_not_add && @@ -813,6 +809,13 @@ int read_tkldb(void) { strlcpy(added->id, tkl->id, sizeof(added->id)); strlcpy(added->spamfilter_id, tkl->spamfilter_id, sizeof(added->spamfilter_id)); + added->hits = tkl->hits; + added->lasthit = tkl->lasthit; + if (TKLIsSpamfilter(added)) + { + added->ptr.spamfilter->hits_except = tkl->ptr.spamfilter->hits_except; + added->ptr.spamfilter->lasthit_except = tkl->ptr.spamfilter->lasthit_except; + } } if (!do_not_add) From faecdd66cdafbe631d5d17ac0b063d5230157c9a Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Wed, 10 Jun 2026 14:27:42 +0200 Subject: [PATCH 12/44] Config-file based *LINES/Spamfilter: preserve hit counters between rehashes. Unlike non-config-based TKLs - which go through tkldb - they are still not preserved through restarts. But at least they are not lost due to REHASH. This is done via a save+restore, a bit complicated, but we have little choice (other than not doing this at all). This also moves remove_config_tkls() from conf.c to tkl.c --- doc/RELEASE-NOTES.md | 2 +- include/h.h | 2 + include/modules.h | 2 + src/api-efunctions.c | 4 + src/conf.c | 42 +-------- src/modules/tkl.c | 207 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 220 insertions(+), 39 deletions(-) diff --git a/doc/RELEASE-NOTES.md b/doc/RELEASE-NOTES.md index 3fed163e0..7418fd1c2 100644 --- a/doc/RELEASE-NOTES.md +++ b/doc/RELEASE-NOTES.md @@ -32,7 +32,7 @@ This is work in progress and may not always be a stable version. each individual server and are not network-wide. This allows IRCOps to see which entries never get any hits and can potentially be removed. * Important exception: config-based spamfilters/bans lose their counters - on `REHASH` and restart. + on restart. * For non-config TKLs, the hit count and last hit timestamp are preserved across reboots (via tkldb). * Again, see *Developers and protocol* for the exact STATS field. diff --git a/include/h.h b/include/h.h index 1e5ddf9df..7b99d4cab 100644 --- a/include/h.h +++ b/include/h.h @@ -848,6 +848,8 @@ extern MODVAR void (*tkl_del_line)(TKL *tkl); extern MODVAR void (*tkl_check_local_remove_shun)(TKL *tmp); extern MODVAR int (*find_tkline_match)(Client *cptr, int skip_soft); extern MODVAR void (*tkl_hit)(Client *client, TKL *tkl); +extern MODVAR void (*remove_config_tkls)(int flag); +extern MODVAR void (*config_tkl_hits_restore)(void); extern MODVAR int (*find_shun)(Client *cptr); extern MODVAR int (*find_spamfilter_user)(Client *client, int flags); extern MODVAR TKL *(*find_qline)(Client *cptr, const char *nick, int *ishold); diff --git a/include/modules.h b/include/modules.h index d378839c6..5617e5ac9 100644 --- a/include/modules.h +++ b/include/modules.h @@ -3056,6 +3056,8 @@ enum EfunctionType { EFUNC_GET_FLOODPROT_CHANNEL_MAX_LINES, EFUNC_FLOODPROT_CHECK_MULTILINE_BATCH, EFUNC_TKL_HIT, + EFUNC_REMOVE_CONFIG_TKLS, + EFUNC_CONFIG_TKL_HITS_RESTORE, }; /* Module flags */ diff --git a/src/api-efunctions.c b/src/api-efunctions.c index 0ce3ffcef..570257a26 100644 --- a/src/api-efunctions.c +++ b/src/api-efunctions.c @@ -74,6 +74,8 @@ void (*tkl_del_line)(TKL *tkl); void (*tkl_check_local_remove_shun)(TKL *tmp); int (*find_tkline_match)(Client *client, int skip_soft); void (*tkl_hit)(Client *client, TKL *tkl); +void (*remove_config_tkls)(int flag); +void (*config_tkl_hits_restore)(void); int (*find_shun)(Client *client); int(*find_spamfilter_user)(Client *client, int flags); TKL *(*find_qline)(Client *client, const char *nick, int *ishold); @@ -424,6 +426,8 @@ void efunctions_init(void) efunc_init_function(EFUNC_TKL_CHECK_LOCAL_REMOVE_SHUN, tkl_check_local_remove_shun, NULL, 0); efunc_init_function(EFUNC_FIND_TKLINE_MATCH, find_tkline_match, NULL, 0); efunc_init_function(EFUNC_TKL_HIT, tkl_hit, NULL, 0); + efunc_init_function(EFUNC_REMOVE_CONFIG_TKLS, remove_config_tkls, NULL, 0); + efunc_init_function(EFUNC_CONFIG_TKL_HITS_RESTORE, config_tkl_hits_restore, NULL, 0); efunc_init_function(EFUNC_FIND_SHUN, find_shun, NULL, 0); efunc_init_function(EFUNC_FIND_SPAMFILTER_USER, find_spamfilter_user, NULL, 0); efunc_init_function(EFUNC_FIND_QLINE, find_qline, NULL, 0); diff --git a/src/conf.c b/src/conf.c index 0e8e7c968..54e64ec96 100644 --- a/src/conf.c +++ b/src/conf.c @@ -274,7 +274,6 @@ int rehash_internal(Client *client); int is_blacklisted_module(const char *name); int modules_default_conf_modified(const char *filebuf); int config_item_allowed_for_config_file(const char *resource, const char *item); -void remove_config_tkls(int flag); void free_operclass_struct(OperClass *o); /** Return the printable string of a 'cep' location, such as set::something::xyz */ @@ -2559,41 +2558,6 @@ int config_read_file(const char *filename, const char *display_name) } } -/** Remove all TKL's that were added by the config file(s). - * This is done after config passed testing and right before - * adding the (new) entries. - */ -void remove_config_tkls(int flag) -{ - TKL *tk, *tk_next; - int index, index2; - - /* IP hashed TKL list */ - for (index = 0; index < TKLIPHASHLEN1; index++) - { - for (index2 = 0; index2 < TKLIPHASHLEN2; index2++) - { - for (tk = tklines_ip_hash[index][index2]; tk; tk = tk_next) - { - tk_next = tk->next; - if (tk->flags & flag) - tkl_del_line(tk); - } - } - } - - /* Generic TKL list */ - for (index = 0; index < TKLISTLEN; index++) - { - for (tk = tklines[index]; tk; tk = tk_next) - { - tk_next = tk->next; - if (tk->flags & flag) - tkl_del_line(tk); - } - } -} - void free_proxy_block(ConfigItem_proxy *e) { free_security_group(e->mask); @@ -2759,8 +2723,6 @@ void config_rehash() safe_free(tld_ptr); } - remove_config_tkls(TKL_FLAG_CONFIG); - for (deny_version_ptr = conf_deny_version; deny_version_ptr; deny_version_ptr = (ConfigItem_deny_version *) next) { next = (ListStruct *)deny_version_ptr->next; safe_free(deny_version_ptr->mask); @@ -12306,6 +12268,10 @@ void central_spamfilter_download_complete(OutgoingWebRequest *request, OutgoingW /* And load the new ones... */ num_rules = config_run_blocks_generic(cfptr, 0); + /* Restore hit counters onto the freshly re-added central spamfilters (matched + * by key), from the snapshot taken in remove_config_tkls() just above. + */ + config_tkl_hits_restore(); active_rules = count_central_spamfilter_rules(); if (iConf.central_spamfilter_verbose > 2) diff --git a/src/modules/tkl.c b/src/modules/tkl.c index fa65d08b5..6a4318af8 100644 --- a/src/modules/tkl.c +++ b/src/modules/tkl.c @@ -99,6 +99,11 @@ int _take_action(Client *client, BanAction *action, const char *reason, long dur int _match_spamfilter(Client *client, const char *str_in, int type, const char *cmd, const char *target, int flags, ClientContext *clictx, TKL **rettk); int _match_spamfilter_mtags(Client *client, MessageTag *mtags, const char *cmd); int check_special_spamfilters_present(void); +char *tkl_hits_key(TKL *tkl, char *buf, size_t len); +void config_tkl_hits_free(ModData *m); +void config_tkl_hits_snapshot(TKL *tkl); +void _config_tkl_hits_restore(void); +void _remove_config_tkls(int flag); int _join_viruschan(Client *client, TKL *tk, int type); void _spamfilter_build_user_string(char *buf, const char *nick, Client *client); int _match_user(const char *rmask, Client *client, int options); @@ -176,6 +181,27 @@ int confusables_spamfilters_present = 0; /**< Are any spamfilters with input-con long previous_spamfilter_utf8 = 0; static int firstboot = 0; +/* Config-file (and central) TKLs are freed and re-created on every /REHASH (and + * central spamfilters on every feed refresh) since they live in the config, not + * in the tkldb, which would otherwise reset their hit counters. To keep the + * counters we stash them here right before the old entry is freed, keyed by a + * stable per-TKL identity (see tkl_hits_key), and copy them back onto the freshly + * re-added entry with the same key. The list is carried across the rehash + * module-unload by Save/LoadPersistentPointer (same trick connthrottle uses). + * This is rehash/refresh-only; on a full restart the counters still reset. + * Covers config server bans, name bans (qlines) and spamfilters; not exceptions. + */ +typedef struct ConfigTKLHits ConfigTKLHits; +struct ConfigTKLHits { + ConfigTKLHits *prev, *next; + char key[256]; + long long hits; + time_t lasthit; + long long hits_except; /* spamfilter only */ + time_t lasthit_except; /* spamfilter only */ +}; +ConfigTKLHits *config_tkl_hits = NULL; + /* s2s-tkl/: per-TKL fields carried over S2S as @s2s-tkl/= on the * TKL command (modeled on s2s-md/). Old servers ignore unknown tags. To add a field, * add a row here plus its serialize/unserialize pair (defined further down). @@ -224,6 +250,8 @@ MOD_TEST() EfunctionAddVoid(modinfo->handle, EFUNC_TKL_CHECK_LOCAL_REMOVE_SHUN, _tkl_check_local_remove_shun); EfunctionAdd(modinfo->handle, EFUNC_FIND_TKLINE_MATCH, _find_tkline_match); EfunctionAddVoid(modinfo->handle, EFUNC_TKL_HIT, _tkl_hit); + EfunctionAddVoid(modinfo->handle, EFUNC_REMOVE_CONFIG_TKLS, _remove_config_tkls); + EfunctionAddVoid(modinfo->handle, EFUNC_CONFIG_TKL_HITS_RESTORE, _config_tkl_hits_restore); EfunctionAdd(modinfo->handle, EFUNC_FIND_SHUN, _find_shun); EfunctionAdd(modinfo->handle, EFUNC_FIND_SPAMFILTER_USER, _find_spamfilter_user); EfunctionAddPVoid(modinfo->handle, EFUNC_FIND_QLINE, TO_PVOIDFUNC(_find_qline)); @@ -260,6 +288,7 @@ MOD_INIT() if (loop.booted == 0) firstboot = 1; LoadPersistentLong(modinfo, previous_spamfilter_utf8); + LoadPersistentPointer(modinfo, config_tkl_hits, config_tkl_hits_free); HookAdd(modinfo->handle, HOOKTYPE_CONFIGRUN, 0, tkl_config_run_spamfilter); HookAdd(modinfo->handle, HOOKTYPE_CONFIGRUN, 0, tkl_config_run_ban); HookAdd(modinfo->handle, HOOKTYPE_CONFIGRUN, 0, tkl_config_run_except); @@ -285,13 +314,21 @@ MOD_LOAD() { check_special_spamfilters_present(); check_set_spamfilter_utf8_setting_changed(); + _config_tkl_hits_restore(); EventAdd(modinfo->handle, "tklexpire", tkl_check_expire, NULL, 5000, 0); return MOD_SUCCESS; } MOD_UNLOAD() { + /* Free our config TKLs here (rather than from config_rehash) so the same pass + * can snapshot the spamfilter hit counters. Call the local impl: the snapshot + * store is a per-instance global, and we must write to ours (saved just below), + * not the new instance the efunc pointer now points to. + */ + _remove_config_tkls(TKL_FLAG_CONFIG); SavePersistentLong(modinfo, previous_spamfilter_utf8); + SavePersistentPointer(modinfo, config_tkl_hits); return MOD_SUCCESS; } @@ -3502,6 +3539,176 @@ void _free_tkl(TKL *tkl) safe_free(tkl); } +/** Build a stable match key for a hit-bearing config/central TKL (server ban, + * name ban or spamfilter), used to pair an old entry with its rebuilt self across + * a rehash / feed refresh. Returns buf, or NULL for types we don't preserve + * (exceptions) or with no usable key. Format: " ", joined + * by a single space (these fields never contain spaces). + */ +char *tkl_hits_key(TKL *tkl, char *buf, size_t len) +{ + char tmp[BUFSIZE]; + char t = tkl_typetochar(tkl->type); + + if (TKLIsSpamfilter(tkl) && tkl->id[0]) + snprintf(buf, len, "%c %s", t, tkl->id); + else if (TKLIsServerBan(tkl)) + snprintf(buf, len, "%c %s", t, tkl_uhost(tkl, tmp, sizeof(tmp), 0)); + else if (TKLIsNameBan(tkl)) + snprintf(buf, len, "%c %s", t, tkl->ptr.nameban->name); + else + return NULL; + return buf; +} + +/** Stash a config/central TKL's hit counters before it is freed (from the removal + * loop in _remove_config_tkls), so they can be copied back onto the same entry + * after a rehash / feed refresh re-adds it. Covers server bans, name bans and + * spamfilters (whatever tkl_hits_key recognises); skips entries that never hit. + */ +void config_tkl_hits_snapshot(TKL *tkl) +{ + ConfigTKLHits *e; + char key[256]; + + if (!(tkl->flags & (TKL_FLAG_CONFIG | TKL_FLAG_CENTRAL_SPAMFILTER))) + return; + if (!tkl_hits_key(tkl, key, sizeof(key))) + return; /* not a hit-bearing config type */ + if (!tkl->hits && !tkl->lasthit && + (!TKLIsSpamfilter(tkl) || + (!tkl->ptr.spamfilter->hits_except && !tkl->ptr.spamfilter->lasthit_except))) + return; /* never hit, nothing to keep */ + + e = safe_alloc(sizeof(ConfigTKLHits)); + strlcpy(e->key, key, sizeof(e->key)); + e->hits = tkl->hits; + e->lasthit = tkl->lasthit; + if (TKLIsSpamfilter(tkl)) + { + e->hits_except = tkl->ptr.spamfilter->hits_except; + e->lasthit_except = tkl->ptr.spamfilter->lasthit_except; + } + AddListItem(e, config_tkl_hits); +} + +/** Free the whole snapshot list and forget it. */ +void config_tkl_hits_free_all(void) +{ + ConfigTKLHits *e, *e_next; + + for (e = config_tkl_hits; e; e = e_next) + { + e_next = e->next; + safe_free(e); + } + config_tkl_hits = NULL; +} + +/** PersistentPointer free callback: used only if the snapshot is never reloaded + * (e.g. final module unload), otherwise MOD_LOAD consumes and frees it. + */ +void config_tkl_hits_free(ModData *m) +{ + config_tkl_hits_free_all(); +} + +/** Copy stashed counters back onto one re-added TKL, matched by key. */ +static void config_tkl_hits_restore_one(TKL *tkl) +{ + ConfigTKLHits *e; + char key[256]; + + if (!(tkl->flags & (TKL_FLAG_CONFIG | TKL_FLAG_CENTRAL_SPAMFILTER))) + return; + if (!tkl_hits_key(tkl, key, sizeof(key))) + return; + for (e = config_tkl_hits; e; e = e->next) + { + if (!strcmp(e->key, key)) + { + tkl->hits = e->hits; + tkl->lasthit = e->lasthit; + if (TKLIsSpamfilter(tkl)) + { + tkl->ptr.spamfilter->hits_except = e->hits_except; + tkl->ptr.spamfilter->lasthit_except = e->lasthit_except; + } + return; + } + } +} + +/** After config/central TKLs have been re-added, copy the stashed hit counters + * back onto the ones with a matching key, then drop the whole stash. Called from + * MOD_LOAD (config rehash) and from the central feed refresh; a no-op on a normal + * boot (the stash is empty). + */ +void _config_tkl_hits_restore(void) +{ + TKL *tkl; + int index, index2; + + if (!config_tkl_hits) + return; + + /* IP-hashed list (zlines, klines, glines) */ + for (index = 0; index < TKLIPHASHLEN1; index++) + for (index2 = 0; index2 < TKLIPHASHLEN2; index2++) + for (tkl = tklines_ip_hash[index][index2]; tkl; tkl = tkl->next) + config_tkl_hits_restore_one(tkl); + + /* Generic list (name bans, spamfilters, non-ip-hashed server bans) */ + for (index = 0; index < TKLISTLEN; index++) + for (tkl = tklines[index]; tkl; tkl = tkl->next) + config_tkl_hits_restore_one(tkl); + + /* Matched entries are applied; entries no longer in the config are discarded. */ + config_tkl_hits_free_all(); +} + +/** Remove all TKLs with the given flag (TKL_FLAG_CONFIG or + * TKL_FLAG_CENTRAL_SPAMFILTER). For config/central spamfilters the hit counters + * are snapshotted first, so a later re-add can restore them. Lives here (rather + * than in conf.c) so the snapshot sits right next to the removal it rides on. + */ +void _remove_config_tkls(int flag) +{ + TKL *tk, *tk_next; + int index, index2; + + /* IP hashed TKL list */ + for (index = 0; index < TKLIPHASHLEN1; index++) + { + for (index2 = 0; index2 < TKLIPHASHLEN2; index2++) + { + for (tk = tklines_ip_hash[index][index2]; tk; tk = tk_next) + { + tk_next = tk->next; + if (tk->flags & flag) + { + config_tkl_hits_snapshot(tk); + tkl_del_line(tk); + } + } + } + } + + /* Generic TKL list */ + for (index = 0; index < TKLISTLEN; index++) + { + for (tk = tklines[index]; tk; tk = tk_next) + { + tk_next = tk->next; + if (tk->flags & flag) + { + config_tkl_hits_snapshot(tk); + tkl_del_line(tk); + } + } + } +} + /** Delete a TKL entry from the list and free it. * @param tkl The TKL entry. */ From 62f3cda8f27998fb21dca41de569945aea431f81 Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Wed, 10 Jun 2026 15:29:26 +0200 Subject: [PATCH 13/44] Make spamfilter IDs start with "SPAM" to be more visible. And this also means shun IDs now start with "H". Update release notes. This, after i realized that for like *LINEs that are added by spamfilter the two ID fields in "STATS gline" are a bit confusing as to which ID is what. Now the spamfilter one starts with "SPAM" so there can be no confusion. The gline one still starts with "G" as before. Since I kept the generated ID length the same, this means there is less bits available for the spamfilter ID, but there are rarely more than 1000 spamfilters, and in that scenario there's just as little birthday attack collision % as with 200k glines, just to illustrate (~0.0015% vs ~0.0018%) --- doc/RELEASE-NOTES.md | 2 +- src/modules/tkl.c | 99 +++++++++++++++++++++++++++----------------- 2 files changed, 61 insertions(+), 40 deletions(-) diff --git a/doc/RELEASE-NOTES.md b/doc/RELEASE-NOTES.md index 7418fd1c2..288e55cd8 100644 --- a/doc/RELEASE-NOTES.md +++ b/doc/RELEASE-NOTES.md @@ -7,7 +7,7 @@ This is work in progress and may not always be a stable version. ### Enhancements: * Server bans and Spamfilters now have a unique ID, like `G7K2MP9WQX3`: * The first letter denotes the type: `G` for gline, `K` for kline, - `Z` for (g)zline. + `Z` for (g)zline, `H` for shun. Spamfilter IDs start with `SPAM`. * The ID is shown to the affected user, so they can paste the ID back to network staff. It is `$banid` in [set::reject-message](https://www.unrealircd.org/docs/Set_block#set::reject-message) diff --git a/src/modules/tkl.c b/src/modules/tkl.c index 6a4318af8..8f4832a79 100644 --- a/src/modules/tkl.c +++ b/src/modules/tkl.c @@ -137,6 +137,7 @@ struct TKLTypeTable unsigned tkltype:1; /**< Is a type available in cmd_tkl() and friends */ unsigned exceptiontype:1; /**< Is a type available for exceptions */ unsigned needip:1; /**< When using this exempt option, only IP addresses are permitted (processed before DNS/ident lookups etc) */ + char *id_prefix; /**< Prefix for generated TKL ids, eg "G", "K", "SPAM". NULL for exempt-only options. Note that shun has "H" here while its type.letter is 's'. */ }; /** This table which defines all TKL types and TKL exception types. @@ -149,26 +150,26 @@ struct TKLTypeTable * - more? */ TKLTypeTable tkl_types[] = { - /* */ - { "gline", 'G', TKL_KILL | TKL_GLOBAL, "G-Line", 1, 1, 0 }, - { "kline", 'k', TKL_KILL, "K-Line", 1, 1, 0 }, - { "gzline", 'Z', TKL_ZAP | TKL_GLOBAL, "Global Z-Line", 1, 1, 1 }, - { "zline", 'z', TKL_ZAP, "Z-Line", 1, 1, 1 }, - { "spamfilter", 'F', TKL_SPAMF | TKL_GLOBAL, "Spamfilter", 1, 1, 0 }, - { "qline", 'Q', TKL_NAME | TKL_GLOBAL, "Q-Line", 1, 1, 0 }, - { "except", 'E', TKL_EXCEPTION | TKL_GLOBAL, "Exception", 1, 0, 0 }, - { "shun", 's', TKL_SHUN | TKL_GLOBAL, "Shun", 1, 1, 0 }, - { "local-qline", 'q', TKL_NAME, "Local Q-Line", 1, 0, 0 }, - { "local-exception", 'e', TKL_EXCEPTION, "Local Exception", 1, 0, 0 }, - { "local-spamfilter", 'f', TKL_SPAMF, "Local Spamfilter", 1, 0, 0 }, - { "blacklist", 'b', TKL_BLACKLIST, "Blacklist", 0, 1, 1 }, - { "connect-flood", 'c', TKL_CONNECT_FLOOD, "Connect flood", 0, 1, 0 }, - { "maxperip", 'm', TKL_MAXPERIP, "Max-per-IP", 0, 1, 0 }, - { "handshake-data-flood", 'd', TKL_HANDSHAKE_DATA_FLOOD, "Handshake data flood", 0, 1, 1 }, - { "antirandom", 'r', TKL_ANTIRANDOM, "Antirandom", 0, 1, 0 }, - { "antimixedutf8", '8', TKL_ANTIMIXEDUTF8, "Antimixedutf8", 0, 1, 0 }, - { "ban-version", 'v', TKL_BAN_VERSION, "Ban Version", 0, 1, 0 }, - { NULL, '\0', 0, NULL, 0, 0, 0 }, + /* */ + { "gline", 'G', TKL_KILL | TKL_GLOBAL, "G-Line", 1, 1, 0, "G" }, + { "kline", 'k', TKL_KILL, "K-Line", 1, 1, 0, "K" }, + { "gzline", 'Z', TKL_ZAP | TKL_GLOBAL, "Global Z-Line", 1, 1, 1, "Z" }, + { "zline", 'z', TKL_ZAP, "Z-Line", 1, 1, 1, "Z" }, + { "spamfilter", 'F', TKL_SPAMF | TKL_GLOBAL, "Spamfilter", 1, 1, 0, "SPAM" }, + { "qline", 'Q', TKL_NAME | TKL_GLOBAL, "Q-Line", 1, 1, 0, "Q" }, + { "except", 'E', TKL_EXCEPTION | TKL_GLOBAL, "Exception", 1, 0, 0, "E" }, + { "shun", 's', TKL_SHUN | TKL_GLOBAL, "Shun", 1, 1, 0, "H" }, + { "local-qline", 'q', TKL_NAME, "Local Q-Line", 1, 0, 0, "Q" }, + { "local-exception", 'e', TKL_EXCEPTION, "Local Exception", 1, 0, 0, "E" }, + { "local-spamfilter", 'f', TKL_SPAMF, "Local Spamfilter", 1, 0, 0, "SPAM" }, + { "blacklist", 'b', TKL_BLACKLIST, "Blacklist", 0, 1, 1, NULL }, + { "connect-flood", 'c', TKL_CONNECT_FLOOD, "Connect flood", 0, 1, 0, NULL }, + { "maxperip", 'm', TKL_MAXPERIP, "Max-per-IP", 0, 1, 0, NULL }, + { "handshake-data-flood", 'd', TKL_HANDSHAKE_DATA_FLOOD, "Handshake data flood", 0, 1, 1, NULL }, + { "antirandom", 'r', TKL_ANTIRANDOM, "Antirandom", 0, 1, 0, NULL }, + { "antimixedutf8", '8', TKL_ANTIMIXEDUTF8, "Antimixedutf8", 0, 1, 0, NULL }, + { "ban-version", 'v', TKL_BAN_VERSION, "Ban Version", 0, 1, 0, NULL }, + { NULL, '\0', 0, NULL, 0, 0, 0, NULL }, }; #define ALL_VALID_EXCEPTION_TYPES "kline, gline, zline, gzline, spamfilter, shun, qline, blacklist, connect-flood, handshake-data-flood, antirandom, antimixedutf8, ban-version" @@ -1331,20 +1332,34 @@ void check_set_spamfilter_utf8_setting_changed(void) } /* === TKL unique IDs === - * Every TKL has a unique id (struct TKL.id): either assigned (a random id generated - * by the server that originates the entry, or an id supplied by an external setter - * such as services), or an empty string if none is known. A native (assigned-here) - * id is <10 x base32>, eg "G7K2MP9WQX3". It is carried over S2S in the - * s2s-tkl/id message tag and persisted by tkldb; there is no derivation. + * Every TKL typically has a unique id (tkl->id), such as "G7K2MP9WQX3" for a gline. + * See the comment below about TKL_GENERATED_ID_LEN for more info. + * These TKLIDs are communicated in S2S via @s2s-tkl/id mtag, and they + * also persist via tkldb. */ -/** Single-letter prefix for a native TKL id, derived from the TKL type. - * Uppercase, with global/local collapsed (eg gzline and zline both 'Z', spamfilter - * and local-spamfilter both 'F'). gline stays 'G' and kline 'K' as their letters differ. +/* The generated TKLID (not to be confused with the maximum allowed length of TKLID): + * This consists of the prefix + the random/hashed base32 chars. Obviously, the idea + * is that this will never clash, so we have to look at birthday attack collision rates: + * - Spamfilter: "SPAM" prefix + 7 chars = 35 bits = ~0.0015% for 1000 spamfilters. + * - All the rest: 1 letter prefix + 10 chars = 50 bits = ~0.0018% for 200k entries + * And obviously both the 1000 spamfilters and 200k GLINE/whatever are a rather + * absurd number of entries, but possible in odd/attack scenarios. */ -static char tkl_id_prefix(int type) +#define TKL_GENERATED_ID_LEN 11 + +/** The TKL ID prefix string. Eg. "G" for a gline or "SPAM" for a spamfilter. */ +static const char *tkl_id_prefix(int type) { - return toupper(_tkl_typetochar(type)); + int i; + + for (i = 0; tkl_types[i].config_name; i++) + if ((tkl_types[i].type == type) && tkl_types[i].id_prefix) + return tkl_types[i].id_prefix; +#ifdef DEBUGMODE + abort(); /* this should never happen */ +#endif + return "?"; } /** Find a TKL by its exact id (case-insensitive). Returns NULL on no match or empty id. */ @@ -1377,16 +1392,18 @@ static void tkl_generate_id(TKL *tkl) { /* Crockford base32 (no I/L/O/U) so the id survives being read aloud */ static const char b32[] = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; - char prefix = tkl_id_prefix(tkl->type); + const char *prefix = tkl_id_prefix(tkl->type); + int prefixlen = strlen(prefix); + int nrandom = TKL_GENERATED_ID_LEN - prefixlen; TKL *other; int attempt, i; for (attempt = 0; attempt < 16; attempt++) { - tkl->id[0] = prefix; - for (i = 1; i <= 10; i++) - tkl->id[i] = b32[getrandom8() % 32]; - tkl->id[i] = '\0'; + strlcpy(tkl->id, prefix, sizeof(tkl->id)); + for (i = 0; i < nrandom; i++) + tkl->id[prefixlen + i] = b32[getrandom8() % 32]; + tkl->id[prefixlen + nrandom] = '\0'; other = find_tkl_by_id(tkl->id); if (!other || (other == tkl)) return; /* unique (or only matches ourselves, if already linked) */ @@ -1511,6 +1528,8 @@ static const char *spamfilter_fallback_id(TKL *tkl) * key just makes the hash deterministic and identical across servers. */ static const char key[16] = "UnrealIRCd rocks"; static char buf[16]; + const char *prefix; + int prefixlen; char content[8192]; char actbuf[2]; uint64_t h; @@ -1526,13 +1545,15 @@ static const char *spamfilter_fallback_id(TKL *tkl) h = siphash(content, key); - buf[0] = tkl_id_prefix(tkl->type); - for (i = 1; i <= 10; i++) + prefix = tkl_id_prefix(tkl->type); + prefixlen = strlen(prefix); + strlcpy(buf, prefix, sizeof(buf)); + for (i = 0; i < TKL_GENERATED_ID_LEN - prefixlen; i++) { - buf[i] = b32[h & 31]; + buf[prefixlen + i] = b32[h & 31]; h >>= 5; } - buf[i] = '\0'; + buf[prefixlen + i] = '\0'; return buf; } From 5850ec9434fa08d0152cd38b00a4b1b11c147ba9 Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Wed, 10 Jun 2026 19:27:20 +0200 Subject: [PATCH 14/44] Show TKL IDs (and related spamfilter TKL ID, if any) in TKL_ADD, TKL_DEL, TKL_EXPIRE and SPAMFILTER_MATCH messages. This uses the newly added functions log_data_optional_string() and log_data_optional_name_value(). The first shows the optional string like "abc" and the second expands to "[name: value]". What's also new is that both of these will swallow a preceding space if there is no value. This so you can just use "Something. $optional_string" and it will expand to "Something." if $optional_string is empty. This makes things less hacky and more human readable :) --- doc/RELEASE-NOTES.md | 4 +- include/h.h | 2 + include/struct.h | 3 ++ src/log.c | 98 +++++++++++++++++++++++++++++++++++++++++--- src/modules/tkl.c | 73 ++++++++++++++++++++------------- 5 files changed, 146 insertions(+), 34 deletions(-) diff --git a/doc/RELEASE-NOTES.md b/doc/RELEASE-NOTES.md index 288e55cd8..959e760ce 100644 --- a/doc/RELEASE-NOTES.md +++ b/doc/RELEASE-NOTES.md @@ -17,9 +17,11 @@ This is work in progress and may not always be a stable version. with e.g. `STATS gline +i G7K2MP9WQX3`. * Spamfilters also have an ID, and we already had something similar that could be used for `SPAMFILTER del ` that was only local-server. - For new spamfilters this is now a network wide ID as well. + For new spamfilters this is now a network wide ID. * When a server ban was placed by a spamfilter, `STATS gline` shows the originating spamfilter's ID, so it can be traced back. + * The TKL ID and related Spamfilter ID (if any), also appear in log + messages `TKL_ADD`, `TKL_DEL`, `TKL_EXPIRE` and `SPAMFILTER_MATCH`. * The `STATS gline` and other TKL stats changed format, see *Developers and protocol* if you use scripts or your client depends on the exact numeric format. diff --git a/include/h.h b/include/h.h index 7b99d4cab..47e99c050 100644 --- a/include/h.h +++ b/include/h.h @@ -1455,6 +1455,8 @@ extern void do_unreal_log(LogLevel loglevel, const char *subsystem, const char * extern void do_unreal_log_raw(LogLevel loglevel, const char *subsystem, const char *event_id, Client *client, const char *msg, ...); extern void do_unreal_log_internal_from_remote(LogLevel loglevel, const char *subsystem, const char *event_id, MultiLine *msg, json_t *json, const char *json_serialized, Client *from_server); extern LogData *log_data_string(const char *key, const char *str); +extern LogData *log_data_optional_name_value(const char *key, const char *label, const char *value); +extern LogData *log_data_optional_string(const char *key, const char *value); extern LogData *log_data_char(const char *key, const char c); extern LogData *log_data_integer(const char *key, int64_t integer); extern LogData *log_data_timestamp(const char *key, time_t ts); diff --git a/include/struct.h b/include/struct.h index 9cb8be391..dbe65bb2c 100644 --- a/include/struct.h +++ b/include/struct.h @@ -228,6 +228,8 @@ typedef OperPermission (*OperClassEntryEvalCallback)(OperClassACLEntryVar* varia typedef enum LogFieldType { LOG_FIELD_INTEGER, // and unsigned? LOG_FIELD_STRING, + LOG_FIELD_OPTIONAL_NAME_VALUE, + LOG_FIELD_OPTIONAL_STRING, LOG_FIELD_CLIENT, LOG_FIELD_CHANNEL, LOG_FIELD_OBJECT, @@ -237,6 +239,7 @@ typedef enum LogFieldType { typedef struct LogData { LogFieldType type; char *key; + char *label; /**< Display label for LOG_FIELD_OPTIONAL_NAME_VALUE, NULL otherwise */ union { int64_t integer; char *string; diff --git a/src/log.c b/src/log.c index 079988b18..8b9bc63a1 100644 --- a/src/log.c +++ b/src/log.c @@ -587,6 +587,39 @@ LogData *log_data_string(const char *key, const char *str) return d; } +/** Like log_data_string(), the JSON is identical, but in buildlogstring() + * if the value is empty the string will be empty plus one preceding space + * character is "eaten". This way you can write things like + * "blablabla. $optionalstring" and both the space and the $optionalstring + * will not appear in the output. + */ +LogData *log_data_optional_string(const char *key, const char *value) +{ + LogData *d = safe_alloc(sizeof(LogData)); + + d->type = LOG_FIELD_OPTIONAL_STRING; + safe_strdup(d->key, key); + safe_strdup(d->value.string, value); + return d; +} + +/** Optional "[label: value]" field. In the JSON the value is stored raw, exactly + * like log_data_string() (so consumers get a clean value, not display text); in + * the text buildlogstring() shows it as "[label: value]". If the value is empty + * the whole thing is left out of the text and one preceding space is consumed, so + * "blablabla. $field" collapses cleanly when $field is empty. + */ +LogData *log_data_optional_name_value(const char *key, const char *label, const char *value) +{ + LogData *d = safe_alloc(sizeof(LogData)); + + d->type = LOG_FIELD_OPTIONAL_NAME_VALUE; + safe_strdup(d->key, key); + safe_strdup(d->label, label); + safe_strdup(d->value.string, value); + return d; +} + LogData *log_data_char(const char *key, const char c) { LogData *d = safe_alloc(sizeof(LogData)); @@ -783,11 +816,13 @@ LogData *log_data_textanalysis(const char *key, TextAnalysis *ta) void log_data_free(LogData *d) { - if (d->type == LOG_FIELD_STRING) + if ((d->type == LOG_FIELD_STRING) || (d->type == LOG_FIELD_OPTIONAL_NAME_VALUE) || + (d->type == LOG_FIELD_OPTIONAL_STRING)) safe_free(d->value.string); else if (((d->type == LOG_FIELD_OBJECT) || (d->type == LOG_FIELD_OBJECT_NOFREE)) && d->value.object) json_decref(d->value.object); + safe_free(d->label); safe_free(d->key); safe_free(d); } @@ -899,14 +934,26 @@ int valid_subsystem(const char *s) * @param name Array of variables names * @param value Array of variable values */ -void buildlogstring(const char *inbuf, char *outbuf, size_t len, json_t *details) +/* Eat one space immediately before the current output position, if any. + * Used when an optional log field outputs nothing. */ +static void buildlogstring_eat_space(char **o, char *outbuf, int *left) +{ + if ((*o > outbuf) && ((*o)[-1] == ' ')) + { + (*o)--; + (*left)++; + } +} + +void buildlogstring(const char *inbuf, char *outbuf, size_t len, json_t *details, json_t *optional_keys) { const char *i, *p; char *o; int left = len - 1; int cnt, found; char varname[256], *varp, *varpp; - json_t *t; + char buf[BUFSIZE]; + json_t *t, *opt; #ifdef DEBUGMODE if (len <= 0) @@ -973,13 +1020,30 @@ void buildlogstring(const char *inbuf, char *outbuf, size_t len, json_t *details { output = json_get_value(t); } - if (output) + /* optional_keys maps each optional field's key to its label (a + * json string) for log_data_optional_name_value, or to json null + * for log_data_optional_string. A non-NULL lookup means "optional". + */ + opt = json_object_get(optional_keys, varname); + if (output && *output) { + if (opt && json_is_string(opt)) + { + /* name:value field: show it as "[label: value]" (the JSON keeps the raw value) */ + ircsnprintf(buf, sizeof(buf), "[%s: %s]", json_string_value(opt), output); + output = buf; + } strlcpy(o, output, left); left -= strlen(output); /* may become <0 */ if (left <= 0) return; /* return - don't write \0 to 'o'. ensured by strlcpy already */ o += strlen(output); /* value entirely written */ + } else + if (opt) + { + /* Optional field that is empty: show nothing and eat a + * preceding space. */ + buildlogstring_eat_space(&o, outbuf, &left); } } else { @@ -1801,6 +1865,7 @@ void do_unreal_log_internal(LogLevel loglevel, const char *subsystem, const char const char *str; json_t *j = NULL; json_t *j_details = NULL; + json_t *optional_keys = NULL; json_t *t; char msgbuf[MAXLOGLENGTH]; const char *loglevel_string = log_level_valtostring(loglevel); @@ -1842,6 +1907,7 @@ void do_unreal_log_internal(LogLevel loglevel, const char *subsystem, const char j = json_object(); j_details = json_object(); + optional_keys = json_object(); json_object_set_new(j, "timestamp", json_string_unreal(timestamp_iso8601_now())); json_object_set_new(j, "level", json_string_unreal(loglevel_string)); @@ -1869,6 +1935,27 @@ void do_unreal_log_internal(LogLevel loglevel, const char *subsystem, const char else json_object_set_new(j_details, d->key, json_null()); break; + case LOG_FIELD_OPTIONAL_NAME_VALUE: + /* Optional: raw value in the JSON (or null), exactly like + * LOG_FIELD_STRING; buildlogstring() shows it as "[label: value]" + * in the text and eats a preceding space when empty. The label is + * stored as this key's value in optional_keys. */ + json_object_set_new(optional_keys, d->key, json_string_unreal(d->label)); + if (d->value.string) + json_object_set_new(j_details, d->key, json_string_unreal(d->value.string)); + else + json_object_set_new(j_details, d->key, json_null()); + break; + case LOG_FIELD_OPTIONAL_STRING: + /* Same JSON as LOG_FIELD_STRING (value as-is, null only when + * NULL); the optional_keys entry (value null = no label) is what + * makes buildlogstring() eat a preceding space when empty. */ + json_object_set_new(optional_keys, d->key, json_null()); + if (d->value.string) + json_object_set_new(j_details, d->key, json_string_unreal(d->value.string)); + else + json_object_set_new(j_details, d->key, json_null()); + break; case LOG_FIELD_CLIENT: json_expand_client(j_details, d->key, d->value.client, 3); break; @@ -1897,9 +1984,10 @@ void do_unreal_log_internal(LogLevel loglevel, const char *subsystem, const char } if (expand_msg) - buildlogstring(msg, msgbuf, sizeof(msgbuf), j_details); + buildlogstring(msg, msgbuf, sizeof(msgbuf), j_details, optional_keys); else strlcpy(msgbuf, msg, sizeof(msgbuf)); + json_decref(optional_keys); json_object_set_new(j, "msg", json_string_unreal(msgbuf)); diff --git a/src/modules/tkl.c b/src/modules/tkl.c index 8f4832a79..bb90d563e 100644 --- a/src/modules/tkl.c +++ b/src/modules/tkl.c @@ -3916,23 +3916,27 @@ void tkl_expire_entry(TKL *tkl) if (TKLIsServerBan(tkl)) { unreal_log(ULOG_INFO, "tkl", "TKL_EXPIRE", NULL, - "Expiring $tkl.type_string '$tkl' [reason: $tkl.reason] [by: $tkl.set_by] [duration: $tkl.duration_string]", - log_data_tkl("tkl", tkl)); + "Expiring $tkl.type_string '$tkl' [reason: $tkl.reason] [by: $tkl.set_by] [duration: $tkl.duration_string] $id $spamfilter_id", + log_data_tkl("tkl", tkl), + log_data_optional_name_value("id", "id", tkl->id), + log_data_optional_name_value("spamfilter_id", "spamfilter-id", tkl->spamfilter_id)); } else if (TKLIsNameBan(tkl)) { if (!tkl->ptr.nameban->hold) { unreal_log(ULOG_INFO, "tkl", "TKL_EXPIRE", NULL, - "Expiring $tkl.type_string '$tkl' [reason: $tkl.reason] [by: $tkl.set_by] [duration: $tkl.duration_string]", - log_data_tkl("tkl", tkl)); + "Expiring $tkl.type_string '$tkl' [reason: $tkl.reason] [by: $tkl.set_by] [duration: $tkl.duration_string] $id", + log_data_tkl("tkl", tkl), + log_data_optional_name_value("id", "id", tkl->id)); } } else if (TKLIsBanException(tkl)) { unreal_log(ULOG_INFO, "tkl", "TKL_EXPIRE", NULL, - "Expiring $tkl.type_string '$tkl' [type: $tkl.exception_types] [reason: $tkl.reason] [by: $tkl.set_by] [duration: $tkl.duration_string]", - log_data_tkl("tkl", tkl)); + "Expiring $tkl.type_string '$tkl' [type: $tkl.exception_types] [reason: $tkl.reason] [by: $tkl.set_by] [duration: $tkl.duration_string] $id", + log_data_tkl("tkl", tkl), + log_data_optional_name_value("id", "id", tkl->id)); } // FIXME: so.. this isn't logged? or what? @@ -4294,10 +4298,11 @@ int spamfilter_check_users(TKL *tkl) /* matched! */ unreal_log(ULOG_INFO, "tkl", "SPAMFILTER_MATCH", client, - "[Spamfilter] $client.details matches filter '$tkl': [cmd: $command: '$str'] [reason: $tkl.reason] [action: $tkl.ban_action]", + "[Spamfilter] $client.details matches filter '$tkl': [cmd: $command: '$str'] [reason: $tkl.reason] [action: $tkl.ban_action] $spamfilter_id", log_data_tkl("tkl", tkl), log_data_string("command", "USER"), - log_data_string("str", spamfilter_user)); + log_data_string("str", spamfilter_user), + log_data_optional_name_value("spamfilter_id", "spamfilter-id", tkl->id)); RunHook(HOOKTYPE_LOCAL_SPAMFILTER, client, spamfilter_user, spamfilter_user, SPAMF_USER, NULL, tkl); matches++; @@ -4988,27 +4993,32 @@ void _sendnotice_tkl_add(TKL *tkl) if (TKLIsServerBan(tkl)) { unreal_log(ULOG_INFO, "tkl", "TKL_ADD", NULL, - "$tkl.type_string added: '$tkl' [reason: $tkl.reason] [by: $tkl.set_by] [duration: $tkl.duration_string]", - log_data_tkl("tkl", tkl)); + "$tkl.type_string added: '$tkl' [reason: $tkl.reason] [by: $tkl.set_by] [duration: $tkl.duration_string] $id $spamfilter_id", + log_data_tkl("tkl", tkl), + log_data_optional_name_value("id", "id", tkl->id), + log_data_optional_name_value("spamfilter_id", "spamfilter-id", tkl->spamfilter_id)); } else if (TKLIsNameBan(tkl)) { unreal_log(ULOG_INFO, "tkl", "TKL_ADD", NULL, - "$tkl.type_string added: '$tkl' [reason: $tkl.reason] [by: $tkl.set_by] [duration: $tkl.duration_string]", - log_data_tkl("tkl", tkl)); + "$tkl.type_string added: '$tkl' [reason: $tkl.reason] [by: $tkl.set_by] [duration: $tkl.duration_string] $id", + log_data_tkl("tkl", tkl), + log_data_optional_name_value("id", "id", tkl->id)); } else if (TKLIsSpamfilter(tkl)) { unreal_log(ULOG_INFO, "tkl", "TKL_ADD", NULL, "Spamfilter added: '$tkl' [type: $tkl.match_type] [targets: $tkl.spamfilter_targets] " - "[action: $tkl.ban_action] [reason: $tkl.reason] [by: $tkl.set_by]", - log_data_tkl("tkl", tkl)); + "[action: $tkl.ban_action] [reason: $tkl.reason] [by: $tkl.set_by] $id", + log_data_tkl("tkl", tkl), + log_data_optional_name_value("id", "id", tkl->id)); } else if (TKLIsBanException(tkl)) { unreal_log(ULOG_INFO, "tkl", "TKL_ADD", NULL, - "$tkl.type_string added: '$tkl' [types: $tkl.exception_types] [by: $tkl.set_by] [duration: $tkl.duration_string]", - log_data_tkl("tkl", tkl)); + "$tkl.type_string added: '$tkl' [types: $tkl.exception_types] [by: $tkl.set_by] [duration: $tkl.duration_string] $id", + log_data_tkl("tkl", tkl), + log_data_optional_name_value("id", "id", tkl->id)); } else { unreal_log(ULOG_ERROR, "tkl", "BUG_UNKNOWN_TKL", NULL, @@ -5026,31 +5036,36 @@ void _sendnotice_tkl_del(const char *removed_by, TKL *tkl) if (TKLIsServerBan(tkl)) { unreal_log(ULOG_INFO, "tkl", "TKL_DEL", NULL, - "$tkl.type_string removed: '$tkl' [reason: $tkl.reason] [by: $removed_by] [set at: $tkl.set_at_string]", + "$tkl.type_string removed: '$tkl' [reason: $tkl.reason] [by: $removed_by] [set at: $tkl.set_at_string] $id $spamfilter_id", log_data_tkl("tkl", tkl), - log_data_string("removed_by", removed_by)); + log_data_string("removed_by", removed_by), + log_data_optional_name_value("id", "id", tkl->id), + log_data_optional_name_value("spamfilter_id", "spamfilter-id", tkl->spamfilter_id)); } else if (TKLIsNameBan(tkl)) { unreal_log(ULOG_INFO, "tkl", "TKL_DEL", NULL, - "$tkl.type_string removed: '$tkl' [reason: $tkl.reason] [by: $removed_by] [set at: $tkl.set_at_string]", + "$tkl.type_string removed: '$tkl' [reason: $tkl.reason] [by: $removed_by] [set at: $tkl.set_at_string] $id", log_data_tkl("tkl", tkl), - log_data_string("removed_by", removed_by)); + log_data_string("removed_by", removed_by), + log_data_optional_name_value("id", "id", tkl->id)); } else if (TKLIsSpamfilter(tkl)) { unreal_log(ULOG_INFO, "tkl", "TKL_DEL", NULL, "Spamfilter removed: '$tkl' [type: $tkl.match_type] [targets: $tkl.spamfilter_targets] " - "[action: $tkl.ban_action] [reason: $tkl.reason] [by: $removed_by] [set at: $tkl.set_at_string]", + "[action: $tkl.ban_action] [reason: $tkl.reason] [by: $removed_by] [set at: $tkl.set_at_string] $id", log_data_tkl("tkl", tkl), - log_data_string("removed_by", removed_by)); + log_data_string("removed_by", removed_by), + log_data_optional_name_value("id", "id", tkl->id)); } else if (TKLIsBanException(tkl)) { unreal_log(ULOG_INFO, "tkl", "TKL_DEL", NULL, - "$tkl.type_string removed: '$tkl' [types: $tkl.exception_types] [by: $removed_by] [set at: $tkl.set_at_string]", + "$tkl.type_string removed: '$tkl' [types: $tkl.exception_types] [by: $removed_by] [set at: $tkl.set_at_string] $id", log_data_tkl("tkl", tkl), - log_data_string("removed_by", removed_by)); + log_data_string("removed_by", removed_by), + log_data_optional_name_value("id", "id", tkl->id)); } else { unreal_log(ULOG_ERROR, "tkl", "BUG_UNKNOWN_TKL", NULL, @@ -6017,24 +6032,26 @@ static void match_spamfilter_hit(Client *client, const char *str_in, const char if (hide_content || (target == SPAMF_RAW)) { unreal_log(ULOG_INFO, "tkl", "SPAMFILTER_MATCH", client, - "[Spamfilter] $client.details matches filter '$tkl': [cmd: $command$_space$destination] [reason: $tkl.reason] [action: $tkl.ban_action]", + "[Spamfilter] $client.details matches filter '$tkl': [cmd: $command$_space$destination] [reason: $tkl.reason] [action: $tkl.ban_action] $spamfilter_id", log_data_tkl("tkl", tkl), log_data_string("command", cmd), log_data_string("_space", destination ? " " : ""), - log_data_string("destination", destination ? destination : "")); + log_data_string("destination", destination ? destination : ""), + log_data_optional_name_value("spamfilter_id", "spamfilter-id", tkl->id)); } else { // Yeah we are re-running the text analysis. TextAnalysis textanalysis; memset(&textanalysis, 0, sizeof(textanalysis)); RunHook(HOOKTYPE_ANALYZE_TEXT, client, str, &textanalysis); unreal_log(ULOG_INFO, "tkl", "SPAMFILTER_MATCH", client, - "[Spamfilter] $client.details matches filter '$tkl': [cmd: $command$_space$destination: '$str'] [reason: $tkl.reason] [action: $tkl.ban_action]", + "[Spamfilter] $client.details matches filter '$tkl': [cmd: $command$_space$destination: '$str'] [reason: $tkl.reason] [action: $tkl.ban_action] $spamfilter_id", log_data_tkl("tkl", tkl), log_data_string("command", cmd), log_data_string("_space", destination ? " " : ""), log_data_string("destination", destination ? destination : ""), log_data_string("str", str), - log_data_textanalysis("text_analysis", &textanalysis)); + log_data_textanalysis("text_analysis", &textanalysis), + log_data_optional_name_value("spamfilter_id", "spamfilter-id", tkl->id)); *content_revealed = 1; } From 57ca415c269973d60e425183189c017b6f4670aa Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Thu, 11 Jun 2026 19:18:34 +0200 Subject: [PATCH 15/44] Add whitespace deletion in buildvarstring() so template can have a space. Basically if a $variable is empty, and there is a space before it in the template string then we delete that space. May seem (or is) a bit over the top but this way the template stays clean, and it may be used/useful in other places as well. This is a behavior change, but I think we can live with it. One can opt- out via BUILDVARSTRING_KEEP_SPACE_FOR_EMPTY_VAR. --- include/struct.h | 1 + src/conf.c | 4 ++-- src/modules/quit.c | 14 ++++++++++---- src/modules/spamreport.c | 4 ++-- src/support.c | 24 +++++++++++++++++++----- 5 files changed, 34 insertions(+), 13 deletions(-) diff --git a/include/struct.h b/include/struct.h index dbe65bb2c..8e09218a7 100644 --- a/include/struct.h +++ b/include/struct.h @@ -2781,6 +2781,7 @@ typedef enum JsonRpcError { #define BUILDVARSTRING_URLENCODE 0x1 #define BUILDVARSTRING_XML 0x2 #define BUILDVARSTRING_UNKNOWN_VAR_IS_EMPTY 0x4 +#define BUILDVARSTRING_KEEP_SPACE_FOR_EMPTY_VAR 0x8 #endif /* __struct_include__ */ diff --git a/src/conf.c b/src/conf.c index 54e64ec96..e56574606 100644 --- a/src/conf.c +++ b/src/conf.c @@ -1934,8 +1934,8 @@ void config_setdefaultsettings(Configuration *i) safe_strdup(i->reject_message_too_many_new_connections_ipv6_range, "Too many new connections from this IPv6 range ($prefix_addr/$prefix_len) [connthrottle]"); safe_strdup(i->reject_message_server_full, "This server is full"); safe_strdup(i->reject_message_unauthorized, "You are not authorized to connect to this server"); - safe_strdup(i->reject_message_kline, "You are not welcome on this server. $bantype: $banreason. Email $klineaddr for more information.$banid"); - safe_strdup(i->reject_message_gline, "You are not welcome on this network. $bantype: $banreason. Email $glineaddr for more information.$banid"); + safe_strdup(i->reject_message_kline, "You are not welcome on this server. $bantype: $banreason. Email $klineaddr for more information. $banid"); + safe_strdup(i->reject_message_gline, "You are not welcome on this network. $bantype: $banreason. Email $glineaddr for more information. $banid"); i->topic_setter = SETTER_NICK_USER_HOST; i->ban_setter = SETTER_NICK_USER_HOST; diff --git a/src/modules/quit.c b/src/modules/quit.c index c4767e07c..7f949d06a 100644 --- a/src/modules/quit.c +++ b/src/modules/quit.c @@ -495,6 +495,7 @@ void _banned_client(Client *client, const char *bantype, const char *reason, con { char buf[512]; char idbuf[64]; + const char *banid; char *fmt = global ? iConf.reject_message_gline : iConf.reject_message_kline; const char *vars[7], *values[7]; MessageTag *mtags = NULL; @@ -504,13 +505,18 @@ void _banned_client(Client *client, const char *bantype, const char *reason, con RunHook(HOOKTYPE_BANNED_CLIENT, client, bantype, reason, global); - /* The " [ID: xxx]" fragment, empty when there is no id. Used both as the $banid - * reject-message variable and appended to the quit reason / real-quit-reason mtag. + /* Create the tklid. We actually need two buffers: + * 1) 'idbuf' is used in snprintf() in the quit reason and is " [ID: %s]" (or "") + * 2) 'banid' is used by buildvarstring() and is just "[ID: %s]" (or "") */ if (!BadPtr(tklid)) + { snprintf(idbuf, sizeof(idbuf), " [ID: %s]", tklid); - else + banid = idbuf + 1; + } else { idbuf[0] = '\0'; + banid = idbuf; + } /* This was: "You are not welcome on this %s. %s: %s. %s" but is now dynamic: */ vars[0] = "bantype"; @@ -524,7 +530,7 @@ void _banned_client(Client *client, const char *bantype, const char *reason, con vars[4] = "ip"; values[4] = GetIP(client); vars[5] = "banid"; - values[5] = idbuf; + values[5] = banid; vars[6] = NULL; values[6] = NULL; buildvarstring(fmt, buf, sizeof(buf), vars, values); diff --git a/src/modules/spamreport.c b/src/modules/spamreport.c index fb3c353c7..d611e2d5f 100644 --- a/src/modules/spamreport.c +++ b/src/modules/spamreport.c @@ -466,7 +466,7 @@ int _spamreport(Client *client, const char *ip, NameValuePrioList *details, cons NameValuePrioList *list = NULL; list = duplicate_nvplist(details); add_nvplist(&list, -1, "ip", ip); - buildvarstring_nvp(s->url, urlbuf, sizeof(urlbuf), list, BUILDVARSTRING_URLENCODE|BUILDVARSTRING_UNKNOWN_VAR_IS_EMPTY); + buildvarstring_nvp(s->url, urlbuf, sizeof(urlbuf), list, BUILDVARSTRING_URLENCODE|BUILDVARSTRING_UNKNOWN_VAR_IS_EMPTY|BUILDVARSTRING_KEEP_SPACE_FOR_EMPTY_VAR); url = urlbuf; safe_free_nvplist(list); if (s->http_method == HTTP_METHOD_POST) @@ -491,7 +491,7 @@ int _spamreport(Client *client, const char *ip, NameValuePrioList *details, cons " \n" "\n", find_nvplist(s->parameters, "staging") ? " staging='1'" : ""); - buildvarstring_nvp(fmtstring, bodybuf, sizeof(bodybuf), list, BUILDVARSTRING_XML|BUILDVARSTRING_UNKNOWN_VAR_IS_EMPTY); + buildvarstring_nvp(fmtstring, bodybuf, sizeof(bodybuf), list, BUILDVARSTRING_XML|BUILDVARSTRING_UNKNOWN_VAR_IS_EMPTY|BUILDVARSTRING_KEEP_SPACE_FOR_EMPTY_VAR); body = bodybuf; safe_free_nvplist(list); // frees all the duplicated lists add_nvplist(&headers, 0, "Content-Type", "text/xml"); diff --git a/src/support.c b/src/support.c index 292d5c3b2..c4dc1c491 100644 --- a/src/support.c +++ b/src/support.c @@ -1477,11 +1477,25 @@ void buildvarstring_nvp(const char *inbuf, char *outbuf, size_t len, NameValuePr output = urlencode(output, outputbuf, sizeof(outputbuf)); if (flags & BUILDVARSTRING_XML) output = xmlescape(output, outputbuf, sizeof(outputbuf)); - strlcpy(o, output, left); - left -= strlen(output); /* may become <0 */ - if (left <= 0) - return; /* return - don't write \0 to 'o'. ensured by strlcpy already */ - o += strlen(output); /* value entirely written */ + if (!*output && !(flags & BUILDVARSTRING_KEEP_SPACE_FOR_EMPTY_VAR)) + { + /* Empty value: eat one preceding space so "Something. $var" + * becomes "Something." Opt out with KEEP_SPACE_FOR_EMPTY_VAR + * (eg URL/XML). Only present-but-empty values are affected; + * NULL values and unknown vars are left alone. + */ + if ((o > outbuf) && (o[-1] == ' ')) + { + o--; + left++; + } + } else { + strlcpy(o, output, left); + left -= strlen(output); /* may become <0 */ + if (left <= 0) + return; /* return - don't write \0 to 'o'. ensured by strlcpy already */ + o += strlen(output); /* value entirely written */ + } } } else { From e2ed1ceca260024e336f300651d3d860343fb29f Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Thu, 11 Jun 2026 19:57:53 +0200 Subject: [PATCH 16/44] Load multiline by default and update release notes a little. --- doc/RELEASE-NOTES.md | 6 ++++++ doc/conf/modules.default.conf | 1 + 2 files changed, 7 insertions(+) diff --git a/doc/RELEASE-NOTES.md b/doc/RELEASE-NOTES.md index 959e760ce..4ed2b6d30 100644 --- a/doc/RELEASE-NOTES.md +++ b/doc/RELEASE-NOTES.md @@ -4,7 +4,13 @@ UnrealIRCd 6.2.6-git This is the git version (development version) for future UnrealIRCd 6.2.6. This is work in progress and may not always be a stable version. +This version enables multiline by default and adds TKL IDs. + ### Enhancements: +* [IRCv3 draft/multiline](https://ircv3.net/specs/extensions/multiline) + is now enabled by default. See the [UnrealIRCd 6.2.4 release](#unrealircd-624) + for all information on what this feature does, the default limits, etc. + To turn it off completely you can use: `blacklist-module multiline;` * Server bans and Spamfilters now have a unique ID, like `G7K2MP9WQX3`: * The first letter denotes the type: `G` for gline, `K` for kline, `Z` for (g)zline, `H` for shun. Spamfilter IDs start with `SPAM`. diff --git a/doc/conf/modules.default.conf b/doc/conf/modules.default.conf index 117b092b5..e2a4ce86b 100644 --- a/doc/conf/modules.default.conf +++ b/doc/conf/modules.default.conf @@ -248,6 +248,7 @@ loadmodule "extended-monitor"; /* add away status, gecos and userhost changes to loadmodule "standard-replies"; /* Standard Replies */ loadmodule "no-implicit-names"; /* Opt out of receiving implicit NAMES when joining a channel */ loadmodule "extended-isupport"; /* draft/extended-isupport */ +loadmodule "multiline"; /* draft/multiline */ /*** RPC modules ***/ // There's a JSON-RPC interface that can be used to communicate with UnrealIRCd From 4384f1127bca370f69d0dba03c0fe278a35d295e Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Fri, 12 Jun 2026 17:37:51 +0200 Subject: [PATCH 17/44] Crule: new server_flood_count() for nick, away, join etc floods. Suggested by westid in https://bugs.unrealircd.org/view.php?id=6477 * New [crule function](https://www.unrealircd.org/docs/Crule) that return the number of times a flood was blocked for that user. For example, `server_flood_count('away')` returns the number of time away-flood was exceeded. Aslo available: `nick`, `join`, `invite`, `knock`, `vhost` and `conversations`. Plus, there is `all` for a total of all. * This can be used in a security-group::rule or spamfilter::rule. Eg: `spamfilter { rule "server_flood_count('nick')>4"; action gline; }` This also - internally - adds a mechanism to run spamfilter rule-only- filters after the command handler, whenever a tag value or other thing changed. That's part of this commit. --- doc/RELEASE-NOTES.md | 7 ++ include/h.h | 3 + include/modules.h | 1 + include/struct.h | 6 +- src/api-efunctions.c | 2 + src/modules/crule.c | 27 +++++ src/modules/jointhrottle.c | 1 + src/modules/tkl.c | 198 ++++++++++++++++++++++++++----------- src/parse.c | 10 ++ src/user.c | 50 +++++++++- 10 files changed, 244 insertions(+), 61 deletions(-) diff --git a/doc/RELEASE-NOTES.md b/doc/RELEASE-NOTES.md index 4ed2b6d30..3de94d934 100644 --- a/doc/RELEASE-NOTES.md +++ b/doc/RELEASE-NOTES.md @@ -44,6 +44,13 @@ This version enables multiline by default and adds TKL IDs. * For non-config TKLs, the hit count and last hit timestamp are preserved across reboots (via tkldb). * Again, see *Developers and protocol* for the exact STATS field. +* New [crule function](https://www.unrealircd.org/docs/Crule) that returns + the number of times a flood was blocked for that user. For example, + `server_flood_count('away')` returns the number of times away-flood + was exceeded. Also available are: `nick`, `join`, `invite`, `knock`, + `vhost` and `conversations`. Plus, there is `all` for the sum of all items. + * This can be used in a security-group::rule or spamfilter::rule. Eg: + `spamfilter { rule "server_flood_count('nick')>4"; reason "Nick flood"; action gline; ban-time 1h; }` ### Changes: * Spamfilter regexes now use more sensible defaults in terms of "max effort", diff --git a/include/h.h b/include/h.h index 47e99c050..abeec2d1c 100644 --- a/include/h.h +++ b/include/h.h @@ -860,6 +860,7 @@ extern MODVAR void (*cmd_tkl)(ClientContext *clictx, Client *client, MessageTag extern MODVAR int (*take_action)(Client *client, BanAction *actions, const char *reason, long duration, int take_action_flags, int *stopped); extern MODVAR int (*match_spamfilter)(Client *client, const char *str_in, int type, const char *cmd, const char *target, int flags, ClientContext *clictx, TKL **rettk); extern MODVAR int (*match_spamfilter_mtags)(Client *client, MessageTag *mtags, const char *cmd); +extern MODVAR void (*run_deferred_rule_only_spamfilters)(Client *client); extern MODVAR int (*join_viruschan)(Client *client, TKL *tk, int type); extern MODVAR const char *(*StripColors)(const char *text); extern MODVAR void (*spamfilter_build_user_string)(char *buf, const char *nick, Client *acptr); @@ -1410,7 +1411,9 @@ extern int flood_limit_exceeded(Client *client, FloodOption opt); extern FloodSettings *find_floodsettings_block(const char *name); extern FloodSettings *get_floodsettings_for_user(Client *client, FloodOption opt); extern MODVAR const char *floodoption_names[]; +extern MODVAR const char *floodoption_shortnames[]; extern void flood_limit_exceeded_log(Client *client, const char *floodname); +extern void flood_blocked_increment(Client *client, FloodOption opt); /* logging */ extern int config_test_log(ConfigFile *conf, ConfigEntry *ce); extern int config_run_log(ConfigFile *conf, ConfigEntry *ce); diff --git a/include/modules.h b/include/modules.h index 5617e5ac9..0691de6fa 100644 --- a/include/modules.h +++ b/include/modules.h @@ -2933,6 +2933,7 @@ enum EfunctionType { EFUNC_TAKE_ACTION, EFUNC_MATCH_SPAMFILTER, EFUNC_MATCH_SPAMFILTER_MTAGS, + EFUNC_RUN_DEFERRED_RULE_ONLY_SPAMFILTERS, EFUNC_JOIN_VIRUSCHAN, EFUNC_FIND_TKLINE_MATCH_ZAP_EX, EFUNC_SEND_LIST, diff --git a/include/struct.h b/include/struct.h index 8e09218a7..f19ff3b22 100644 --- a/include/struct.h +++ b/include/struct.h @@ -1455,8 +1455,9 @@ extern void moddatatype_dump(Client *client); /** Flood counters for local clients */ typedef struct FloodCounter { - int count; - long t; + int count; /**< Maximum allowed number per... */ + long t; /**< ..time period */ + int blocked; /**< How many times this flood limit was hit */ } FloodCounter; /** This is the list of different flood counters that we keep for local clients. */ @@ -1564,6 +1565,7 @@ struct LocalClient { RPCClient *rpc; /**< RPC Client, or NULL */ Tag *tags; /**< Tags from spamfilter */ int tags_serial; /**< To keep track of 'tags' changes */ + int spamfilter_run_tags_serial; /**< The last time (well, tags_serial) that rule-only-spamfilters ran. */ }; /** User information (persons, not servers), you use client->user to access these (see also @link Client @endlink). diff --git a/src/api-efunctions.c b/src/api-efunctions.c index 570257a26..76c6f2f8b 100644 --- a/src/api-efunctions.c +++ b/src/api-efunctions.c @@ -86,6 +86,7 @@ void (*cmd_tkl)(ClientContext *clictx, Client *client, MessageTag *mtags, int pa int (*take_action)(Client *client, BanAction *action, const char *reason, long duration, int take_action_flags, int *stopped); int (*match_spamfilter)(Client *client, const char *str_in, int type, const char *cmd, const char *target, int flags, ClientContext *clictx, TKL **rettk); int (*match_spamfilter_mtags)(Client *client, MessageTag *mtags, const char *cmd); +void (*run_deferred_rule_only_spamfilters)(Client *client); int (*join_viruschan)(Client *client, TKL *tk, int type); const char *(*StripColors)(const char *text); void (*spamfilter_build_user_string)(char *buf, const char *nick, Client *client); @@ -438,6 +439,7 @@ void efunctions_init(void) efunc_init_function(EFUNC_TAKE_ACTION, take_action, NULL, 0); efunc_init_function(EFUNC_MATCH_SPAMFILTER, match_spamfilter, NULL, 0); efunc_init_function(EFUNC_MATCH_SPAMFILTER_MTAGS, match_spamfilter_mtags, NULL, 0); + efunc_init_function(EFUNC_RUN_DEFERRED_RULE_ONLY_SPAMFILTERS, run_deferred_rule_only_spamfilters, NULL, 0); efunc_init_function(EFUNC_JOIN_VIRUSCHAN, join_viruschan, NULL, 0); efunc_init_function(EFUNC_STRIPCOLORS, StripColors, NULL, 0); efunc_init_function(EFUNC_SPAMFILTER_BUILD_USER_STRING, spamfilter_build_user_string, NULL, 0); diff --git a/src/modules/crule.c b/src/modules/crule.c index c7d5c5219..55649ca86 100644 --- a/src/modules/crule.c +++ b/src/modules/crule.c @@ -125,6 +125,7 @@ static int crule__not(crule_context *, int, void **); static int crule_online_time(crule_context *, int, void **); static int crule_reputation(crule_context *, int, void **); static int crule_tag(crule_context *, int, void **); +static int crule_server_flood_count(crule_context *, int, void **); static int crule_in_channel(crule_context *, int, void **); static int crule_destination(crule_context *, int, void **); static int crule_cap_version(crule_context *, int, void **); @@ -260,6 +261,7 @@ struct crule_funclistent crule_funclist[] = { {"non_ascii_percentage", 0, crule_non_ascii_percentage}, {"online_time", 0, crule_online_time}, {"reputation", 0, crule_reputation}, + {"server_flood_count", 1, crule_server_flood_count}, {"server_port", 0, crule_server_port}, {"tag", 1, crule_tag}, {"text_byte_count", 0, crule_text_byte_count}, @@ -449,6 +451,31 @@ static int crule_reputation(crule_context *context, int numargs, void *crulearg[ return 0; } +/* server_flood_count('away') etc: how many times the client hit that server flood limit + * this session. 'all' gives the grand total across types. Unknown type returns 0. + */ +static int crule_server_flood_count(crule_context *context, int numargs, void *crulearg[]) +{ + const char *type = (char *)crulearg[0]; + int i, total = 0; + + if (!context || !context->client || !MyConnect(context->client)) + return 0; + + if (!strcasecmp(type, "all")) + { + for (i = 0; i < MAXFLOODOPTIONS; i++) + total += context->client->local->flood[i].blocked; + return total; + } + + for (i = 0; i < MAXFLOODOPTIONS; i++) + if (floodoption_shortnames[i] && !strcasecmp(floodoption_shortnames[i], type)) + return context->client->local->flood[i].blocked; + + return 0; /* unknown flood type */ +} + static int crule_tag(crule_context *context, int numargs, void *crulearg[]) { Tag *tag; diff --git a/src/modules/jointhrottle.c b/src/modules/jointhrottle.c index 58a24049f..0f8044a09 100644 --- a/src/modules/jointhrottle.c +++ b/src/modules/jointhrottle.c @@ -153,6 +153,7 @@ int jointhrottle_can_join(Client *client, Channel *channel, const char *key, cha { if (!ValidatePermissionsForPath("immune:join-flood",client,NULL,channel,NULL) && isjthrottled(client, channel)) { + flood_blocked_increment(client, FLD_JOIN); *errmsg = STR_ERR_TOOMANYJOINS; return ERR_TOOMANYJOINS; } diff --git a/src/modules/tkl.c b/src/modules/tkl.c index bb90d563e..0ed5d5d06 100644 --- a/src/modules/tkl.c +++ b/src/modules/tkl.c @@ -98,6 +98,7 @@ CMD_FUNC(_cmd_tkl); int _take_action(Client *client, BanAction *action, const char *reason, long duration, int take_action_flags, int *stopped); int _match_spamfilter(Client *client, const char *str_in, int type, const char *cmd, const char *target, int flags, ClientContext *clictx, TKL **rettk); int _match_spamfilter_mtags(Client *client, MessageTag *mtags, const char *cmd); +void _run_deferred_rule_only_spamfilters(Client *client); int check_special_spamfilters_present(void); char *tkl_hits_key(TKL *tkl, char *buf, size_t len); void config_tkl_hits_free(ModData *m); @@ -267,6 +268,7 @@ MOD_TEST() EfunctionAdd(modinfo->handle, EFUNC_TAKE_ACTION, _take_action); EfunctionAdd(modinfo->handle, EFUNC_MATCH_SPAMFILTER, _match_spamfilter); EfunctionAdd(modinfo->handle, EFUNC_MATCH_SPAMFILTER_MTAGS, _match_spamfilter_mtags); + EfunctionAddVoid(modinfo->handle, EFUNC_RUN_DEFERRED_RULE_ONLY_SPAMFILTERS, _run_deferred_rule_only_spamfilters); EfunctionAdd(modinfo->handle, EFUNC_JOIN_VIRUSCHAN, _join_viruschan); EfunctionAddVoid(modinfo->handle, EFUNC_SPAMFILTER_BUILD_USER_STRING, _spamfilter_build_user_string); EfunctionAdd(modinfo->handle, EFUNC_MATCH_USER, _match_user); @@ -6029,6 +6031,14 @@ static void match_spamfilter_hit(Client *client, const char *str_in, const char highest_action = highest_ban_action(tkl->ptr.spamfilter->action); if (highest_action > BAN_ACT_SET) { + if (!cmd) + { + /* On-tag-change has no context, so log without the [cmd: ...] part */ + unreal_log(ULOG_INFO, "tkl", "SPAMFILTER_MATCH", client, + "[Spamfilter] $client.details matches filter '$tkl': [reason: $tkl.reason] [action: $tkl.ban_action] $spamfilter_id", + log_data_tkl("tkl", tkl), + log_data_optional_name_value("spamfilter_id", "spamfilter-id", tkl->id)); + } else if (hide_content || (target == SPAMF_RAW)) { unreal_log(ULOG_INFO, "tkl", "SPAMFILTER_MATCH", client, @@ -6085,6 +6095,128 @@ static void match_spamfilter_hit(Client *client, const char *str_in, const char } +/** Run rule-only spamfilters: ones with no ::match/::target, only a ::rule. + * These run when a tag changes. The most severe hit (highest action) wins and is set in *winner_tkl. + * The caller then takes that action. This function is used both by: + * 1) match_spamfilter(): when a tag changed during a message + * 2) parse_client_queued(): after a command ran and there was a tag change + * or similar, like server_flood_count('..') changed value. + */ +static void run_rule_only_spamfilter_loop(Client *client, const char *str_in, const char *str, + int target, const char *cmd, const char *destination, + int flags, ClientContext *clictx, TKL **winner_tkl, + char user_is_exempt_general, char user_is_exempt_central, + int *stop_processing_general_spamfilters, + int *stop_processing_central_spamfilters, + int *content_revealed) +{ + TKL *tkl; + crule_context context; + + *stop_processing_general_spamfilters = *stop_processing_central_spamfilters = 0; /* reset */ + for (tkl = tklines[tkl_hash('F')]; tkl; tkl = tkl->next) + { + if (tkl->ptr.spamfilter->target || + (tkl->ptr.spamfilter->match->type != MATCH_NONE) || + !tkl->ptr.spamfilter->rule) + { + continue; + } + + /* Skip spamfilters due to a 'stop' action from an earlier spamfilter + * or set::spamfilter::stop-on-first-match. + * We treat such stops as separate for central & general spamfilters + * so they don't affect each other. + */ + if (IsCentralSpamfilter(tkl)) + { + if (*stop_processing_central_spamfilters) + continue; + } else { + if (*stop_processing_general_spamfilters) + continue; + } + + if ((flags & SPAMFLAG_NOWARN) && only_actions_of_type(tkl->ptr.spamfilter->action, BAN_ACT_WARN)) + continue; + + /* If the action is 'soft' (for non-logged in users only) then + * don't bother running the spamfilter if the user is logged in. + */ + if (IsLoggedIn(client) && only_soft_actions(tkl->ptr.spamfilter->action)) + continue; + + memset(&context, 0, sizeof(context)); + context.client = client; + context.text = str_in; + context.destination = destination; + context.clictx = clictx; + if (!crule_eval(&context, tkl->ptr.spamfilter->rule)) + continue; + + match_spamfilter_hit(client, str_in, str, target, cmd, destination, + tkl, winner_tkl, + user_is_exempt_general, user_is_exempt_central, + stop_processing_general_spamfilters, stop_processing_central_spamfilters, + content_revealed, + 1); + /* and continue (yes, always, no stopping on first match) */ + } +} + +/** Run rule-only spamfilters, after a tag change happened. This gets called from + * parse_client_queued(), so it is safe to kill a client without having to deal + * with complex conditions. Since there is no "message" here, context is empty. + */ +void _run_deferred_rule_only_spamfilters(Client *client) +{ + TKL *winner_tkl = NULL; + char user_is_exempt_general = 0; + char user_is_exempt_central = 0; + int stop_processing_general_spamfilters = 0; + int stop_processing_central_spamfilters = 0; + int content_revealed = 0; + crule_context context; + + if (!client->local) + return; + + /* Mark all tag changes up to now as handled. Done up-front so the early-returns below + * (exempt users etc) don't make us re-run on every following command. + */ + client->local->spamfilter_run_tags_serial = client->local->tags_serial; + + if (!client->user || ValidatePermissionsForPath("immune:server-ban:spamfilter",client,NULL,NULL,NULL) || IsULine(client)) + return; + + memset(&context, 0, sizeof(context)); + context.client = client; + context.text = ""; + context.destination = NULL; + context.clictx = NULL; + + /* Client exempt from spamfilter checking? */ + if (find_tkl_exception(TKL_SPAMF, client) || + (iConf.spamfilter_except && user_allowed_by_security_group_context(client, iConf.spamfilter_except, &context))) + { + user_is_exempt_general = 1; + } + if (user_allowed_by_security_group_context(client, iConf.central_spamfilter_except, &context)) + user_is_exempt_central = 1; + + run_rule_only_spamfilter_loop(client, "", "", 0, NULL, NULL, 0, NULL, + &winner_tkl, user_is_exempt_general, user_is_exempt_central, + &stop_processing_general_spamfilters, &stop_processing_central_spamfilters, + &content_revealed); + + if (winner_tkl && !match_spamfilter_exempt(winner_tkl, user_is_exempt_general, user_is_exempt_central)) + { + char *reason = unreal_decodespace(winner_tkl->ptr.spamfilter->tkl_reason); + take_action_ex(client, winner_tkl->ptr.spamfilter->action, reason, + winner_tkl->ptr.spamfilter->tkl_duration, TAKE_ACTION_SKIP_SET, NULL, winner_tkl->id); + } +} + /** match_spamfilter: executes the spamfilter on the input string. * @param str The text (eg msg text, notice text, part text, quit text, etc * @param target The spamfilter target (SPAMF_*) @@ -6108,7 +6240,6 @@ int _match_spamfilter(Client *client, const char *str_in, int target, const char struct rusage rnow, rprev; long ms_past; #endif - int tags_serial = client->local ? client->local->tags_serial : 0; char user_is_exempt_general = 0; char user_is_exempt_central = 0; int stop_processing_general_spamfilters = 0; @@ -6266,65 +6397,16 @@ int _match_spamfilter(Client *client, const char *str_in, int target, const char } } - if (client->local && (client->local->tags_serial != tags_serial)) + if (client->local && (client->local->tags_serial != client->local->spamfilter_run_tags_serial)) { - /* A tag has been changed: - * Run spamfilters that have no 'match' and no 'targets', - * these are special in the sense that they only run - * whenever a tag is changed. + /* A tag changed (eg a spamfilter 'set' action during this message): run the + * rule-only spamfilters, which only run when a tag has changed. */ - stop_processing_general_spamfilters = stop_processing_central_spamfilters = 0; /* reset this */ - for (tkl = tklines[tkl_hash('F')]; tkl; tkl = tkl->next) - { - crule_context context; - - if (tkl->ptr.spamfilter->target || - (tkl->ptr.spamfilter->match->type != MATCH_NONE) || - !tkl->ptr.spamfilter->rule) - { - continue; - } - - /* Skip spamfilters due to a 'stop' action from an earlier spamfilter - * or set::spamfilter::stop-on-first-match. - * We treat such stops as separate for central & general spamfilters - * so they don't affect each other. - */ - if (IsCentralSpamfilter(tkl)) - { - if (stop_processing_central_spamfilters) - continue; - } else { - if (stop_processing_general_spamfilters) - continue; - } - - - if ((flags & SPAMFLAG_NOWARN) && only_actions_of_type(tkl->ptr.spamfilter->action, BAN_ACT_WARN)) - continue; - - /* If the action is 'soft' (for non-logged in users only) then - * don't bother running the spamfilter if the user is logged in. - */ - if (IsLoggedIn(client) && only_soft_actions(tkl->ptr.spamfilter->action)) - continue; - - memset(&context, 0, sizeof(context)); - context.client = client; - context.text = str_in; - context.destination = destination; - context.clictx = clictx; - if (!crule_eval(&context, tkl->ptr.spamfilter->rule)) - continue; - - match_spamfilter_hit(client, str_in, str, target, cmd, destination, - tkl, &winner_tkl, - user_is_exempt_general, user_is_exempt_central, - &stop_processing_general_spamfilters, &stop_processing_central_spamfilters, - &content_revealed, - 1); - /* and continue (yes, always, no stopping on first match) */ - } + run_rule_only_spamfilter_loop(client, str_in, str, target, cmd, destination, flags, clictx, + &winner_tkl, user_is_exempt_general, user_is_exempt_central, + &stop_processing_general_spamfilters, &stop_processing_central_spamfilters, + &content_revealed); + client->local->spamfilter_run_tags_serial = client->local->tags_serial; } tkl = winner_tkl; diff --git a/src/parse.c b/src/parse.c index 70b0dd08e..00862720e 100644 --- a/src/parse.c +++ b/src/parse.c @@ -133,6 +133,16 @@ void parse_client_queued(Client *client) return; dopacket(client, buf, dolen); + + /* Tags and similar may change outside of match_spamfilter(), such as a flood + * counter or a central spamfilter tag. We run rule-only spamfilters here. + * This is outside the command handler so we can safely kill the client here. + */ + if (!IsDead(client) && client->local && + (client->local->tags_serial != client->local->spamfilter_run_tags_serial)) + { + run_deferred_rule_only_spamfilters(client); + } if (IsDead(client)) return; diff --git a/src/user.c b/src/user.c index 8cd563503..679f9cc0c 100644 --- a/src/user.c +++ b/src/user.c @@ -190,6 +190,7 @@ int target_limit_exceeded(Client *client, void *target, const char *name) add_fake_lag(client, 2000); /* lag them up as well */ flood_limit_exceeded_log(client, "max-concurrent-conversations"); + flood_blocked_increment(client, FLD_CONVERSATIONS); sendnumeric(client, ERR_TARGETTOOFAST, name, (long long)(client->local->nexttarget - TStime())); return 1; @@ -899,6 +900,7 @@ int flood_limit_exceeded(Client *client, FloodOption opt) if (client->local->flood[opt].count > f->limit[opt]) { flood_limit_exceeded_log(client, floodoption_names[opt]); + flood_blocked_increment(client, opt); return 1; /* Flood limit hit! */ } @@ -958,6 +960,49 @@ MODVAR const char *floodoption_names[] = { NULL }; +/* Per-session flood-block counter names, parallel to floodoption_names[]. + * These are the type names accepted by the server_flood_count() crule function, eg server_flood_count('away'), + * for use in security-group::rule and spamfilter::rule to react to users who keep + * tripping flood limits. NULL means this flood type is not counted: lag-penalty is a + * soft penalty rather than a block, and multiline is not counted. + */ +MODVAR const char *floodoption_shortnames[] = { + "nick", /* FLD_NICK */ + "join", /* FLD_JOIN */ + "away", /* FLD_AWAY */ + "invite", /* FLD_INVITE */ + "knock", /* FLD_KNOCK */ + "conversations", /* FLD_CONVERSATIONS */ + NULL, /* FLD_LAG_PENALTY: not counted (soft penalty, not a block) */ + "vhost", /* FLD_VHOST */ + NULL, /* FLD_MULTILINE: not counted */ + NULL +}; + +/** Count a flood-block for this client. + * Used for: + * - increments the per-type session counter, which is read via server_flood_count('away') etc in a crule + * - the bump_tag_serial() call is what lets rule-only spamfilters re-check (eg + * server_flood_count('away') > 5) at the next safe boundary, see parse_client_queued(); flood + * counts share that "something changed, re-check" signal with the tag system. + * @param client The local client that got flood-blocked + * @param opt The flood type (eg FLD_AWAY) + */ +void flood_blocked_increment(Client *client, FloodOption opt) +{ + if (!MyConnect(client)) + return; + + /* Some flood types are not counted, eg lag-penalty (a soft penalty, not a block) */ + if (!floodoption_shortnames[opt]) + return; + + if (client->local->flood[opt].blocked < 65535) + client->local->flood[opt].blocked++; + + bump_tag_serial(client); +} + /** Lookup GEO information for an IP address. * @param ip The IP to lookup * @returns A struct containing all the details. Must be freed by caller! @@ -1042,8 +1087,11 @@ Tag *find_tag(Client *client, const char *name) void bump_tag_serial(Client *client) { + /* This is only ever compared with != (a change counter), so we just need a + * wrap point high enough that it never realistically aliases within a session. + */ client->local->tags_serial++; - if (client->local->tags_serial > 10000) + if (client->local->tags_serial >= INT_MAX) client->local->tags_serial = 0; } From 029675f86788ba6b9523d0ccd892818c69b3a2b6 Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Sat, 13 Jun 2026 07:46:37 +0200 Subject: [PATCH 18/44] Similar to previous, add total_channel_flood_count() for +f/+F limits exceeded * `total_channel_flood_count('..setting..')` returns the number of times `+f`/`+F` limits were exceeded by that user in all channels the user is or was in. Available are: `nick`, `join`, `knock`, `msg`, `ctcp`, `text`, `repeat` and `paste` (and `all` for the sum). --- doc/RELEASE-NOTES.md | 18 ++++--- include/h.h | 2 + include/modules.h | 1 + src/api-efunctions.c | 2 + src/misc.c | 5 ++ src/modules/chanmodes/floodprot.c | 90 +++++++++++++++++++++++++++++++ src/modules/crule.c | 14 +++++ 7 files changed, 126 insertions(+), 6 deletions(-) diff --git a/doc/RELEASE-NOTES.md b/doc/RELEASE-NOTES.md index 3de94d934..96f4ac71f 100644 --- a/doc/RELEASE-NOTES.md +++ b/doc/RELEASE-NOTES.md @@ -44,12 +44,18 @@ This version enables multiline by default and adds TKL IDs. * For non-config TKLs, the hit count and last hit timestamp are preserved across reboots (via tkldb). * Again, see *Developers and protocol* for the exact STATS field. -* New [crule function](https://www.unrealircd.org/docs/Crule) that returns - the number of times a flood was blocked for that user. For example, - `server_flood_count('away')` returns the number of times away-flood - was exceeded. Also available are: `nick`, `join`, `invite`, `knock`, - `vhost` and `conversations`. Plus, there is `all` for the sum of all items. - * This can be used in a security-group::rule or spamfilter::rule. Eg: +* New [crule functions](https://www.unrealircd.org/docs/Crule) that return + the number of times a flood was blocked for a user. + * `server_flood_count('..setting..')` returns the number of times + set::anti-flood settings were exceeded by that user. Available are: + `away`, `nick`, `join`, `invite`, `knock`, `vhost` and `conversations`. + Plus, there is `all` for the sum of all items. + * `total_channel_flood_count('..setting..')` returns the number of + times `+f`/`+F` limits were exceeded by that user in all channels + the user is or was in. Available are: `nick`, `join`, `knock`, `msg`, + `ctcp`, `text`, `repeat` and `paste` (and `all` for the sum). + * All these are counted for the duration of the session (user connection). + * These can be used in a security-group::rule or spamfilter::rule. Eg: `spamfilter { rule "server_flood_count('nick')>4"; reason "Nick flood"; action gline; ban-time 1h; }` ### Changes: diff --git a/include/h.h b/include/h.h index abeec2d1c..83660c717 100644 --- a/include/h.h +++ b/include/h.h @@ -967,6 +967,7 @@ extern MODVAR void (*isupport_check_for_changes)(void); extern MODVAR int (*get_connections_from_ip)(Client *client); extern MODVAR int (*get_floodprot_channel_max_lines)(Channel *channel); extern MODVAR int (*floodprot_check_multiline_batch)(Channel *channel, Client *client, int line_count); +extern MODVAR int (*channel_flood_blocked_count)(Client *client, const char *type); /* /Efuncs */ /* TLS functions */ @@ -1008,6 +1009,7 @@ extern int is_silenced_default_handler(Client *client, Client *acptr); extern int get_connections_from_ip_default_handler(Client *client); extern int get_floodprot_channel_max_lines_default_handler(Channel *channel); extern int floodprot_check_multiline_batch_default_handler(Channel *channel, Client *client, int line_count); +extern int channel_flood_blocked_count_default_handler(Client *client, const char *type); extern void do_unreal_log_remote_deliver_default_handler(LogLevel loglevel, const char *subsystem, const char *event_id, MultiLine *msg, const char *json_serialized); extern int make_oper_default_handler(Client *client, const char *operblock_name, const char *operclass, ConfigItem_class *clientclass, long modes, const char *snomask, const char *vhost, const char *autojoin_channels); extern void webserver_send_response_default_handler(Client *client, int status, char *msg); diff --git a/include/modules.h b/include/modules.h index 0691de6fa..94fe4c02f 100644 --- a/include/modules.h +++ b/include/modules.h @@ -3056,6 +3056,7 @@ enum EfunctionType { EFUNC_GET_CONNECTIONS_FROM_IP, EFUNC_GET_FLOODPROT_CHANNEL_MAX_LINES, EFUNC_FLOODPROT_CHECK_MULTILINE_BATCH, + EFUNC_CHANNEL_FLOOD_BLOCKED_COUNT, EFUNC_TKL_HIT, EFUNC_REMOVE_CONFIG_TKLS, EFUNC_CONFIG_TKL_HITS_RESTORE, diff --git a/src/api-efunctions.c b/src/api-efunctions.c index 76c6f2f8b..c204a79cd 100644 --- a/src/api-efunctions.c +++ b/src/api-efunctions.c @@ -198,6 +198,7 @@ void (*isupport_check_for_changes)(void); int (*get_connections_from_ip)(Client *client); int (*get_floodprot_channel_max_lines)(Channel *channel); int (*floodprot_check_multiline_batch)(Channel *channel, Client *client, int line_count); +int (*channel_flood_blocked_count)(Client *client, const char *type); Efunction *EfunctionAddMain(Module *module, EfunctionType eftype, int (*func)(), void (*vfunc)(), void *(*pvfunc)(), char *(*stringfunc)(), const char *(*conststringfunc)()) { @@ -558,4 +559,5 @@ void efunctions_init(void) efunc_init_function(EFUNC_GET_CONNECTIONS_FROM_IP, get_connections_from_ip, get_connections_from_ip_default_handler, 0); efunc_init_function(EFUNC_GET_FLOODPROT_CHANNEL_MAX_LINES, get_floodprot_channel_max_lines, get_floodprot_channel_max_lines_default_handler, 0); efunc_init_function(EFUNC_FLOODPROT_CHECK_MULTILINE_BATCH, floodprot_check_multiline_batch, floodprot_check_multiline_batch_default_handler, 0); + efunc_init_function(EFUNC_CHANNEL_FLOOD_BLOCKED_COUNT, channel_flood_blocked_count, channel_flood_blocked_count_default_handler, 0); } diff --git a/src/misc.c b/src/misc.c index af3362486..8be7dfdf5 100644 --- a/src/misc.c +++ b/src/misc.c @@ -1514,6 +1514,11 @@ int floodprot_check_multiline_batch_default_handler(Channel *channel, Client *cl return 0; } +int channel_flood_blocked_count_default_handler(Client *client, const char *type) +{ + return 0; +} + int make_oper_default_handler(Client *client, const char *operblock_name, const char *operclass, ConfigItem_class *clientclass, long modes, const char *snomask, const char *vhost, const char *autojoin_channels) diff --git a/src/modules/chanmodes/floodprot.c b/src/modules/chanmodes/floodprot.c index 2fbadea06..2b871c3bc 100644 --- a/src/modules/chanmodes/floodprot.c +++ b/src/modules/chanmodes/floodprot.c @@ -125,6 +125,24 @@ struct ChannelFloodProfile { /* Global variables */ ModDataInfo *mdflood = NULL; + +/* Per-local-client tally of how many times the user was blocked by channel flood + * protection (+f/+F), per flood type. Read via the channel_flood_blocked_count efunc + * (eg the total_channel_flood_count() crule). Local-only, freed on disconnect. + */ +typedef struct ChannelFloodBlocks { int blocked[NUMFLD]; } ChannelFloodBlocks; +ModDataInfo *md_channelflood_blocked = NULL; +/* Friendly type names for the efunc/crule, indexed by enum Flood. KEEP IN SYNC. */ +static const char *channelfloodtype_names[NUMFLD] = { + "ctcp", /* CHFLD_CTCP */ + "join", /* CHFLD_JOIN */ + "knock", /* CHFLD_KNOCK */ + "msg", /* CHFLD_MSG */ + "nick", /* CHFLD_NICK */ + "text", /* CHFLD_TEXT */ + "repeat", /* CHFLD_REPEAT */ + "paste" /* CHFLD_PASTE */ +}; Cmode_t EXTMODE_FLOODLIMIT = 0L; Cmode_t EXTMODE_FLOOD_PROFILE = 0L; static int timedban_available = 1; /**< Set to 1 if extbans/timedban module is loaded. Assumed 1 during config load due to set::modes-on-join race. */ @@ -185,6 +203,9 @@ void inherit_settings(ChannelFloodProtection *from, ChannelFloodProtection *to); void reapply_profiles(void); int _get_floodprot_channel_max_lines(Channel *channel); int _floodprot_check_multiline_batch(Channel *channel, Client *client, int line_count); +void channelfloodblocks_free(ModData *m); +static void channel_flood_blocked_increment(Client *client, int what); +int _channel_flood_blocked_count(Client *client, const char *type); MOD_TEST() { @@ -193,6 +214,7 @@ MOD_TEST() HookAdd(modinfo->handle, HOOKTYPE_CONFIGTEST, 0, floodprot_config_test_antiflood_block); EfunctionAdd(modinfo->handle, EFUNC_GET_FLOODPROT_CHANNEL_MAX_LINES, _get_floodprot_channel_max_lines); EfunctionAdd(modinfo->handle, EFUNC_FLOODPROT_CHECK_MULTILINE_BATCH, _floodprot_check_multiline_batch); + EfunctionAdd(modinfo->handle, EFUNC_CHANNEL_FLOOD_BLOCKED_COUNT, _channel_flood_blocked_count); return MOD_SUCCESS; } @@ -242,6 +264,15 @@ MOD_INIT() mdflood = ModDataAdd(modinfo->handle, mreq); if (!mdflood) abort(); + + memset(&mreq, 0, sizeof(mreq)); + mreq.name = "channelfloodblocks"; + mreq.type = MODDATATYPE_LOCAL_CLIENT; + mreq.free = channelfloodblocks_free; + md_channelflood_blocked = ModDataAdd(modinfo->handle, mreq); + if (!md_channelflood_blocked) + abort(); + if (!floodprot_msghash_key) { floodprot_msghash_key = safe_alloc(16); @@ -1455,6 +1486,8 @@ int floodprot_can_send_to_channel(Client *client, Channel *channel, Membership * flood_type = CHFLD_TEXT; } + channel_flood_blocked_increment(client, flood_type); + if (fld->action[flood_type] == 'd') { /* Drop the message */ @@ -1786,7 +1819,10 @@ int do_floodprot(Channel *channel, Client *client, int what) (TStime() - fld->timer[what] < fld->per)) { if (MyUser(client)) + { do_floodprot_action(channel, what); + channel_flood_blocked_increment(client, what); + } return 1; /* flood detected! */ } } @@ -1996,6 +2032,60 @@ void memberflood_free(ModData *md) safe_free(md->ptr); } +void channelfloodblocks_free(ModData *m) +{ + safe_free(m->ptr); +} + +/* Count a channel-flood-block (+f/+F) for this local user, by flood type (enum Flood). + * bump_tag_serial() lets rule-only spamfilters re-check at the next safe boundary + * (eg total_channel_flood_count('nick') > 5), see parse_client_queued(). + */ +static void channel_flood_blocked_increment(Client *client, int what) +{ + ChannelFloodBlocks *b; + + if (!MyConnect(client) || (what < 0) || (what >= NUMFLD)) + return; + + if (moddata_local_client(client, md_channelflood_blocked).ptr == NULL) + moddata_local_client(client, md_channelflood_blocked).ptr = safe_alloc(sizeof(ChannelFloodBlocks)); + + b = (ChannelFloodBlocks *)moddata_local_client(client, md_channelflood_blocked).ptr; + if (b->blocked[what] < 65535) + b->blocked[what]++; + + bump_tag_serial(client); +} + +/* efunc: how many times this local user was channel-flood-blocked of type 'type' + * (a name from channelfloodtype_names[], or "all" for the grand total). Unknown -> 0. + */ +int _channel_flood_blocked_count(Client *client, const char *type) +{ + ChannelFloodBlocks *b; + int i, total = 0; + + if (!client || !MyConnect(client)) + return 0; + if (moddata_local_client(client, md_channelflood_blocked).ptr == NULL) + return 0; + b = (ChannelFloodBlocks *)moddata_local_client(client, md_channelflood_blocked).ptr; + + if (!strcasecmp(type, "all")) + { + for (i = 0; i < NUMFLD; i++) + total += b->blocked[i]; + return total; + } + + for (i = 0; i < NUMFLD; i++) + if (!strcasecmp(channelfloodtype_names[i], type)) + return b->blocked[i]; + + return 0; +} + int floodprot_stats(Client *client, const char *flag) { if (*flag != 'S') diff --git a/src/modules/crule.c b/src/modules/crule.c index 55649ca86..fea0f2994 100644 --- a/src/modules/crule.c +++ b/src/modules/crule.c @@ -126,6 +126,7 @@ static int crule_online_time(crule_context *, int, void **); static int crule_reputation(crule_context *, int, void **); static int crule_tag(crule_context *, int, void **); static int crule_server_flood_count(crule_context *, int, void **); +static int crule_total_channel_flood_count(crule_context *, int, void **); static int crule_in_channel(crule_context *, int, void **); static int crule_destination(crule_context *, int, void **); static int crule_cap_version(crule_context *, int, void **); @@ -266,6 +267,7 @@ struct crule_funclistent crule_funclist[] = { {"tag", 1, crule_tag}, {"text_byte_count", 0, crule_text_byte_count}, {"text_character_count", 0, crule_text_character_count}, + {"total_channel_flood_count", 1, crule_total_channel_flood_count}, {"unicode_block_count", 0, crule_unicode_block_count}, {"unicode_count", 1, crule_unicode_count}, {"uppercase_percentage", 0, crule_uppercase_percentage}, @@ -476,6 +478,18 @@ static int crule_server_flood_count(crule_context *context, int numargs, void *c return 0; /* unknown flood type */ } +/* total_channel_flood_count('nick') etc: how many times the client was blocked by channel + * flood protection (+f/+F) of that type this session, summed across all their channels. + * 'all' is the grand total. Backed by the floodprot module via the channel_flood_blocked_count + * efunc (returns 0 if floodprot is not loaded, or for an unknown type). + */ +static int crule_total_channel_flood_count(crule_context *context, int numargs, void *crulearg[]) +{ + if (!context || !context->client) + return 0; + return channel_flood_blocked_count(context->client, (char *)crulearg[0]); +} + static int crule_tag(crule_context *context, int numargs, void *crulearg[]) { Tag *tag; From 300038149363f3041b6a6d2c4cb2d28679a204de Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Sat, 13 Jun 2026 09:24:18 +0200 Subject: [PATCH 19/44] Fix multiline-concat behavior for fallback clients. We were merging draft/multiline-concat lines together server-side before sending them to non-multiline clients. This could truncate oversized merged lines. We now simply send them as separate lines. Reported by ProgVal in https://bugs.unrealircd.org/view.php?id=6628 --- src/modules/multiline.c | 64 ++++++----------------------------------- 1 file changed, 8 insertions(+), 56 deletions(-) diff --git a/src/modules/multiline.c b/src/modules/multiline.c index a4415f984..86d210182 100644 --- a/src/modules/multiline.c +++ b/src/modules/multiline.c @@ -57,7 +57,6 @@ struct MultilineBatch { /* Buffered lines */ MLine *lines; MLine *lines_tail; - MLine *fallback_lines; /**< Cached fallback lines (built once, reused for all non-multiline clients) */ }; /** State for an S2S multiline batch being received from a remote server */ @@ -453,7 +452,6 @@ static void multiline_free_batch(MultilineBatch *batch) safe_free(batch->fail_message); free_message_tags(batch->client_mtags); multiline_free_lines(batch->lines); - multiline_free_lines(batch->fallback_lines); safe_free(batch); } @@ -592,51 +590,6 @@ static char *multiline_concat_text(MultilineBatch *batch) return buf; } -/** Build fallback text: merge concat lines into their predecessors. - * Returns a list of "logical lines" for fallback delivery. - * Caller must free the returned list with multiline_free_lines(). - */ -static MLine *multiline_build_fallback_lines(MultilineBatch *batch) -{ - MLine *out = NULL, *out_tail = NULL; - MLine *l; - - for (l = batch->lines; l; l = l->next) - { - if (l->concat && out_tail) - { - /* Append to current logical line without separator */ - const char *append = l->text ? l->text : ""; - int newlen = strlen(out_tail->text) + strlen(append) + 1; - char *newtext = safe_alloc(newlen); - strlcpy(newtext, out_tail->text, newlen); - strlcat(newtext, append, newlen); - safe_free(out_tail->text); - out_tail->text = newtext; - } else { - /* Start a new logical line */ - MLine *newline = safe_alloc(sizeof(MLine)); - safe_strdup(newline->text, l->text ? l->text : ""); - newline->concat = 0; - newline->next = NULL; - if (out_tail) - out_tail->next = newline; - else - out = newline; - out_tail = newline; - } - } - return out; -} - -/** Get cached fallback lines, building them on first call */ -static MLine *multiline_get_fallback_lines(MultilineBatch *batch) -{ - if (!batch->fallback_lines) - batch->fallback_lines = multiline_build_fallback_lines(batch); - return batch->fallback_lines; -} - /** Check if a batch has at least one non-blank line */ static int multiline_batch_has_content(MultilineBatch *batch) { @@ -1412,19 +1365,20 @@ static void multiline_send_batch_to_client(Client *to, Client *from, MultilineBa static void multiline_send_fallback_to_client(Client *to, Client *from, MultilineBatch *batch, MessageTag *base_mtags, const char *targetstr, const char *cmd) { - MLine *fallback_lines, *l; + MLine *l; MessageTag *rest_mtags; int first = 1; - fallback_lines = multiline_get_fallback_lines(batch); - /* Build rest_mtags: base_mtags minus msgid. * Spec: msgid MUST only be on the first line, but other tags * (time, account, etc.) MAY be on subsequent lines. */ rest_mtags = duplicate_mtags_for_subsequent_lines(base_mtags); - for (l = fallback_lines; l; l = l->next) + /* This client has no multiline support, so just send each line as a + * normal separate message. + */ + for (l = batch->lines; l; l = l->next) { /* Spec: "Servers MUST NOT send blank lines to clients * that have not negotiated the multiline capability." @@ -1463,7 +1417,6 @@ static void multiline_deliver_to_local_members(Channel *channel, Client *from, { LocalMember *lm; MLine *l; - MLine *fallback_lines; // merged lines for non-multiline clients MessageTag *m; // temporary for building tag lists MessageTag *rest_mtags; // base_mtags minus first-only tags (msgid, +draft/reply) MessageTag *mtags_line; // rest_mtags + batch=, for multiline content lines @@ -1512,9 +1465,8 @@ static void multiline_deliver_to_local_members(Channel *channel, Client *from, for (i = 0; i < batch->line_count; i++) cache_lines[i] = linecache_init(); - /* Fallback path: one cache per non-blank fallback line */ - fallback_lines = multiline_get_fallback_lines(batch); - for (l = fallback_lines; l; l = l->next) + /* Fallback path: one cache per non-blank line */ + for (l = batch->lines; l; l = l->next) if (l->text && l->text[0]) fallback_count++; if (fallback_count > 0) @@ -1572,7 +1524,7 @@ static void multiline_deliver_to_local_members(Channel *channel, Client *from, first = 1; i = 0; - for (l = fallback_lines; l; l = l->next) + for (l = batch->lines; l; l = l->next) { /* Spec: "Servers MUST NOT send blank lines to clients * that have not negotiated the multiline capability." From 65f918e8e9d9dbbfc1ee288176133e5bdd446e1a Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Sat, 13 Jun 2026 12:06:39 +0200 Subject: [PATCH 20/44] Add json_expand_flood_counts() and make available in Central Spamreport and JSON-RPC. This exposes the newly added flood counters from 4384f1127bca370f69d0dba03c0fe278a35d295e and 029675f86788ba6b9523d0ccd892818c69b3a2b6 in JSON. I didn't want to put it in every JSON log message. So right now it is only in: * JSON-RPC with object_detail_level >= 5. * Central Spamreport I may expand it later to one or a few other areas. --- doc/RELEASE-NOTES.md | 2 ++ include/h.h | 3 ++ include/modules.h | 1 + src/api-efunctions.c | 2 ++ src/json.c | 51 +++++++++++++++++++++++++++++++ src/misc.c | 4 +++ src/modules/central-blocklist.c | 9 +++++- src/modules/chanmodes/floodprot.c | 29 ++++++++++++++++++ 8 files changed, 100 insertions(+), 1 deletion(-) diff --git a/doc/RELEASE-NOTES.md b/doc/RELEASE-NOTES.md index 96f4ac71f..04277cd01 100644 --- a/doc/RELEASE-NOTES.md +++ b/doc/RELEASE-NOTES.md @@ -57,6 +57,7 @@ This version enables multiline by default and adds TKL IDs. * All these are counted for the duration of the session (user connection). * These can be used in a security-group::rule or spamfilter::rule. Eg: `spamfilter { rule "server_flood_count('nick')>4"; reason "Nick flood"; action gline; ban-time 1h; }` +* Central Spamreport now also receives those flood counters. ### Changes: * Spamfilter regexes now use more sensible defaults in terms of "max effort", @@ -113,6 +114,7 @@ This version enables multiline by default and adds TKL IDs. Older databases still load. Downside: you cannot downgrade UnrealIRCd. * JSON for TKL entries (server logs and JSON-RPC) now includes `id`, and `spamfilter_id` for spamfilter-created server bans. +* JSON-RPC `user.get` now also exposes the flood counters. UnrealIRCd 6.2.5 ----------------- diff --git a/include/h.h b/include/h.h index 83660c717..2e8b0c9d0 100644 --- a/include/h.h +++ b/include/h.h @@ -968,6 +968,7 @@ extern MODVAR int (*get_connections_from_ip)(Client *client); extern MODVAR int (*get_floodprot_channel_max_lines)(Channel *channel); extern MODVAR int (*floodprot_check_multiline_batch)(Channel *channel, Client *client, int line_count); extern MODVAR int (*channel_flood_blocked_count)(Client *client, const char *type); +extern MODVAR void (*channel_flood_expand_json)(json_t *root, Client *client); /* /Efuncs */ /* TLS functions */ @@ -1010,6 +1011,7 @@ extern int get_connections_from_ip_default_handler(Client *client); extern int get_floodprot_channel_max_lines_default_handler(Channel *channel); extern int floodprot_check_multiline_batch_default_handler(Channel *channel, Client *client, int line_count); extern int channel_flood_blocked_count_default_handler(Client *client, const char *type); +extern void channel_flood_expand_json_default_handler(json_t *root, Client *client); extern void do_unreal_log_remote_deliver_default_handler(LogLevel loglevel, const char *subsystem, const char *event_id, MultiLine *msg, const char *json_serialized); extern int make_oper_default_handler(Client *client, const char *operblock_name, const char *operclass, ConfigItem_class *clientclass, long modes, const char *snomask, const char *vhost, const char *autojoin_channels); extern void webserver_send_response_default_handler(Client *client, int status, char *msg); @@ -1342,6 +1344,7 @@ extern void json_expand_client_security_groups(json_t *parent, Client *client); extern void json_expand_channel(json_t *j, const char *key, Channel *channel, int detail); extern void json_expand_tkl(json_t *j, const char *key, TKL *tkl, int detail); extern void json_expand_textanalysis(json_t *root, const char *key, TextAnalysis *ta, int detail); +extern void json_expand_flood_counts(json_t *root, const char *key, Client *client); extern void json_expand_mask_list(json_t *parent, const char *key, ConfigItem_mask *mask); extern void json_expand_name_list(json_t *parent, const char *key, NameList *list); extern void json_expand_nvplist(json_t *parent, const char *key, NameValuePrioList *list); diff --git a/include/modules.h b/include/modules.h index 94fe4c02f..198765da3 100644 --- a/include/modules.h +++ b/include/modules.h @@ -3057,6 +3057,7 @@ enum EfunctionType { EFUNC_GET_FLOODPROT_CHANNEL_MAX_LINES, EFUNC_FLOODPROT_CHECK_MULTILINE_BATCH, EFUNC_CHANNEL_FLOOD_BLOCKED_COUNT, + EFUNC_CHANNEL_FLOOD_EXPAND_JSON, EFUNC_TKL_HIT, EFUNC_REMOVE_CONFIG_TKLS, EFUNC_CONFIG_TKL_HITS_RESTORE, diff --git a/src/api-efunctions.c b/src/api-efunctions.c index c204a79cd..386bd0548 100644 --- a/src/api-efunctions.c +++ b/src/api-efunctions.c @@ -199,6 +199,7 @@ int (*get_connections_from_ip)(Client *client); int (*get_floodprot_channel_max_lines)(Channel *channel); int (*floodprot_check_multiline_batch)(Channel *channel, Client *client, int line_count); int (*channel_flood_blocked_count)(Client *client, const char *type); +void (*channel_flood_expand_json)(json_t *root, Client *client); Efunction *EfunctionAddMain(Module *module, EfunctionType eftype, int (*func)(), void (*vfunc)(), void *(*pvfunc)(), char *(*stringfunc)(), const char *(*conststringfunc)()) { @@ -560,4 +561,5 @@ void efunctions_init(void) efunc_init_function(EFUNC_GET_FLOODPROT_CHANNEL_MAX_LINES, get_floodprot_channel_max_lines, get_floodprot_channel_max_lines_default_handler, 0); efunc_init_function(EFUNC_FLOODPROT_CHECK_MULTILINE_BATCH, floodprot_check_multiline_batch, floodprot_check_multiline_batch_default_handler, 0); efunc_init_function(EFUNC_CHANNEL_FLOOD_BLOCKED_COUNT, channel_flood_blocked_count, channel_flood_blocked_count_default_handler, 0); + efunc_init_function(EFUNC_CHANNEL_FLOOD_EXPAND_JSON, channel_flood_expand_json, channel_flood_expand_json_default_handler, 0); } diff --git a/src/json.c b/src/json.c index c7fdf55fb..89ea6ca7a 100644 --- a/src/json.c +++ b/src/json.c @@ -202,6 +202,7 @@ void json_expand_client_security_groups(json_t *parent, Client *client) * detail=2: everything, except 'channels' * detail=3: everything, with 'channels' being a max 384 character string (meant for JSON logging only) * detail=4: everything, with 'channels' object (full). + * detail=5: this also adds "flood" counters */ void json_expand_client(json_t *j, const char *key, Client *client, int detail) { @@ -296,6 +297,12 @@ void json_expand_client(json_t *j, const char *key, Client *client, int detail) if (client->local && client->local->idle_since) json_object_set_new(child, "idle_since", json_timestamp(client->local->idle_since)); + /* Flood counters: only at level 5+, so not in JSON-RPC default user.get (level 4) + * or in JSON logs (level 3). + */ + if (detail >= 5) + json_expand_flood_counts(child, "flood", client); + if (client->user) { char buf[512]; @@ -667,6 +674,50 @@ void json_expand_textanalysis(json_t *root, const char *key, TextAnalysis *ta, i } } +/** Add a "flood" object to JSON: how often this client hit flood limits this session. + * Under it are two parts: + * "server" server flood limits (set::anti-flood): nick, away, join, etc. + * "channel" channel flood protection (+f/+F), totals over the channels the user is in. + * If the client hit no flood limits, no flood object is added. + * + * @param root The parent JSON object + * @param key Key to put the flood object in, or NULL to write under root directly. + * @param client The client (we only produce output for local clients) + */ +void json_expand_flood_counts(json_t *root, const char *key, Client *client) +{ + json_t *flood, *server; + int i; + + if (!client->local) + return; /* counters are per-connection, local clients only */ + + flood = key ? json_object() : root; + + /* server-level flood limits (set::anti-flood) */ + server = json_object(); + for (i = 0; i < MAXFLOODOPTIONS; i++) + { + if (floodoption_shortnames[i] && (client->local->flood[i].blocked > 0)) + json_object_set_new(server, floodoption_shortnames[i], json_integer(client->local->flood[i].blocked)); + } + if (json_object_size(server) > 0) + json_object_set_new(flood, "server", server); + else + json_decref(server); + + /* channel-level flood protection (+f/+F), via floodprot; adds a "channel" object if nonzero */ + channel_flood_expand_json(flood, client); + + if (key) + { + if (json_object_size(flood) > 0) + json_object_set_new(root, key, flood); + else + json_decref(flood); + } +} + /** Expand a ConfigItem_mask list to a JSON array. * @param parent The parent JSON object * @param key The key name for the array diff --git a/src/misc.c b/src/misc.c index 8be7dfdf5..ed1a20aed 100644 --- a/src/misc.c +++ b/src/misc.c @@ -1519,6 +1519,10 @@ int channel_flood_blocked_count_default_handler(Client *client, const char *type return 0; } +void channel_flood_expand_json_default_handler(json_t *root, Client *client) +{ +} + int make_oper_default_handler(Client *client, const char *operblock_name, const char *operclass, ConfigItem_class *clientclass, long modes, const char *snomask, const char *vhost, const char *autojoin_channels) diff --git a/src/modules/central-blocklist.c b/src/modules/central-blocklist.c index 2d2528ad3..9254cf004 100644 --- a/src/modules/central-blocklist.c +++ b/src/modules/central-blocklist.c @@ -1130,7 +1130,7 @@ CMD_OVERRIDE_FUNC(cbl_override_spamreport_gather) int _central_spamreport(Client *client, Client *by, const char *url) { - json_t *j, *requests, *data, *cmds, *item; + json_t *j, *requests, *data, *cmds, *item, *clientobj; OutgoingWebRequest *w; NameValuePrioList *headers = NULL; int num; @@ -1165,6 +1165,13 @@ int _central_spamreport(Client *client, Client *by, const char *url) data = json_deep_copy(CBL(client)->handshake); /* .. deep copy. */ json_object_set_new(requests, client->id, data); /* ..and steal reference */ + + /* We add the flood counters here explicitly, because when "client" was created + * earlier there were no or limited flood counts yet (during registration phase). + */ + clientobj = json_object_get(data, "client"); + json_expand_flood_counts(clientobj ? clientobj : data, "flood", client); + cmds = json_object(); json_object_set_new(data, "commands", cmds); start = CBL(client)->last_cmds_slot; diff --git a/src/modules/chanmodes/floodprot.c b/src/modules/chanmodes/floodprot.c index 2b871c3bc..0ca545232 100644 --- a/src/modules/chanmodes/floodprot.c +++ b/src/modules/chanmodes/floodprot.c @@ -206,6 +206,7 @@ int _floodprot_check_multiline_batch(Channel *channel, Client *client, int line_ void channelfloodblocks_free(ModData *m); static void channel_flood_blocked_increment(Client *client, int what); int _channel_flood_blocked_count(Client *client, const char *type); +void _channel_flood_expand_json(json_t *root, Client *client); MOD_TEST() { @@ -215,6 +216,7 @@ MOD_TEST() EfunctionAdd(modinfo->handle, EFUNC_GET_FLOODPROT_CHANNEL_MAX_LINES, _get_floodprot_channel_max_lines); EfunctionAdd(modinfo->handle, EFUNC_FLOODPROT_CHECK_MULTILINE_BATCH, _floodprot_check_multiline_batch); EfunctionAdd(modinfo->handle, EFUNC_CHANNEL_FLOOD_BLOCKED_COUNT, _channel_flood_blocked_count); + EfunctionAddVoid(modinfo->handle, EFUNC_CHANNEL_FLOOD_EXPAND_JSON, _channel_flood_expand_json); return MOD_SUCCESS; } @@ -2086,6 +2088,33 @@ int _channel_flood_blocked_count(Client *client, const char *type) return 0; } +/* efunc: add a "channel" object (type -> count) to 'root' for this local user, listing only + * the channel-flood-block (+f/+F) types with a nonzero count this session. Adds nothing when + * there is no data. Used for: spamreport and JSON-RPC (via json_expand_flood_counts()). + */ +void _channel_flood_expand_json(json_t *root, Client *client) +{ + ChannelFloodBlocks *b; + json_t *channel; + int i; + + if (!client || !MyConnect(client)) + return; + if (moddata_local_client(client, md_channelflood_blocked).ptr == NULL) + return; + b = (ChannelFloodBlocks *)moddata_local_client(client, md_channelflood_blocked).ptr; + + channel = json_object(); + for (i = 0; i < NUMFLD; i++) + if (b->blocked[i] > 0) + json_object_set_new(channel, channelfloodtype_names[i], json_integer(b->blocked[i])); + + if (json_object_size(channel) > 0) + json_object_set_new(root, "channel", channel); + else + json_decref(channel); +} + int floodprot_stats(Client *client, const char *flag) { if (*flag != 'S') From 7667307b0ee9fa3ecec475a7fc806a54b363395e Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Sat, 13 Jun 2026 12:37:43 +0200 Subject: [PATCH 21/44] JSON-RPC user.get can now expose more fields by forwarding the request to the server where the user is actually on. Think of idle time etc. * JSON-RPC: We can now route `user.get` requests to the server that user is on. This so we can fetch all fields for that user (including flood counters, idle time, snomask) that are normally not available remotely. * We do this automatically in `user.get` when `object_detail_level` is 5+. * You can force this explicitly with `object_remote_fetch` set to `true`. So you can also use it with detail level 2 if you want, e.g. if you don't need the flood counters but do want the idle time. * When RRPC is not available we answer ourselves (so safe fallback, but you won't have the local-only fields). Oh and we deliberately don't do this in `user.list`, as doing it there would mean a single request could result in hundreds of semi-`user.get` calls across multiple servers. --- doc/RELEASE-NOTES.md | 11 ++++++++++- src/modules/rpc/user.c | 26 +++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/doc/RELEASE-NOTES.md b/doc/RELEASE-NOTES.md index 04277cd01..6942ea158 100644 --- a/doc/RELEASE-NOTES.md +++ b/doc/RELEASE-NOTES.md @@ -114,7 +114,16 @@ This version enables multiline by default and adds TKL IDs. Older databases still load. Downside: you cannot downgrade UnrealIRCd. * JSON for TKL entries (server logs and JSON-RPC) now includes `id`, and `spamfilter_id` for spamfilter-created server bans. -* JSON-RPC `user.get` now also exposes the flood counters. +* JSON-RPC `user.get` can now expose flood counters (see next). +* JSON-RPC: We can now route `user.get` requests to the server that user is + on. This so we can fetch all fields for that user (including flood + counters, idle time, snomask) that are normally not available remotely. + * We do this automatically in `user.get` when `object_detail_level` is 5+. + * You can force this explicitly with `object_remote_fetch` set to `true`. + So you can also use it with detail level 2 if you want, e.g. if you + don't need the flood counters but do want the idle time. + * When RRPC is not available we answer ourselves (so safe fallback, but + you won't have the local-only fields). UnrealIRCd 6.2.5 ----------------- diff --git a/src/modules/rpc/user.c b/src/modules/rpc/user.c index 07598d3b8..3d03d9c37 100644 --- a/src/modules/rpc/user.c +++ b/src/modules/rpc/user.c @@ -188,10 +188,11 @@ RPC_CALL_FUNC(rpc_user_list) RPC_CALL_FUNC(rpc_user_get) { - json_t *result, *list, *item; + json_t *result; const char *nick; Client *acptr; int details; + int remote_fetch; REQUIRE_PARAM_STRING("nick", nick); @@ -201,6 +202,19 @@ RPC_CALL_FUNC(rpc_user_get) rpc_error(client, request, JSON_RPC_ERROR_INVALID_PARAMS, "Using an 'object_detail_level' of 3 is not allowed in user.* calls, use 0, 1, 2 or 4."); return; } + /* Some fields (flood counters, idle time, snomasks, ..) only exist + * on the server where the user is on. If 'object_remote_fetch' is + * set to true, then we forward the request to the corresponding + * server. This defaults to false, except when details >= 5, then + * the default is true. Yeah a bit complicated, but that way with + * level 5+ we actually include the fields you want :D. + * Note that default user.get without object_detail_level defaults + * to detail level 4, so default is off. Which is intended, because + * all of this does come at a cost of network latency (server may + * even be multiple hops away) versus locally responding which is + * much faster. + */ + OPTIONAL_PARAM_BOOLEAN("object_remote_fetch", remote_fetch, details >= 5); if (!(acptr = find_user(nick, NULL))) { @@ -208,6 +222,16 @@ RPC_CALL_FUNC(rpc_user_get) return; } + /* Forward to the user's own server if requested and RRPC is supported + * along the entire routing path. Otherwise, just fall back. + */ + if (remote_fetch && !MyConnect(acptr) && acptr->uplink && + rrpc_supported_simple(acptr->uplink, NULL)) + { + rpc_send_request_to_remote(client, acptr->uplink, request); + return; + } + result = json_object(); json_expand_client(result, "client", acptr, details); rpc_response(client, request, result); From 2089aa4ec4f98e9ef9f11077d08a6bedca505e36 Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Sat, 13 Jun 2026 14:49:11 +0200 Subject: [PATCH 22/44] In RPC_CALL_ERROR show the actual error --- src/modules/rpc/rpc.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/modules/rpc/rpc.c b/src/modules/rpc/rpc.c index d8c463f15..0890b67c5 100644 --- a/src/modules/rpc/rpc.c +++ b/src/modules/rpc/rpc.c @@ -925,8 +925,9 @@ void _rpc_error(Client *client, json_t *request, JsonRpcError error_code, const json_object_set_new(error, "message", json_string_unreal(error_message)); unreal_log(ULOG_INFO, "rpc", "RPC_CALL_ERROR", client, - "[rpc] Client $client: RPC call $method", - log_data_string("method", method ? method : "")); + "[rpc] Client $client: RPC call $method: $error_message", + log_data_string("method", method ? method : ""), + log_data_string("error_message", error_message)); json_serialized = json_dumps(j, 0); From 8d783204dd20a664c1ef491accd519a99e46f7d6 Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Sat, 13 Jun 2026 16:04:43 +0200 Subject: [PATCH 23/44] JSON-RPC: Remote RPC was broken and causing "not authorized" error messages. This was used by `server.rehash` and `server.module_list`. Plus, this release `user.get` under some circumstances. This is now fixed but requires the target server to be on UnrealIRCd 6.2.6. If the target server does not meet this condition then we error telling the server "does not support remote JSON-RPC". This was first reported by AdmiraL- in https://bugs.unrealircd.org/view.php?id=6611 --- doc/RELEASE-NOTES.md | 6 ++++++ src/modules/rpc/rpc.c | 47 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/doc/RELEASE-NOTES.md b/doc/RELEASE-NOTES.md index 6942ea158..fc21b0209 100644 --- a/doc/RELEASE-NOTES.md +++ b/doc/RELEASE-NOTES.md @@ -71,6 +71,12 @@ This version enables multiline by default and adds TKL IDs. * The following config items previously raised a config error: allow channel::except, deny channel::except and spamfilter::except. * Hardening of the built-in HTTPS client +* JSON-RPC: Remote RPC was broken and causing "not authorized" error messages. + This was used by `server.rehash` and `server.module_list`. Plus, + this release `user.get` under some circumstances. This is now + fixed but requires the target server to be on UnrealIRCd 6.2.6. + If the target server does not meet this condition then we error + telling the server "does not support remote JSON-RPC". ### Developers and protocol: * URL API: The OutgoingWebRequest `max_size` (introduced last release) now diff --git a/src/modules/rpc/rpc.c b/src/modules/rpc/rpc.c index 0890b67c5..fec689d85 100644 --- a/src/modules/rpc/rpc.c +++ b/src/modules/rpc/rpc.c @@ -9,7 +9,7 @@ ModuleHeader MOD_HEADER = { "rpc/rpc", - "1.0.4", + "1.0.5", "RPC module for remote management", "UnrealIRCd Team", "unrealircd-6", @@ -1885,6 +1885,7 @@ void rpc_call_remote(RRPC *r) return; } client = make_client(server->direction, server); + SetRPC(client); strlcpy(client->id, r->source, sizeof(client->id)); client->rpc = safe_alloc(sizeof(RPCClient)); strlcpy(client->name, "RPC:remote", sizeof(client->name)); @@ -2029,7 +2030,7 @@ void rpc_send_generic_to_remote(Client *source, Client *target, const char *requ safe_free(json_serialized); } -int _rrpc_supported_simple(Client *target, char **problem_server) +int rrpc_supported_simple_helper(Client *target, char **problem_server) { if (!moddata_client_get(target, "rrpc")) { @@ -2037,20 +2038,51 @@ int _rrpc_supported_simple(Client *target, char **problem_server) *problem_server = target->name; return 0; } - if ((target != target->direction) && !rrpc_supported_simple(target->direction, problem_server)) + if ((target != target->direction) && !rrpc_supported_simple_helper(target->direction, problem_server)) return 0; return 1; } +int _rrpc_supported_simple(Client *target, char **problem_server) +{ + /* We require the RPC module and at least module version 1.0.5. + * This is because there is a bug in 1.0.4 where remote RPC did + * not work due to an auth check that was only meant locally. + */ + return rrpc_supported(target, "rpc", "1.0.5", problem_server); +} + int _rrpc_supported(Client *target, const char *module, const char *minimum_version, char **problem_server) { + const char *have_version; + if (!moddata_client_get(target, "rrpc")) { if (problem_server) *problem_server = target->name; return 0; } - if ((target != target->direction) && !rrpc_supported_simple(target->direction, problem_server)) + + /* If a specific rpc module (and optionally a minimum version) is requested, check that + * 'target' actually advertises it. The module list (name -> version) travels via the + * synced "rrpc" moddata, so we can check this for any server on the network. + */ + if (module) + { + have_version = get_nvplist(RRPCMODULES(target), module); + if (!have_version || + (minimum_version && (strnatcmp(have_version, minimum_version) < 0))) + { + if (problem_server) + *problem_server = target->name; + return 0; + } + } + + /* And the entire path towards 'target' must support remote RPC (transport only; + * intermediate servers just relay, so they don't need the module itself). + */ + if ((target != target->direction) && !rrpc_supported_simple_helper(target->direction, problem_server)) return 0; return 1; } @@ -2190,6 +2222,13 @@ RPC_CALL_FUNC(rpc_rpc_add_timer) RPCHandler *handler; RPCTimer *timer; + /* Remote timers would be weird, complex and are not supported */ + if (!MyConnect(client)) + { + rpc_error(client, request, JSON_RPC_ERROR_INVALID_REQUEST, "rpc.add_timer is not available for remote RPC requests"); + return; + } + REQUIRE_PARAM_INTEGER("every_msec", every_msec); REQUIRE_PARAM_STRING("timer_id", timer_id); From 1162da4a9e96bf8d683b1656cc381f47fc1ec574 Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Tue, 16 Jun 2026 20:45:31 +0200 Subject: [PATCH 24/44] * Server linking and certificates: we now treat listener blocks that are `serversonly` (such as port 6900 in the example.conf) and link { } blocks in a different way than regular listen { } blocks: * If there are different certificates used in the serversonly listen block vs link blocks, then this is almost always means server linking is broken, so we now print a warning on boot and rehash. * We also print an 'advice' if any of these are not using (long-lived) self-signed certificate. This is because CA issued certificates are typically not suitable because they typically rotate keys and thus change the `spkifp`. Changing spkifp breaks server linking. We will now print an advice along with command and config block instructions to fix it. * We now use `set::server-linking::tls-options` for link { } blocks and listen { } blocks that are `serversonly`. All the rest uses the `set::tls` settings by default (eg the regular listen { } block on 6697). * This means our guide on [Using Let's Encrypt with UnrealIRCd](https://www.unrealircd.org/docs/Using_Let's_Encrypt_with_UnrealIRCd) and generic usage is more intuitive. You just set both set settings and then no longer need to use any tls-options in listen blocks or link blocks. The example conf has also been updated with this. * If `set::server-linking::tls-options` is not configured, it defaults to `set::tls`, so there is no unexpected behavior change for anyone. * In a future release we will make server linking with `spkifp` mandatory, so all of this helps with getting people ready for that, making such a future transition smooth. TODO: Update wiki, better wording in release notes, etc. This also changes the default example conf: /* RECOMMENDED: * Everyone should be using IRC over SSL/TLS on port 6697. However, to use * it properly, you have to get a "real" certificate instead of the * self-signed default certificate that was generated by the installer. * The Let's Encrypt initiative allows you to get a free certificate that is * issued by a trusted Certificate Authority. Instructions are at: * https://www.unrealircd.org/docs/Using_Let's_Encrypt_with_UnrealIRCd * * When you follow that guide you will have a "dual certificate" setup: * set::tls: * Your trusted CA certificate, served to clients on port 6697. * (key and certificate change and renew every xx days automatically) * set::server-linking::tls-options * A long-lived self-signed certificate for server linking, with * a stable 'spkifp' signature that you use in link blocks. * This certificate is used automatically in "serversonly" listen blocks * (port 6900 in this configuration file) and automatically used for all * link { } blocks. * */ //set { // tls { // certificate "/etc/letsencrypt/live/irc.example.org/fullchain.pem"; // key "/etc/letsencrypt/live/irc.example.org/privkey.pem"; // } // server-linking { // tls-options { // certificate "tls/server.cert.pem"; // key "tls/server.key.pem"; // } // } //} --- doc/RELEASE-NOTES.md | 24 +++ doc/conf/examples/example.conf | 33 ++++ include/dynconf.h | 3 + include/h.h | 6 + include/modules.h | 10 + src/conf.c | 174 +++++++++++++++--- src/modules/server.c | 326 +++++++++++++++++++++++++++++++++ src/tls.c | 47 ++++- src/unrealircdctl.c | 7 +- unrealircd.in | 25 ++- 10 files changed, 613 insertions(+), 42 deletions(-) diff --git a/doc/RELEASE-NOTES.md b/doc/RELEASE-NOTES.md index fc21b0209..bfa2b9bce 100644 --- a/doc/RELEASE-NOTES.md +++ b/doc/RELEASE-NOTES.md @@ -60,6 +60,30 @@ This version enables multiline by default and adds TKL IDs. * Central Spamreport now also receives those flood counters. ### Changes: +* Server linking and certificates: we now treat listener blocks that are + `serversonly` (such as port 6900 in the example.conf) and link { } blocks + in a different way than regular listen { } blocks: + * If there are different certificates used in the serversonly listen block + vs link blocks, then this is almost always means server linking is broken, + so we now print a warning on boot and rehash. + * We also print an 'advice' if any of these are not using (long-lived) + self-signed certificate. This is because CA issued certificates are + typically not suitable because they typically rotate keys and thus change + the `spkifp`. Changing spkifp breaks server linking. We will now print + an advice along with command and config block instructions to fix it. + * We now use `set::server-linking::tls-options` for link { } blocks + and listen { } blocks that are `serversonly`. All the rest uses the + `set::tls` settings by default (eg the regular listen { } block on 6697). + * This means our guide on + [Using Let's Encrypt with UnrealIRCd](https://www.unrealircd.org/docs/Using_Let's_Encrypt_with_UnrealIRCd) + and generic usage is more intuitive. You just set both set settings + and then no longer need to use any tls-options in listen blocks or link + blocks. The example conf has also been updated with this. + * If `set::server-linking::tls-options` is not configured, it defaults + to `set::tls`, so there is no unexpected behavior change for anyone. + * In a future release we will make server linking with `spkifp` mandatory, + so all of this helps with getting people ready for that, making such + a future transition smooth. * Spamfilter regexes now use more sensible defaults in terms of "max effort", similar to what PHP has been using for years. This means very slow regexes will now raise a `SPAMFILTER_REGEX_ERROR` warning during execution if diff --git a/doc/conf/examples/example.conf b/doc/conf/examples/example.conf index 61511b08e..43fe7e04e 100644 --- a/doc/conf/examples/example.conf +++ b/doc/conf/examples/example.conf @@ -599,6 +599,39 @@ set { } } +/* RECOMMENDED: + * Everyone should be using IRC over SSL/TLS on port 6697. However, to use + * it properly, you have to get a "real" certificate instead of the + * self-signed default certificate that was generated by the installer. + * The Let's Encrypt initiative allows you to get a free certificate that is + * issued by a trusted Certificate Authority. Instructions are at: + * https://www.unrealircd.org/docs/Using_Let's_Encrypt_with_UnrealIRCd + * + * When you follow that guide you will have a "dual certificate" setup: + * set::tls: + * Your trusted CA certificate, served to clients on port 6697. + * (key and certificate change and renew every xx days automatically) + * set::server-linking::tls-options + * A long-lived self-signed certificate for server linking, with + * a stable 'spkifp' signature that you use in link blocks. + * This certificate is used automatically in "serversonly" listen blocks + * (port 6900 in this configuration file) and automatically used for all + * link { } blocks. + * + */ +//set { +// tls { +// certificate "/etc/letsencrypt/live/irc.example.org/fullchain.pem"; +// key "/etc/letsencrypt/live/irc.example.org/privkey.pem"; +// } +// server-linking { +// tls-options { +// certificate "tls/server.cert.pem"; +// key "tls/server.key.pem"; +// } +// } +//} + /* * The following will configure connection throttling of "unknown users". * diff --git a/include/dynconf.h b/include/dynconf.h index 4385c2fa5..1f83146a0 100644 --- a/include/dynconf.h +++ b/include/dynconf.h @@ -100,6 +100,9 @@ struct Configuration { int maxdccallow; int anti_spam_quit_message_time; TLSOptions *tls_options; + TLSOptions *server_linking_tls_options; /**< set::server-linking::tls-options, NULL if not configured */ + int server_linking_mixed_certificates; /**< set::server-linking::mixed-certificates: suppress the s2s cert mismatch warning */ + int server_linking_allow_ca_certificate; /**< set::server-linking::allow-ca-certificate: suppress the "CA cert used for linking" advice */ Policy plaintext_policy_user; MultiLine *plaintext_policy_user_message; Policy plaintext_policy_oper; diff --git a/include/h.h b/include/h.h index 2e8b0c9d0..78ff91a01 100644 --- a/include/h.h +++ b/include/h.h @@ -983,6 +983,8 @@ extern const char *ssl_error_str(int err, int my_errno); extern void unreal_tls_client_handshake(int, int, void *); extern void SSL_set_nonblocking(SSL *s); extern SSL_CTX *init_ctx(TLSOptions *tlsoptions, int server); +extern void test_tlsblock(ConfigFile *conf, ConfigEntry *cep, int *totalerrors); +extern void set_server_linking_tlsoptions_ce(ConfigEntry *ce); extern const char *tls_get_cipher(Client *client); extern TLSOptions *get_tls_options_for_client(Client *acptr); extern int outdated_tls_client(Client *acptr); @@ -1138,6 +1140,7 @@ extern MODVAR json_t *json_rehash_log; extern MODVAR int debugfd; extern void convert_to_absolute_path(char **path, const char *reldir); extern char *convert_to_absolute_path_duplicate(char *path, char *reldir); +extern const char *display_path(const char *path, const char *reldir); extern int has_user_mode(Client *acptr, char mode); extern int has_channel_mode(Channel *channel, char mode); extern int has_channel_mode_raw(Cmode_t m, char mode); @@ -1187,6 +1190,9 @@ extern void sendbufto_one(Client *to, char *msg, unsigned int quick); extern MODVAR int current_serial; extern const char *spki_fingerprint(Client *acptr); extern const char *spki_fingerprint_ex(X509 *x509_cert); +extern const char *spkifp_from_ctx(SSL_CTX *ctx); +extern const char *server_linking_spkifp(TLSOptions *tlsoptions); +extern int is_trusted_cert(SSL_CTX *ctx); extern int is_module_loaded(const char *name); extern int is_blacklisted_module(const char *name); extern void close_std_descriptors(void); diff --git a/include/modules.h b/include/modules.h index 198765da3..4ef90b2cb 100644 --- a/include/modules.h +++ b/include/modules.h @@ -1564,6 +1564,8 @@ extern APICallback *APICallbackAdd(Module *module, APICallback *mreq); #define HOOKTYPE_KNOWN_USER_CACHE_CHANGE 131 /** See hooktype_chanmsg_multiline() */ #define HOOKTYPE_CHANMSG_MULTILINE 132 +/** See hooktype_postconf() */ +#define HOOKTYPE_POSTCONF 133 /* Adding a new hook here? @@ -2050,6 +2052,13 @@ int hooktype_rehash(void); */ int hooktype_rehash_complete(void); +/** Called at the end of postconf(), after the configuration is fully applied and + * TLS contexts are (re)built, on both boot and rehash (function prototype for + * HOOKTYPE_POSTCONF). + * @return The return value is ignored (use return 0) + */ +int hooktype_postconf(void); + /** Called when searching for a test function for a specific configuration item (function prototype for HOOKTYPE_CONFIGTEST). * This is part of the configuration API, which is better documented at the * wiki at https://www.unrealircd.org/docs/Dev:Configuration_API @@ -2794,6 +2803,7 @@ _UNREAL_ERROR(_hook_error_incompatible, "Incompatible hook function. Check argum ((hooktype == HOOKTYPE_UMODE_CHANGE) && !ValidateHook(hooktype_umode_change, func)) || \ ((hooktype == HOOKTYPE_TOPIC) && !ValidateHook(hooktype_topic, func)) || \ ((hooktype == HOOKTYPE_REHASH_COMPLETE) && !ValidateHook(hooktype_rehash_complete, func)) || \ + ((hooktype == HOOKTYPE_POSTCONF) && !ValidateHook(hooktype_postconf, func)) || \ ((hooktype == HOOKTYPE_TKL_ADD) && !ValidateHook(hooktype_tkl_add, func)) || \ ((hooktype == HOOKTYPE_TKL_DEL) && !ValidateHook(hooktype_tkl_del, func)) || \ ((hooktype == HOOKTYPE_LOCAL_KILL) && !ValidateHook(hooktype_local_kill, func)) || \ diff --git a/src/conf.c b/src/conf.c index e56574606..1be9af664 100644 --- a/src/conf.c +++ b/src/conf.c @@ -201,8 +201,8 @@ int reloadable_perm_module_unloaded(void); int tls_tests(void); /* Conf sub-sub-functions */ -void test_tlsblock(ConfigFile *conf, ConfigEntry *cep, int *totalerrors); -void conf_tlsblock(ConfigFile *conf, ConfigEntry *cep, TLSOptions *tlsoptions); +void conf_tlsblock(ConfigFile *conf, ConfigEntry *cep, TLSOptions *tlsoptions, TLSOptions *inherit_from); +TLSOptions *duplicate_tls_options(TLSOptions *src); void free_tls_options(TLSOptions *tlsoptions); /* @@ -248,6 +248,7 @@ Configuration iConf; Configuration tempiConf; BestPractices bestpractices; MODVAR ConfigFile *conf = NULL; +static ConfigEntry *server_linking_tlsoptions_ce = NULL; /* Hack to get set::server-linking::tls-options */ extern NameValueList *config_defines; MODVAR int ipv6_disabled = 0; MODVAR Client *remote_rehash_client = NULL; @@ -1770,6 +1771,8 @@ void free_iConf(Configuration *i) free_tls_options(i->tls_options); i->tls_options = NULL; safe_free(i->tls_options); + free_tls_options(i->server_linking_tls_options); + i->server_linking_tls_options = NULL; safe_free_multiline(i->plaintext_policy_user_message); safe_free_multiline(i->plaintext_policy_oper_message); safe_free(i->outdated_tls_policy_user_message); @@ -1921,6 +1924,9 @@ void config_setdefaultsettings(Configuration *i) */ safe_strdup(i->tls_options->outdated_ciphers, "AES*,RC4*,DES*"); i->tls_options->certificate_expiry_notification = 1; + i->server_linking_tls_options = NULL; /* set::server-linking::tls-options, not configured by default */ + i->server_linking_mixed_certificates = 0; + i->server_linking_allow_ca_certificate = 0; i->plaintext_policy_user = POLICY_ALLOW; i->plaintext_policy_oper = POLICY_DENY; i->plaintext_policy_server = POLICY_DENY; @@ -2101,6 +2107,8 @@ void postconf(void) log_data_integer("port", bestpractices.listen_nontls_port)); bestpractices.listen_nontls_port_hits++; } + + RunHook(HOOKTYPE_POSTCONF); } int isanyserverlinked(void) @@ -3162,6 +3170,15 @@ int config_run_blocks_generic(ConfigFile *cfptr, int skip_priority_blocks) return processed; } +/** Remember the set::server-linking::tls-options block so core can work on it + * after all the other set blocks have been processed (ordering issue). + * This is a bit of a hack, but... yeah... + */ +void set_server_linking_tlsoptions_ce(ConfigEntry *ce) +{ + server_linking_tlsoptions_ce = ce; +} + int config_run_blocks(void) { ConfigEntry *ce; @@ -3172,6 +3189,8 @@ int config_run_blocks(void) Hook *h; ConfigItem_allow *allow; + server_linking_tlsoptions_ce = NULL; /* processed late, see the set:: handling below */ + /* Stage 1: first the priority blocks, in the order as specified * in config_run_priority_blocks[] */ @@ -3221,6 +3240,19 @@ int config_run_blocks(void) snprintf(tmp, sizeof(tmp), "%s/tls/server.key.pem", CONFDIR); add_name_list(tempiConf.tls_options->key_files, tmp); } + + /* And NOW that set::tls is fully done (including the default + * certificate above), process set::server-linking::tls-options. + * We do this here and not at parse time in _conf_set(), because + * this could inherit from set::tls, so we have to do it in this + * particular order. + */ + if (server_linking_tlsoptions_ce) + { + tempiConf.server_linking_tls_options = safe_alloc(sizeof(TLSOptions)); + conf_tlsblock(conf, server_linking_tlsoptions_ce, tempiConf.server_linking_tls_options, tempiConf.tls_options); + server_linking_tlsoptions_ce = NULL; + } } } @@ -3567,6 +3599,24 @@ void convert_to_absolute_path(char **path, const char *reldir) *path = s; } +/** Return 'path' relative to 'reldir' if it lives below it, otherwise return + * 'path' unchanged (absolute path, URL, etc). + * This can be used to turn a path into relative again if convert_to_absolute_path() + * previously worked on it. + * @returns a pointer into 'path' (read-only). + */ +const char *display_path(const char *path, const char *reldir) +{ + size_t len; + + if (!path || !reldir) + return path; + len = strlen(reldir); + if (!strncmp(path, reldir, len) && (path[len] == '/' || path[len] == '\\')) + return path + len + 1; + return path; +} + /* Similar to convert_to_absolute_path() but returns a duplicated string. * Don't forget to free! */ @@ -5559,7 +5609,17 @@ void conf_listen_configure(const char *ip, int port, SocketType socket_type, int if (tlsconfig) { listen->tls_options = safe_alloc(sizeof(TLSOptions)); - conf_tlsblock(conf, tlsconfig, listen->tls_options); + conf_tlsblock(conf, tlsconfig, listen->tls_options, + ((options & LISTENER_SERVERSONLY) && tempiConf.server_linking_tls_options) ? + tempiConf.server_linking_tls_options : tempiConf.tls_options); + listen->ssl_ctx = init_ctx(listen->tls_options, 1); + } + else if ((options & LISTENER_SERVERSONLY) && tempiConf.server_linking_tls_options) + { + /* No explicit tls-options on this serversonly listener, but + * set::server-linking::tls-options exists/is configured. Use that one. + */ + listen->tls_options = duplicate_tls_options(tempiConf.server_linking_tls_options); listen->ssl_ctx = init_ctx(listen->tls_options, 1); } @@ -6563,7 +6623,7 @@ int _conf_sni(ConfigFile *conf, ConfigEntry *ce) sni = safe_alloc(sizeof(ConfigItem_listen)); safe_strdup(sni->name, name); sni->tls_options = safe_alloc(sizeof(TLSOptions)); - conf_tlsblock(conf, tlsconfig, sni->tls_options); + conf_tlsblock(conf, tlsconfig, sni->tls_options, tempiConf.tls_options); sni->ssl_ctx = init_ctx(sni->tls_options, 1); AddListItem(sni, conf_sni); @@ -6665,7 +6725,9 @@ int _conf_link(ConfigFile *conf, ConfigEntry *ce) else if (!strcmp(cepp->name, "ssl-options") || !strcmp(cepp->name, "tls-options")) { link->tls_options = safe_alloc(sizeof(TLSOptions)); - conf_tlsblock(conf, cepp, link->tls_options); + conf_tlsblock(conf, cepp, link->tls_options, + tempiConf.server_linking_tls_options ? + tempiConf.server_linking_tls_options : tempiConf.tls_options); link->ssl_ctx = init_ctx(link->tls_options, 0); } } @@ -6711,6 +6773,20 @@ int _conf_link(ConfigFile *conf, ConfigEntry *ce) if (!link->hub && !link->leaf) safe_strdup(link->hub, "*"); + /* TLS Configuration for this link block: if this link connects out + * (any non-'insecure' link, including plaintext links that upgrade to + * TLS via STARTTLS), and there is no explicit link::outgoing::tls-options + * set, then we use set::server-linking::tls-options. + */ + if ((link->outgoing.hostname || link->outgoing.file) && + !(link->outgoing.options & CONNECT_OUTGOING_INSECURE) && + !link->tls_options && + tempiConf.server_linking_tls_options) + { + link->tls_options = duplicate_tls_options(tempiConf.server_linking_tls_options); + link->ssl_ctx = init_ctx(link->tls_options, 0); + } + AppendListItem(link, conf_link); return 0; } @@ -7610,7 +7686,7 @@ void test_tlsblock(ConfigFile *conf, ConfigEntry *cep, int *totalerrors) TLSOptions *tlsoptions = safe_alloc(sizeof(TLSOptions)); SSL_CTX *ctx; - conf_tlsblock(conf, cep, tlsoptions); + conf_tlsblock(conf, cep, tlsoptions, tempiConf.tls_options); ctx = init_ctx(tlsoptions, 1); free_tls_options(tlsoptions); @@ -7641,31 +7717,71 @@ void free_tls_options(TLSOptions *tlsoptions) safe_free(tlsoptions); } -void conf_tlsblock(ConfigFile *conf, ConfigEntry *cep, TLSOptions *tlsoptions) +/** Make a complete (deep) copy of TLSOptions. + * Unlike conf_tlsblock()'s inheritance, this is a 100% clone with no + * base+block merge, so certificate_files and key_files are copied as-is + * (a multi-cert RSA+ECC or ECC+postquantum set stays intact, so be + * careful if you have to deal with overriding this that you don't + * accidentally "add" but "replace" instead). + * If you are in config-based code, you almost definately will want to + * use conf_tlsblock() instead. + */ +TLSOptions *duplicate_tls_options(TLSOptions *src) +{ + TLSOptions *n; + + if (!src) + return NULL; + + n = safe_alloc(sizeof(TLSOptions)); + n->certificate_files = duplicate_name_list(src->certificate_files); + n->key_files = duplicate_name_list(src->key_files); + safe_strdup(n->trusted_ca_file, src->trusted_ca_file); + n->protocols = src->protocols; + safe_strdup(n->ciphers, src->ciphers); + safe_strdup(n->ciphersuites, src->ciphersuites); + safe_strdup(n->groups, src->groups); + safe_strdup(n->signature_algorithms, src->signature_algorithms); + safe_strdup(n->outdated_protocols, src->outdated_protocols); + safe_strdup(n->outdated_ciphers, src->outdated_ciphers); + n->options = src->options; + n->renegotiate_bytes = src->renegotiate_bytes; + n->renegotiate_timeout = src->renegotiate_timeout; + n->sts_port = src->sts_port; + n->sts_duration = src->sts_duration; + n->sts_preload = src->sts_preload; + n->certificate_expiry_notification = src->certificate_expiry_notification; + return n; +} + +void conf_tlsblock(ConfigFile *conf, ConfigEntry *cep, TLSOptions *tlsoptions, TLSOptions *inherit_from) { ConfigEntry *cepp, *ceppp; NameValue *ofl; - /* First, inherit settings from set::options::tls */ - if (tlsoptions != tempiConf.tls_options) + /* First, inherit settings from the base TLS options (inherit_from). + * This is either set::tls (the usual case), or for link blocks and + * serversonly listeners it's set::server-linking::tls-options. + */ + if (tlsoptions != inherit_from) { // certificate_files: done at end of function // key_files: done at end of function - safe_strdup(tlsoptions->trusted_ca_file, tempiConf.tls_options->trusted_ca_file); - tlsoptions->protocols = tempiConf.tls_options->protocols; - safe_strdup(tlsoptions->ciphers, tempiConf.tls_options->ciphers); - safe_strdup(tlsoptions->ciphersuites, tempiConf.tls_options->ciphersuites); - safe_strdup(tlsoptions->groups, tempiConf.tls_options->groups); - safe_strdup(tlsoptions->signature_algorithms, tempiConf.tls_options->signature_algorithms); - safe_strdup(tlsoptions->outdated_protocols, tempiConf.tls_options->outdated_protocols); - safe_strdup(tlsoptions->outdated_ciphers, tempiConf.tls_options->outdated_ciphers); - tlsoptions->options = tempiConf.tls_options->options; - tlsoptions->renegotiate_bytes = tempiConf.tls_options->renegotiate_bytes; - tlsoptions->renegotiate_timeout = tempiConf.tls_options->renegotiate_timeout; - tlsoptions->sts_port = tempiConf.tls_options->sts_port; - tlsoptions->sts_duration = tempiConf.tls_options->sts_duration; - tlsoptions->sts_preload = tempiConf.tls_options->sts_preload; - tlsoptions->certificate_expiry_notification = tempiConf.tls_options->certificate_expiry_notification; + safe_strdup(tlsoptions->trusted_ca_file, inherit_from->trusted_ca_file); + tlsoptions->protocols = inherit_from->protocols; + safe_strdup(tlsoptions->ciphers, inherit_from->ciphers); + safe_strdup(tlsoptions->ciphersuites, inherit_from->ciphersuites); + safe_strdup(tlsoptions->groups, inherit_from->groups); + safe_strdup(tlsoptions->signature_algorithms, inherit_from->signature_algorithms); + safe_strdup(tlsoptions->outdated_protocols, inherit_from->outdated_protocols); + safe_strdup(tlsoptions->outdated_ciphers, inherit_from->outdated_ciphers); + tlsoptions->options = inherit_from->options; + tlsoptions->renegotiate_bytes = inherit_from->renegotiate_bytes; + tlsoptions->renegotiate_timeout = inherit_from->renegotiate_timeout; + tlsoptions->sts_port = inherit_from->sts_port; + tlsoptions->sts_duration = inherit_from->sts_duration; + tlsoptions->sts_preload = inherit_from->sts_preload; + tlsoptions->certificate_expiry_notification = inherit_from->certificate_expiry_notification; } /* Now process the options */ @@ -7797,12 +7913,12 @@ void conf_tlsblock(ConfigFile *conf, ConfigEntry *cep, TLSOptions *tlsoptions) * additional certs/keys due to the nature of it being a name list. * So we simply only add these here at the end if they were not set. */ - if (tlsoptions != tempiConf.tls_options) + if (tlsoptions != inherit_from) { if (!tlsoptions->certificate_files) - tlsoptions->certificate_files = duplicate_name_list(tempiConf.tls_options->certificate_files); + tlsoptions->certificate_files = duplicate_name_list(inherit_from->certificate_files); if (!tlsoptions->key_files) - tlsoptions->key_files = duplicate_name_list(tempiConf.tls_options->key_files); + tlsoptions->key_files = duplicate_name_list(inherit_from->key_files); } } @@ -8309,7 +8425,7 @@ int _conf_set(ConfigFile *conf, ConfigEntry *ce) } else if (!strcmp(cep->name, "ssl") || !strcmp(cep->name, "tls")) { /* no need to alloc tempiConf.tls_options since config_defaults() already ensures it exists */ - conf_tlsblock(conf, cep, tempiConf.tls_options); + conf_tlsblock(conf, cep, tempiConf.tls_options, tempiConf.tls_options); } else if (!strcmp(cep->name, "plaintext-policy")) { @@ -11870,7 +11986,7 @@ const char *link_generator_spkifp(TLSOptions *tlsoptions) void link_generator(void) { ConfigItem_listen *lstn; - TLSOptions *tlsopt = iConf.tls_options; /* never null */ + TLSOptions *tlsopt = iConf.server_linking_tls_options ? iConf.server_linking_tls_options : iConf.tls_options; /* set::server-linking::tls-options and otherwise set::tls */ int port = 0; char *ip = NULL; const char *spkifp; diff --git a/src/modules/server.c b/src/modules/server.c index c4ae56814..29b86286f 100644 --- a/src/modules/server.c +++ b/src/modules/server.c @@ -73,6 +73,7 @@ const char *_check_deny_link(ConfigItem_link *link, int auto_connect); int server_stats_denylink_all(Client *client, const char *para); int server_stats_denylink_auto(Client *client, const char *para); int server_quit_reset_autoconnect_time(Client *client, MessageTag *mtags); +int check_server_linking_cert_consistency(void); /* Global variables */ static cfgstruct cfg; @@ -114,6 +115,7 @@ MOD_INIT() HookAdd(modinfo->handle, HOOKTYPE_STATS, 0, server_stats_denylink_all); HookAdd(modinfo->handle, HOOKTYPE_STATS, 0, server_stats_denylink_auto); HookAdd(modinfo->handle, HOOKTYPE_SERVER_QUIT, 0, server_quit_reset_autoconnect_time); + HookAdd(modinfo->handle, HOOKTYPE_POSTCONF, 0, check_server_linking_cert_consistency); CommandAdd(modinfo->handle, "SERVER", cmd_server, MAXPARA, CMD_UNREGISTERED|CMD_SERVER); CommandAdd(modinfo->handle, "SID", cmd_sid, MAXPARA, CMD_SERVER); @@ -199,6 +201,11 @@ int server_config_test_set_server_linking(ConfigFile *cf, ConfigEntry *ce, int t for (cep = ce->items; cep; cep = cep->next) { + if (!strcmp(cep->name, "ssl-options") || !strcmp(cep->name, "tls-options")) + { + test_tlsblock(cf, cep, &errors); + continue; + } if (!cep->value) { config_error("%s:%i: blank set::server-linking::%s without value", @@ -239,6 +246,14 @@ int server_config_test_set_server_linking(ConfigFile *cf, ConfigEntry *ce, int t continue; } } else + if (!strcmp(cep->name, "mixed-certificates")) + { + /* yes/no, applied at config run */ + } else + if (!strcmp(cep->name, "allow-ca-certificate")) + { + /* yes/no, applied at config run */ + } else { config_error("%s:%i: unknown directive set::server-linking::%s", cep->file->filename, cep->line_number, cep->name); @@ -257,6 +272,13 @@ int server_config_run_set_server_linking(ConfigFile *cf, ConfigEntry *ce, int ty for (cep = ce->items; cep; cep = cep->next) { + if (!strcmp(cep->name, "ssl-options") || !strcmp(cep->name, "tls-options")) + { + /* Processed late in core config_run_blocks(), after set::tls is + * finalized, so it inherits the same way listen/link do. + */ + set_server_linking_tlsoptions_ce(cep); + } else if (!strcmp(cep->name, "autoconnect-strategy")) { cfg.autoconnect_strategy = autoconnect_strategy_strtoval(cep->value); @@ -268,12 +290,316 @@ int server_config_run_set_server_linking(ConfigFile *cf, ConfigEntry *ce, int ty if (!strcmp(cep->name, "handshake-timeout")) { cfg.handshake_timeout = config_checkval(cep->value, CFG_TIME); + } else + if (!strcmp(cep->name, "mixed-certificates")) + { + tempiConf.server_linking_mixed_certificates = config_checkval(cep->value, CFG_YESNO); + } else + if (!strcmp(cep->name, "allow-ca-certificate")) + { + tempiConf.server_linking_allow_ca_certificate = config_checkval(cep->value, CFG_YESNO); } } return 1; } +/* Helper for heck_server_linking_cert_consistency(). We build the suggested + * config file block here. + * When 'opts' has a certificate, use those cert/key paths (which are + * for a self-signed cert already in use somewhere). + * When 'opts' is NULL, there is no self signed certificate in use for linking + * (which usually means the user has overriden the default certs with CA certs), + * so we simply recommend to run ./unrealircd mkcert to create a new one. + */ +static void check_server_linking_cert_consistency_suggest_fix(TLSOptions *opts, char *buf, size_t buflen) +{ + char inner[512]; + NameList *c, *k; + + inner[0] = '\0'; + if (opts && opts->certificate_files && opts->key_files) + { + for (c = opts->certificate_files, k = opts->key_files; c && k; c = c->next, k = k->next) + { + char line[384]; + snprintf(line, sizeof(line), + " certificate \"%s\";\n" + " key \"%s\";\n", + display_path(c->name, CONFDIR), display_path(k->name, CONFDIR)); + strlcat(inner, line, sizeof(inner)); + } + } + if (inner[0]) + { + snprintf(buf, buflen, + "set {\n" + " server-linking {\n" + " tls-options {\n" + "%s" + " }\n" + " }\n" + "}", inner); + } + else + { + /* Nothing self-signed is in use, so do not name an existing file (it + * may be the CA certificate). Recommend generating a dedicated one. + * The mkcert command uses an absolute path (mkcert follows normal path + * rules, no config-dir magic) so the files land in the config dir no + * matter the working directory; the config block stays config-relative. + */ + snprintf(buf, buflen, + "Generate one with: ./unrealircd mkcert %s/tls/server-linking\n" + "Then set:\n" + "set {\n" + " server-linking {\n" + " tls-options {\n" + " certificate \"tls/server-linking.cert.pem\";\n" + " key \"tls/server-linking.key.pem\";\n" + " }\n" + " }\n" + "}", CONFDIR); + } +} + +/** HOOKTYPE_POSTCONF: advise on the certificate(s) used for server linking. + * A server pins a single SPKI fingerprint per server, so all our server-to-server + * paths (serversonly listeners and outgoing TLS links) must present the same + * certificate. We also require it to be a long-lived self-signed ceritifcate as + * a publicly-trusted CA cert is short(er)-lived and typically rekeys on renewal, + * which changes the spkifp and breaks linking). Because this runs in postconf, + * all the TLS context are fully available (this runs on both boot & rehash). + */ +int check_server_linking_cert_consistency(void) +{ + struct { + char desc[128]; + char spki[64]; + int trusted; + int has_own_tls_options; /* explicit per-block tls-options, vs the global default */ + TLSOptions *opts; + } s2s[128]; + int n = 0, i, capped = 0, mismatch = 0, diff = -1, rec = -1, ca = -1; + int n_inbound = 0, n_outbound = 0; + ConfigItem_listen *listen; + ConfigItem_link *link; + SSL_CTX *ctx; + const char *fp; + char fix[768]; + + /* Gather every server-to-server path that presents a certificate. */ + for (listen = conf_listen; listen; listen = listen->next) + { + if (listen->flag.temporary) + continue; /* block pending removal on rehash, holds stale tls_options */ + /* Only serversonly listeners. A port presents one certificate to + * everyone on it, so a listener that also serves clients can't carry the + * linking cert without handing that cert to clients too. The dual-cert + * setup therefore needs serversonly linking ports; this scoping is + * deliberate, not an oversight (widening to all server-capable listeners + * would false-warn on the common client port that isn't marked + * clientsonly). + */ + if (!(listen->options & LISTENER_SERVERSONLY)) + continue; + if (n >= (int)(sizeof(s2s)/sizeof(s2s[0]))) { capped = 1; break; } + /* A serversonly listener may also be reached plaintext and upgraded via + * STARTTLS, which still presents listener->ssl_ctx, so no LISTENER_TLS + * check. Prefer the already-built context (no disk I/O); only build a + * fresh one when there is none yet (a plain path at boot, before init_tls()). + */ + ctx = listen->ssl_ctx ? listen->ssl_ctx : ctx_server; + fp = ctx ? spkifp_from_ctx(ctx) + : server_linking_spkifp(listen->tls_options ? listen->tls_options : iConf.tls_options); + if (!fp) + continue; + strlcpy(s2s[n].spki, fp, sizeof(s2s[n].spki)); + snprintf(s2s[n].desc, sizeof(s2s[n].desc), "listen %s:%d", + listen->ip ? listen->ip : "*", listen->port); + s2s[n].trusted = ctx ? is_trusted_cert(ctx) : 0; + s2s[n].opts = listen->tls_options ? listen->tls_options : iConf.tls_options; + s2s[n].has_own_tls_options = (listen->tls_options != NULL); + n++; + n_inbound++; + } + for (link = conf_link; link && !capped; link = link->next) + { + if (link->flag.temporary) + continue; + /* Only outgoing links that present a certificate and may be spkifp/cert- + * verified by the peer. Skip incoming-only, 'insecure' (plaintext) and + * password-authenticated links (our cert is not pinned by either side). + */ + if (!link->outgoing.hostname && !link->outgoing.file) + continue; + if (link->outgoing.options & CONNECT_OUTGOING_INSECURE) + continue; + if (link->auth && link->auth->type == AUTHTYPE_PLAINTEXT) + continue; + if (n >= (int)(sizeof(s2s)/sizeof(s2s[0]))) { capped = 1; break; } + ctx = link->ssl_ctx ? link->ssl_ctx : ctx_client; + fp = ctx ? spkifp_from_ctx(ctx) + : server_linking_spkifp(link->tls_options ? link->tls_options : iConf.tls_options); + if (!fp) + continue; + strlcpy(s2s[n].spki, fp, sizeof(s2s[n].spki)); + snprintf(s2s[n].desc, sizeof(s2s[n].desc), "link %s", link->servername); + s2s[n].trusted = ctx ? is_trusted_cert(ctx) : 0; + s2s[n].opts = link->tls_options ? link->tls_options : iConf.tls_options; + s2s[n].has_own_tls_options = (link->tls_options != NULL); + n++; + n_outbound++; + } + + if (capped) + unreal_log(ULOG_WARNING, "config", "SERVER_LINKING_CERT_CHECK_CAPPED", NULL, + "Too many server-to-server TLS paths; only the first $num were checked " + "for certificate consistency.", + log_data_integer("num", n)); + + if (n == 0) + return 0; + + /* Mismatch = not all SPKIs equal. Recommended cert = first non-trusted one. */ + for (i = 1; i < n; i++) + { + if (strcasecmp(s2s[0].spki, s2s[i].spki)) + { + mismatch = 1; + if (diff < 0) + diff = i; + } + } + for (i = 0; i < n; i++) + { + if (!s2s[i].trusted) + { + rec = i; + break; + } + } + check_server_linking_cert_consistency_suggest_fix(rec >= 0 ? s2s[rec].opts : NULL, fix, sizeof(fix)); + + /* A mismatch only breaks linking when the same peer could be served two + * different certificates, which needs both an inbound path (a serversonly + * listener) and an outbound path (an outgoing link). If every path is the + * same direction (for example outgoing links only), each peer pins exactly + * the one certificate it sees, so differing certificates are harmless and + * we stay silent. + */ + if (mismatch && n_inbound && n_outbound) + { + if (iConf.server_linking_mixed_certificates) + return 0; /* admin acknowledged intentionally-different s2s certs */ + + if (iConf.server_linking_tls_options) + { + /* set::server-linking is configured, so the mismatch is caused by + * blocks that override it with their own tls-options. Name those + * (SPKI differs from the server-linking cert) so the admin knows + * exactly what to remove. + */ + char offenders[512]; + char slfp[64]; + const char *raw; + + offenders[0] = '\0'; + slfp[0] = '\0'; + raw = server_linking_spkifp(iConf.server_linking_tls_options); + if (raw) + strlcpy(slfp, raw, sizeof(slfp)); + for (i = 0; i < n; i++) + { + if (slfp[0] && strcasecmp(s2s[i].spki, slfp)) + { + if (offenders[0]) + strlcat(offenders, ", ", sizeof(offenders)); + strlcat(offenders, s2s[i].desc, sizeof(offenders)); + strlcat(offenders, " { }", sizeof(offenders)); + } + } + unreal_log(ULOG_WARNING, "config", "SERVER_LINKING_CERT_MISMATCH", NULL, + "This server uses a different TLS certificate for incoming server " + "connections than for outgoing links ($desc1 vs $desc2). This can break " + "server linking.\nRemove tls-options from the following blocks, which " + "override set::server-linking::tls-options: $offenders", + log_data_string("desc1", s2s[0].desc), + log_data_string("desc2", s2s[diff >= 0 ? diff : 0].desc), + log_data_string("offenders", offenders[0] ? offenders : "the relevant link/listen blocks")); + } + else + { + /* set::server-linking is not configured. Adding it fixes blocks that + * use the global/default certificate, but any block with its OWN + * tls-options whose certificate differs would still override it, so + * name those so the admin removes them too. + */ + char overrides[512]; + const char *rec_spki = (rec >= 0) ? s2s[rec].spki : ""; + + overrides[0] = '\0'; + for (i = 0; i < n; i++) + { + if (s2s[i].has_own_tls_options && (rec < 0 || strcasecmp(s2s[i].spki, rec_spki))) + { + if (overrides[0]) + strlcat(overrides, ", ", sizeof(overrides)); + strlcat(overrides, s2s[i].desc, sizeof(overrides)); + strlcat(overrides, " { }", sizeof(overrides)); + } + } + if (overrides[0]) + unreal_log(ULOG_WARNING, "config", "SERVER_LINKING_CERT_MISMATCH", NULL, + "This server uses a different TLS certificate for incoming server " + "connections than for outgoing links ($desc1 vs $desc2). This can break " + "server linking.\nFirst, remove tls-options from the following blocks: $overrides\n" + "After that, set one certificate for all server linking:\n$fix", + log_data_string("desc1", s2s[0].desc), + log_data_string("desc2", s2s[diff >= 0 ? diff : 0].desc), + log_data_string("fix", fix), + log_data_string("overrides", overrides)); + else + unreal_log(ULOG_WARNING, "config", "SERVER_LINKING_CERT_MISMATCH", NULL, + "This server uses a different TLS certificate for incoming server " + "connections than for outgoing links ($desc1 vs $desc2). This can break " + "server linking. Set one certificate for all server linking:\n$fix", + log_data_string("desc1", s2s[0].desc), + log_data_string("desc2", s2s[diff >= 0 ? diff : 0].desc), + log_data_string("fix", fix)); + } + return 0; + } + + /* Reached when all paths use the same certificate, or when a difference was + * found but cannot break linking (all paths the same direction, see above). + * Either way, advise if any path uses a public CA cert, since those rotate + * on renewal and change the spkifp. Scan all paths, not just s2s[0]: after a + * suppressed single-direction mismatch the CA cert may be on a later path. + */ + for (i = 0; i < n; i++) + { + if (s2s[i].trusted) + { + ca = i; + break; + } + } + if (ca >= 0 && !iConf.server_linking_allow_ca_certificate) + { + unreal_log(ULOG_ADVICE, "config", "SERVER_LINKING_CA_CERTIFICATE", NULL, + "Server linking is using a publicly-trusted (CA) certificate ($desc). " + "When such a certificate is renewed it usually gets a new key, which changes " + "the spkifp and breaks linking. For server linking, use a long-lived " + "self-signed certificate instead:\n$fix\n" + "(If you reuse the same key for certificate renewals, then set " + "set::server-linking::allow-ca-certificate yes; to silence this advice.)", + log_data_string("desc", s2s[ca].desc), + log_data_string("fix", fix)); + } + return 0; +} + int server_config_test_deny_link(ConfigFile *cf, ConfigEntry *ce, int type, int *errs) { int errors = 0; diff --git a/src/tls.c b/src/tls.c index ea46588b5..ca278160e 100644 --- a/src/tls.c +++ b/src/tls.c @@ -1202,7 +1202,13 @@ static int fatal_tls_error(int ssl_error, int where, int my_errno, Client *clien /** Do a TLS handshake after a STARTTLS, as a client */ int client_starttls(Client *client) { - if ((client->local->ssl = SSL_new(ctx_client)) == NULL) + /* If this is an outgoing server link, then use link::outgoing::tls-options, + * falling back to set::server-linking::tls-options. + * Otherwise, use the client context. + */ + SSL_CTX *ctx = (client->server && client->server->conf && client->server->conf->ssl_ctx) ? client->server->conf->ssl_ctx : ctx_client; + + if ((client->local->ssl = SSL_new(ctx)) == NULL) goto fail_starttls; SetTLS(client); @@ -1560,6 +1566,45 @@ const char *spki_fingerprint_ex(X509 *x509_cert) return NULL; } +/** Return the spkifp of the certificate specified as an SSL_CTX. + */ +const char *spkifp_from_ctx(SSL_CTX *ctx) +{ + SSL *ssl; + X509 *cert; + const char *fp = NULL; + + if (!ctx) + return NULL; + ssl = SSL_new(ctx); + if (ssl) + { + cert = SSL_get_certificate(ssl); + if (cert) + fp = spki_fingerprint_ex(cert); + SSL_free(ssl); + } + return fp; +} + +/** Return the spkifp of the certificate as specified in 'tlsoptions'. + * NOTE: if you already have an SSL_CTX then spkifp_from_ctx() is much faster. + */ +const char *server_linking_spkifp(TLSOptions *tlsoptions) +{ + SSL_CTX *ctx; + const char *fp; + + if (!tlsoptions) + return NULL; + ctx = init_ctx(tlsoptions, 1); + if (!ctx) + return NULL; + fp = spkifp_from_ctx(ctx); + SSL_CTX_free(ctx); + return fp; +} + /** Returns 1 if the client is using an outdated protocol or cipher, 0 otherwise */ int outdated_tls_client(Client *client) { diff --git a/src/unrealircdctl.c b/src/unrealircdctl.c index 7655af103..d188834a4 100644 --- a/src/unrealircdctl.c +++ b/src/unrealircdctl.c @@ -193,9 +193,10 @@ void unrealircdctl_spkifp(int argc, char *argv[]) if (!file) { - printf("NOTE: This script uses the default certificate location (any set::tls settings\n" - "are ignored). If this is not what you want then specify a certificate\n" - "explicitly like this: %s spkifp conf/tls/example.pem\n\n", UNREALCMD); + printf("NOTE: This script uses the default certificate location. Any\n" + "set::server-linking::tls-options and set::tls settings are ignored.\n" + "If this is not what you want, then specify a certificate explicitly\n" + "like this: %s spkifp conf/tls/example.pem\n\n", UNREALCMD); safe_strdup(file, "tls/server.cert.pem"); convert_to_absolute_path(&file, CONFDIR); } diff --git a/unrealircd.in b/unrealircd.in index 79bd8ed5a..6be667ae7 100644 --- a/unrealircd.in +++ b/unrealircd.in @@ -271,8 +271,14 @@ elif [ "$1" = "spki" -o "$1" = "spkifp" ] ; then $UNREALIRCDCTL $* elif [ "$1" = "mkcert" ] ; then TLSDIR="$CONFDIR/tls" - KEY="$TLSDIR/server.key.pem" - CERT="$TLSDIR/server.cert.pem" + BASE="$CONFDIR/tls/server" + if [ "$2" != "" ]; then + # Optional second argument: path prefix for the certificate/key + BASE="$2" + fi + KEY="$BASE.key.pem" + CERT="$BASE.cert.pem" + OUTDIR=`dirname "$CERT"` # Locate the OpenSSL configuration template. After 'make install' it # lives in the TLS directory. During initial setup (./Config), before @@ -287,15 +293,15 @@ elif [ "$1" = "mkcert" ] ; then exit 1 fi - if [ ! -d "$TLSDIR" ]; then - mkdir -p "$TLSDIR" || exit 1 - chmod 0700 "$TLSDIR" + if [ ! -d "$OUTDIR" ]; then + mkdir -p "$OUTDIR" || exit 1 + chmod 0700 "$OUTDIR" fi REPLACED=0 if [ -f "$CERT" ] || [ -f "$KEY" ]; then - echo "This command will replace your existing server certificate and key." - echo "(in $TLSDIR)" + echo "This command will replace your existing certificate and key." + echo "(in $OUTDIR)" echo -n "Do you wish to proceed? [Y|N] " read answer case "$answer" in @@ -328,12 +334,12 @@ elif [ "$1" = "mkcert" ] ; then echo "Generating self-signed certificate..." "$OPENSSLPATH" req -new -x509 -key "$KEY" -config "$CNF" -days 3650 -sha256 -out "$CERT" || exit 1 - echo "Setting permissions on server.*.pem files..." + echo "Setting permissions on the certificate and key..." chmod o-rwx "$KEY" "$CERT" chmod g-rwx "$KEY" "$CERT" echo "" - echo "A new self-signed certificate and key have been generated in $TLSDIR" + echo "A new self-signed certificate and key have been generated in $OUTDIR" if [ "$REPLACED" = 1 ]; then echo "Your previous certificate and key were backed up with a .old suffix." fi @@ -514,6 +520,7 @@ else echo " release candidate if the --rc argument is used)" echo "unrealircd mkpasswd Hash a password" echo "unrealircd mkcert Create or replace the self-signed TLS certificate" + echo " (optionally pass a path prefix; normal path rules apply)" echo "unrealircd version Display the UnrealIRCd version" echo "unrealircd module Install and uninstall 3rd party modules" echo "unrealircd croncheck For use in crontab: this checks if the server" From ce6f0782623725007d6704deff3f6cc75d730ce8 Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Wed, 17 Jun 2026 15:15:03 +0200 Subject: [PATCH 25/44] Deal better with multiple spkifp, such as ECC + ML-DSA. We now cache them and "./unrealircd genlinkblock" outputs multiple password ".." { spkifp; } lines in such a case. Other than that some cleaning up of recently-added-functions that are now no longer needed: we now create ctx_link_server and ctx_link_client that represent set::server-linking::tls-options for incoming and outgoing links. Which can be NULL, and then we use ctx_server / ctx_client (set::tls). Also add proper documentation on this. When using ./unrealircd spkifp, tell ./unrealircd genblock is cooler. Nah.. it takes more factors into account, genlinkblock, so is preferred :D --- doc/RELEASE-NOTES.md | 2 + include/h.h | 6 +- include/struct.h | 1 + src/conf.c | 110 ++++++------------------ src/modules/server.c | 78 +++++++++-------- src/modules/starttls.c | 4 +- src/modules/stats.c | 12 +-- src/socket.c | 2 +- src/tls.c | 190 +++++++++++++++++++++++++++++++---------- src/unrealircdctl.c | 13 +-- 10 files changed, 238 insertions(+), 180 deletions(-) diff --git a/doc/RELEASE-NOTES.md b/doc/RELEASE-NOTES.md index bfa2b9bce..4df3a55d8 100644 --- a/doc/RELEASE-NOTES.md +++ b/doc/RELEASE-NOTES.md @@ -84,6 +84,8 @@ This version enables multiline by default and adds TKL IDs. * In a future release we will make server linking with `spkifp` mandatory, so all of this helps with getting people ready for that, making such a future transition smooth. + * The command `./unrealircd genlinkblock` now also deals with ECC + ML-DSA + where you have multiple `password ".." { spkifp; }` entries. * Spamfilter regexes now use more sensible defaults in terms of "max effort", similar to what PHP has been using for years. This means very slow regexes will now raise a `SPAMFILTER_REGEX_ERROR` warning during execution if diff --git a/include/h.h b/include/h.h index 78ff91a01..028232325 100644 --- a/include/h.h +++ b/include/h.h @@ -983,6 +983,10 @@ extern const char *ssl_error_str(int err, int my_errno); extern void unreal_tls_client_handshake(int, int, void *); extern void SSL_set_nonblocking(SSL *s); extern SSL_CTX *init_ctx(TLSOptions *tlsoptions, int server); +extern SSL_CTX *tls_ctx_for_listener(ConfigItem_listen *listener); +extern SSL_CTX *tls_ctx_for_outgoing_link(ConfigItem_link *link); +extern TLSOptions *tls_options_for_listener(ConfigItem_listen *listener); +extern TLSOptions *tls_options_for_outgoing_link(ConfigItem_link *link); extern void test_tlsblock(ConfigFile *conf, ConfigEntry *cep, int *totalerrors); extern void set_server_linking_tlsoptions_ce(ConfigEntry *ce); extern const char *tls_get_cipher(Client *client); @@ -1190,8 +1194,6 @@ extern void sendbufto_one(Client *to, char *msg, unsigned int quick); extern MODVAR int current_serial; extern const char *spki_fingerprint(Client *acptr); extern const char *spki_fingerprint_ex(X509 *x509_cert); -extern const char *spkifp_from_ctx(SSL_CTX *ctx); -extern const char *server_linking_spkifp(TLSOptions *tlsoptions); extern int is_trusted_cert(SSL_CTX *ctx); extern int is_module_loaded(const char *name); extern int is_blacklisted_module(const char *name); diff --git a/include/struct.h b/include/struct.h index f19ff3b22..a5640502f 100644 --- a/include/struct.h +++ b/include/struct.h @@ -1893,6 +1893,7 @@ typedef struct TLSOptions TLSOptions; struct TLSOptions { NameList *certificate_files; NameList *key_files; + NameList *spkifp; /**< spkifp of each loaded certificate, computed by init_ctx() */ char *trusted_ca_file; unsigned int protocols; char *ciphers; diff --git a/src/conf.c b/src/conf.c index 1be9af664..aee42faec 100644 --- a/src/conf.c +++ b/src/conf.c @@ -202,7 +202,6 @@ int tls_tests(void); /* Conf sub-sub-functions */ void conf_tlsblock(ConfigFile *conf, ConfigEntry *cep, TLSOptions *tlsoptions, TLSOptions *inherit_from); -TLSOptions *duplicate_tls_options(TLSOptions *src); void free_tls_options(TLSOptions *tlsoptions); /* @@ -3252,6 +3251,10 @@ int config_run_blocks(void) tempiConf.server_linking_tls_options = safe_alloc(sizeof(TLSOptions)); conf_tlsblock(conf, server_linking_tlsoptions_ce, tempiConf.server_linking_tls_options, tempiConf.tls_options); server_linking_tlsoptions_ce = NULL; + /* ctx_link_server and ctx_link_client is + * created/updated by init_tls() and reinit_tls(). Those + * also compute the cached spkifp. + */ } } } @@ -5614,14 +5617,10 @@ void conf_listen_configure(const char *ip, int port, SocketType socket_type, int tempiConf.server_linking_tls_options : tempiConf.tls_options); listen->ssl_ctx = init_ctx(listen->tls_options, 1); } - else if ((options & LISTENER_SERVERSONLY) && tempiConf.server_linking_tls_options) - { - /* No explicit tls-options on this serversonly listener, but - * set::server-linking::tls-options exists/is configured. Use that one. - */ - listen->tls_options = duplicate_tls_options(tempiConf.server_linking_tls_options); - listen->ssl_ctx = init_ctx(listen->tls_options, 1); - } + /* A serversonly listener with no tls-options of its own uses the shared + * set::server-linking context at runtime (tls_ctx_for_listener()), so we + * deliberately do NOT build a per-listener context here. + */ /* For modules that hook CONFIG_LISTEN and CONFIG_LISTEN_OPTIONS. * Yeah, ugly we have this here.. @@ -6773,19 +6772,10 @@ int _conf_link(ConfigFile *conf, ConfigEntry *ce) if (!link->hub && !link->leaf) safe_strdup(link->hub, "*"); - /* TLS Configuration for this link block: if this link connects out - * (any non-'insecure' link, including plaintext links that upgrade to - * TLS via STARTTLS), and there is no explicit link::outgoing::tls-options - * set, then we use set::server-linking::tls-options. + /* An outgoing link with no link::outgoing::tls-options of its own uses the + * shared set::server-linking context at connect time + * (tls_ctx_for_outgoing_link()), so we do NOT build a per-link context here. */ - if ((link->outgoing.hostname || link->outgoing.file) && - !(link->outgoing.options & CONNECT_OUTGOING_INSECURE) && - !link->tls_options && - tempiConf.server_linking_tls_options) - { - link->tls_options = duplicate_tls_options(tempiConf.server_linking_tls_options); - link->ssl_ctx = init_ctx(link->tls_options, 0); - } AppendListItem(link, conf_link); return 0; @@ -7706,6 +7696,7 @@ void free_tls_options(TLSOptions *tlsoptions) safe_free_name_list(tlsoptions->certificate_files); safe_free_name_list(tlsoptions->key_files); + safe_free_name_list(tlsoptions->spkifp); safe_free(tlsoptions->trusted_ca_file); safe_free(tlsoptions->ciphers); safe_free(tlsoptions->ciphersuites); @@ -7717,43 +7708,6 @@ void free_tls_options(TLSOptions *tlsoptions) safe_free(tlsoptions); } -/** Make a complete (deep) copy of TLSOptions. - * Unlike conf_tlsblock()'s inheritance, this is a 100% clone with no - * base+block merge, so certificate_files and key_files are copied as-is - * (a multi-cert RSA+ECC or ECC+postquantum set stays intact, so be - * careful if you have to deal with overriding this that you don't - * accidentally "add" but "replace" instead). - * If you are in config-based code, you almost definately will want to - * use conf_tlsblock() instead. - */ -TLSOptions *duplicate_tls_options(TLSOptions *src) -{ - TLSOptions *n; - - if (!src) - return NULL; - - n = safe_alloc(sizeof(TLSOptions)); - n->certificate_files = duplicate_name_list(src->certificate_files); - n->key_files = duplicate_name_list(src->key_files); - safe_strdup(n->trusted_ca_file, src->trusted_ca_file); - n->protocols = src->protocols; - safe_strdup(n->ciphers, src->ciphers); - safe_strdup(n->ciphersuites, src->ciphersuites); - safe_strdup(n->groups, src->groups); - safe_strdup(n->signature_algorithms, src->signature_algorithms); - safe_strdup(n->outdated_protocols, src->outdated_protocols); - safe_strdup(n->outdated_ciphers, src->outdated_ciphers); - n->options = src->options; - n->renegotiate_bytes = src->renegotiate_bytes; - n->renegotiate_timeout = src->renegotiate_timeout; - n->sts_port = src->sts_port; - n->sts_duration = src->sts_duration; - n->sts_preload = src->sts_preload; - n->certificate_expiry_notification = src->certificate_expiry_notification; - return n; -} - void conf_tlsblock(ConfigFile *conf, ConfigEntry *cep, TLSOptions *tlsoptions, TLSOptions *inherit_from) { ConfigEntry *cepp, *ceppp; @@ -11967,29 +11921,14 @@ int reloadable_perm_module_unloaded(void) return ret; } -const char *link_generator_spkifp(TLSOptions *tlsoptions) -{ - SSL_CTX *ctx; - SSL *ssl; - X509 *cert; - - ctx = init_ctx(tlsoptions, 1); - if (!ctx) - exit(1); - ssl = SSL_new(ctx); - if (!ssl) - exit(1); - cert = SSL_get_certificate(ssl); - return spki_fingerprint_ex(cert); -} - void link_generator(void) { ConfigItem_listen *lstn; TLSOptions *tlsopt = iConf.server_linking_tls_options ? iConf.server_linking_tls_options : iConf.tls_options; /* set::server-linking::tls-options and otherwise set::tls */ int port = 0; char *ip = NULL; - const char *spkifp; + SSL_CTX *ctx; + NameList *fp; for (lstn = conf_listen; lstn; lstn = lstn->next) { @@ -12014,8 +11953,9 @@ void link_generator(void) exit(1); } - spkifp = link_generator_spkifp(tlsopt); - if (!spkifp) + /* init_ctx() will compute the spkifp(s) */ + ctx = init_ctx(tlsopt, 1); + if (!ctx || !tlsopt->spkifp) { printf("Could not calculate spkifp. Maybe you have uncommon TLS options set? Odd...\n"); exit(1); @@ -12033,14 +11973,18 @@ void link_generator(void) " hostname %s;\n" " port %d;\n" " options { tls; autoconnect; }\n" - " }\n" - " password \"%s\" { spkifp; }\n" - " class servers;\n" - "}\n", + " }\n", conf_me->name, ip ? ip : conf_me->name, - port, - spkifp); + port); + /* The simple case is a single password ".." { spkifp; } line, but we also + * have to deal with the case of multiple certificate/keys with multiple + * password..spkifp lines. Like for ECC+RSA or ECC+ML-DSA. + */ + for (fp = tlsopt->spkifp; fp; fp = fp->next) + printf(" password \"%s\" { spkifp; }\n", fp->name); + printf(" class servers;\n" + "}\n"); printf("################################################################################\n"); exit(0); } diff --git a/src/modules/server.c b/src/modules/server.c index 29b86286f..1794de21b 100644 --- a/src/modules/server.c +++ b/src/modules/server.c @@ -363,10 +363,26 @@ static void check_server_linking_cert_consistency_suggest_fix(TLSOptions *opts, } } +/* Return 1 if two spkifp lists hold the same set of fingerprints (order- + * independent). Used to compare the certificate sets of two server-to-server paths. + */ +static int spkifp_sets_equal(NameList *a, NameList *b) +{ + NameList *e; + + for (e = a; e; e = e->next) + if (!find_name_list(b, e->name)) + return 0; + for (e = b; e; e = e->next) + if (!find_name_list(a, e->name)) + return 0; + return 1; +} + /** HOOKTYPE_POSTCONF: advise on the certificate(s) used for server linking. - * A server pins a single SPKI fingerprint per server, so all our server-to-server + * A peer authenticates us by our SPKI fingerprint(s), so all our server-to-server * paths (serversonly listeners and outgoing TLS links) must present the same - * certificate. We also require it to be a long-lived self-signed ceritifcate as + * certificate(s). We also require it to be a long-lived self-signed ceritifcate as * a publicly-trusted CA cert is short(er)-lived and typically rekeys on renewal, * which changes the spkifp and breaks linking). Because this runs in postconf, * all the TLS context are fully available (this runs on both boot & rehash). @@ -375,7 +391,6 @@ int check_server_linking_cert_consistency(void) { struct { char desc[128]; - char spki[64]; int trusted; int has_own_tls_options; /* explicit per-block tls-options, vs the global default */ TLSOptions *opts; @@ -385,12 +400,13 @@ int check_server_linking_cert_consistency(void) ConfigItem_listen *listen; ConfigItem_link *link; SSL_CTX *ctx; - const char *fp; char fix[768]; /* Gather every server-to-server path that presents a certificate. */ for (listen = conf_listen; listen; listen = listen->next) { + TLSOptions *opts; + if (listen->flag.temporary) continue; /* block pending removal on rehash, holds stale tls_options */ /* Only serversonly listeners. A port presents one certificate to @@ -405,31 +421,29 @@ int check_server_linking_cert_consistency(void) continue; if (n >= (int)(sizeof(s2s)/sizeof(s2s[0]))) { capped = 1; break; } /* A serversonly listener may also be reached plaintext and upgraded via - * STARTTLS, which still presents listener->ssl_ctx, so no LISTENER_TLS - * check. Prefer the already-built context (no disk I/O); only build a - * fresh one when there is none yet (a plain path at boot, before init_tls()). + * STARTTLS, which still presents listener->ssl_ctx, so no LISTENER_TLS check. */ - ctx = listen->ssl_ctx ? listen->ssl_ctx : ctx_server; - fp = ctx ? spkifp_from_ctx(ctx) - : server_linking_spkifp(listen->tls_options ? listen->tls_options : iConf.tls_options); - if (!fp) - continue; - strlcpy(s2s[n].spki, fp, sizeof(s2s[n].spki)); + opts = tls_options_for_listener(listen); + if (!opts || !opts->spkifp) + continue; /* no certificate loaded, nothing to compare */ + ctx = tls_ctx_for_listener(listen); snprintf(s2s[n].desc, sizeof(s2s[n].desc), "listen %s:%d", listen->ip ? listen->ip : "*", listen->port); s2s[n].trusted = ctx ? is_trusted_cert(ctx) : 0; - s2s[n].opts = listen->tls_options ? listen->tls_options : iConf.tls_options; + s2s[n].opts = opts; s2s[n].has_own_tls_options = (listen->tls_options != NULL); n++; n_inbound++; } for (link = conf_link; link && !capped; link = link->next) { + TLSOptions *opts; + if (link->flag.temporary) continue; /* Only outgoing links that present a certificate and may be spkifp/cert- - * verified by the peer. Skip incoming-only, 'insecure' (plaintext) and - * password-authenticated links (our cert is not pinned by either side). + * authenticated by the peer. Skip incoming-only, 'insecure' (plaintext) and + * password-authenticated links (our cert is not authenticated by either side). */ if (!link->outgoing.hostname && !link->outgoing.file) continue; @@ -438,15 +452,13 @@ int check_server_linking_cert_consistency(void) if (link->auth && link->auth->type == AUTHTYPE_PLAINTEXT) continue; if (n >= (int)(sizeof(s2s)/sizeof(s2s[0]))) { capped = 1; break; } - ctx = link->ssl_ctx ? link->ssl_ctx : ctx_client; - fp = ctx ? spkifp_from_ctx(ctx) - : server_linking_spkifp(link->tls_options ? link->tls_options : iConf.tls_options); - if (!fp) - continue; - strlcpy(s2s[n].spki, fp, sizeof(s2s[n].spki)); + opts = tls_options_for_outgoing_link(link); + if (!opts || !opts->spkifp) + continue; /* no certificate loaded, nothing to compare */ + ctx = tls_ctx_for_outgoing_link(link); snprintf(s2s[n].desc, sizeof(s2s[n].desc), "link %s", link->servername); s2s[n].trusted = ctx ? is_trusted_cert(ctx) : 0; - s2s[n].opts = link->tls_options ? link->tls_options : iConf.tls_options; + s2s[n].opts = opts; s2s[n].has_own_tls_options = (link->tls_options != NULL); n++; n_outbound++; @@ -461,10 +473,10 @@ int check_server_linking_cert_consistency(void) if (n == 0) return 0; - /* Mismatch = not all SPKIs equal. Recommended cert = first non-trusted one. */ + /* Mismatch = not all certificate sets equal. Recommended cert = first non-trusted one. */ for (i = 1; i < n; i++) { - if (strcasecmp(s2s[0].spki, s2s[i].spki)) + if (!spkifp_sets_equal(s2s[0].opts->spkifp, s2s[i].opts->spkifp)) { mismatch = 1; if (diff < 0) @@ -501,17 +513,12 @@ int check_server_linking_cert_consistency(void) * exactly what to remove. */ char offenders[512]; - char slfp[64]; - const char *raw; offenders[0] = '\0'; - slfp[0] = '\0'; - raw = server_linking_spkifp(iConf.server_linking_tls_options); - if (raw) - strlcpy(slfp, raw, sizeof(slfp)); for (i = 0; i < n; i++) { - if (slfp[0] && strcasecmp(s2s[i].spki, slfp)) + if (iConf.server_linking_tls_options->spkifp && + !spkifp_sets_equal(s2s[i].opts->spkifp, iConf.server_linking_tls_options->spkifp)) { if (offenders[0]) strlcat(offenders, ", ", sizeof(offenders)); @@ -536,12 +543,11 @@ int check_server_linking_cert_consistency(void) * name those so the admin removes them too. */ char overrides[512]; - const char *rec_spki = (rec >= 0) ? s2s[rec].spki : ""; overrides[0] = '\0'; for (i = 0; i < n; i++) { - if (s2s[i].has_own_tls_options && (rec < 0 || strcasecmp(s2s[i].spki, rec_spki))) + if (s2s[i].has_own_tls_options && (rec < 0 || !spkifp_sets_equal(s2s[i].opts->spkifp, s2s[rec].opts->spkifp))) { if (overrides[0]) strlcat(overrides, ", ", sizeof(overrides)); @@ -554,7 +560,7 @@ int check_server_linking_cert_consistency(void) "This server uses a different TLS certificate for incoming server " "connections than for outgoing links ($desc1 vs $desc2). This can break " "server linking.\nFirst, remove tls-options from the following blocks: $overrides\n" - "After that, set one certificate for all server linking:\n$fix", + "After that, use the same certificate(s) for all server linking:\n$fix", log_data_string("desc1", s2s[0].desc), log_data_string("desc2", s2s[diff >= 0 ? diff : 0].desc), log_data_string("fix", fix), @@ -563,7 +569,7 @@ int check_server_linking_cert_consistency(void) unreal_log(ULOG_WARNING, "config", "SERVER_LINKING_CERT_MISMATCH", NULL, "This server uses a different TLS certificate for incoming server " "connections than for outgoing links ($desc1 vs $desc2). This can break " - "server linking. Set one certificate for all server linking:\n$fix", + "server linking. Use the same certificate(s) for all server linking:\n$fix", log_data_string("desc1", s2s[0].desc), log_data_string("desc2", s2s[diff >= 0 ? diff : 0].desc), log_data_string("fix", fix)); diff --git a/src/modules/starttls.c b/src/modules/starttls.c index bdaee2f42..7bcfc6856 100644 --- a/src/modules/starttls.c +++ b/src/modules/starttls.c @@ -68,8 +68,8 @@ CMD_FUNC(cmd_starttls) if (!MyConnect(client) || !IsUnknown(client)) return; - ctx = client->local->listener->ssl_ctx ? client->local->listener->ssl_ctx : ctx_server; - tls_options = client->local->listener->tls_options ? client->local->listener->tls_options->options : iConf.tls_options->options; + ctx = tls_ctx_for_listener(client->local->listener); + tls_options = tls_options_for_listener(client->local->listener)->options; /* This should never happen? */ if (!ctx) diff --git a/src/modules/stats.c b/src/modules/stats.c index 63a47d6fd..485067c54 100644 --- a/src/modules/stats.c +++ b/src/modules/stats.c @@ -565,15 +565,11 @@ int stats_port(Client *client, const char *para) } if (listener->options & LISTENER_TLS) { + TLSOptions *o = tls_options_for_listener(listener); + const char *kind = listener->tls_options ? "" : (o == iConf.server_linking_tls_options) ? "server-linking " : "default "; NameList *n, *n2; - if (listener->tls_options) - { - for (n = listener->tls_options->certificate_files, n2 = listener->tls_options->key_files; n && n2; n = n->next, n2 = n2->next) - sendtxtnumeric(client, "- using tls certificate %s + key %s", n->name, n2->name); - } else { - for (n = iConf.tls_options->certificate_files, n2 = iConf.tls_options->key_files; n && n2; n = n->next, n2 = n2->next) - sendtxtnumeric(client, "- using default tls certificate %s + key %s", n->name, n2->name); - } + for (n = o->certificate_files, n2 = o->key_files; n && n2; n = n->next, n2 = n2->next) + sendtxtnumeric(client, "- using %stls certificate %s + key %s", kind, n->name, n2->name); } } return 0; diff --git a/src/socket.c b/src/socket.c index 1fe4fe46d..fb498ce40 100644 --- a/src/socket.c +++ b/src/socket.c @@ -939,7 +939,7 @@ Client *add_connection(ConfigItem_listen *listener, int fd) if ((listener->options & LISTENER_TLS) && ctx_server) { - SSL_CTX *ctx = listener->ssl_ctx ? listener->ssl_ctx : ctx_server; + SSL_CTX *ctx = tls_ctx_for_listener(listener); if (ctx) { diff --git a/src/tls.c b/src/tls.c index ca278160e..0a3dc5355 100644 --- a/src/tls.c +++ b/src/tls.c @@ -48,8 +48,10 @@ int cipher_check(SSL_CTX *ctx, char **errstr); int certificate_quality_check(SSL_CTX *ctx, char **errstr); /* The TLS structures */ -SSL_CTX *ctx_server; -SSL_CTX *ctx_client; +SSL_CTX *ctx_server; /**< Default TLS context for incoming connections (set::tls). */ +SSL_CTX *ctx_client; /**< Default TLS context for outgoing connections (set::tls). */ +static SSL_CTX *ctx_link_server = NULL; /**< Default TLS context for incoming server links (set::server-linking::tls-options). NULL when not set, then use ctx_server instead. */ +static SSL_CTX *ctx_link_client = NULL; /**< Default TLS context for outgoing server links (set::server-linking::tls-options). NULL when not set, then use ctx_client instead. */ char *TLSKeyPasswd; @@ -381,6 +383,16 @@ SSL_CTX *init_ctx(TLSOptions *tlsoptions, int server) // TODO: verify same amount of certificate_files vs key_files :D + /* (Re)compute the cached spkifp list for these options. init_ctx() can be + * called more than once on the same TLSOptions (eg ctx_server + ctx_client, + * and again on rehash), so clear it first to avoid duplicate entries. + * + * Note: rebuilding spkifp is a side effect, so throwaway callers (the expiry + * check, test_tlsblock) also rebuild the live list. Harmless for now. + * TODO: add an init_ctx() flag so those callers can skip it. + */ + safe_free_name_list(tlsoptions->spkifp); + for (n = tlsoptions->certificate_files, n2 = tlsoptions->key_files; n && n2; n = n->next, n2 = n2->next) { if (SSL_CTX_use_certificate_chain_file(ctx, n->name) <= 0) @@ -422,6 +434,24 @@ SSL_CTX *init_ctx(TLSOptions *tlsoptions, int server) } goto fail; } + + /* Cache the spkifp of this just-loaded leaf certificate, so consumers + * (genlinkblock and the server-linking advisor) see all of a multi-cert + * server's fingerprints without re-reading files. SSL_new() + + * SSL_get_certificate() works on all OpenSSL/LibreSSL versions and + * returns the context's current (= just-loaded) certificate. + */ + { + SSL *probe = SSL_new(ctx); + if (probe) + { + X509 *leafcert = SSL_get_certificate(probe); + const char *fp = leafcert ? spki_fingerprint_ex(leafcert) : NULL; + if (fp) + add_name_list(tlsoptions->spkifp, fp); + SSL_free(probe); + } + } } if (SSL_CTX_set_cipher_list(ctx, tlsoptions->ciphers) == 0) @@ -579,6 +609,8 @@ SSL_CTX *init_ctx(TLSOptions *tlsoptions, int server) return ctx; fail: + /* Don't leave a partial cached spkifp behind on failure. */ + safe_free_name_list(tlsoptions->spkifp); SSL_CTX_free(ctx); return NULL; } @@ -719,6 +751,56 @@ int early_init_tls(void) return 1; } +/** Return the TLS context an incoming connection on this listener should use. + * A serversonly listener with no per-listener tls-options presents the + * set::server-linking certificate (when configured); everything else uses the + * normal set::tls server context. + */ +SSL_CTX *tls_ctx_for_listener(ConfigItem_listen *listener) +{ + if (listener->ssl_ctx) + return listener->ssl_ctx; + if ((listener->options & LISTENER_SERVERSONLY) && ctx_link_server) + return ctx_link_server; + return ctx_server; +} + +/** Return the TLS context an outgoing server link should use. A link with no + * per-link tls-options presents the set::server-linking certificate (when + * configured); otherwise the normal set::tls client context. + */ +SSL_CTX *tls_ctx_for_outgoing_link(ConfigItem_link *link) +{ + if (link->ssl_ctx) + return link->ssl_ctx; + if (ctx_link_client) + return ctx_link_client; + return ctx_client; +} + +/** Return the effective TLSOptions for an incoming connection on this listener + * (same selection as tls_ctx_for_listener(), but the options struct). Used for + * STARTTLS option flags and by the server-linking certificate advisor. + */ +TLSOptions *tls_options_for_listener(ConfigItem_listen *listener) +{ + if (listener->tls_options) + return listener->tls_options; + if ((listener->options & LISTENER_SERVERSONLY) && iConf.server_linking_tls_options) + return iConf.server_linking_tls_options; + return iConf.tls_options; +} + +/** Return the effective TLSOptions for an outgoing server link. */ +TLSOptions *tls_options_for_outgoing_link(ConfigItem_link *link) +{ + if (link->tls_options) + return link->tls_options; + if (iConf.server_linking_tls_options) + return iConf.server_linking_tls_options; + return iConf.tls_options; +} + /** Initialize the server and client contexts. * This is only possible after reading the configuration file. */ @@ -730,6 +812,15 @@ int init_tls(void) ctx_client = init_ctx(iConf.tls_options, 0); if (!ctx_client) return 0; + if (iConf.server_linking_tls_options) + { + ctx_link_server = init_ctx(iConf.server_linking_tls_options, 1); + if (!ctx_link_server) + return 0; + ctx_link_client = init_ctx(iConf.server_linking_tls_options, 0); + if (!ctx_link_client) + return 0; + } return 1; } @@ -764,6 +855,45 @@ int reinit_tls(void) SSL_CTX_free(ctx_client); ctx_client = tmp; /* activate */ + /* set::server-linking::tls-options (free+NULL if no longer configured) */ + if (iConf.server_linking_tls_options) + { + tmp = init_ctx(iConf.server_linking_tls_options, 1); + if (!tmp) + { + unreal_log(ULOG_ERROR, "config", "TLS_RELOAD_FAILED", NULL, + "TLS Reload failed at set::server-linking::tls-options. See previous errors."); + return 0; + } + if (ctx_link_server) + SSL_CTX_free(ctx_link_server); + ctx_link_server = tmp; /* activate */ + + tmp = init_ctx(iConf.server_linking_tls_options, 0); + if (!tmp) + { + unreal_log(ULOG_ERROR, "config", "TLS_RELOAD_FAILED", NULL, + "TLS Reload failed at set::server-linking::tls-options (client). See previous errors."); + return 0; + } + if (ctx_link_client) + SSL_CTX_free(ctx_link_client); + ctx_link_client = tmp; /* activate */ + } + else + { + if (ctx_link_server) + { + SSL_CTX_free(ctx_link_server); + ctx_link_server = NULL; + } + if (ctx_link_client) + { + SSL_CTX_free(ctx_link_client); + ctx_link_client = NULL; + } + } + /* listen::tls-options.... */ for (listen = conf_listen; listen; listen = listen->next) { @@ -875,6 +1005,16 @@ TLSOptions *get_tls_options_for_client(Client *client) return client->server->conf->tls_options; if (client->local && client->local->listener && client->local->listener->tls_options) return client->local->listener->tls_options; + /* No own tls-options: fall back to set::server-linking for s2s paths (like + * tls_ctx_for_*() does for the context), else set::tls. + */ + if (iConf.server_linking_tls_options) + { + if (client->server && client->server->conf) + return iConf.server_linking_tls_options; + if (client->local->listener && (client->local->listener->options & LISTENER_SERVERSONLY)) + return iConf.server_linking_tls_options; + } return iConf.tls_options; } @@ -882,7 +1022,7 @@ TLSOptions *get_tls_options_for_client(Client *client) void unreal_tls_client_handshake(int fd, int revents, void *data) { Client *client = data; - SSL_CTX *ctx = (client->server && client->server->conf && client->server->conf->ssl_ctx) ? client->server->conf->ssl_ctx : ctx_client; + SSL_CTX *ctx = (client->server && client->server->conf) ? tls_ctx_for_outgoing_link(client->server->conf) : ctx_client; TLSOptions *tlsoptions = get_tls_options_for_client(client); if (!ctx) @@ -1206,7 +1346,7 @@ int client_starttls(Client *client) * falling back to set::server-linking::tls-options. * Otherwise, use the client context. */ - SSL_CTX *ctx = (client->server && client->server->conf && client->server->conf->ssl_ctx) ? client->server->conf->ssl_ctx : ctx_client; + SSL_CTX *ctx = (client->server && client->server->conf) ? tls_ctx_for_outgoing_link(client->server->conf) : ctx_client; if ((client->local->ssl = SSL_new(ctx)) == NULL) goto fail_starttls; @@ -1566,45 +1706,6 @@ const char *spki_fingerprint_ex(X509 *x509_cert) return NULL; } -/** Return the spkifp of the certificate specified as an SSL_CTX. - */ -const char *spkifp_from_ctx(SSL_CTX *ctx) -{ - SSL *ssl; - X509 *cert; - const char *fp = NULL; - - if (!ctx) - return NULL; - ssl = SSL_new(ctx); - if (ssl) - { - cert = SSL_get_certificate(ssl); - if (cert) - fp = spki_fingerprint_ex(cert); - SSL_free(ssl); - } - return fp; -} - -/** Return the spkifp of the certificate as specified in 'tlsoptions'. - * NOTE: if you already have an SSL_CTX then spkifp_from_ctx() is much faster. - */ -const char *server_linking_spkifp(TLSOptions *tlsoptions) -{ - SSL_CTX *ctx; - const char *fp; - - if (!tlsoptions) - return NULL; - ctx = init_ctx(tlsoptions, 1); - if (!ctx) - return NULL; - fp = spkifp_from_ctx(ctx); - SSL_CTX_free(ctx); - return fp; -} - /** Returns 1 if the client is using an outdated protocol or cipher, 0 otherwise */ int outdated_tls_client(Client *client) { @@ -1744,6 +1845,9 @@ EVENT(tls_check_expiry) /* set block */ check_certificate_expiry_tlsoptions_and_warn(iConf.tls_options); + if (iConf.server_linking_tls_options) + check_certificate_expiry_tlsoptions_and_warn(iConf.server_linking_tls_options); + for (listen = conf_listen; listen; listen = listen->next) if (listen->tls_options) check_certificate_expiry_tlsoptions_and_warn(listen->tls_options); diff --git a/src/unrealircdctl.c b/src/unrealircdctl.c index d188834a4..5c1547ec1 100644 --- a/src/unrealircdctl.c +++ b/src/unrealircdctl.c @@ -193,10 +193,13 @@ void unrealircdctl_spkifp(int argc, char *argv[]) if (!file) { - printf("NOTE: This script uses the default certificate location. Any\n" - "set::server-linking::tls-options and set::tls settings are ignored.\n" - "If this is not what you want, then specify a certificate explicitly\n" - "like this: %s spkifp conf/tls/example.pem\n\n", UNREALCMD); + printf("IMPORTANT: If you are configuring server linking then we HIGHLY recommend\n" + "using the '%s genlinkblock' command instead. It outputs a complete\n" + "link block, with the right port and the correct spkifp(s), and it uses\n" + "your configured set::server-linking and set::tls certificates. This\n" + "'spkifp' command ignores those and just reads the default certificate,\n" + "or a file you specify like '%s spkifp conf/tls/example.pem'.\n\n", + UNREALCMD, UNREALCMD); safe_strdup(file, "tls/server.cert.pem"); convert_to_absolute_path(&file, CONFDIR); } @@ -205,7 +208,7 @@ void unrealircdctl_spkifp(int argc, char *argv[]) { printf("Could not open certificate: %s\n" "You can specify a certificate like this: %s spkifp conf/tls/example.pem\n", - UNREALCMD, file); + file, UNREALCMD); exit(1); } From c100059fa766c9e0b385b569b66b3e39ff5316e3 Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Wed, 17 Jun 2026 18:41:09 +0200 Subject: [PATCH 26/44] Add new function: append_name_list(). Use it at two places where we print copy-pastable config blocks. Previously we used add_name_list(), which uses insert at beginning, which would reverse the order. Also changed duplicate_name_list() to preserve order. Previously it reversed the order of all items. --- include/h.h | 1 + include/struct.h | 4 +++- src/conf.c | 4 ++-- src/list.c | 24 +++++++++++++++++++----- src/tls.c | 2 +- 5 files changed, 26 insertions(+), 9 deletions(-) diff --git a/include/h.h b/include/h.h index 028232325..83ce96d78 100644 --- a/include/h.h +++ b/include/h.h @@ -1255,6 +1255,7 @@ extern int write_str(FILE *fd, const char *x); extern int read_str(FILE *fd, char **x); extern void _free_entire_name_list(NameList *n); extern void _add_name_list(NameList **list, const char *name); +extern void _append_name_list(NameList **list, const char *name); extern void _del_name_list(NameList **list, const char *name); extern NameList *duplicate_name_list(NameList *e); extern NameList *find_name_list(NameList *list, const char *name); diff --git a/include/struct.h b/include/struct.h index a5640502f..2c38bd4f2 100644 --- a/include/struct.h +++ b/include/struct.h @@ -802,7 +802,7 @@ typedef struct NameList NameList; * that only have a name and no other properties. * * Use the following functions to add, find and delete entries: - * add_name_list(), find_name_list(), del_name_list(), free_entire_name_list() + * add_name_list(), append_name_list(), find_name_list(), del_name_list(), free_entire_name_list() */ struct NameList { NameList *prev, *next; @@ -815,6 +815,8 @@ struct NameList { /** Add an entry to a NameList */ #define add_name_list(list, str) _add_name_list(&list, str) +/** Append an entry to the end of a NameList */ +#define append_name_list(list, str) _append_name_list(&list, str) /** Delete an entry from a NameList - AND free it */ #define del_name_list(list, str) _del_name_list(&list, str) diff --git a/src/conf.c b/src/conf.c index aee42faec..27ef5a914 100644 --- a/src/conf.c +++ b/src/conf.c @@ -7801,12 +7801,12 @@ void conf_tlsblock(ConfigFile *conf, ConfigEntry *cep, TLSOptions *tlsoptions, T else if (!strcmp(cepp->name, "certificate")) { convert_to_absolute_path(&cepp->value, CONFDIR); - add_name_list(tlsoptions->certificate_files, cepp->value); + append_name_list(tlsoptions->certificate_files, cepp->value); } else if (!strcmp(cepp->name, "key")) { convert_to_absolute_path(&cepp->value, CONFDIR); - add_name_list(tlsoptions->key_files, cepp->value); + append_name_list(tlsoptions->key_files, cepp->value); } else if (!strcmp(cepp->name, "trusted-ca-file")) { diff --git a/src/list.c b/src/list.c index 60a03ea55..5e735a11c 100644 --- a/src/list.c +++ b/src/list.c @@ -563,6 +563,13 @@ void _add_name_list(NameList **list, const char *name) AddListItem(e, *list); } +void _append_name_list(NameList **list, const char *name) +{ + NameList *e = safe_alloc(sizeof(NameList)+strlen(name)+1); + strcpy(e->name, name); /* safe, allocated above */ + AppendListItem(e, *list); +} + void _free_entire_name_list(NameList *n) { NameList *n_next; @@ -576,13 +583,20 @@ void _free_entire_name_list(NameList *n) NameList *duplicate_name_list(NameList *e) { - NameList *ret = NULL; - - if (e == NULL) - return NULL; + NameList *ret = NULL, *tail = NULL, *n; + /* We do manual pointer tracking here to speed things up */ for (; e; e = e->next) - add_name_list(ret, e->name); + { + n = safe_alloc(sizeof(NameList)+strlen(e->name)+1); + strcpy(n->name, e->name); /* safe, allocated above */ + if (tail) + tail->next = n; + else + ret = n; + n->prev = tail; + tail = n; + } return ret; } diff --git a/src/tls.c b/src/tls.c index 0a3dc5355..772914a39 100644 --- a/src/tls.c +++ b/src/tls.c @@ -448,7 +448,7 @@ SSL_CTX *init_ctx(TLSOptions *tlsoptions, int server) X509 *leafcert = SSL_get_certificate(probe); const char *fp = leafcert ? spki_fingerprint_ex(leafcert) : NULL; if (fp) - add_name_list(tlsoptions->spkifp, fp); + append_name_list(tlsoptions->spkifp, fp); SSL_free(probe); } } From 4966b59812dd0328f9912ee02e3222bb319c2054 Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Wed, 17 Jun 2026 19:48:33 +0200 Subject: [PATCH 27/44] Update release notes [skip ci] --- doc/RELEASE-NOTES.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/doc/RELEASE-NOTES.md b/doc/RELEASE-NOTES.md index 4df3a55d8..74756e053 100644 --- a/doc/RELEASE-NOTES.md +++ b/doc/RELEASE-NOTES.md @@ -4,7 +4,10 @@ UnrealIRCd 6.2.6-git This is the git version (development version) for future UnrealIRCd 6.2.6. This is work in progress and may not always be a stable version. -This version enables multiline by default and adds TKL IDs. +This version enables multiline by default, adds TKL IDs and tracking of +hit counts on *LINES/Spamfilter. New crule functions were added to fetch +server flood counts. Guidance to admins for server linking with 'spkifp' +has been improved. ### Enhancements: * [IRCv3 draft/multiline](https://ircv3.net/specs/extensions/multiline) @@ -92,10 +95,17 @@ This version enables multiline by default and adds TKL IDs. this happens (should be extremely rare). * The UnrealIRCd base directory (eg `~/unrealircd/`) is now created with 0700 permissions, just like most subdirectories were. +* We now have `./unrealircd mkcert` which replaces `make pem` + certificate/key generation. +* Translation updates: `help.fr.conf` ### Fixes: * The following config items previously raised a config error: allow channel::except, deny channel::except and spamfilter::except. +* deny channel::mask with a [Mask item](https://www.unrealircd.org/docs/Mask_item) + caused a config error. +* Long multiline messages could be cut off when sent to clients that do not + support multiline. * Hardening of the built-in HTTPS client * JSON-RPC: Remote RPC was broken and causing "not authorized" error messages. This was used by `server.rehash` and `server.module_list`. Plus, From e7459df725559e8e4f02a4f446190d825317b2b2 Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Wed, 17 Jun 2026 20:38:46 +0200 Subject: [PATCH 28/44] Another URL API fix --- src/url_unreal.c | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/url_unreal.c b/src/url_unreal.c index 799f6e5f1..ddb45a538 100644 --- a/src/url_unreal.c +++ b/src/url_unreal.c @@ -824,9 +824,14 @@ int https_handle_response_header(Download *handle, char *readbuf, int n) } } - if (lastloc) + if (!end_of_request && lastloc) { - /* Last line was cut somewhere, save it for next round. */ + /* Save a cut-off (incomplete) header line for the next packet. + * Only while still reading headers: once end_of_request is set, + * lastloc points at body data that https_handle_response_body() + * already owns via handle->lefttoparse/lefttoparselen, so touching + * it here would desync those two and cause an out-of-bounds read. + */ safe_strdup(handle->lefttoparse, lastloc); } From d7962e1bbb0e9bae6f5faeba3bbe0e5ad51a4cf8 Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Wed, 17 Jun 2026 20:42:41 +0200 Subject: [PATCH 29/44] Fix crash (NULL pointer) with old-style set::anti-flood block (we should actually remove this one day :D) --- src/conf.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/conf.c b/src/conf.c index 27ef5a914..3bc58f783 100644 --- a/src/conf.c +++ b/src/conf.c @@ -9220,8 +9220,9 @@ int _test_set(ConfigFile *conf, ConfigEntry *ce) } else if (!strcmp(ceppp->name, "away-count")) { - int temp = atol(ceppp->value); + int temp; CheckNull(ceppp); + temp = atol(ceppp->value); if (temp < 1 || temp > 255) { config_error("%s:%i: set::anti-flood::away-count must be between 1 and 255", From 320d2c28efc220c221f7187781db63f5438a5248 Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Thu, 18 Jun 2026 19:18:02 +0200 Subject: [PATCH 30/44] Fix theoretical OOB write in chmode_str(). In practice this is no issue. Not in UnrealIRCd itself: it is only used in one place, STATS with a big buffer. And unrealircd-contrib 3rd party modules has no consumers. --- src/conf.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/conf.c b/src/conf.c index 3bc58f783..a022aafa6 100644 --- a/src/conf.c +++ b/src/conf.c @@ -725,11 +725,12 @@ void chmode_str(struct ChMode *modes, char *mbuf, char *pbuf, size_t mbuf_size, { Cmode *cm; - if (!(mbuf_size && pbuf_size)) + if ((mbuf_size < 2) || !pbuf_size) return; *pbuf = 0; *mbuf++ = '+'; + mbuf_size--; for (cm=channelmodes; cm; cm = cm->next) { From b5f45d016022b18bb16eccbfd3a63ad17ca0ff2b Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Thu, 18 Jun 2026 19:55:42 +0200 Subject: [PATCH 31/44] Update NULL check in config_item_allowed_for_config_file() - no real issue. This is unreachable in current code paths, but could be some day. --- src/conf.c | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/conf.c b/src/conf.c index a022aafa6..1ca930bd8 100644 --- a/src/conf.c +++ b/src/conf.c @@ -2937,6 +2937,8 @@ int config_item_allowed_for_config_file(const char *resource, const char *item) /* Special hardcoded handling for central spamfilter */ if (!strcmp(resource, "central_spamfilter.conf")) { + if (!item) + return 0; if (!strcmp(item, "spamfilter") || !strcmp(item, "ban")) return 1; @@ -2953,7 +2955,7 @@ int config_item_allowed_for_config_file(const char *resource, const char *item) if (rs->restrict_config == NULL) return 1; /* No restrictions */ - if (item == NULL) + if (!item) return 0; if (find_name_list(rs->restrict_config, item)) From ecde1b6479d55730e56cfdf7ebac7427c1404b93 Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Fri, 19 Jun 2026 19:39:08 +0200 Subject: [PATCH 32/44] Add bounds checking to message_tag_escape(). This fixes an OOB write that cannot be reached by users. Only a hostile server could cause it in some situations. Even then, in my tests this did not cause a crash (it goes into bss too, not heap or stack). --- src/modules/message-tags.c | 67 ++++++++++++++++++-------------------- 1 file changed, 32 insertions(+), 35 deletions(-) diff --git a/src/modules/message-tags.c b/src/modules/message-tags.c index ac5b06e22..e1fda9431 100644 --- a/src/modules/message-tags.c +++ b/src/modules/message-tags.c @@ -105,45 +105,42 @@ void message_tag_unescape(char *in, char *out) } /** Escape a message tag (name or value). - * @param in The input string - * @param out The output string for writing - * @note No size checking, so ensure that the output buffer - * is at least twice as long as the input buffer + 1. + * @param in The input string + * @param out The output buffer + * @param outsize Size of the output buffer + * @note Escaping can double the length of the input, so the output is + * truncated if it does not fit. We always NUL-terminate and never + * write a half-finished escape sequence. */ -void message_tag_escape(char *in, char *out) +void message_tag_escape(char *in, char *out, size_t outsize) { + char *o = out; + char *end = out + outsize; + for (; *in; in++) { - if (*in == ';') - { - *out++ = '\\'; - *out++ = ':'; - } else - if (*in == ' ') - { - *out++ = '\\'; - *out++ = 's'; - } else - if (*in == '\\') + if (*in == ';' || *in == ' ' || *in == '\\' || *in == '\r' || *in == '\n') { - *out++ = '\\'; - *out++ = '\\'; - } else - if (*in == '\r') - { - *out++ = '\\'; - *out++ = 'r'; - } else - if (*in == '\n') - { - *out++ = '\\'; - *out++ = 'n'; - } else - { - *out++ = *in; + if (end - o < 3) /* 2-byte escape + NUL */ + break; + *o++ = '\\'; + if (*in == ';') + *o++ = ':'; + else if (*in == ' ') + *o++ = 's'; + else if (*in == '\\') + *o++ = '\\'; + else if (*in == '\r') + *o++ = 'r'; + else + *o++ = 'n'; + } else { + if (end - o < 2) /* 1 byte + NUL */ + break; + *o++ = *in; } } - *out = '\0'; + *o = '\0'; } /** Incoming filter for message tags */ @@ -300,11 +297,11 @@ const char *_mtags_to_string(MessageTag *m, Client *client) continue; if (m->value) { - message_tag_escape(m->name, name); - message_tag_escape(m->value, value); + message_tag_escape(m->name, name, sizeof(name)); + message_tag_escape(m->value, value, sizeof(value)); snprintf(tbuf, sizeof(tbuf), "%s=%s;", name, value); } else { - message_tag_escape(m->name, name); + message_tag_escape(m->name, name, sizeof(name)); snprintf(tbuf, sizeof(tbuf), "%s;", name); } strlcat(buf, tbuf, sizeof(buf)); From f5d59dd152b31342381b3c9264fc5d5a5debdc26 Mon Sep 17 00:00:00 2001 From: Valerie Liu <79415174+ValwareIRC@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:17:02 +0100 Subject: [PATCH 33/44] Support ratified tags for reply-tag and no-implicit-names (PR #336) The IRCv3 specifications for these have been ratified: - https://ircv3.net/specs/client-tags/reply - https://ircv3.net/specs/extensions/no-implicit-names Both the draft and ratified names are supported during a transition period. --- src/misc.c | 2 +- src/modules/join.c | 2 +- src/modules/no-implicit-names.c | 13 ++----------- src/modules/reply-tag.c | 6 +----- 4 files changed, 5 insertions(+), 18 deletions(-) diff --git a/src/misc.c b/src/misc.c index ed1a20aed..8481a5845 100644 --- a/src/misc.c +++ b/src/misc.c @@ -1361,7 +1361,7 @@ MessageTag *duplicate_mtags(MessageTag *mtags) /** Duplicate a message tag list, excluding tags with MTAG_HANDLER_FLAGS_FIRST_ONLY. * Used to build the tag set for subsequent lines (lines 2..N), - * where tags like msgid and +draft/reply should not be repeated. + * where tags like msgid, +reply and +draft/reply should not be repeated. */ MessageTag *duplicate_mtags_for_subsequent_lines(MessageTag *mtags) { diff --git a/src/modules/join.c b/src/modules/join.c index 110e0a935..06dcdf809 100644 --- a/src/modules/join.c +++ b/src/modules/join.c @@ -270,7 +270,7 @@ void _join_channel(Channel *channel, Client *client, MessageTag *recv_mtags, con parv[0] = NULL; parv[1] = channel->name; parv[2] = NULL; - if (!HasCapability(client,"draft/no-implicit-names") /* && !HasCapability(client, "no-implicit-names") */) + if (!HasCapability(client,"draft/no-implicit-names") && !HasCapability(client, "no-implicit-names")) do_cmd(client, NULL, "NAMES", 2, parv);; unreal_log(ULOG_INFO, "join", "LOCAL_CLIENT_JOIN", client, diff --git a/src/modules/no-implicit-names.c b/src/modules/no-implicit-names.c index c0175617c..213ec0e71 100644 --- a/src/modules/no-implicit-names.c +++ b/src/modules/no-implicit-names.c @@ -40,24 +40,15 @@ MOD_INIT() MARK_AS_OFFICIAL_MODULE(modinfo); - /** We only add the draft/ version for now */ memset(&cap, 0, sizeof(cap)); cap.name = NO_IMPLICIT_NAMES_CAP_DRAFT; if (!ClientCapabilityAdd(modinfo->handle, &cap, &CAP_NO_IMPLICIT_NAMES_DRAFT)) - { return MOD_FAILED; - } - /** This is for the future :D - * If you add this, then also update - * _join_channel in src/modules/join.c to check for it, - * as it is currently commented out there as well. - */ - /** memset(&cap, 0, sizeof(cap)); cap.name = NO_IMPLICIT_NAMES_CAP; - ClientCapabilityAdd(modinfo->handle, &cap, &CAP_NO_IMPLICIT_NAMES); - */ + if (!ClientCapabilityAdd(modinfo->handle, &cap, &CAP_NO_IMPLICIT_NAMES)) + return MOD_FAILED; return MOD_SUCCESS; } diff --git a/src/modules/reply-tag.c b/src/modules/reply-tag.c index 0ec51d007..14b521f05 100644 --- a/src/modules/reply-tag.c +++ b/src/modules/reply-tag.c @@ -42,13 +42,11 @@ MOD_INIT() MARK_AS_OFFICIAL_MODULE(modinfo); -#if 0 memset(&mtag, 0, sizeof(mtag)); mtag.name = "+reply"; mtag.is_ok = replytag_mtag_is_ok; - mtag.flags = MTAG_HANDLER_FLAGS_NO_CAP_NEEDED; + mtag.flags = MTAG_HANDLER_FLAGS_NO_CAP_NEEDED | MTAG_HANDLER_FLAGS_FIRST_ONLY; MessageTagHandlerAdd(modinfo->handle, &mtag); -#endif memset(&mtag, 0, sizeof(mtag)); mtag.name = "+draft/reply"; @@ -98,14 +96,12 @@ void mtag_add_replytag(Client *client, MessageTag *recv_mtags, MessageTag **mtag if (IsUser(client)) { -#if 0 m = find_mtag(recv_mtags, "+reply"); if (m) { m = duplicate_mtag(m); AddListItem(m, *mtag_list); } -#endif m = find_mtag(recv_mtags, "+draft/reply"); if (m) { From 59d497726b56a6380a7509a8952825c16ca50316 Mon Sep 17 00:00:00 2001 From: Valerie Liu <79415174+ValwareIRC@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:19:10 +0100 Subject: [PATCH 34/44] chathistory: implement draft/chathistory-end tag (PR #337) Signals to the client that it has reached the end of the history and there are no more messages to fetch. The tag is attached to the BATCH opener when the server returns the last page of results. Only sent to clients that negotiated the draft/chathistory capability. --- include/h.h | 2 +- src/api-history-backend.c | 12 ++++++-- src/modules/chanmodes/history.c | 2 +- src/modules/chathistory.c | 50 +++++++++++++++++++++++++++++++-- src/modules/history.c | 2 +- 5 files changed, 60 insertions(+), 8 deletions(-) diff --git a/include/h.h b/include/h.h index 83ce96d78..26934f4e8 100644 --- a/include/h.h +++ b/include/h.h @@ -1241,7 +1241,7 @@ extern HistoryResult *history_request(const char *object, HistoryFilter *filter) extern int history_delete(const char *object, HistoryFilter *filter, int *rejected_deletes); extern int history_destroy(const char *object); extern int can_receive_history(Client *client); -extern void history_send_result(Client *client, HistoryResult *r); +extern void history_send_result(Client *client, HistoryResult *r, int end_of_pagination); extern void free_history_result(HistoryResult *r); extern void free_history_filter(HistoryFilter *f); extern void special_delayed_unloading(void); diff --git a/src/api-history-backend.c b/src/api-history-backend.c index 9472bf119..4e86e2730 100644 --- a/src/api-history-backend.c +++ b/src/api-history-backend.c @@ -374,11 +374,12 @@ static void history_send_result_multiline_fallback(Client *client, HistoryLogLin * @param client The client to send to. * @param r The history result retrieved via history_request(). */ -void history_send_result(Client *client, HistoryResult *r) +void history_send_result(Client *client, HistoryResult *r, int end_of_pagination) { char batch[BATCHLEN+1]; HistoryLogLine *l; int has_multiline; + MessageTag *batch_open_mtags = NULL; if (!can_receive_history(client)) return; @@ -388,7 +389,14 @@ void history_send_result(Client *client, HistoryResult *r) { /* Start a new batch */ generate_batch_id(batch); - sendto_one(client, NULL, ":%s BATCH +%s chathistory %s", me.name, batch, r->object); + if (end_of_pagination) + { + batch_open_mtags = safe_alloc(sizeof(MessageTag)); + safe_strdup(batch_open_mtags->name, "draft/chathistory-end"); + } + sendto_one(client, batch_open_mtags, ":%s BATCH +%s chathistory %s", me.name, batch, r->object); + if (batch_open_mtags) + free_message_tags(batch_open_mtags); } has_multiline = HasCapability(client, "draft/multiline") && HasCapability(client, "batch"); diff --git a/src/modules/chanmodes/history.c b/src/modules/chanmodes/history.c index 2f50a7065..5f9bbca17 100644 --- a/src/modules/chanmodes/history.c +++ b/src/modules/chanmodes/history.c @@ -740,7 +740,7 @@ int history_join(Client *client, Channel *channel, MessageTag *mtags) r = history_request(channel->name, &filter); if (r) { - history_send_result(client, r); + history_send_result(client, r, 0); free_history_result(r); } } diff --git a/src/modules/chathistory.c b/src/modules/chathistory.c index 4f47ce628..fe6adf618 100644 --- a/src/modules/chathistory.c +++ b/src/modules/chathistory.c @@ -26,6 +26,7 @@ struct ChatHistoryTarget { /* Forward declarations */ CMD_FUNC(cmd_chathistory); +int chathistory_end_mtag_is_ok(Client *client, const char *name, const char *value); /* Global variables */ long CAP_CHATHISTORY = 0L; @@ -35,13 +36,22 @@ long CAP_CHATHISTORY = 0L; MOD_INIT() { ClientCapabilityInfo c; + ClientCapability *cap; + MessageTagHandlerInfo mtag; MARK_AS_OFFICIAL_MODULE(modinfo); CommandAdd(modinfo->handle, "CHATHISTORY", cmd_chathistory, MAXPARA, CMD_USER); memset(&c, 0, sizeof(c)); c.name = "draft/chathistory"; - ClientCapabilityAdd(modinfo->handle, &c, &CAP_CHATHISTORY); + cap = ClientCapabilityAdd(modinfo->handle, &c, &CAP_CHATHISTORY); + + memset(&mtag, 0, sizeof(mtag)); + mtag.name = "draft/chathistory-end"; + mtag.is_ok = chathistory_end_mtag_is_ok; + mtag.clicap_handler = cap; + MessageTagHandlerAdd(modinfo->handle, &mtag); + return MOD_SUCCESS; } @@ -57,6 +67,14 @@ MOD_UNLOAD() return MOD_SUCCESS; } +/** The draft/chathistory-end tag is server-generated only; reject it from clients */ +int chathistory_end_mtag_is_ok(Client *client, const char *name, const char *value) +{ + if (IsServer(client)) + return 1; + return 0; +} + int chathistory_token(const char *str, char *token, char **store) { char request[BUFSIZE]; @@ -188,14 +206,30 @@ void chathistory_targets(Client *client, HistoryFilter *filter, int limit) /* 2. Now send it to the client */ + /* Count total matching targets to determine end-of-pagination */ + { + ChatHistoryTarget *t; + for (t = targets; t; t = t->next) + sent++; + } + batch[0] = '\0'; if (HasCapability(client, "batch")) { /* Start a new batch */ + MessageTag *batch_open_mtags = NULL; generate_batch_id(batch); - sendto_one(client, NULL, ":%s BATCH +%s draft/chathistory-targets", me.name, batch); + if (sent < limit) + { + batch_open_mtags = safe_alloc(sizeof(MessageTag)); + safe_strdup(batch_open_mtags->name, "draft/chathistory-end"); + } + sendto_one(client, batch_open_mtags, ":%s BATCH +%s draft/chathistory-targets", me.name, batch); + if (batch_open_mtags) + free_message_tags(batch_open_mtags); } + sent = 0; for (; targets; targets = targets_next) { targets_next = targets->next; @@ -418,7 +452,17 @@ CMD_FUNC(cmd_chathistory) if (fakelag_ms > 5000) fakelag_ms = 5000; add_fake_lag(client, fakelag_ms); - history_send_result(client, r); + /* Count logical messages to determine end-of-pagination. + * Each entry in r->log is a head message (multiline continuation + * lines are linked via next_in_batch, not next). + */ + { + HistoryLogLine *l; + int count = 0; + for (l = r->log; l; l = l->next) + count++; + history_send_result(client, r, count < filter->limit); + } } } diff --git a/src/modules/history.c b/src/modules/history.c index d99823ee6..5d6f6f012 100644 --- a/src/modules/history.c +++ b/src/modules/history.c @@ -130,7 +130,7 @@ CMD_FUNC(cmd_history) if ((r = history_request(channel->name, &filter))) { - history_send_result(client, r); + history_send_result(client, r, 0); free_history_result(r); } } From 37977fcfe62755267c8fcafc5624b6f0727d3eed Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Fri, 19 Jun 2026 21:28:15 +0200 Subject: [PATCH 35/44] Don't send draft/chathistory-end for AROUND. As around does not have a directorion, but is a midpoint, and we send X lines above Y under, so end does not make sense there anyway (which of the two ends?). We simply avoid sending it. --- src/modules/chathistory.c | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/modules/chathistory.c b/src/modules/chathistory.c index fe6adf618..ed04447df 100644 --- a/src/modules/chathistory.c +++ b/src/modules/chathistory.c @@ -453,15 +453,20 @@ CMD_FUNC(cmd_chathistory) fakelag_ms = 5000; add_fake_lag(client, fakelag_ms); /* Count logical messages to determine end-of-pagination. - * Each entry in r->log is a head message (multiline continuation - * lines are linked via next_in_batch, not next). + * Not for AROUND: it is a centered window, so getting + * fewer than the limit does not mean we hit the end. */ { HistoryLogLine *l; int count = 0; - for (l = r->log; l; l = l->next) - count++; - history_send_result(client, r, count < filter->limit); + int end_of_pagination = 0; + if (filter->cmd != HFC_AROUND) + { + for (l = r->log; l; l = l->next) + count++; + end_of_pagination = (count < filter->limit); + } + history_send_result(client, r, end_of_pagination); } } } From 570c32ea6705ccf3f9907fa67c78320707e1e8cb Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Fri, 19 Jun 2026 21:30:47 +0200 Subject: [PATCH 36/44] Fix CHATHISTORY TARGETS sending one target too little if limit is hit And attach draft/chathistory-end when exactly 'limit' targets exist and nothing more. --- src/modules/chathistory.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/chathistory.c b/src/modules/chathistory.c index ed04447df..929677ddd 100644 --- a/src/modules/chathistory.c +++ b/src/modules/chathistory.c @@ -219,7 +219,7 @@ void chathistory_targets(Client *client, HistoryFilter *filter, int limit) /* Start a new batch */ MessageTag *batch_open_mtags = NULL; generate_batch_id(batch); - if (sent < limit) + if (sent <= limit) { batch_open_mtags = safe_alloc(sizeof(MessageTag)); safe_strdup(batch_open_mtags->name, "draft/chathistory-end"); @@ -233,7 +233,7 @@ void chathistory_targets(Client *client, HistoryFilter *filter, int limit) for (; targets; targets = targets_next) { targets_next = targets->next; - if (++sent < limit) + if (sent++ < limit) chathistory_targets_send_line(client, targets, batch); safe_free(targets->datetime); safe_free(targets->object); From 09a732e2c146836972a24eb34e4934d6803071b7 Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Sat, 20 Jun 2026 08:29:47 +0200 Subject: [PATCH 37/44] Redo draft/chathistory-end from yesterday in a different way. The previous mechanism (from yesterday) was a bit too simple at the chathistory.c where returned_lines < limit would set the end tag but it would not deal with the situation where returned_lines == limit which is ambigious. So we had to move up a layer (or is it down?), don't handle this in chathistory.c but in the backend. A new struct field r->reached_end was added for this (set by backend). --- include/modules.h | 1 + src/modules/chathistory.c | 17 +--------------- src/modules/history_backend_mem.c | 33 +++++++++++++++++++++++++------ 3 files changed, 29 insertions(+), 22 deletions(-) diff --git a/include/modules.h b/include/modules.h index 4ef90b2cb..a466c73c1 100644 --- a/include/modules.h +++ b/include/modules.h @@ -621,6 +621,7 @@ struct HistoryResult { HistoryLogLine *log_tail; /**< Last entry in the log lines */ int num_lines; /**< Total number of lines in the result */ int num_bytes; /**< Total bytes of all lines in the result */ + int reached_end; /**< Set by backend: 1 = no more history beyond this page, 0 = more may exist (or unknown). Used by the draft/chathistory-end tag. Default 0. */ }; /** History Backend */ diff --git a/src/modules/chathistory.c b/src/modules/chathistory.c index 929677ddd..1eebbc70c 100644 --- a/src/modules/chathistory.c +++ b/src/modules/chathistory.c @@ -452,22 +452,7 @@ CMD_FUNC(cmd_chathistory) if (fakelag_ms > 5000) fakelag_ms = 5000; add_fake_lag(client, fakelag_ms); - /* Count logical messages to determine end-of-pagination. - * Not for AROUND: it is a centered window, so getting - * fewer than the limit does not mean we hit the end. - */ - { - HistoryLogLine *l; - int count = 0; - int end_of_pagination = 0; - if (filter->cmd != HFC_AROUND) - { - for (l = r->log; l; l = l->next) - count++; - end_of_pagination = (count < filter->limit); - } - history_send_result(client, r, end_of_pagination); - } + history_send_result(client, r, r->reached_end); } } diff --git a/src/modules/history_backend_mem.c b/src/modules/history_backend_mem.c index 392765bb1..98c355b1f 100644 --- a/src/modules/history_backend_mem.c +++ b/src/modules/history_backend_mem.c @@ -893,6 +893,7 @@ static int hbm_return_after(HistoryResult *r, HistoryLogObject *h, HistoryFilter HistoryLogLine *l, *n; int written = 0; int started = 0; + int reached_end = 1; MessageTag *m; for (l = h->head; l; l = l->next) @@ -922,14 +923,20 @@ static int hbm_return_after(HistoryResult *r, HistoryLogObject *h, HistoryFilter break; } + /* Limit reached but there are more lines available */ + if (written >= filter->limit) + { + reached_end = 0; + break; + } /* Add line to the return buffer */ n = duplicate_log_line(l); hbm_result_append_line(r, n); - if (++written >= filter->limit) - break; + written++; } } + r->reached_end = reached_end; return written; } @@ -946,6 +953,7 @@ static int hbm_return_before(HistoryResult *r, HistoryLogObject *h, HistoryFilte HistoryLogLine *l, *n; int written = 0; int started = 0; + int reached_end = 1; MessageTag *m; for (l = h->tail; l; l = l->prev) @@ -975,14 +983,20 @@ static int hbm_return_before(HistoryResult *r, HistoryLogObject *h, HistoryFilte break; } + /* Limit reached but there are more lines available */ + if (written >= filter->limit) + { + reached_end = 0; + break; + } /* Add line to the return buffer */ n = duplicate_log_line(l); hbm_result_prepend_line(r, n); - if (++written >= filter->limit) - break; + written++; } } + r->reached_end = reached_end; return written; } @@ -997,6 +1011,7 @@ static int hbm_return_latest(HistoryResult *r, HistoryLogObject *h, HistoryFilte { HistoryLogLine *l, *n; int written = 0; + int reached_end = 1; MessageTag *m; for (l = h->tail; l; l = l->prev) @@ -1007,12 +1022,18 @@ static int hbm_return_latest(HistoryResult *r, HistoryLogObject *h, HistoryFilte if (filter->msgid_a && !strcmp(l->msgid, filter->msgid_a)) break; /* Stop now */ + /* Limit reached but there are more lines available */ + if (written >= filter->limit) + { + reached_end = 0; + break; + } n = duplicate_log_line(l); hbm_result_prepend_line(r, n); - if (++written >= filter->limit) - break; + written++; } + r->reached_end = reached_end; return written; } From 8d1df6a8231f3227f7330b0ce8c2d67a4002b950 Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Sat, 20 Jun 2026 09:15:09 +0200 Subject: [PATCH 38/44] Make nofakelag also mean that deliberate add_fake_lag() does not lag up. Eg on failed oper attempts, that sort of things. Previously it was still adding fake lag. This complicated unrealircd-tests :). As always, nofakelag should never be used in normal conditions, it disables the most important protection we have (fake lag bumping). If you want lower lag for a group of users, the right tool is set::anti-flood::name-of-security-group::lag-penalty and ::lag-penalty-bytes See https://www.unrealircd.org/docs/Special_users --- src/parse.c | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/parse.c b/src/parse.c index 00862720e..cdd170ee7 100644 --- a/src/parse.c +++ b/src/parse.c @@ -676,8 +676,12 @@ long parse_addlag(Client *client, int command_bytes, int mtags_bytes) */ void add_fake_lag(Client *client, long msec) { - if (!MyConnect(client)) + if (!MyConnect(client) || IsNoFakeLag(client)) + return; +#ifdef FAKELAG_CONFIGURABLE + if (client->local->class && (client->local->class->options & CLASS_OPT_NOFAKELAG)) return; +#endif client->local->fake_lag_msec += msec; client->local->fake_lag += (client->local->fake_lag_msec / 1000); From 3fafd320677d7beab403d6cd82743bf0d5a9babf Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Sat, 20 Jun 2026 09:31:49 +0200 Subject: [PATCH 39/44] Fix end marker missing for 0 result in some CHATHISTORY BETWEEN. --- src/modules/history_backend_mem.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/history_backend_mem.c b/src/modules/history_backend_mem.c index 98c355b1f..74f9e928a 100644 --- a/src/modules/history_backend_mem.c +++ b/src/modules/history_backend_mem.c @@ -1289,8 +1289,9 @@ static int hbm_return_between(HistoryResult *r, HistoryLogObject *h, HistoryFilt f.msgid_b = filter->msgid_a; return hbm_return_after(r, h, &f); } - /* else direction is -1 which means not found / invalid */ + /* else direction is -1 which means not found / invalid */ + r->reached_end = 1; return 0; } From 2475f2559623148cc2fa39ba7ceafb29b8b05904 Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Sat, 20 Jun 2026 10:53:01 +0200 Subject: [PATCH 40/44] ** UnrealIRCd 6.2.6-rc1 ** --- configure | 2 +- configure.ac | 2 +- doc/Config.header | 2 +- doc/RELEASE-NOTES.md | 13 +++++++++---- doc/conf/modules.default.conf | 2 +- extras/doxygen/Doxyfile | 2 +- include/windows/setup.h | 2 +- src/version.c.SH | 2 +- src/windows/unrealinst.iss | 2 +- 9 files changed, 17 insertions(+), 12 deletions(-) diff --git a/configure b/configure index 91b5c7be2..0344bfcac 100755 --- a/configure +++ b/configure @@ -2713,7 +2713,7 @@ printf "%s\n" "#define UNREAL_VERSION_MINOR $UNREAL_VERSION_MINOR" >>confdefs.h # The version suffix such as a beta marker or release candidate # marker. (e.g.: -rcX for unrealircd-3.2.9-rcX). This macro is a # string instead of an integer because it contains arbitrary data. -UNREAL_VERSION_SUFFIX="-git" +UNREAL_VERSION_SUFFIX="-rc1" printf "%s\n" "#define UNREAL_VERSION_SUFFIX \"$UNREAL_VERSION_SUFFIX\"" >>confdefs.h diff --git a/configure.ac b/configure.ac index 2236d1552..7fb1869d0 100644 --- a/configure.ac +++ b/configure.ac @@ -38,7 +38,7 @@ AC_DEFINE_UNQUOTED([UNREAL_VERSION_MINOR], [$UNREAL_VERSION_MINOR], [Minor versi # The version suffix such as a beta marker or release candidate # marker. (e.g.: -rcX for unrealircd-3.2.9-rcX). This macro is a # string instead of an integer because it contains arbitrary data. -UNREAL_VERSION_SUFFIX=["-git"] +UNREAL_VERSION_SUFFIX=["-rc1"] AC_DEFINE_UNQUOTED([UNREAL_VERSION_SUFFIX], ["$UNREAL_VERSION_SUFFIX"], [Version suffix such as a beta marker or release candidate marker. (e.g.: -rcX for unrealircd-3.2.9-rcX)]) AC_PATH_PROG(RM,rm) diff --git a/doc/Config.header b/doc/Config.header index ffb6aa2f7..37037770f 100644 --- a/doc/Config.header +++ b/doc/Config.header @@ -7,7 +7,7 @@ \___/|_| |_|_| \___|\__,_|_|\___/\_| \_| \____/\__,_| Configuration Program - for UnrealIRCd 6.2.6 + for UnrealIRCd 6.2.6-rc1 This program will help you to compile your IRC server, and ask you questions regarding the compile-time settings of it during the process. diff --git a/doc/RELEASE-NOTES.md b/doc/RELEASE-NOTES.md index 74756e053..7099bcdc3 100644 --- a/doc/RELEASE-NOTES.md +++ b/doc/RELEASE-NOTES.md @@ -1,8 +1,8 @@ -UnrealIRCd 6.2.6-git -================= +UnrealIRCd 6.2.6-rc1 +===================== -This is the git version (development version) for future UnrealIRCd 6.2.6. -This is work in progress and may not always be a stable version. +This is the Release Candidate for future version 6.2.6. You can help us by +testing this release and reporting bugs to https://bugs.unrealircd.org/ This version enables multiline by default, adds TKL IDs and tracking of hit counts on *LINES/Spamfilter. New crule functions were added to fetch @@ -98,6 +98,11 @@ has been improved. * We now have `./unrealircd mkcert` which replaces `make pem` certificate/key generation. * Translation updates: `help.fr.conf` +* CHATHISTORY now sends a `draft/chathistory-end` if the end of history + has been reached ([a recent spec improvement](https://github.com/unrealircd/unrealircd/pull/337)). +* The IRCv3 [reply](https://ircv3.net/specs/client-tags/reply) client tag + and [no-implicit-names](https://ircv3.net/specs/extensions/no-implicit-names) + extensions have been ratified. During the transition period we support both. ### Fixes: * The following config items previously raised a config error: diff --git a/doc/conf/modules.default.conf b/doc/conf/modules.default.conf index e2a4ce86b..d030c85ca 100644 --- a/doc/conf/modules.default.conf +++ b/doc/conf/modules.default.conf @@ -1,4 +1,4 @@ -/* [6.2.6-git] +/* [6.2.6-rc1] * This file will load (nearly) all modules available on UnrealIRCd. * So all commands, channel modes, user modes, etc.. * diff --git a/extras/doxygen/Doxyfile b/extras/doxygen/Doxyfile index 8c4f034d9..8af96d7d4 100644 --- a/extras/doxygen/Doxyfile +++ b/extras/doxygen/Doxyfile @@ -38,7 +38,7 @@ PROJECT_NAME = "UnrealIRCd" # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 6.2.6-git +PROJECT_NUMBER = 6.2.6-rc1 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/include/windows/setup.h b/include/windows/setup.h index bf1ca3fd1..78434658f 100644 --- a/include/windows/setup.h +++ b/include/windows/setup.h @@ -76,6 +76,6 @@ /* Version suffix such as a beta marker or release candidate marker. (e.g.: -rcX for unrealircd-3.2.9-rcX) */ -#define UNREAL_VERSION_SUFFIX "-git" +#define UNREAL_VERSION_SUFFIX "-rc1" #endif diff --git a/src/version.c.SH b/src/version.c.SH index e1806c5cb..e3e73ecf6 100644 --- a/src/version.c.SH +++ b/src/version.c.SH @@ -7,7 +7,7 @@ echo "Extracting src/version.c..." if [ -d ../.git ]; then SUFFIX="-$(git rev-parse --short HEAD)" fi -id="6.2.6-git$SUFFIX" +id="6.2.6-rc1$SUFFIX" echo "$id" if test -r version.c diff --git a/src/windows/unrealinst.iss b/src/windows/unrealinst.iss index a941220ee..3dfdbf7d5 100755 --- a/src/windows/unrealinst.iss +++ b/src/windows/unrealinst.iss @@ -6,7 +6,7 @@ [Setup] AppName=UnrealIRCd 6 -AppVerName=UnrealIRCd 6.2.6-git +AppVerName=UnrealIRCd 6.2.6-rc1 AppPublisher=UnrealIRCd Team AppPublisherURL=https://www.unrealircd.org AppSupportURL=https://www.unrealircd.org From cf5703fec0608b1ec2bd6ac54fc637edde94f4a4 Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Sat, 20 Jun 2026 14:01:23 +0200 Subject: [PATCH 41/44] Windows packager: get rid of in-innosetup-signing (handled outside this now) --- src/windows/unrealinst.iss | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/windows/unrealinst.iss b/src/windows/unrealinst.iss index 3dfdbf7d5..cfeb492da 100755 --- a/src/windows/unrealinst.iss +++ b/src/windows/unrealinst.iss @@ -43,35 +43,35 @@ Name: "fixperm"; Description: "Make UnrealIRCd folder writable by current user"; [Files] ; UnrealIRCd binaries -Source: "UnrealIRCd.exe"; DestDir: "{app}\bin"; Flags: ignoreversion signonce +Source: "UnrealIRCd.exe"; DestDir: "{app}\bin"; Flags: ignoreversion Source: "UnrealIRCd.pdb"; DestDir: "{app}\bin"; Flags: ignoreversion -Source: "unrealircdctl.exe"; DestDir: "{app}\bin"; Flags: ignoreversion signonce -Source: "unrealsvc.exe"; DestDir: "{app}\bin"; Flags: ignoreversion signonce +Source: "unrealircdctl.exe"; DestDir: "{app}\bin"; Flags: ignoreversion +Source: "unrealsvc.exe"; DestDir: "{app}\bin"; Flags: ignoreversion ; TLS certificate generation helpers Source: "src\windows\makecert.bat"; DestDir: "{app}\bin"; Flags: ignoreversion Source: "doc\conf\tls\tls.cnf"; DestDir: "{app}\bin"; Flags: ignoreversion ; UnrealIRCd modules -Source: "src\modules\*.dll"; DestDir: "{app}\modules"; Flags: ignoreversion signonce -Source: "src\modules\chanmodes\*.dll"; DestDir: "{app}\modules\chanmodes"; Flags: ignoreversion signonce -Source: "src\modules\usermodes\*.dll"; DestDir: "{app}\modules\usermodes"; Flags: ignoreversion signonce -Source: "src\modules\extbans\*.dll"; DestDir: "{app}\modules\extbans"; Flags: ignoreversion signonce -Source: "src\modules\rpc\*.dll"; DestDir: "{app}\modules\rpc"; Flags: ignoreversion signonce -Source: "src\modules\third\*.dll"; DestDir: "{app}\modules\third"; Flags: ignoreversion skipifsourcedoesntexist signonce +Source: "src\modules\*.dll"; DestDir: "{app}\modules"; Flags: ignoreversion +Source: "src\modules\chanmodes\*.dll"; DestDir: "{app}\modules\chanmodes"; Flags: ignoreversion +Source: "src\modules\usermodes\*.dll"; DestDir: "{app}\modules\usermodes"; Flags: ignoreversion +Source: "src\modules\extbans\*.dll"; DestDir: "{app}\modules\extbans"; Flags: ignoreversion +Source: "src\modules\rpc\*.dll"; DestDir: "{app}\modules\rpc"; Flags: ignoreversion +Source: "src\modules\third\*.dll"; DestDir: "{app}\modules\third"; Flags: ignoreversion skipifsourcedoesntexist ; Libraries -Source: "c:\dev\unrealircd-6-libs\pcre2\bin\pcre*.dll"; DestDir: "{app}\bin"; Flags: ignoreversion signonce -Source: "c:\dev\unrealircd-6-libs\argon2\vs2015\build\*.dll"; DestDir: "{app}\bin"; Flags: ignoreversion signonce -Source: "c:\dev\unrealircd-6-libs\libsodium\bin\x64\Release\v142\dynamic\*.dll"; DestDir: "{app}\bin"; Flags: ignoreversion signonce -Source: "c:\dev\unrealircd-6-libs\jansson\bin\*.dll"; DestDir: "{app}\bin"; Flags: ignoreversion signonce -Source: "c:\dev\unrealircd-6-libs\c-ares\msvc\cares\dll-release\cares.dll"; DestDir: "{app}\bin"; Flags: ignoreversion signonce -Source: "c:\dev\unrealircd-6-libs\openssl\bin\openssl.exe"; DestDir: "{app}\bin"; Flags: ignoreversion signonce -Source: "c:\dev\unrealircd-6-libs\openssl\bin\*.dll"; DestDir: "{app}\bin"; Flags: ignoreversion signonce -Source: "c:\dev\unrealircd-6-libs\GeoIP\libGeoIP\*.dll"; DestDir: "{app}\bin"; Flags: ignoreversion signonce -Source: "c:\dev\unrealircd-6-libs\setacl.exe"; DestDir: "{app}\tmp"; Flags: ignoreversion signonce +Source: "c:\dev\unrealircd-6-libs\pcre2\bin\pcre*.dll"; DestDir: "{app}\bin"; Flags: ignoreversion +Source: "c:\dev\unrealircd-6-libs\argon2\vs2015\build\*.dll"; DestDir: "{app}\bin"; Flags: ignoreversion +Source: "c:\dev\unrealircd-6-libs\libsodium\bin\x64\Release\v142\dynamic\*.dll"; DestDir: "{app}\bin"; Flags: ignoreversion +Source: "c:\dev\unrealircd-6-libs\jansson\bin\*.dll"; DestDir: "{app}\bin"; Flags: ignoreversion +Source: "c:\dev\unrealircd-6-libs\c-ares\msvc\cares\dll-release\cares.dll"; DestDir: "{app}\bin"; Flags: ignoreversion +Source: "c:\dev\unrealircd-6-libs\openssl\bin\openssl.exe"; DestDir: "{app}\bin"; Flags: ignoreversion +Source: "c:\dev\unrealircd-6-libs\openssl\bin\*.dll"; DestDir: "{app}\bin"; Flags: ignoreversion +Source: "c:\dev\unrealircd-6-libs\GeoIP\libGeoIP\*.dll"; DestDir: "{app}\bin"; Flags: ignoreversion +Source: "c:\dev\unrealircd-6-libs\setacl.exe"; DestDir: "{app}\tmp"; Flags: ignoreversion #ifdef USE_CURL -Source: "c:\dev\unrealircd-6-libs\curl\bin\libcurl.dll"; DestDir: "{app}\bin"; Flags: ignoreversion signonce +Source: "c:\dev\unrealircd-6-libs\curl\bin\libcurl.dll"; DestDir: "{app}\bin"; Flags: ignoreversion #endif Source: "doc\conf\tls\curl-ca-bundle.crt"; DestDir: "{app}\conf\tls"; Flags: ignoreversion From 5a93480976500591d3e4fdc34c9dc6b263904029 Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Sun, 21 Jun 2026 09:19:47 +0200 Subject: [PATCH 42/44] SECURITY.md: add "Scope" and "Use of AI or other tools" And a minor README.md update to add a few more links. --- README.md | 5 ++++- SECURITY.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index da5743987..a267f03f8 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ We use a private BuildBot instance to test each commit. The **tested** systems a likely to work too): * Linux: Debian (10, 11, 12, 13), Ubuntu (18.04, 20.04, 22.04, 24.04, 26.04) * FreeBSD: 15 -* Windows: Visual Studio 2019 +* Windows: 11 UnrealIRCd is architecture-agnostic. Most of the BuildBot workers run on x64 but we also have some on x86 and arm64 to ensure these work as well. @@ -48,3 +48,6 @@ also have some on x86 and arm64 to ensure these work as well. * https://bugs.unrealircd.org - Bug tracker * https://fosstodon.org/@unrealircd - Mastodon * https://twitter.com/Unreal_IRCd - Twitter +* [SECURITY.md](https://github.com/unrealircd/unrealircd/blob/unreal60_dev/SECURITY.md#security-policy) - How to report security issues +* [LICENSE](https://github.com/unrealircd/unrealircd/blob/unreal60_dev/LICENSE) - LICENSE: GPLv2 or later +* [Contributing](https://www.unrealircd.org/docs/Contributing) - How to help: report bugs, test, write or translate documentations, .. diff --git a/SECURITY.md b/SECURITY.md index f7144792c..449409c9f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -3,19 +3,66 @@ ## Supported Versions * The latest *stable* release of UnrealIRCd 6 -See [UnrealIRCd releases](https://www.unrealircd.org/docs/UnrealIRCd_releases) for information on older versions and End Of Life dates. +See [UnrealIRCd releases](https://www.unrealircd.org/docs/UnrealIRCd_releases) +for information on older versions and End Of Life dates. + +## Scope + +In general, issues triggered by regular users involving memory safety issues +(such as OOB read/write or UAF), sensitive information disclosure, privilege elevation, +Denial of Service (e.g. a crash), or remote code execution fall within the scope of +this security policy. + +Issues that require IRCOp rights, server-to-server traffic, or editing of config +files may still fall within scope, but are classified case by case depending on +the impact and circumstances. + +Issues that require shell access as the same user running UnrealIRCd are not +considered security issues. See the +[full policy](https://www.unrealircd.org/docs/Policy:_Handling_of_security_issues) +for details. + +## Use of AI or other tools + +It is normal and acceptable to use tools for finding security vulnerabilities. +We use them ourselves as well: AI, static code analyzers, fuzzing. This is all fine. + +If a tool flagged an issue then we ask only **one extra thing**: that you +**reproduce the issue** on your own local server. So: confirm the issue by +actually running UnrealIRCd with a reproducer (which usually means: by sending +IRC traffic to trigger the bug). This is because tools regularly flag something +as an issue but in practice it may be impossible to happen because of some extra +check somewhere or other requirements. + +If you are trying to reproduce an issue, then we suggest running `./Config` and +answering `Yes` to the near-last question about AddressSanitizer (ASan). Please +include both the reproducer and the ASan output in the bug report. It helps us a +lot. + +If you are submitting issues and fail to follow the procedure above, expect us +to ask you again to reproduce the issue. If you refuse to do so, don't respond +in a timely manner, or send in multiple reports without doing so, then we will +close the bug report and may proceed with putting you on ignore, banning or +deleting your account, or similar. Giving a reproducer is not a big ask and is +normal procedure nowadays. It should be part of your standard workflow if you +are a security researcher. ## Reporting a Vulnerability -Please report issues on the [bug tracker](https://bugs.unrealircd.org) and in the bug submit form **set the 'View Status' to 'private'**. +Please report issues on the [bug tracker](https://bugs.unrealircd.org) and in +the bug submit form **set the 'View Status' to 'private'**. -Do not report security issues on the forums or in a public IRC channel such as #unreal-support. -If you insist on e-mail then you can use syzop@unrealircd.org or security@unrealircd.org. Again, the bug tracker is preferred. +Do not report security issues as a Pull Request, on the forums or in a public +IRC channel such as #unreal-support. If you insist on e-mail then you can use +syzop@unrealircd.org or security@unrealircd.org. Again, the bug tracker is +preferred. -If you are *unsure* if something is a security issue, then report it at the bug tracker as a 'private' bug anyway. Better safe than sorry. +If you found a real issue but are *unsure* if it is a security issue, then +report it at the bug tracker as a 'private' bug anyway. Better safe than sorry. Do not ask around in public channels or forums. -You should get a response or at least an acknowledgement soon. If you don't hear back within 24 hours, then please try to contact us again. +You should get a response or at least an acknowledgement soon. If you don't hear +back within 24 hours, then please try to contact us again. ## Full policy See https://www.unrealircd.org/docs/Policy:_Handling_of_security_issues for full information. From 12d92fcba5256cbbe552896e9698d31c24dd372a Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Sun, 21 Jun 2026 10:14:22 +0200 Subject: [PATCH 43/44] a more minor update: 1) obviously only provide ASan report if relevant (eg memory issue), not for like priv escalation :) 2) "If you are submitting issues and fail to follow the procedure above" was in the AI/tooling paragraph, but just in case someone reads it out of that context we now scope AGAIN it to that ONLY. This so normal users (that have nothing to do with AI/tooling) are not scared off in reporting real issues. [skip ci] --- SECURITY.md | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 449409c9f..ffc7861dd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -35,17 +35,19 @@ as an issue but in practice it may be impossible to happen because of some extra check somewhere or other requirements. If you are trying to reproduce an issue, then we suggest running `./Config` and -answering `Yes` to the near-last question about AddressSanitizer (ASan). Please -include both the reproducer and the ASan output in the bug report. It helps us a -lot. - -If you are submitting issues and fail to follow the procedure above, expect us -to ask you again to reproduce the issue. If you refuse to do so, don't respond -in a timely manner, or send in multiple reports without doing so, then we will -close the bug report and may proceed with putting you on ignore, banning or -deleting your account, or similar. Giving a reproducer is not a big ask and is -normal procedure nowadays. It should be part of your standard workflow if you -are a security researcher. +answering `Yes` to the near-last question about AddressSanitizer (ASan), +especially for memory safety issues. Please include the reproducer and any +relevant ASan output in the bug report. ASan output is useful even if a normal +build does not visibly crash. It helps us a lot. + +If you used AI, static code analyzers, fuzzing, or similar tools and fail to +follow the procedure above, expect us to ask you again to reproduce the issue. +If you refuse to do so, don't respond in a timely manner, or keep sending reports +without doing so after we asked, then we will close the bug report. For repeat +offenders, we may proceed with putting you on ignore, banning or deleting your +account, or similar. Giving a reproducer is not a big ask and is normal procedure +nowadays. It should be part of your standard workflow if you are a security +researcher. ## Reporting a Vulnerability From 684f6515d4ff49a8334ebb07d5ef01735fed29c7 Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Mon, 22 Jun 2026 09:00:30 +0200 Subject: [PATCH 44/44] "CAP LS" may only respond 1 line, we now advertise less. "CAP LS 302" unaffected. When not using version 302, such as with "CAP LS", the specification does not allow us to use continuation lines. This means all advertised caps must fit into one line. That is no longer always the case, especially if you load 3rd party capabilities. So we need to scratch advertising some capabilities to <302 clients. "CAP LS 302" is unaffected. Note that version 302 in the specification exists since at least November 2017, so most clients use that one. According to https://ircv3.net/software/clients the following clients are affected by this change: Desktop Clients * KVIrc * Circe * catgirl * BitchX * Pidgin * LimeChat Mobile Clients * IRC for Android * LimeChat And various older versions of other clients (obviously). NOTE: The source is only that IRCv3 page. I did not check manually. For this particular commit. We filter out various unrealircd.org informative CAPs and the vendor specific json-log. So that isn't much of a problem. However, in the future we may be forced to filter out more capabilities to make room. It would be much better if all clients are on >=302. Also, I should mention we are not the only IRCd out there, so I can't vouch on what other IRCds (will) do when hitting this non-302-limit. Reported by ProgVal in https://bugs.unrealircd.org/view.php?id=6630 --- doc/RELEASE-NOTES.md | 6 ++++++ include/modules.h | 2 ++ src/api-clicap.c | 1 + src/modules/cap.c | 3 +++ src/modules/history_backend_mem.c | 1 + src/modules/json-log-tag.c | 1 + src/modules/link-security.c | 1 + src/modules/plaintext-policy.c | 1 + 8 files changed, 16 insertions(+) diff --git a/doc/RELEASE-NOTES.md b/doc/RELEASE-NOTES.md index 7099bcdc3..d5ddbb7b3 100644 --- a/doc/RELEASE-NOTES.md +++ b/doc/RELEASE-NOTES.md @@ -120,6 +120,12 @@ has been improved. telling the server "does not support remote JSON-RPC". ### Developers and protocol: +* If you use `CAP LS` instead of `CAP LS 302` then you will now miss various + capabilities. We had to trim it down because only 302 and later allow + responses that span multiple lines. If your client is still not using 302 + then please do so soon (the IRCv3 spec is from Nov'2017). For this first + change, you won't miss out much, but somewhere in the future this can + become a real problem for you. * URL API: The OutgoingWebRequest `max_size` (introduced last release) now also caps file-backed downloads. Default for file-backed when left at 0 is 50MB (`DOWNLOAD_MAX_SIZE_FILE_BACKED`). For memory-backed, it stays diff --git a/include/modules.h b/include/modules.h index a466c73c1..8a5e1dfac 100644 --- a/include/modules.h +++ b/include/modules.h @@ -517,6 +517,7 @@ struct ClientCapability { MessageTagHandler *mtag_handler; /**< For reverse dependency */ Module *owner; /**< Module introducing this CAP. */ char unloaded; /**< Internal flag to indicate module is being unloaded */ + int minimum_cap_version; /**< Minimum CAP version to show this CAP */ }; typedef struct { @@ -524,6 +525,7 @@ typedef struct { int flags; int (*visible)(Client *); const char *(*parameter)(Client *); + int minimum_cap_version; } ClientCapabilityInfo; /** @defgroup MessagetagAPI Message tag API diff --git a/src/api-clicap.c b/src/api-clicap.c index bb6991c97..5c6060cf2 100644 --- a/src/api-clicap.c +++ b/src/api-clicap.c @@ -204,6 +204,7 @@ ClientCapability *ClientCapabilityAdd(Module *module, ClientCapabilityInfo *clic /* Add or update the following fields: */ clicap->owner = module; clicap->flags = clicap_request->flags; + clicap->minimum_cap_version = clicap_request->minimum_cap_version; clicap->visible = clicap_request->visible; clicap->parameter = clicap_request->parameter; diff --git a/src/modules/cap.c b/src/modules/cap.c index 0b4a5d01c..f989de702 100644 --- a/src/modules/cap.c +++ b/src/modules/cap.c @@ -165,6 +165,9 @@ static void clicap_generate(Client *client, const char *subcmd, int flags) if (cap->visible && !cap->visible(client)) continue; /* hidden */ + if (cap->minimum_cap_version && (client->local->cap_protocol < cap->minimum_cap_version)) + continue; /* skip: doesn't meet minimum CAP version */ + if (flags) { if (!cap->cap || !(client->local->caps & cap->cap)) diff --git a/src/modules/history_backend_mem.c b/src/modules/history_backend_mem.c index 74f9e928a..6cbbe1c3c 100644 --- a/src/modules/history_backend_mem.c +++ b/src/modules/history_backend_mem.c @@ -428,6 +428,7 @@ static void init_history_storage(ModuleInfo *modinfo) cap.name = "unrealircd.org/history-storage"; cap.flags = CLICAP_FLAGS_ADVERTISE_ONLY; cap.parameter = history_storage_capability_parameter; + cap.minimum_cap_version = 302; ClientCapabilityAdd(modinfo->handle, &cap, NULL); } diff --git a/src/modules/json-log-tag.c b/src/modules/json-log-tag.c index 89d86106b..92e758a01 100644 --- a/src/modules/json-log-tag.c +++ b/src/modules/json-log-tag.c @@ -54,6 +54,7 @@ MOD_INIT() memset(&cap, 0, sizeof(cap)); cap.name = "unrealircd.org/json-log"; + cap.minimum_cap_version = 302; c = ClientCapabilityAdd(modinfo->handle, &cap, &CAP_JSON_LOG); memset(&mtag, 0, sizeof(mtag)); diff --git a/src/modules/link-security.c b/src/modules/link-security.c index 76e8bed11..30fac0fba 100644 --- a/src/modules/link-security.c +++ b/src/modules/link-security.c @@ -80,6 +80,7 @@ MOD_LOAD() cap.name = "unrealircd.org/link-security"; cap.flags = CLICAP_FLAGS_ADVERTISE_ONLY; cap.parameter = link_security_capability_parameter; + cap.minimum_cap_version = 302; ClientCapabilityAdd(modinfo->handle, &cap, NULL); EventAdd(modinfo->handle, "checklinksec", checklinksec, NULL, 2000, 0); diff --git a/src/modules/plaintext-policy.c b/src/modules/plaintext-policy.c index d5159d682..dd82b655c 100644 --- a/src/modules/plaintext-policy.c +++ b/src/modules/plaintext-policy.c @@ -71,5 +71,6 @@ void init_plaintext_policy(ModuleInfo *modinfo) cap.name = "unrealircd.org/plaintext-policy"; cap.flags = CLICAP_FLAGS_ADVERTISE_ONLY; cap.parameter = plaintext_policy_capability_parameter; + cap.minimum_cap_version = 302; ClientCapabilityAdd(modinfo->handle, &cap, NULL); }