很久以前便见到CU论坛上有一篇关于qmail源码注释的文章,可惜当时作者只写了smtpd的部分,并没有涉及qmail的整个运行机理。笔者最近由于工作的原因,研究了一下qmail,由于qmail的代码很混乱,又没有很好的注释,的却是晦涩难懂,笔者花了一个星期才勉强把它的大致原理搞清楚,希望这篇文章能够给渴望了解qmail代码的朋友们一点帮助。
关于smtpd的部分,可参看本版的精华区。本文将会跳过smtpd,介绍qmail的核心程序qmail-send。
首先我们来看一下qmail的入队过程(就是将收下一封邮件并将其保存到qmail的磁盘队列中已被用于后续的投递)。当一封邮件被qmail-smtpd收下来时我们并不能称该邮件已经入队了,在qmail中邮件的入队是由一个临时的名为qmail-queue的进程来完成的,其实这个进程也没干什么事,就是将邮件保存到队列的mess目录下(名称以数字msgID来描述),mess顾名思义就是message的意思,指得是整封邮件,当然仅仅保存这封邮件还是不够的,qmail-queue还将这封邮件的信封信息,即MAIL FROM, RCPT TO按照一定的格式写入到intd目录下,并将该文件link到todo目录下,至此,整个qmail的入队工作已经完成,此时qmail-queue会通过unix下的命名管道通知qmail-send有新消息来了。
接下去就是本文着重要介绍的qmail-send的工作了。qmail-send的工作就是对入队了的消息进行预处理,将消息分派到本地或远程两个队列中,同时qmail-send也是整个qmail投递过程通信的核心进程。
和qmail-send通信的有log进程,clean进程,lspawn及rspawn等进程,log是用来记录日志的,clean是删除队列中消息的,lspawn,rspawn很显然就是用来产生一个本地或者远程的投递进程的。
整个通信过程都是通过匿名管道来完成的。qmail将管道机制处理的极其优雅,对于qmail-send而言,文件描述符0, 1, 2, 3, 4, 5, 6 分别为相应的管道,0是日志管道,就是向log进程发送日志的,在log进程中接受日志的文件描述符同样也是0。对于其他进程clean, lspawn, rspawn而言,他们分别从文件描述符0中读取来自qmail-send的讯息,并将处理结果通过文件描述符1返回给qmail-send。
qmail-send
qmail-log 0 ------------------------ 0
qmail-local 0 ------------------------ 1
1 ------------------------ 2
qmail-remote 0 ------------------------ 3
1 ------------------------ 4
qmail-clean 0 ------------------------- 5
1 ------------------------- 6
OK,讲了这么多的废话我们还是开始分析qmail-send的代码吧,当然在这里我并不想把所有的代码都贴出来,毕竟有近2000行的代码,我主要是想介绍一下以下5个函数:
comm_do(&wfds);
del_do(&rfds);
todo_do(&rfds);
pass_do();
cleanup_do();
这五个函数出现在主循环while中,是qmail-send不断在执行的事情,把这五个函数搞清楚了也就大致能了解qmail是如何工作的了。
void comm_do(fd_set *wfds)
{
int c;
for (c = 0; c < CHANNELS; ++c) {
if (flagspawnalive[c]) {
if (comm_buf[c].s && comm_buf[c].len) {
if (FD_ISSET(chanfdout[c],wfds)) {
int w;
int len;
len = comm_buf[c].len;
w = write(chanfdout[c], comm_buf[c].s + comm_pos[c], len
- comm_pos[c]);
if (w <= 0) {
if ((w == -1) && (errno == error_pipe)) {
spawndied(c);
} else {
continue; /* kernel select() bug; can't avoidbusy-looping */
}
} else {
comm_pos[c] += w;
if (comm_pos[c] == len)
comm_buf[c].len = 0;
}
}
}
}
}
}
CHANNELS=2,对应lspawn和rspawn进程,这个函数很简单,它只是将需要投递的信息写入到相应管道中去,至于这些投递信息是如何产生的,我将会在下面介绍。
void del_do(fd_set *rfds)
{
int c;
for (c = 0; c < CHANNELS; ++c) {
if (flagspawnalive[c])
if (FD_ISSET(chanfdin[c],rfds))
del_dochan(c);
}
}
OH,SHIT!这又是一个简单的函数,它只是负责从相应的lspawn和rspawn管道中读取投递成功或失败的信息。
void todo_do(fd_set *rfds)代码是在太长了,我不想把它全部贴出来,在这里我省略了一些错误的处理。
todo_do的工作是什么? 前面我们已经说过入队的过程,当一封邮件入队后qmail并不是马上就去投递它了,我们需要对它进行一些预处理,首先我们从todo目录下寻找是否有新的邮件入队了,如果有我们读取该文件获得它的信封信息,将MAIL FROM信息写入到info目录下,并判断RCPT TO邮件地址是本地还是远程的,如果是本地的就已相应的msgID作为文件名写入到local目录下,同样如果是远程的,则会写入到remote目录下,随后qmail通过qmail-clean将todo目录下的该文件删除,至此该消息的预处理就算结束了。
//
// 获取todo目录下一条讯息的文件名
//
d = readdir(tododir);
len = scan_ulong(d->d_name, &id);
//
// 组合成todo目录下该文件的完整路径,并将其打开
// 该文件的内容我们在上面已经介绍过,它存储的就是
// 对应该msgID的邮件的MAIL FROM和RCPT TO
// 的内容
fnmake_todo(id);
fd = open_read(fn.s);
//
// 组合成info目录下该msg对应的完整路径名
//
fnmake_info(id);
//
// 以写方式创建并打开该文件
//
fdinfo = open_excl(fn.s);
//
// 初始化对这两个文件进行读写的buffer工具函数。
//
substdio_fdbuf(&ss,read,fd,todobuf,sizeof(todobuf)); substdio_fdbuf(&ssinfo,write,fdinfo,todobufinfo,sizeof(todobufinfo));
for ( ; ; ) // 从todo/id 中读取信息,每次一个记录,以’\0’为分割符
{
if (getln(&ss, &todoline, &match, '\0') == -1)
{
goto fail;
}
if (!match) break;
switch(todoline.s[0])
{
case 'u': // 获取该msg的uid
scan_ulong(todoline.s + 1,&uid);
break;
case 'p': // 获取该msg的pid
scan_ulong(todoline.s + 1,&pid);
break;
case 'F': // 将 MAIL FROM信息写入到info/id文件中去
if (substdio_putflush(&ssinfo,todoline.s,todoline.len) == -1)
{
goto fail;
}
break;
case 'T': // 将RCPT TO信息写入到local/split/id或者
// remote/split/id
//
// 写入rwline buffer并判断该RCPT TO是本地的还是远程的,
// c = 0 代表本地, 1代表remote的
//
switch(rewrite(todoline.s + 1))
{
case 0: nomem(); goto fail;
case 2: c = 1; break;
default: c = 0; break;
}
if (fdchan[c] == -1)
{
//
// 根据c和msgid组装出local/split/id或者remote/split/id
// 这样的完整路径名
//
fnmake_chanaddr(id,c);
//
// 打开文件并初始化buffer写入的工具函数
//
fdchan[c] = open_excl(fn.s);
if (fdchan[c] == -1)
{
goto fail;
}
substdio_fdbuf(&sschan[c], write, fdchan[c],
todobufchan[c],sizeof(todobufchan[c]));
flagchan[c] = 1;
}
//
// 将RCPT TO信息写入到对应的文件中去。
//
if (substdio_bput(&sschan[c],rwline.s,rwline.len) == -1)
{
fnmake_chanaddr(id,c);
log3("warning: trouble writing to ",fn.s,"\n"); goto fail;
}
break;
}
}
//
// 以下这么多代码只是干了同步文件和buffer并将文件关闭这一些简单的事情。
//
close(fd);
fd = -1;
fnmake_info(id);
if (substdio_flush(&ssinfo) == -1)
{
goto fail;
}
if (fsync(fdinfo) == -1)
{
goto fail;
}
close(fdinfo);
fdinfo = -1;
for (c = 0;c < CHANNELS;++c)
if (fdchan[c] != -1)
{
fnmake_chanaddr(id,c);
if (substdio_flush(&sschan[c]) == -1)
{
goto fail;
}
if (fsync(fdchan[c]) == -1)
{
goto fail;
}
close(fdchan[c]);
fdchan[c] = -1;
}
////////////////////////////////////////////////////////////////////
fnmake_todo(id);
//
// 将todo目录下的todo/id路径写入到qmail-clean的管道中去,也就是让
// qmail-clean 去删除todo/id这个文件
//
if (substdio_putflush(&sstoqc,fn.s,fn.len) == -1)
{
cleandied();
return;
}
//
// 等待qmail-clean的返回,我们从管道中读取一个字符, ‘+’ 表示删除成功
//
if (substdio_get(&ssfromqc,&ch,1) != 1)
{
cleandied();
return;
}
if (ch != '+')
{
return;
}
//
// pe为优先级级队列的节点,qmail在内存中保存了好几条以时间排序的优先级队列
//
pe.id = id;
pe.dt = now(); // timestamp为当前时间
//
// 将该节点插入到相应local或者remote的优先级队列中去,至于该优先级队列
// 使用的到底是最小堆排序还是其他,我在这里不想赘述,我们只要知道它是一
// 个以时间为优先级的从小到大排列的队列就可以了。
//
for (c = 0; c < CHANNELS; ++c)
if (flagchan[c])
while (!prioq_insert(&pqchan[c],&pe))
nomem();
for (c = 0; c < CHANNELS; ++c)
if (flagchan[c])
break;
//
// 这个基本是不可能发生的flagchan[0] flagchan[1]都为0,OH SHIT!
//
if (c == CHANNELS)
while (!prioq_insert(&pqdone,&pe))
nomem();
return;
待续.................................................................................
前一部分介绍了一封邮件在qmail中的入队过程以及预处理过程, 当邮件被smtpd接收下后它只是被保存在对应pid的临时目录下,qmail-queue负责将它写入到mess目录下,并将这封信的信封信息写入到intd下,同时将该文件link到todo目录下,并告知qmail-send已经有新邮件来了。qmail-send会扫描todo目录,提取mail from和rcpt to信息,将mail from保存在info目录下的对应文件中,rcpt to写入local或者remote目录的对应文件中去,随后通知qmail-clean删除todo下的该文件,并将该id插入到内存队列中去,至此我们说qmail的入队的预处理已经基本完成了。
接下去就是要通知qmail-lspawn或者qmail-rspawn去投递邮件了,comm_do所作的就是将投递的信息通过管道传递给lspawn或者rspawn并由他们产生出一个新的进程去做实际的邮件投递。但是我们还没见到这些投递信息是怎么产生了,是谁把他写入到comm_buf中去的,我想应该就是在下面这个函数中吧!
void pass_do()
{
int c;
struct prioq_elt pe;
for (c = 0;c < CHANNELS;++c)
pass_dochan(c);
//
// pqfail为由于系统错误导致的无法暂时无法投递的队列
//
if (prioq_min(&pqfail,&pe))
if (pe.dt <= recent)
{
prioq_delmin(&pqfail);
// 做简要的一致性检测,如果没什么问题就将它加入内存投递队列
// 否则则被加入到完成队列中
pqadd(pe.id);
}
if (prioq_min(&pqdone,&pe))
if (pe.dt <= recent)
{
prioq_delmin(&pqdone);
// 确认该msg是否已经完成了,如果是检测是否需要bounce,同时自己
// 删除info下的对应文件,最后通过管道告知qmail-clean去删除mess
// 目录下的邮件本身
messdone(pe.id);
}
}
乍看一下还真看不出来,那就慢慢分析吧。先看看pass_dochan做了什么。
void pass_dochan(int c)
{
datetime_sec birth;
struct prioq_elt pe;
static stralloc line = {0};
int match;
//
// qmail每次只投递一个用户,对于有多个rcpt to的邮件qmail会投递多次
// 此处判断相当于判断是否已经把所有的rcpt to都投完了,如果是就开启
// 一个新的任务,如果没有,go on do it
//
if (!pass[c].id)
{
//
// 判断是否还有free的job item, job对应于local/split/id或者
// remote/split/id的投递任务, job有一个引用计数和numtodo,
// numtodo其实就是对应的文件中有多少个需要投递的用户
//
if (!job_avail())
return;
//
// 获取相应的local或者remote内存优先级队列中的优先级最高的,也就
// 是时间最小的那项
//
if (!prioq_min(&pqchan[c],&pe))
return;
//
// 与当前时间做比较,是否可以fire了
//
if (pe.dt > recent)
return;
//
// 组装出local/split/id或者remote/split/id
//
fnmake_chanaddr(pe.id,c);
//
// 将该节点从内存队列中删除
//
prioq_delmin(&pqchan[c]);
//
// 初始化pass结构
//
pass[c].mpos = 0;
// 文件描述符指向的是存有RCPT TO的文件local/split/id,或者
// remote/split/id
pass[c].fd = open_read(fn.s);
//
// 从info目录下的对应文件中读取mail from信息,将mail from写到
// line buffer中去,同时将该文件的创建时间通过birth保存,qmail
// 通过该时间判断邮件在队列中驻留的时间
if (!getinfo(&line,&birth,pe.id))
{
goto trouble;
}
pass[c].id = pe.id;
//
// 初始化local/split/id或者remote/split/id文件的buffer工具函数
//
substdio_fdbuf(&pass[c].ss,read,pass[c].fd,pass[c].buf,sizeof(pass[c].buf));
//
// 开启一个新的job,从job数组中寻找一个尚未被占用的job,标识已被
// ref,同时初始化一些其他变量
//
pass[c].j = job_open(pe.id,c);
//
// 计算下一次重试的时间,如果这封邮件在队列中呆了太长时间,就
// 将flagdying置为1
//
jo[pass[c].j].retry = nextretry(birth,c);
jo[pass[c].j].flagdying = (recent > birth + lifetime);
//
// 将mail from信息copy到对应job结构下的sender中
//
while (!stralloc_copy(&jo[pass[c].j].sender,&line))
nomem();
}
// 判断spawn进程是否还alive,comm_buf是否能够写入,并发数是
// 否到达了设置数
if (!del_avail(c)) return;
// 读取一个rcpt to
if (getln(&pass[c].ss,&line,&match,'\0') == -1)
{
...
return;
}
switch(line.s[0])
{
case 'T':
++jo[pass[c].j].numtodo;
//
// 将投递的信息写入到comm_buf中去
del_start(pass[c].j,pass[c].mpos,line.s + 1);
break;
case 'D':
break;
default:
...
return;
}
// 标识下一个rcpt to的位置
pass[c].mpos += line.len;
return;
trouble:
...
}
void del_start(int j, seek_pos mpos, char *recip)
{
int i;
int c;
//
// local还是remote的channel
//
c = jo[j].channel;
//
// 分配一个del 结构
//
for (i = 0;i < concurrency[c];++i)
if (!d[c][i].used)
break;
if (i == concurrency[c]) return;
//
// copy 接收者地址到del结构中
//
if (!stralloc_copys(&d[c][i].recip,recip))
{
nomem();
return;
}
if (!stralloc_0(&d[c][i].recip))
{
nomem();
return;
}
// 设置该del结构的相关变量
d[c][i].j = j; ++jo[j].refs;
d[c][i].delid = masterdelid++;
d[c][i].mpos = mpos;
d[c][i].used = 1; ++concurrencyused[c];
//
// 将投递信息写入到comm_buf中去
//
comm_write(c, i, jo[j].id, jo[j]. sender.s, recip);
del_status();
}
准备好了需要投递的信息,并通过我们上面所提到的comm_do(&wfds)将投递信息通过pipe传递给lspawn或者rspawn. del_do不断的监听来自spawn进程的回复结果,它通过调用del_dochan来实现的。
void del_dochan(int c)
{
char ch;
int delnum;
int test_i = 0;
int i, r;
// 读取来自spawn process的信息
r = read(chanfdin[c], delbuf, sizeof(delbuf));
/* handle every character read from the spawn */
for (i = 0; i < r; ++i) {
ch = delbuf[i];
while (!stralloc_append(&dline[c],&ch)) nomem();
if (dline[c].len > REPORTMAX) {
dline[c].len = REPORTMAX;
}
/* qmail-lspawn and qmail-rspawn are responsible for keeping it
short */
/* but from a security point of view, we don't trust rspawn */
if (!ch && (dline[c].len > 1)) {
delnum = (unsigned int) (unsigned char) dline[c].s[0];
if ((delnum < 0) || (delnum >= concurrency[c])
|| !d[c][delnum].used) {
/* delnum out of range exception, maybe pipe broken */
...
}
else {
strnum3[fmt_ulong(strnum3,d[c][delnum].delid)] = 0;
//
// Z代表需要尝试重试,此时我们需要判断flagdying是否被置为1,
// 如果为1则代表投给特定用户的该邮件在队列中驻留了太长时间,
// 可能已经多次尝试投递但都失败了,此时要将其标记为投递失败'D'
//
if (dline[c].s[1] == 'Z') {
if (jo[d[c][delnum].j].flagdying) {
dline[c].s[1] = 'D';
--dline[c].len; /* strip the last LF */
while (!stralloc_cats(&dline[c], DSN_QUEUE_TOO_LONG)) nomem();
while (!stralloc_0(&dline[c])) nomem();
}
}
switch(dline[c].s[1]) {
case 'K': // 首字母为K代表投递成功
// 标示发往特定用户的邮件已经成功,在相应的
// local/split/id或者remote/split/id文件中该rcpt to
// 被置为D
markdone(c, jo[d[c][delnum].j].id,
d[c][delnum].mpos);
--jo[d[c][delnum].j].numtodo;
break;
case 'Z': // 需要重试
break;
case 'D': // 投递失败
//
// 加入到bounce中
//
addbounce(jo[d[c][delnum].j].id,
d[c][delnum].recip.s, dline[c].s + 2);
//
// 标识相应的投递已经结束
//
markdone(c,jo[d[c][delnum].j].id,
d[c][delnum].mpos);
--jo[d[c][delnum].j].numtodo;
break;
default:
}
//
// 如果该job的引用计数为1,同时所有的用户都已经投送成功,
// 相应的上面我们所说的对应该job的文件就会被删除,它还会
// 检测另一个对应目录下的文件(例如local/split/id)是否已
// 经不存在了如果是,当// 然整封邮件已经投递结束,就往完成
// 队列中插入一条记录, 否则的话继续将它加入到内存投递队
// 列中,只是时间变为下一次重试的时间
job_close(d[c][delnum].j);
d[c][delnum].used = 0;
--concurrencyused[c];
del_status();
}
dline[c].len = 0; /*reset the dline */
}
}
}
.........................................................................................
cleanup_do() 其实就是将队列mess目录下一些已经投递成功但尚未删除的邮件删掉。
现在我们大致可以得出qmail对邮件的处理过程,由前端smtpd收下邮件,通过零时进程qmail-queue将邮件入队。qmail-queue首先在pid目录下创建一个inode,以这个inode的序列号作为该mail message的id,随后将该inode硬连接到mess目录下,写入邮件的内容,并在intd目录下创建一个同名的文件,写入MAIL FROM和RCPT TO等信息后又将该文件link到todo目录下。至此,整个邮件的入队过程完成。
qmail-send不断扫描todo目录,如果有新邮件,它将todo文件中的MAIL FROM写入到info目录下的同名文件中,RCPT TO分别写入到local和remote目录下,删除todo下的文件随后将该id加入到remote或者local的内存队列中.(预处理完成)。 qmail在内存中有四条队列,分别为前面所说的两条,和一条标示已经完成一份邮件处理的队列以及一条文件系统发生错误时才会被使用到的队列. job对应一个local或者remote下保存rcpt to的文件,rcpt to的地址以T作为开头,当往该用户成功投递后qmail就将T改为D标示投递成功或者永久性失败.
qmail-send不断从remote或者local的内存队列中取下一个id,给它分配一个对应的job,并从相应的info, local或者remote目录下取到MAIL FROM和RCPT TO并将它写到管道的buffer中,最后才被写入管道. 管道是qmail-send用来与两个长驻进程rspawn和lspawn通信的方式.rspawn或者lspawn收到来自qmail-send的投递信息后创建一个新的进程去投递,rspawn或者lspawn通过wait这个进程结束时的exit code来确定投递的成功与否.然后再通过管道将结果返回给qmail-send. qmail-send检查投递的结果,如果成功就如上面所说的将rcpt标示为D,否则确定是要重试还是多次尝试失败放弃。当一个job中所有的RCPT TO都被处理完后,qmail-send将相应的local或者remote目录下的文件删除,当一个msgid对应的local或者remote下的 rcpt to全被处理完时(即这几个文件已经不存在了),则该id会被加入到完成队列中去. qmail-send也会不断检测这个完成队列,取下一个id,通过检测bounce目录下是否有对应的文件来决定是否需要bounce,并删除info目录下的对应文件最后提交给qmail-clean将mess目录下的文件删除。
这些应该就是一封邮件在qmail中处理的大致过程,当然qmail-send 并不像我所说的那样线性地执行下来的,它是通过一个循环不断地执行前面所说的那五个函数来实现这一过程的。,