diff --git a/data/hostserv.example.conf b/data/hostserv.example.conf index 368658c70..ca471ecff 100644 --- a/data/hostserv.example.conf +++ b/data/hostserv.example.conf @@ -168,7 +168,13 @@ command { service = "HostServ"; name = "ON"; command = "hostserv/on"; } /* * hs_request * - * Provides the commands hostserv/request, hostserv/activate, hostserv/reject, and hostserv/waiting. + * Provides the commands: + * hostserv/request - Requests a vhost. + * hostserv/activate - Approves a requested vhost. + * hostserv/reject - Rejects a requested vhost. + * hostserv/waiting - Lists pending vhost requests. + * hostserv/validate - Allows self-service approval of vhosts using DNS + * validation (requires the dns module). * * Used to manage vhosts requested by users. */ @@ -186,11 +192,24 @@ module * If set, Anope will send a memo to all services staff when a new vhost is requested. */ #memooper = yes + + /* + * If DNS validation is enabled, how long should users have to wait between + * attempts at DNS validation. Defaults to 5 minutes. + */ + #validationcooldown = 5m + + /* + * If DNS validation is enabled, the TXT record to look for when determining + * if the requester controls the domain. Defaults to anope-dns-validation. + */ + #validationcooldown = "anope-dns-validation" } command { service = "HostServ"; name = "REQUEST"; command = "hostserv/request"; } command { service = "HostServ"; name = "ACTIVATE"; command = "hostserv/activate"; permission = "hostserv/set"; } command { service = "HostServ"; name = "REJECT"; command = "hostserv/reject"; permission = "hostserv/set"; } command { service = "HostServ"; name = "WAITING"; command = "hostserv/waiting"; permission = "hostserv/set"; } +#command { service = "HostServ"; name = "VALIDATE"; command = "hostserv/validate"; } /* * hs_set diff --git a/include/modules/hostserv/request.h b/include/modules/hostserv/request.h index a1805a298..6597b37a0 100644 --- a/include/modules/hostserv/request.h +++ b/include/modules/hostserv/request.h @@ -18,6 +18,8 @@ public: Anope::string ident; Anope::string host; time_t time = 0; + Anope::string validation_token; + time_t last_validation = 0; virtual ~HostRequest() = default; }; diff --git a/language/anope.en_US.po b/language/anope.en_US.po index fd86fdca0..033fae42a 100644 --- a/language/anope.en_US.po +++ b/language/anope.en_US.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: Anope\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-07-30 15:30+0100\n" -"PO-Revision-Date: 2025-07-30 15:30+0100\n" +"POT-Creation-Date: 2025-09-06 17:45+0100\n" +"PO-Revision-Date: 2025-09-06 17:45+0100\n" "Last-Translator: Sadie Powell \n" "Language-Team: English\n" "Language: en_US\n" @@ -5609,6 +5609,10 @@ msgstr "" msgid "Unable to find regex engine %s." msgstr "" +#, c-format +msgid "Unable to find the DNS record required to validate %s. If you have not already done this add a TXT record for %s with the value %s and re-execute this command." +msgstr "" + #, c-format msgid "Unable to load module %s." msgstr "" @@ -5767,6 +5771,10 @@ msgstr "" msgid "VHost for %s has been rejected." msgstr "" +#, c-format +msgid "VHost for %s has been validated using DNS." +msgstr "" + #, c-format msgid "VHost for account %s set to %s." msgstr "" @@ -5784,6 +5792,9 @@ msgstr "" msgid "VIEW [mask | list]" msgstr "" +msgid "Validates a previously requested vhost using DNS" +msgstr "" + msgid "Value" msgstr "" @@ -6107,6 +6118,10 @@ msgstr "" msgid "You must wait %s before registering your nick." msgstr "" +#, c-format +msgid "You must wait for %s before trying DNS validation again." +msgstr "" + msgid "You need to be identified to use this command." msgstr "" @@ -6269,7 +6284,15 @@ msgstr "" msgid "Your requested vhost has been rejected. Reason: %s" msgstr "" -msgid "Your vhost has been requested." +msgid "Your requested vhost has been validated via DNS." +msgstr "" + +#, c-format +msgid "Your vhost %s has been requested." +msgstr "" + +#, c-format +msgid "Your vhost %s has been requested. If the requested vhost is for a valid DNS name you can add a TXT record for %s with the value %s and automatically approve your vhost using %s." msgstr "" #, c-format diff --git a/modules/hostserv/hs_request.cpp b/modules/hostserv/hs_request.cpp index c3ccd39da..3a1487aac 100644 --- a/modules/hostserv/hs_request.cpp +++ b/modules/hostserv/hs_request.cpp @@ -15,12 +15,20 @@ */ #include "module.h" +#include "modules/dns.h" #include "modules/hostserv/request.h" +static ServiceReference dnsmanager("DNS::Manager", "dns/manager"); static ServiceReference memoserv("MemoServService", "MemoServ"); static void req_send_memos(Module *me, CommandSource &source, const Anope::string &vident, const Anope::string &vhost); +namespace +{ + // The name of the DNS record used for validation. + Anope::string validation_record; +} + struct HostRequestImpl final : HostRequest , Serializable @@ -29,6 +37,23 @@ struct HostRequestImpl final : Serializable("HostRequest") { } + + static HostRequestImpl *Get(NickAlias *na) + { + return na ? na->GetExt("hostrequest") : nullptr; + } + + Anope::string Mask() const + { + if (ident.empty()) + return host; + return ident + "@" + host; + } + + Anope::string GetValidationRecord() const + { + return Anope::Format("%s=%s", validation_record.c_str(), this->validation_token.c_str()); + } }; struct HostRequestTypeImpl final @@ -46,6 +71,8 @@ struct HostRequestTypeImpl final data.Store("ident", req->ident); data.Store("host", req->host); data.Store("time", req->time); + data.Store("validation_token", req->validation_token); + data.Store("last_validation", req->last_validation); } Serializable *Unserialize(Serializable *obj, Serialize::Data &data) const override @@ -68,12 +95,96 @@ struct HostRequestTypeImpl final data["ident"] >> req->ident; data["host"] >> req->host; data["time"] >> req->time; + data["validation_token"] >> req->validation_token; + data["last_validation"] >> req->last_validation; } return req; } }; +class DNSHostResolver final + : public DNS::Request +{ +private: + Command *command; + Reference nickalias; + CommandSource source; + + void HandleError(HostRequestImpl *hr) + { + source.Reply(_( + "Unable to find the DNS record required to validate \002%s\002. If you have not already " + "done this add a TXT record for %s with the value %s and re-execute this command." + ), + hr->Mask().c_str(), + hr->host.c_str(), + hr->GetValidationRecord().c_str() + ); + } + +public: + DNSHostResolver(Command *cmd, HostRequest *hr, NickAlias *na, const CommandSource &src) + : Request(dnsmanager, cmd->module, hr->host, DNS::QUERY_TXT, false) + , command(cmd) + , nickalias(na) + , source(src) + { + hr->last_validation = Anope::CurTime; + Log(LOG_DEBUG) << "Checking " << hr->host << " for " << hr->validation_token; + } + + void OnError(const DNS::Query *record) override + { + NickAlias *na = nickalias; + if (!na) + return; // Nick has been dropped. + + auto *hr = HostRequestImpl::Get(na); + if (!hr) + { + source.Reply(_("No request for nick %s found."), source.GetNick().c_str()); + return; + } + + HandleError(hr); + } + + void OnLookupComplete(const DNS::Query *record) override + { + NickAlias *na = nickalias; + if (!na) + return; // Nick has been dropped. + + auto *hr = HostRequestImpl::Get(na); + if (!hr) + { + source.Reply(_("No request for nick %s found."), source.GetNick().c_str()); + return; + } + + for (const auto &answer : record->answers) + { + if (answer.rdata != hr->GetValidationRecord()) + continue; // Not for us. + + na->SetVHost(hr->ident, hr->host, source.GetNick(), hr->time); + FOREACH_MOD(OnSetVHost, (na)); + + if (Config->GetModule(command->module).Get("memouser") && memoserv) + memoserv->Send(source.service->nick, na->nick, _("Your requested vhost has been validated via DNS."), true); + + source.Reply(_("VHost for %s has been validated using DNS."), na->nick.c_str()); + Log(LOG_COMMAND, source, command) << "for " << na->nick << " for vhost " << hr->Mask(); + na->Shrink("hostrequest"); + + return; // We're done. + } + + HandleError(hr); + } +}; + class CommandHSRequest final : public Command { @@ -171,9 +282,28 @@ public: req.ident = user; req.host = host; req.time = Anope::CurTime; + req.validation_token = Anope::Random(Config->GetBlock("options").Get("codelength", "15")); na->Extend("hostrequest", req); - source.Reply(_("Your vhost has been requested.")); + BotInfo *bi; + Anope::string cmd; + if (dnsmanager && Command::FindCommandFromService("hostserv/validate", bi, cmd)) + { + source.Reply(_( + "Your vhost \002%s\002 has been requested. If the requested vhost is for a valid " + "DNS name you can add a TXT record for %s with the value %s and automatically " + "approve your vhost using \002%s\002." + ), + req.Mask().c_str(), + req.host.c_str(), + req.GetValidationRecord().c_str(), + bi->GetQueryCommand("hostserv/validate").c_str() + ); + } + else + source.Reply(_("Your vhost \002%s\002 has been requested."), req.Mask().c_str()); + + req_send_memos(owner, source, user, host); Log(LOG_COMMAND, source, this) << "to request new vhost " << (!user.empty() ? user + "@" : "") << host; } @@ -357,6 +487,72 @@ public: } }; +class CommandHSValidate final + : public Command +{ +public: + time_t cooldown; + + CommandHSValidate(Module *creator) + : Command(creator, "hostserv/validate", 0) + { + this->SetDesc(_("Validates a previously requested vhost using DNS")); + } + + void Execute(CommandSource &source, const std::vector ¶ms) override + { + if (Anope::ReadOnly) + { + source.Reply(READ_ONLY_MODE); + return; + } + + auto *na = NickAlias::Find(source.GetNick()); + if (!na || na->nc != source.GetAccount()) + { + source.Reply(ACCESS_DENIED); + return; + } + + auto *req = HostRequestImpl::Get(na); + if (!req) + { + source.Reply(_("No request for nick %s found."), source.GetNick().c_str()); + return; + } + + auto next_validation = req->last_validation + cooldown; + if (req->last_validation && next_validation > Anope::CurTime) + { + source.Reply(_("You must wait for %s before trying DNS validation again."), + Anope::Duration(next_validation - Anope::CurTime).c_str()); + return; + } + + DNSHostResolver *res = nullptr; + try + { + if (!dnsmanager) + throw SocketException("DNS is not available"); + + res = new DNSHostResolver(this, req, na, source); + dnsmanager->Process(res); + } + catch (const SocketException &ex) + { + Log(this->module) << ex.GetReason(); + source.Reply("Unable to validate vhosts right now. Please try again later."); + delete res; + } + } + + bool OnHelp(CommandSource &source, const Anope::string &subcommand) override + { + // TODO + return true; + } +}; + class HSRequest final : public Module { @@ -364,6 +560,7 @@ class HSRequest final CommandHSActivate commandhsactive; CommandHSReject commandhsreject; CommandHSWaiting commandhswaiting; + CommandHSValidate commandhsvalidate; ExtensibleItem hostrequest; HostRequestTypeImpl request_type; @@ -374,11 +571,19 @@ public: , commandhsactive(this) , commandhsreject(this) , commandhswaiting(this) + , commandhsvalidate(this) , hostrequest(this, "hostrequest") { if (!IRCD || !IRCD->CanSetVHost) throw ModuleException("Your IRCd does not support vhosts"); } + + void OnReload(Configuration::Conf &conf) override + { + const auto &block = conf.GetModule(this); + commandhsvalidate.cooldown = block.Get("validationcooldown", "5m"); + validation_record = block.Get("dnsrecord", "anope-dns-validation"); + } }; static void req_send_memos(Module *me, CommandSource &source, const Anope::string &vident, const Anope::string &vhost)