From 889d521e05f2e922683d48c74eb00290e7a2f548 Mon Sep 17 00:00:00 2001 From: Sadie Powell Date: Wed, 17 Jul 2024 00:06:50 +0100 Subject: Shuffle the modules about a bit. --- modules/dnsbl.cpp | 683 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 683 insertions(+) create mode 100644 modules/dnsbl.cpp (limited to 'modules/dnsbl.cpp') diff --git a/modules/dnsbl.cpp b/modules/dnsbl.cpp new file mode 100644 index 000000000..5e63d2280 --- /dev/null +++ b/modules/dnsbl.cpp @@ -0,0 +1,683 @@ +/* + * InspIRCd -- Internet Relay Chat Daemon + * + * Copyright (C) 2021 Dominic Hamon + * Copyright (C) 2020 Matt Schatz + * Copyright (C) 2018-2019 linuxdaemon + * Copyright (C) 2017-2024 Sadie Powell + * Copyright (C) 2013, 2016 Adam + * Copyright (C) 2013, 2015-2016 Attila Molnar + * Copyright (C) 2012 Robby + * Copyright (C) 2009-2010 Daniel De Graaf + * Copyright (C) 2007 Dennis Friis + * Copyright (C) 2007 Craig Edwards + * Copyright (C) 2006-2007, 2009 Robin Burchell + * + * This file is part of InspIRCd. InspIRCd is free software: you can + * redistribute it and/or modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation, version 2. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + + +#ifndef _WIN32 +# include +#endif + +#include "inspircd.h" +#include "extension.h" +#include "modules/dns.h" +#include "modules/shun.h" +#include "modules/stats.h" +#include "numerichelper.h" +#include "timeutils.h" +#include "xline.h" + +class DNSBLEntry final +{ +public: + enum class Action + : uint8_t + { + // G-line users who's IP address is in the DNSBL. + GLINE, + + // Kill users who's IP address is in the DNSBL. + KILL, + + // K-line users who's IP address is in the DNSBL. + KLINE, + + // Mark users who's IP address is in the DNSBL. + MARK, + + // Shun users who's IP address is in the DNSBL. + SHUN, + + // Z-line users who's IP address is in the DNSBL. + ZLINE, + }; + + enum class Type + : uint8_t + { + // DNSBL results will be compared against the specified bit mask. + BITMASK, + + // DNSBL results will be compared against a numeric range of values. + RECORD, + }; + + // The action to take against users who's IP address is in this DNSBL. + Action action; + + // A bitmask of DNSBL result types to match against. + unsigned int bitmask; + + // The domain name of this DNSBL. + std::string domain; + + // If action is set to mark then a new hostname to set on users who's IP address is in this DNSBL. + std::string markhost; + + // If action is set to mark then a new username to set on users who's IP address is in this DNSBL. + std::string markuser; + + // The human readable name of this DNSBL. + std::string name; + + // A range of DNSBL result types to match against. + CharState records; + + // The message to send to users who's IP address is in a DNSBL. + std::string reason; + + // The number of seconds to wait for a DNSBL request before assuming it has failed. + unsigned int timeout; + + // The type of result that this DNSBL will provide. + Type type; + + // The number of errors which have occurred when querying this DNSBL. + unsigned long stats_errors = 0; + + // The number of hits which have occurred when querying this DNSBL. + unsigned long stats_hits = 0; + + // The number of misses which have occurred when querying this DNSBL. + unsigned long stats_misses = 0; + + // If action is set to gline, kline, or zline then the duration for an X-line to last for. + unsigned long xlineduration; + + DNSBLEntry(const Module* mod, const std::shared_ptr& tag) + { + name = tag->getString("name"); + if (name.empty()) + throw ModuleException(mod, " can not be empty at " + tag->source.str()); + + domain = tag->getString("domain"); + if (domain.empty()) + throw ModuleException(mod, " can not be empty at " + tag->source.str()); + + action = tag->getEnum("action", Action::KILL, { + { "gline", Action::GLINE }, + { "kill", Action::KILL }, + { "kline", Action::KLINE }, + { "mark", Action::MARK }, + { "shun", Action::SHUN }, + { "zline", Action::ZLINE }, + }); + + const std::string typestr = tag->getString("type"); + if (insp::equalsci(typestr, "bitmask")) + { + type = Type::BITMASK; + + bitmask = tag->getNum("bitmask", 0); + records = 0; + } + else if (insp::equalsci(typestr, "record")) + { + type = Type::RECORD; + + irc::portparser recordrange(tag->getString("records"), false); + for (long record = 0; (record = recordrange.GetToken()); ) + { + if (record < 0 || record > UCHAR_MAX) + throw ModuleException(mod, " can only hold records between 0 and 255 at " + tag->source.str()); + + records.set(record); + } + } + else + { + throw ModuleException(mod, typestr + " is an invalid value for ; acceptable values are 'bitmask' or 'records' at " + + tag->source.str()); + } + + reason = tag->getString("reason", "Your IP (%ip%) has been blacklisted by the %dnsbl% DNSBL.", 1, ServerInstance->Config->Limits.MaxLine); + timeout = static_cast(tag->getDuration("timeout", 0, 1, 60)); + markuser = tag->getString("user"); + markhost = tag->getString("host"); + xlineduration = tag->getDuration("duration", 60*60, 1); + } +}; + +class DNSBLMask final +{ +public: + // The username to give a user because they were in a DNSBL (). + std::string user; + + // The hostname to give a user because they were in a DNSBL (). + std::string host; + + // The reason why the user was given this user@host (). + std::string reason; + + DNSBLMask(const std::shared_ptr& cfg, const std::string& msg) + : user(cfg->markuser) + , host(cfg->markhost) + , reason(msg) + { + } +}; + +typedef std::vector> DNSBLEntries; +typedef SimpleExtItem MaskExtItem; +typedef ListExtItem> MarkExtItem; + +// Data which is shared with DNS lookup classes. +class SharedData final +{ +private: + std::string ReverseIP(const irc::sockets::sockaddrs& sa) + { + switch (sa.family()) + { + case AF_INET: + { + auto a = (unsigned int) sa.in4.sin_addr.s_addr & 0xFF; + auto b = (unsigned int) (sa.in4.sin_addr.s_addr >> 8) & 0xFF; + auto c = (unsigned int) (sa.in4.sin_addr.s_addr >> 16) & 0xFF; + auto d = (unsigned int) (sa.in4.sin_addr.s_addr >> 24) & 0xFF; + return fmt::format("{}.{}.{}.{}", d, c, b, a); + } + + case AF_INET6: + { + const std::string hexaddr = Hex::Encode(sa.in6.sin6_addr.s6_addr, 16); + std::string reversedip; + reversedip.reserve(hexaddr.length() * 2); + for (const auto hexchr : insp::reverse_range(hexaddr)) + { + reversedip.push_back(hexchr); + reversedip.push_back('.'); + } + reversedip.pop_back(); + return reversedip; + } + + default: + break; + } + + // Clients can't be in a DNSBL if they aren't connected via IPv4 or IPv6. + return {}; + } + +public: + // Counts the number of DNSBL lookups waiting for this user. + IntExtItem countext; + + // Reference to the DNS manager. + DNS::ManagerRef dns; + + // The DNSBL entries from the config. + DNSBLEntries dnsbls; + + // The DNSBL marks which are set on a user. + MarkExtItem markext; + + // The user@host to set on a marked user when they are connected. + MaskExtItem maskext; + + SharedData(Module* mod) + : countext(mod, "dnsbl-pending", ExtensionType::USER) + , dns(mod) + , markext(mod, "dnsbl-match", ExtensionType::USER) + , maskext(mod, "dnsbl-mask", ExtensionType::USER) + { + } + + // Performs one or more DNSBL lookups on the specified user. + void Lookup(LocalUser* user); +}; + +class DNSBLResolver final + : public DNS::Request +{ +private: + std::shared_ptr config; + SharedData& data; + const irc::sockets::sockaddrs sa; + const std::string uuid; + + template + void AddLine(const char* type, const std::string& reason, unsigned long duration, LocalUser* user, Extra&&... extra) + { + if (user->exempt) + return; // This user shouldn't be banned. + + auto line = new Line(ServerInstance->Time(), duration, MODNAME "@" + ServerInstance->Config->ServerName, reason, std::forward(extra)...); + if (!ServerInstance->XLines->AddLine(line, nullptr)) + { + ServerInstance->Users.QuitUser(user, "Killed (" + reason + ")"); + delete line; + return; + } + + ServerInstance->SNO.WriteToSnoMask('x', "{} added a timed {} on {}, expires in {} (on {}): {}", + line->source, type, line->Displayable(), Duration::ToString(line->duration), + Time::ToString(line->expiry), line->reason); + ServerInstance->XLines->ApplyLines(); + } + +public: + DNSBLResolver(Module* mod, SharedData& sd, const std::string& hostname, LocalUser* u, const std::shared_ptr& cfg) + : DNS::Request(*sd.dns, mod, hostname, DNS::QUERY_A, true, cfg->timeout) + , config(cfg) + , data(sd) + , sa(u->client_sa) + , uuid(u->uuid) + { + } + + /* Note: This may be called multiple times for multiple A record results */ + void OnLookupComplete(const DNS::Query* r) override + { + /* Check the user still exists */ + LocalUser* them = ServerInstance->Users.FindUUID(uuid); + if (!them || them->client_sa != sa) + { + config->stats_misses++; + return; + } + + intptr_t i = data.countext.Get(them); + if (i) + data.countext.Set(them, i - 1); + + // The DNSBL reply must contain an A result. + const DNS::ResourceRecord* const ans_record = r->FindAnswerOfType(DNS::QUERY_A); + if (!ans_record) + { + config->stats_errors++; + ServerInstance->SNO.WriteGlobalSno('d', "{} returned an result with no IPv4 address.", + config->name); + return; + } + + // The DNSBL reply must be a valid IPv4 address. + in_addr resultip; + if (inet_pton(AF_INET, ans_record->rdata.c_str(), &resultip) != 1) + { + config->stats_errors++; + ServerInstance->SNO.WriteGlobalSno('d', "{} returned an invalid IPv4 address: {}", + config->name, ans_record->rdata); + return; + } + + // The DNSBL reply should be in the 127.0.0.0/8 range. + if ((resultip.s_addr & 0xFF) != 127) + { + config->stats_errors++; + ServerInstance->SNO.WriteGlobalSno('d', "{} returned an IPv4 address which is outside of the 127.0.0.0/8 subnet: {}", + config->name, ans_record->rdata); + return; + } + + bool match = false; + unsigned int result = 0; + switch (config->type) + { + case DNSBLEntry::Type::BITMASK: + { + result = (resultip.s_addr >> 24) & config->bitmask; + match = (result != 0); + break; + } + case DNSBLEntry::Type::RECORD: + { + result = resultip.s_addr >> 24; + match = (config->records[result] == 1); + break; + } + } + + if (match) + { + const std::string reason = Template::Replace(config->reason, { + { "dnsbl", config->name }, + { "dnsbl.url", Percent::Encode(config->name) }, + { "ip", them->GetAddress() }, + { "network", ServerInstance->Config->Network }, + { "network.url", Percent::Encode(ServerInstance->Config->Network) }, + { "result", ConvToStr(result) }, + }); + + config->stats_hits++; + + switch (config->action) + { + case DNSBLEntry::Action::KILL: + { + if (!them->exempt) + ServerInstance->Users.QuitUser(them, "Killed (" + reason + ")"); + break; + } + case DNSBLEntry::Action::MARK: + { + if (!config->markuser.empty() || !config->markhost.empty()) + { + // Store the u@h mask for later to avoid being overwritten by username/hostname lookups. + data.maskext.SetFwd(them, config, reason); + + // If the user is already connected we should just do this now. + if (them->IsFullyConnected()) + creator->OnUserConnect(them); + } + + data.markext.GetRef(them).push_back(config->name); + break; + } + case DNSBLEntry::Action::KLINE: + { + AddLine("K-line", reason, config->xlineduration, them, them->GetBanUser(true), them->GetAddress()); + break; + } + case DNSBLEntry::Action::GLINE: + { + AddLine("G-line", reason, config->xlineduration, them, them->GetBanUser(true), them->GetAddress()); + break; + } + case DNSBLEntry::Action::ZLINE: + { + AddLine("Z-line", reason, config->xlineduration, them, them->GetAddress()); + break; + } + case DNSBLEntry::Action::SHUN: + { + AddLine("Shun", reason, config->xlineduration, them, them->GetAddress()); + break; + } + } + + ServerInstance->SNO.WriteGlobalSno('d', "{} {} ({}) detected as being on the '{}' DNSBL with result {}{}", + them->IsFullyConnected() ? "User" : "Connecting user", them->GetRealMask(), them->GetAddress(), + config->name, result, them->exempt ? " -- exempt" : ""); + } + else + config->stats_misses++; + } + + void OnError(const DNS::Query* q) override + { + bool is_miss = true; + switch (q->error) + { + case DNS::ERROR_NO_RECORDS: + case DNS::ERROR_DOMAIN_NOT_FOUND: + config->stats_misses++; + break; + + default: + config->stats_errors++; + is_miss = false; + break; + } + + LocalUser* them = ServerInstance->Users.FindUUID(uuid); + if (!them || them->client_sa != sa) + return; + + intptr_t i = data.countext.Get(them); + if (i) + data.countext.Set(them, i - 1); + + if (is_miss) + return; + + ServerInstance->SNO.WriteGlobalSno('d', "An error occurred whilst checking whether {} ({}) is on the '{}' DNSBL: {}", + them->GetRealMask(), them->GetAddress(), config->name, data.dns->GetErrorStr(q->error)); + } +}; + +class CommandDNSBL final + : public Command +{ +private: + SharedData& data; + +public: + CommandDNSBL(Module* mod, SharedData& sd) + : Command(mod, "DNSBL", 1, 2) + , data(sd) + { + access_needed = CmdAccess::OPERATOR; + syntax = { "[ []]" }; + translation = { TR_NICK, TR_TEXT }; + } + + CmdResult Handle(User* user, const Params& parameters) override + { + User* target; + if (IS_LOCAL(user)) + target = ServerInstance->Users.FindNick(parameters[0], true); + else + target = ServerInstance->Users.FindUUID(parameters[0], true); + + // We couldn't find the user so just give up. + if (!target) + { + user->WriteNumeric(Numerics::NoSuchNick(parameters[0])); + return CmdResult::FAILURE; + } + + auto* ltarget = IS_LOCAL(target); + if (!ltarget) + return CmdResult::SUCCESS; // The user is on another server so just let it forward. + + intptr_t count = data.countext.Get(ltarget); + if (count) + { + // TODO: replace this with a FAIL stdrpl when we can network those. + user->WriteRemoteNotice(fmt::format("*** DNSBL: Unable to recheck {}: still waiting on {} DNSBLs from the previous check.", + ltarget->nick, count)); + return CmdResult::FAILURE; + } + + // TODO: replace this with a NOTE stdrpl when we can network those. + user->WriteRemoteNotice(fmt::format("*** DNSBL: Rechecking {} against {} DNSBLs.", ltarget->nick, data.dnsbls.size())); + + const bool has_reason = parameters.size() > 1; + ServerInstance->SNO.WriteGlobalSno('d', "{} is rechecking whether {} ({}) is in a DNSBL{}{}", user->nick, + ltarget->nick, ltarget->GetAddress(), has_reason ? ": " : "", has_reason ? parameters[1] : "."); + + data.Lookup(ltarget); + return CmdResult::SUCCESS; + } + + RouteDescriptor GetRouting(User* user, const Params& parameters) override + { + return ROUTE_OPT_UCAST(parameters[0]); + } +}; + +void SharedData::Lookup(LocalUser* user) +{ + if (!dns) + return; // The core_dns module is not loaded. + + if (!user->GetClass()->config->getBool("usednsbl", true)) + return; // The user's class is exempt from DNSBL lookups. + + const std::string reversedip = ReverseIP(user->client_sa); + ServerInstance->Logs.Debug(MODNAME, "Reversed IP {} => {}", user->GetAddress(), reversedip); + + countext.Set(user, dnsbls.size()); + + // For each DNSBL, we will run through this lookup + for (const auto& dnsbl : dnsbls) + { + // Fill hostname with a dnsbl style host (d.c.b.a.domain.tld) + const std::string hostname = reversedip + "." + dnsbl->domain; + + // Try to do the DNSBL lookup. + auto* r = new DNSBLResolver(dns->creator, *this, hostname, user, dnsbl); + try + { + dns->Process(r); + } + catch (const DNS::Exception& ex) + { + delete r; + ServerInstance->Logs.Debug(MODNAME, "DNSBL lookup error: {}", ex.GetReason()); + } + + if (user->quitting) + break; // DNS resolver found a cached hit. + } +} + +class ModuleDNSBL final + : public Module + , public Stats::EventListener +{ +private: + SharedData data; + CommandDNSBL cmd; + +public: + ModuleDNSBL() + : Module(VF_VENDOR, "Allows the server administrator to check the IP address of connecting users against a DNSBL.") + , Stats::EventListener(this) + , data(this) + , cmd(this, data) + { + } + + void init() override + { + ServerInstance->SNO.EnableSnomask('d', "DNSBL"); + } + + void Prioritize() override + { + Module* corexline = ServerInstance->Modules.Find("core_xline"); + ServerInstance->Modules.SetPriority(this, I_OnChangeRemoteAddress, PRIORITY_AFTER, corexline); + } + + void ReadConfig(ConfigStatus& status) override + { + DNSBLEntries newdnsbls; + for (const auto& [_, tag] : ServerInstance->Config->ConfTags("dnsbl")) + { + auto entry = std::make_shared(this, tag); + newdnsbls.push_back(entry); + } + data.dnsbls.swap(newdnsbls); + } + + void OnChangeRemoteAddress(LocalUser* user) override + { + if (!user->quitting && user->GetClass()) + data.Lookup(user); + } + + ModResult OnPreChangeConnectClass(LocalUser* user, const std::shared_ptr& klass, std::optional& errnum) override + { + const std::string dnsbl = klass->config->getString("dnsbl"); + if (dnsbl.empty()) + return MOD_RES_PASSTHRU; + + MarkExtItem::List* match = data.markext.Get(user); + if (!match) + { + ServerInstance->Logs.Debug("CONNECTCLASS", "The {} connect class is not suitable as it requires a DNSBL mark.", + klass->GetName()); + return MOD_RES_DENY; + } + + for (const auto& mark : *match) + { + if (InspIRCd::Match(mark, dnsbl)) + return MOD_RES_PASSTHRU; + } + + ServerInstance->Logs.Debug("CONNECTCLASS", "The {} connect class is not suitable as the DNSBL marks ({}) do not match {}.", + klass->GetName(), insp::join(*match, ", "), dnsbl); + return MOD_RES_DENY; + } + + ModResult OnCheckReady(LocalUser* user) override + { + // Block until all of the DNSBL lookups are complete. + return data.countext.Get(user) ? MOD_RES_DENY : MOD_RES_PASSTHRU; + } + + void OnUserConnect(LocalUser* user) override + { + DNSBLMask* ih = data.maskext.Get(user); + if (ih) + { + if (!ih->user.empty()) + { + user->ChangeDisplayedUser(ih->user); + user->WriteNotice("Your username has been set to " + ih->user + ": " + ih->reason); + } + + if (!ih->host.empty()) + { + user->ChangeDisplayedHost(ih->host); + user->WriteNotice("Your hostname has been set to " + ih->host + ": " + ih->reason); + } + + data.maskext.Unset(user); + } + } + + ModResult OnStats(Stats::Context& stats) override + { + if (stats.GetSymbol() != 'd') + return MOD_RES_PASSTHRU; + + unsigned long total_hits = 0; + unsigned long total_misses = 0; + unsigned long total_errors = 0; + for (const auto& dnsbl : data.dnsbls) + { + total_hits += dnsbl->stats_hits; + total_misses += dnsbl->stats_misses; + total_errors += dnsbl->stats_errors; + + stats.AddGenericRow(fmt::format("The \"{}\" DNSBL had {} hits, {} misses, and {} errors", + dnsbl->name, dnsbl->stats_hits, dnsbl->stats_misses, dnsbl->stats_errors)); + } + + stats.AddGenericRow("Total DNSBL hits: " + ConvToStr(total_hits)); + stats.AddGenericRow("Total DNSBL misses: " + ConvToStr(total_misses)); + stats.AddGenericRow("Total DNSBL errors: " + ConvToStr(total_errors)); + return MOD_RES_DENY; + } +}; + +MODULE_INIT(ModuleDNSBL) -- cgit v1.3.1-10-gc9f91