Files
BARANTMail/main.cpp
T
Brad ArantandClaude Sonnet 5 407cc2e915 Add per-domain DKIM signing, admin console TLS, log rotation, and resolver timeout scoping
- DKIM signing was hardcoded to one domain; now keyed per-domain off
  the sending address, fixing mail for every domain but the original.
- Admin console (port 1027) is now TLS-wrapped instead of plaintext.
- barantmail.log now rotates by size (config-driven) instead of
  growing unbounded (previously hit 23GB).
- ResolverThreadScope.h: a <setenv> scoping fix for name resolution
  inside a <try> block that previously did not survive its own
  container.
- IMAPArgs.cpp: fixed "UID STORE 1:*" wrongly requiring UID 1 to
  exist, which silently made empty-folder operations delete nothing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-06 17:39:06 +00:00

234 lines
12 KiB
C++

#include <string>
#include "EPoll.h"
#include "ConsoleServer.h"
#include "Exception.h"
#include "File.h"
#include "Log.h"
#include "IPAddress.h"
#include "MailFileSystem.h"
#include "SMTPServer.h"
#include "POP3Server.h"
#include "IMAPServer.h"
#include "SendMail.h"
#include "BARANTMailConfig.h"
#include "DKIMSigner.h"
#include <map>
#include "__ADMIN_DOMAIN.h"
#include "__ADMIN_MAILBOX.h"
#include "__ADMIN_ALIAS.h"
#include "__ADMIN_PASSWORD.h"
#include <fcntl.h>
#include <iomanip>
#include <iostream>
#include <signal.h>
#include <sstream>
#include "ResolverThreadScope.h"
int main(int argc, char **argv) {
// Every socket write in this process (outbound relay in
// SendMail::deliverToHost(), and every client-facing SMTP/IMAP/
// POP3 session) can hit a peer that already reset the connection.
// The default SIGPIPE disposition kills the WHOLE process on the
// very next write to it - not just that one connection - which is
// exactly what repeated crash logs traced back to (a relay thread
// writing DATA to an MX host that reset mid-write, taking down
// every other in-flight connection with it). write()/send() still
// report the failure normally via EPIPE once this is ignored.
signal(SIGPIPE, SIG_IGN);
// /var/log is root:syslog-owned and not writable by an unprivileged
// user - this constructor throws immediately (before anything else
// in main() runs) rather than silently falling back, when run as
// the ordinary account this is actually deployed under.
coreutils::Log(new coreutils::File("barantmail.log", O_WRONLY | O_APPEND | O_CREAT, 0644));
coreutils::Log(coreutils::LOG_INFO) << "BMAMAIL called with link " << argv[0];
std::string arg = argv[0];
if(arg.substr(arg.find_last_of("\\/") + 1) == "sendmail2") {
try {
coreutils::Log(coreutils::LOG_INFO) << "Starting Mail Injector. Build " << __DATE__ << " " << __TIME__;
char buffer[1000000];
int index = 0;
int len = 0;
while((len = read(0, &buffer[index], 1000000)) > 0)
index += len;
coreutils::MString message(buffer, index);
coreutils::Log(coreutils::LOG_DEBUG_1) << "Number of bytes read: " << index;
coreutils::ZString mailPath("/var/mail");
mail::MailFileSystem mailFileSystem(mailPath);
// Same config file the server branch defaults to - loaded here
// too (this branch never reads barantmail.cfg otherwise) purely
// for DKIM: mail injected locally through this CLI path is just
// as much "outbound mail from this server" as anything queued
// through SMTP, and should be signed the same way. A missing
// config file here (BARANTMailConfig's own JFile falls back to
// an empty "{}") just means getDkimEntries() is empty and this
// continues unsigned, not a hard failure.
std::map<std::string, mail::DKIMSigner*> *dkimSigners = nullptr;
try {
mail::BARANTMailConfig config("barantmail.cfg");
coreutils::Log::maxLogSize = config.getLogMaxSizeBytes();
coreutils::Log::maxLogBackups = config.getLogMaxBackups();
auto dkimEntries = config.getDkimEntries();
if (!dkimEntries.empty()) {
dkimSigners = new std::map<std::string, mail::DKIMSigner*>();
for (auto &entry : dkimEntries)
(*dkimSigners)[entry.domain] = new mail::DKIMSigner(entry.domain, entry.selector, entry.privateKeyPath);
}
} catch (...) {
// Config missing hostName/mailPath/smtp.port (all required -
// see BARANTMailConfig's own doc comment) - fine, this
// branch doesn't need any of those, only dkim is optional.
}
// A local, one-off message id for this injection's own log
// file - matches __SMTP_DATA::generateMailFileName()'s scheme
// (nanosecond timestamp) so both codepaths' logs sit in
// .sendlog/ with the same naming convention.
struct timespec timex;
clock_gettime(CLOCK_REALTIME, &timex);
std::stringstream idStream;
idStream << timex.tv_sec << "." << std::setfill('0') << std::setw(9) << timex.tv_nsec;
mail::SendMail mail("localhost", mailFileSystem.getSendLogPath().str(), idStream.str(),
coreutils::MString(argv[1]), coreutils::MString(argv[2]), message, dkimSigners);
bool ok = mail.send();
coreutils::Log(coreutils::LOG_DEBUG_1) << "send result: " << (ok ? "delivered" : "failed");
std::cout << (ok ? "250 OK\n" : "550 delivery failed\n");
}
catch(coreutils::Exception exception) {
std::cout << exception.text << " Error reason is '" << strerror(exception.errorNumber) << "' in file " << exception.file << " at line " << exception.line << std::endl;
}
} else {
try {
coreutils::Log(coreutils::LOG_INFO) << "BMAMail Server starting. Build " << __DATE__ << " " << __TIME__;
std::string configPath = (argc > 1) ? argv[1] : "barantmail.cfg";
mail::BARANTMailConfig config(configPath);
coreutils::Log::maxLogSize = config.getLogMaxSizeBytes();
coreutils::Log::maxLogBackups = config.getLogMaxBackups();
std::string hostName = config.getHostName();
std::string ipAddress = "0.0.0.0";
// ZString's std::string constructor takes a non-const reference,
// which can't bind to a temporary - a named local is required.
std::string mailPathStr = config.getMailPath();
coreutils::ZString mailPath(mailPathStr);
mail::MailFileSystem mailFileSystem(mailPath);
core::EPoll ePoll;
// glibc's resolver state (_res, touched by res_query()/
// getaddrinfo() inside SPFChecker/DMARCChecker) is thread-local -
// confirmed directly (a standalone test program) that a fresh
// thread's _res starts zeroed and its own first resolver call
// lazily auto-initializes it to glibc's compiled-in defaults
// (RES_TIMEOUT=5s, RES_DFLRETRY=2 tries), regardless of anything
// set on a different thread beforehand. An earlier fix attempt
// set _res.retrans/_res.retry once here in main() before
// start() - a pure no-op for every one of these 4 worker
// threads, none of which ever saw it. See ResolverThreadScope's
// own doc comment for the full reasoning; same shape as
// MysqlThreadScope's per-thread mysql_thread_init() need just
// below.
static mail::ResolverThreadScope resolverThreadScope;
ePoll.start(4, 1000, &resolverThreadScope);
// One map of signers, shared by every outbound-relaying listener -
// each DKIMSigner holds no per-message state (see its own doc
// comment), so there's no reason for the plaintext and TLS SMTP
// listeners to each load their own copies of the same keys. Built
// once here regardless of how many domains are configured -
// SendMail picks the entry matching each individual outbound
// message's own sender domain at send time (see its own doc
// comment) rather than this server having one fixed identity.
std::map<std::string, mail::DKIMSigner*> *dkimSigners = nullptr;
auto dkimEntries = config.getDkimEntries();
if (!dkimEntries.empty()) {
dkimSigners = new std::map<std::string, mail::DKIMSigner*>();
for (auto &entry : dkimEntries)
(*dkimSigners)[entry.domain] = new mail::DKIMSigner(entry.domain, entry.selector, entry.privateKeyPath);
}
// Port 25 always runs unencrypted and always owns the mail queue
// watch (see SMTPServer's own doc comment on watchQueue) - an
// inbound peer MTA relaying mail in has no TLS-first negotiation
// to fall back on, so this is the one listener every deployment
// needs regardless of what else is configured.
mail::SMTPServer smtpServer(ePoll, hostName, mailFileSystem,
core::IPAddress(ipAddress, config.getSmtpPort()), nullptr, true,
dkimSigners);
mail::SMTPServer *smtpTLSServer = nullptr;
if (config.hasSmtpTLS())
smtpTLSServer = new mail::SMTPServer(ePoll, hostName, mailFileSystem,
core::IPAddress(ipAddress, config.getSmtpTLSPort()),
config.getTlsInfo(), false, dkimSigners);
mail::POP3Server *pop3Server = nullptr;
if (config.hasPop3())
pop3Server = new mail::POP3Server(ePoll, hostName, mailFileSystem,
core::IPAddress(ipAddress, config.getPop3Port()),
config.getTlsInfo());
mail::IMAPServer *imapServer = nullptr;
if (config.hasImap())
imapServer = new mail::IMAPServer(ePoll, hostName, mailFileSystem,
core::IPAddress(ipAddress, config.getImapPort()),
config.getTlsInfo());
// Real, authenticated domain/mailbox/alias/password administration -
// replaces what used to be an unauthenticated console that also
// exposed the raw SMTP command set (AUTH/DATA/EHLO/EXPN/HELO/...)
// to anyone who could reach the port; confirmed reachable
// externally (bound 0.0.0.0) with a login flow that never actually
// checked anything. Only started when barantmail.cfg has a real
// "admin" section configured - matching the same optional-listener
// pattern as smtpTLS/imap/pop3 above, rather than ever handing out
// an admin port with nothing to authenticate against.
core::ConsoleServer *consoleServer = nullptr;
mail::__ADMIN_DOMAIN *adminDomain = nullptr;
mail::__ADMIN_MAILBOX *adminMailbox = nullptr;
mail::__ADMIN_ALIAS *adminAlias = nullptr;
mail::__ADMIN_PASSWORD *adminPassword = nullptr;
if (config.hasAdmin()) {
std::string adminUser = config.getAdminUser();
std::string adminPasswordValue = config.getAdminPassword();
consoleServer = new core::ConsoleServer(ePoll, core::IPAddress(ipAddress, config.getAdminPort()), config.getTlsInfo(),
[adminUser, adminPasswordValue](std::string user, std::string password) {
return user == adminUser && password == adminPasswordValue;
});
adminDomain = new mail::__ADMIN_DOMAIN(mailFileSystem);
adminMailbox = new mail::__ADMIN_MAILBOX(mailFileSystem);
adminAlias = new mail::__ADMIN_ALIAS(mailFileSystem);
adminPassword = new mail::__ADMIN_PASSWORD(mailFileSystem);
// Command lookup upper-cases the typed keyword before matching
// against these registered names (see CommandList::processRequest) -
// every name here has to already be upper case or it can never
// be found at all, which is exactly why "help" silently did
// nothing even after CommandList::processCommand() itself was
// fixed to list commands: it was registered as lower-case "help"
// here and could never be looked up by the upper-cased key.
consoleServer->commands.add(consoleServer->commands, "HELP");
consoleServer->commands.add(*adminDomain, "DOMAIN");
consoleServer->commands.add(*adminMailbox, "MAILBOX");
consoleServer->commands.add(*adminAlias, "ALIAS");
consoleServer->commands.add(*adminPassword, "PASSWORD");
}
while(true)
sleep(3600);
ePoll.stop();
}
catch(coreutils::Exception exception) {
std::cout << exception.text << " Error reason is '" << strerror(exception.errorNumber) << "' in file " << exception.file << " at line " << exception.line << std::endl;
}
}
}