Pull to refresh

Протокол SMTP. Пишем SMTP-сервер на C

Reading time12 min
Views2.2K

Недавно я захотел подкачать свои знания языка C. Я решил написать небольшой проект используя только стандартную библиотеку языка. Чтобы извлечь ещё больше пользы из данного проекта, я решил изучить новую для меня технологию. Этой технологией стал протокол прикладного уровня SMTP, а проектом – небольшой SMTP-сервер. Сегодня я расскажу, как работает протокол SMTP, а также как я реализовал сервер, работающий с ним.

Протокол SMTP

SMTP – протокол прикладного уровня, предназначенный для передачи электронных писем между клиентом и сервером. Изначально, SMTP был представлен в стандарте RFC 780 в 1981 году, а затем дополнен в RFC 821 в следующем году. В этом стандарте был описан механизм передачи электронных писем между клиентами ARPANET. Стандарт настолько старый, что тогда ещё даже не существовало Интернета. Из-за быстрого развития технологий, стандарт часто приходилось дополнять и переопределять, об этом мы поговорим немножко позже.

SMTP работает по принципу клиент-сервер. После подключения к серверу, клиент должен представить себя. Это делается с помощью команды HELO. Клиент отправляет эту команды вместе со своим именем хоста, чтобы сервер идентифицировал его. После этого, клиент и сервер готовы к передаче писем.

Основной процедурой для протокола является SMTP-транзакция (SMTP mail transaction). Она позволяет нам отправлять электронные письма. Транзакция состоит из трёх шагов и трёх команд:

  • Определение обратного пути (reverse-path). Команда MAIL,

  • Определение путей до получателей письма. Команда RCPT,

  • Определение содержимого письма. Команда DATA.

Обратный путь – путь, по которому получатель письма должен прислать ответ. Путь до получателя, соответственно, тот, по которому письмо пройдет, перед тем, как попасть в почтовый ящик получателя.

Например возьмём прямой путь <@foo.local,@bar.local:carl@quz.local> и обратный путь <john@domain.local>. В прямом пути содержатся имена хостов, через которые должно пройти письмо, перед тем, как оно прибудет к получателю. Сначала письмо попадёт на хост foo.local, затем на bar.local и в конце оно попадет в почтовый ящик пользователя carl на хосте quz.local.

Промежуточные сервера должны изменять поля обратного и обычного пути. После того как письмо прибудет на хост foo.local, сервер на этом хосте должен направить письмо хосту bar.local с обратным путём <@foo.local:john@domain.local> и прямым путём <@bar.local:carl@quz.local> (в обратном пути добавился сервер, который получил письмо, а в прямом пути он исчез).

Путь может состоять из одного адреса, например обратный путь<john@domain.local> и прямой путь <carl@domain.local>. Письмо дойдёт до получателя, если отправитель расположен на том же сервере, что и получатель.

После определения путей, мы должны определить содержимое письма. Это та полезная информация, отправитель хочет передать получателю. После того как клиент ввёл команду DATA, сервер начинает записывать содержимое письма. Для того чтобы сервер прекратил запись на нужно отправить ему одну точку на новой строке. Как только клиент закончил вводить содержимое письма, сервер сразу же начинает его обработку.

SMTP-транзакция
SMTP-транзакция

Письмо состоит из заголовков и текста. Для их разделения используется одна пустая строка. В заголовках письма обычно содержится тема (Subject), отправитель и получатель (From и To) и дата отправки письма (Date). Эти поля добавляются самим клиентом.

Сервера также могут добавлять свои заголовки. Например, каждый сервер после получения письма добавляет заголовок Received, который содержит информацию об хосте-отправителе, хосте-получателе и дате получения письма.

Полученное письмо
Полученное письмо

Расширения SMTP

Протокол STMP неоднократно расширялся. После первого представления в RFC 821, протокол был обновлен в стандарте RFC 2821. Главным нововведением стала команда EHLO (extended hello). Она стала использоваться вместо HELO. Если сервер новую команду, то после приветствия, сервер отправит расширенный список команд, которые были определены в других RFC.

Одним из первых расширений стало введение команды SIZE, позволяющей определять максимальный размер сообщения. Это расширение описано в RFC 1870. Команда STARTTLS, которая позволяет установить сессию TLS между клиентом и сервером, была представлена в RFC 3207. А в RFC 4954 была добавлена команда AUTH, которая позволяет проводить аутентификацию на STMP-сервере.

Последним большим обновлением протокола на данный момент является RFC 5321. Именно этот стандарт реализован на всех современных SMTP-серверах.

Пишем SMTP-сервер на C

Для реализации своего сервера я выбрал один из первых стандартов STMP – RFC 821, так как в старых стандартах намного меньше функционала, который необходимо реализовывать, чем в новых.

В стандарте указан минимальный набор команд, которые должен реализовывать сервер:

  • HELO – команда для инициализации сессии,

  • MAIL – команда для указания обратного пути,

  • RCPT – команда для указания прямого пути,

  • DATA – команда для получения содержимого письма,

  • RSET – команда для сброса транзакции,

  • NOOP – команда для бездействия,

  • QUIT – команда для закрытия сессии.

В качестве языка программирования я выбрал C по указанным в начале причинам. В качестве среды разработки я использовал Visual Studio. Но это не означает, что сервер работает только на Windows. Я не использовал никаких библиотек кроме стандартной библиотеки C. Перейдём к коду.

Функция main

В функции main мы загружаем конфигурационный файл и инициализируем слущающий сокет. Так как я использовал Windows в качестве операционной системы, я использовал библиотеку Winsock2.h, для работы с сокетами. Альтернативой для Linux является библиотека socket.h.

При каждом новом подключении к серверу мы создаем обслуживающий поток, с точкой входа serve_connection. В этой функции происходит основная работа с клиентом.

int main(int argv, char* argc[]) {

    thrd_t new_thread;
    WSADATA wsa_data;

    if (argv < 2) {
        printf("Usage: %s config_file_path", argc[0]);
        return 1;
    }

    int status = WSAStartup(MAKEWORD(2, 2), &wsa_data);
    if (status != 0) {
        return 1;
    }

    config_parse_file(argc[1]);

    SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

    struct sockaddr_in server_address;
    server_address.sin_family = AF_INET;
    server_address.sin_addr.s_addr = INADDR_ANY;
    server_address.sin_port = htons(config_get_listen_port());

    bind(sock, &server_address, sizeof(server_address));
    listen(sock, MAX_PENDING_CONNECTIONS);

    while (1) {
        SOCKET new_sock = accept(sock, NULL, NULL);
        if (new_sock == -1) {
            continue;
        }
        thrd_create(&new_thread, serve_connection, new_sock);
    }

	return 0;
}

Обслуживание клиентов

В начале функции serve_connection мы инициализируем нужные нам переменные и структуры и оповещаем клиента о том, что мы готовы обслужить его. После этого мы входим в бесконечный цикл, в котором принимаем команды от клиента. Для каждой команды существует свой обработчик. После того как клиент закончит взаимодействие с сервером мы освобождаем ресурсы и закрываем сокет. Пойдём по порядку.

В самом начале происходит инциализация структур и буфера для обмена сообщениями, а также инициализации переменной состояния.

void serve_connection(SOCKET sock) {
	int status = 0;
	enum SERVER_STATE current_state = DEFAULT;

	char* buffer = init_socket_buffer();

	struct smtp_request* smtp_request = init_smtp_request();
	struct server_session* server_session = init_server_session();

	send_response(sock, buffer, SERVICE_READY);

Самая главная структура здесь – smtp_request, которая представляет собой SMTP-транзакцию:

struct smtp_request {
	struct email_address* mail_from;
	struct smtp_request_recipient* rcpt_to_list;
	char* data;
};

В этой структуре содержатся три основных поля транзакции: обратный путь, список прямых путей и содержимое письма.

Структура email_address используется довольно часто. Она является простой обёрткой для двух строк, домена и имени пользователя, которые можно быстро преобразовать в одну строку и обратно.

struct email_address {
	char* user;
	char* domain;
};

Структура smtp_request_recipient является связным списком, так как письмо может иметь несколько получателей.

struct smtp_request_recipient {
	struct email_address* email_address;
	struct list list;
};

Для реализации связного списка я использовал приём из ядра Linux. В ядре для реализации всех связных списков используется одна структура list_head которая содержит указатель на саму себя. Эта структура встраивается в другую структуру, которую необходимо сделать связным списком. После этого, используя специальный макрос, мы можем получить доступ к родительской структуре list_head. Таким образом мы имеем одну реализацию для связного списка для всех наших структур.

Дальше в функции serve_connection идёт бесконечный цикл обработки команд. Я убрал некоторые обработчики команд, чтобы не растягивать листинг кода.

while (1) {

    status = get_message(sock, buffer);
	if (status == STATUS_ERROR) break;

    // Если соединение оборвалось – останавливаем цикл

	lower_buffer(buffer);

    // Другие обработчики команд

    if (buffer_has_command("helo ", buffer)) {
    	status = serve_helo(sock, buffer, server_session);
    	if (status == STATUS_OK) {
      		initialize_session(&smtp_request, &current_state);
    	}
    	continue;
    }

    if (buffer_has_command("mail from:", buffer)) {
        if (!validate_state(current_state, INITIALIZED)) {
    		send_response(sock, buffer, BAD_SEQUENCE);
    		continue;
    	}
    
    	status = serve_mail_from(sock, buffer, smtp_request);
    	if (status == STATUS_OK) { 
    		set_state(&current_state, HAS_MAIL_FROM);
    	}
    	continue;
    }

    // Другие обработчики команд
  
    if (buffer_has_command("data", buffer)) {
        if (!validate_state(current_state, HAS_RCPT_TO)) {
            send_response(sock, buffer, BAD_SEQUENCE);
      		continue;
        }
    
        status = serve_data(sock, buffer, smtp_request);
        if (status == STATUS_OK) {
        	process_smtp_request(smtp_request, server_session);
    
        	initialize_session(&smtp_request, &current_state);
        }
        continue;
    }

    // Другие обработчики команд

    // Если команда не обработана, значит мы её не поддерживаем
    // Возвращаем SYNTAX_ERROR

	send_response(sock, buffer, SYNTAX_ERROR);
}

Перед началом SMTP-транзакции мы должны получить команду HELO. Как только мы получили её, мы инициализируем новую сессию с клиентом. Это происходит в функции initialize_session:

static enum STATUS initialize_session(struct smtp_request** smtp_request, enum SERVER_STATE* current_state) {
	clean_smtp_request(*smtp_request);
	*smtp_request = init_smtp_request();
	set_state(current_state, INITIALIZED);
	return STATUS_OK;
}

У сервера существует четыре возможных состояния, которые позволяют выполнять различные команды. Инициализирую новую сессию, мы переходим к состоянию INITIALIZED. После назначения прямого пути и обратного пути мы меняем состояние на HAS_MAIL_FROM и HAS_RCPT_TO соответственно. Сбросить текущее состояние и начать новую сессию можно с помощью команды RSET.

Диаграмма состояний
Диаграмма состояний

После инициализации сессии мы готовы обрабатывать другие команды. Рассмотрим обработку команды MAIL. После проверки состояния мы запускаем обработчик команды. В данном случае это функция serve_mail_from. Для каждой команды существует свой обработчик, который хранится в отдельном файле.

Обработчики выглядят вот так:

enum STATUS serve_mail_from(SOCKET sock, char* buffer, struct smtp_request* smtp_request) {
	if (validate_with_args(buffer, "mail from:", ":") == STATUS_NOT_OK) {
		send_response(sock, buffer, SYNTAX_ERROR_PARAMETERS);
		return STATUS_NOT_OK;
	}

	char* mail_from = get_value_from_buffer(buffer, ":");
	mail_from = trim_string(mail_from);

	if (validate_email_string(mail_from) == STATUS_NOT_OK) {
		send_response(sock, buffer, SYNTAX_ERROR_PARAMETERS);
		free(mail_from);
		return STATUS_NOT_OK;
	}

	struct email_address* mail_from_email_address = string_to_email_address(mail_from);
	free(mail_from);
	smtp_request_set_mail_from(smtp_request, mail_from_email_address);

	send_response(sock, buffer, ACTION_OK);

	return STATUS_OK;
}

Обработчик состоит из трёх частей: базовые проверки (validate_with_args и validate_without_args в функциях без аргументов), специфичные для команды проверки и выполнения действия. В данном случае мы устанавливаем поле mail_from для структуры smtp_request.

Обработка транзакции начинается сразу же после завершения команды DATA. После обработки мы заново инициализируем сессию, возвращаясь к состоянию INITIALIZED.

Обычно, после того, как клиент отправил письмо он сразу же заканчивает сессии командой QUIT. После завершения сессии мы выходим из цикла, затем выполняется освобождение ресурсов и завершение обрабатывающего потока:

	clean_smtp_request(smtp_request);
	clean_server_session(server_session);
	clean_socket_buffer(buffer);
	socket_cleanup(sock);
}

Обработка транзакции

Обработка транзакции происходит в функции process_smtp_request. В этой функции происходит обработка тела письма и добавление временной метки, заголовков сервера и сессионных заголовков.

После обработки мы рассылаем письмо всем получателем путём перебора связного списка. list_parent – тот самый макрос который похож на container_of из ядра Linux. Он получает родительскую структуру для структуры list.

void process_smtp_request(struct smtp_request* smtp_request, struct server_session* server_session) {
	struct mail* mail = init_mail();

	mail_parse_headers(mail, smtp_request->data);
	mail_add_timestamp(mail, smtp_request->mail_from);
	mail_add_server_headers(mail, smtp_request);
	mail_add_session_headers(mail, server_session);
	
	struct smtp_request_recipient* last_recipient = smtp_request->rcpt_to_list;
	char* final_text = build_mail(mail);
	while (1) {
		deliver_mail(final_text, last_recipient);
		if (last_recipient->list.prev == NULL) {
			break;
		}
		last_recipient = list_parent(last_recipient->list.prev, struct smtp_request_recipient, list);
	}
	free(final_text);

	clean_mail(mail);
}

Здесь главная структура это mail, которая представляет собой готовое письмо.

struct mail {
	char* text;
	char* timestamp;
	struct mail_header* headers_list;
};

Поле text – это текст письма без заголовков. Поле timestamp – обязательная метка времени, которая ставится в начале каждого письма. headers_list– связный список заголовков:

struct mail_header {
	char* name;
	char* value;
	struct list list;
};

Сериализацией заголовков занимается функция mail_parse_headers . Эта функция преобразовывает заголовки в связный список и отделяет их от основного текста письма. Это позволяет нам легко работать с ними, добавлять новые и удалять старые.

Это преимущество используется в функции mail_add_server_headers , которая добавляет заголовки From и To, если они отсутствуют, и заголовки Return-Path и X-Original-To.

enum STATUS mail_add_server_headers(struct mail* mail, struct smtp_request* smtp_request) {
	char* mail_from_string = email_address_to_string(smtp_request->mail_from);
	mail_add_header_if_not_exists(mail, "From", mail_from_string);
  
	char* mail_from_string_with_arrows = email_address_string_add_arrows(mail_from_string);
	mail_replace_header(mail, "Return-Path", mail_from_string_with_arrows);
  
	free(mail_from_string);

	char* all_recipients = get_all_recipients(smtp_request);
	mail_add_header_if_not_exists(mail, "To", all_recipients);
	mail_replace_header(mail, "X-Original-To", all_recipients);
  
	free(all_recipients);

	return STATUS_OK;
}

После добавления всех заголовков мы можем собрать письмо. Это происходит в функции build_mail. В ней мы добавляем все заголовки и текст письма в один буфер, после чего, письмо готово к доставке. Здесь мы также используем перебор связного списка при помощи макроса list_parent.

char* build_mail(struct mail* mail) {
	char* result = calloc(MAIL_SIZE, sizeof(char));
	add_to_buffer(result, mail->timestamp);

	struct mail_header* current_header = mail->headers_list;

	while (1) {
		flush_to_buffer(result, 2, "%s: %s\r\n", current_header->name, current_header->value);
		if (current_header->list.prev == NULL) {
			break;
		}
		current_header = list_parent(current_header->list.prev, struct mail_header, list);
	}

	flush_to_buffer(result, 1, "\r\n%s\r\n", mail->text);

	return result;
}

Далее идёт доставка письма в почтовый ящик пользователя. Почтовый ящик является файлом, к концу которого добавляется новое письмо. Доставка происходит в функции deliver_mail, которая является обёрткой для функции wirte_mail_to_file.

Функция get_full_mail_path добавляет к имени получателя путь до папки с почтой, которая указывается в конфигурации.

static enum STATUS deliver_mail(char* mail, struct smtp_request_recipient* smtp_request_recipient) {
	write_mail_to_file(smtp_request_recipient->email_address->user, mail);

	return STATUS_OK;
}

static enum STATUS write_mail_to_file(char* recipient, char* buffer) {

	char* full_path = get_full_mail_path(recipient);

	FILE* file_ptr = fopen(full_path, "ab");
	if (file_ptr == NULL) {
		return STATUS_ERROR;
	}

	fprintf(file_ptr, "%s", buffer);
	fclose(file_ptr);
	free(full_path);

	return STATUS_OK;
}

Конфигурация

Для того, чтобы не перекомпилировать сервер каждый раз, когда нам захотелось изменить конфигурацию, я добавил простенький модуль обработки конфигурационных файлов.

Конфигурация представлена глобальной структурой config:

struct config {
	char* domain;
	char* mail_path;
	char* listen_port;
	struct user* users_list;
	char* hostname;
} config;

Перед запуском сервера, мы считываем и обрабатываем конфигурационный файл, который был передан через аргумент командной строки. Обработка происходит в функции config_parse_file:

void config_parse_file(char* filename) {
	char* buffer = read_config_file(filename);

	config_parse_buffer(buffer);

	free(buffer);
}

void config_parse_buffer(char* buffer) {
	config.domain = get_config_param(buffer, "domain", "domain.local");
	config.mail_path = get_config_param(buffer, "mail_path", "./");
	config.listen_port = get_config_param(buffer, "listen_port", "25");
	config.users_list = get_config_users(buffer, "users", "");
	config.hostname = get_config_hostname();
}

Здесь мы находим в файле соответствующие поля, и назначаем их полям структуры config. Если мы не находим нужное поле в файле, то мы ставим значение по умолчанию.

Для доступа к полям структуры я сделал отдельные функции:

char* config_get_domain();
char* config_get_mail_path();
int config_get_listen_port();
struct user* config_get_users();
char* config_get_hostname();

Вот так выглядит пример конфигурационного файла:

domain = domain.local
mail_path = E:/
listen_port = 1025
users = john, carl, ann

Отправляем письмо через Mutt

Для тестирования сервера я использовал SMTP-клиент Mutt. Он позволяет отправлять письма, не углубляясь в подробности протокола. Перед отправкой нужно настроить некоторые параметры. Нам нужно установить наш почтовый адрес, IP-адрес сервера и отключить расширения протокола SMTP.

Конфигурационный файл Mutt хранится по пути ~/.muttrc. Для установки нужных нам параметров мы должны добавить следующие строчки в файл:

set from = john@domain.local
set smtp_url = smtp://172.18.160.1:1025
set ssl_starttls = no
set ssl_force_tls = no

Теперь мы можем отправлять письма на наш сервер. Давайте напишем тестовое письмо:

Тестовое письмо
Тестовое письмо

После отправки письма, в почтовом ящике пользователя carl мы видим следующее письмо:

Полученное письмо
Полученное письмо

Заключение

Данный сервер поддерживает все команды из стандарта RFC 821 и полностью совместим с современными SMTP-клиентами. Возможно в будущем я добавлю команды из новых стандартов и напишу про это новую статью.

Это мой первый относительно большой проект на C. Как я и ожидал, мне удалось улучшить мои навыки владения данным языком программирования. Также я значительно углубил свои знания протокола SMTP. Исходный код проекта я оставил на GitHub.

GitHub проекта: https://github.com/Reedus0/MailServer

Tags:
Hubs:
+14
Comments5

Articles