Файл конфигурации
Вся настройка приложения живёт в одном JSON-файле; отдельных .env нет. Путь передаётся флагом -c:
./exec/cwfr -c /path/to/config.json # демонизируется (Release)
./exec/cwfr -c /path/to/config.json -f # остаться на переднем плане-f нужен под супервизором и в контейнерах: процесс, который форкается и выходит, systemd или docker читают как упавший.
Перезагрузить конфигурацию без остановки сервера — сигналом SIGUSR1 (pkill -USR1 cwfr); поведение задаётся ключом main.reload. Подробности — в разделе Горячая перезагрузка.
Как читается конфигурация
Файл разбирается целиком до того, как сервер начнёт слушать сокеты, и любая ошибка останавливает запуск с сообщением в журнал (syslog, journalctl -t cwfr). Порядок такой: main → servers → databases → storages → mimetypes → sessions → task_manager → translations → mail, затем политики HTTP/2 и HTTP/3 из main.env.
Из этого следуют два практических правила:
- Пути к обработчикам проверяются при старте.
.soизroutesиtask_managerзагружаются немедленно, имена функций резолвятся черезdlsym. Опечатка в пути или в имени функции — сервер не поднимется. - Код возврата осмыслен. Процесс не сообщает об успехе, пока конфиг не прочитан, не проверен, не применён, а все воркеры не начали слушать. Поэтому
cwfr -c config.json && ...работает так, как написано.
Какие секции обязательны
| Секция | Обязательна | Что будет без неё |
|---|---|---|
main | да | Отказ запуска |
servers | да, непустая | Отказ запуска |
mimetypes | да, непустая | Отказ запуска |
databases | нет | Запросы к БД недоступны |
storages | нет | Файловые хранилища недоступны |
sessions | нет | Сессии недоступны |
task_manager | нет | Планировщик не запускается |
translations | нет | i18n отключён |
mail | нет | Прямая доставка на MX, письма уходят без подписи |
migrations | нет | Ничего: секция рантаймом не читается |
Секция main
Обязательная. Все перечисленные ниже ключи, кроме env, обязательны — отсутствие любого из них останавливает запуск.
workers число обязательный
Количество воркеров, принимающих соединения и читающих/пишущих данные. Должно быть ≥ 1.
Воркеры — это потоки одного процесса, а не отдельные процессы. Поэтому всё, что описано как «на процесс» (бюджет памяти QUIC, кеш сжатых представлений, лимит QUIC-соединений), на их число не умножается.
threads число обязательный
Количество потоков, исполняющих обработчики и формирующих ответы. Должно быть ≥ 1. Отделены от воркеров намеренно: медленный обработчик не должен останавливать чтение сокетов.
reload soft | hard обязательный
Режим горячей перезагрузки (по SIGUSR1):
soft— перезагрузка с сохранением активных соединенийhard— перезагрузка с принудительным закрытием соединений
Значение по умолчанию в коде — soft, но ключ всё равно обязан присутствовать в файле.
client_max_body_size число обязательный
Максимальный размер тела запроса в байтах, ≥ 1.
В HTTP/1.1 заголовок Content-Length больше лимита приводит к 400 Bad Request ещё на разборе заголовков; тело не читается, ответ содержит Connection: close, и соединение закрывается. Входящие запросы с Transfer-Encoding, включая chunked, не поддерживаются и также получают 400 Bad Request с закрытием соединения. В HTTP/2 превышение лимита при приёме тела приводит к сбросу потока (RST_STREAM с кодом INTERNAL_ERROR), а в HTTP/3 — к ответу 413 Content Too Large. Тем же значением ограничены кадры WebSocket и ответы, которые принимает встроенный HTTP-клиент.
body_store object
Необязательные настройки хранения входящего тела запроса. По умолчанию:
"body_store": {
"mode": "auto",
"file_threshold": 1048576
}mode: "auto", "memory" или "file"; по умолчанию "auto".autoиспользует порогfile_threshold, чтобы хранить тело запроса в RAM,fileзаписывает тело запроса в файлmain.tmp.file_threshold: целое число байтов от 0 до 4294967295; по умолчанию 1048576 (1 МиБ).
В режиме auto тело меньше порога хранятся в памяти, а тело, равное или больше порога — во временном файле. 0 принудительно выбирает файлы.
Режимы memory и file не используют file_threshold.
Применяется к HTTP/1.1, HTTP/2, HTTP/3 и обоим WebSocket-протоколам, включая распакованные сообщения. Во всех режимах действует client_max_body_size.
tmp строка обязательный
Каталог временных файлов: туда выгружаются большие тела запросов и загружаемые файлы. Без завершающего слэша — "/tmp/" считается ошибкой конфигурации, "/tmp" верно.
Он же — запасной каталог для теневых копий пересобранных обработчиков, когда каталог с самим .so недоступен на запись (см. Горячая перезагрузка).
gzip массив строк обязательный
Список MIME-типов для автоматического сжатия ответов. Ключ обязателен, но массив может быть пустым ([]) — тогда сжатие выключено.
Тип в списке становится предметом переговоров, а не сжимается всегда. Ответ уходит сжатым, только если он не меньше 1 КБ и заголовок Accept-Encoding запроса это позволяет: gzip назван явно либо есть *, когда gzip не упомянут; gzip;q=0 — отказ, а запрос вообще без Accept-Encoding получает несжатые байты. Любой ответ, тип которого есть в этом списке, несёт Vary: Accept-Encoding — сжали его или нет, — чтобы общий кэш различал два представления; их ETag тоже разные (у сжатого суффикс -gzip).
Сжатие статики стоит дорого и платится заново на каждый запрос: файл в 92 КБ уходит в zlib на ~410 мкс процессорного времени — вчетверо больше, чем вся остальная обработка ответа. Два параметра в main.env убирают эту работу, каждый по-своему; оба выключены по умолчанию.
gzip_static boolean main.env
Отдавать готовый <файл>.gz, если он лежит рядом с файлом: тогда сервер не сжимает вообще ничего — тело едет с диска, с Content-Length вместо chunked.
Файл-близнец берётся, только если он не старше оригинала — устаревший артефакт сборки не отдаётся никогда, вместо него работает обычное сжатие на лету. Проверка стоит один open() и делается лишь для тех ответов, которые и так были бы сжаты (тип в списке main.gzip, клиент принимает gzip, размер не меньше 1 КБ), поэтому клиент, не просивший сжатия, за неё не платит.
"env": { "gzip_static": true }Сервер такие файлы не создаёт — он только отдаёт уже лежащие рядом, а писать их должна сборка. Если она этого не умеет, подойдёт постобработка готового каталога:
find dist -type f \( -name '*.html' -o -name '*.css' -o -name '*.js' \) -size +1k \
-exec gzip -9 -k -f {} +Сжимать имеет смысл только те типы, что перечислены в main.gzip: рядом с файлом любого другого типа близнец не ищется вовсе. Порог -size +1k повторяет порог сервера — файл меньше 1 КБ не сжимается, и .gz у него не спрашивается.
Повторять генерацию нужно после каждой сборки. gzip -k сохраняет mtime оригинала, поэтому свежий близнец проверку на устаревание проходит; а близнец, оставшийся от прошлой сборки, окажется старше новых файлов — и сервер молча вернётся к сжатию на лету, ничего не сообщив в лог.
Content-Length в таком ответе — длина сжатых байтов: HTTP считает длину тела после применения content-coding, а chunked не нужен, потому что размер известен ещё до отправки первого байта (при сжатии на лету он неизвестен, поэтому там chunked). Разжатый размер клиенту не сообщается ни одним заголовком, зато попадает в ETag — валидаторы снимаются с оригинала до подмены на .gz.
gzip_cache_size, gzip_cache_max_file числа main.env
Кеш сжатых представлений в памяти: файл сжимается один раз, дальше все запросы получают готовые байты. Полезен там, где .gz рядом с файлами положить негде.
| Параметр | По умолчанию | Описание |
|---|---|---|
gzip_cache_size | 0 (выключено) | Общий бюджет памяти под сжатые представления, в байтах |
gzip_cache_max_file | 1048576 | Наибольший исходный файл, который кладётся в кеш, в байтах |
"env": { "gzip_cache_size": 33554432, "gzip_cache_max_file": 1048576 }Ключ записи — путь, mtime и размер исходного файла, поэтому перезаписанный файл никогда не отдаётся старым содержимым: сменился любой из трёх — это другой ресурс, он сжимается заново. Бюджет соблюдается вытеснением по давности использования (LRU), а gzip_cache_max_file не даёт одному большому файлу вытеснить всё остальное; запись, которую читает незавершённый ответ, живёт до конца этого ответа, даже если кеш её уже отпустил. Значение больше бюджета зажимается до бюджета — запись такого размера всё равно никогда бы не поместилась.
Кеш один на весь сервер: воркеры — это потоки одного процесса, поэтому gzip_cache_size не умножается на их число.
ETag от режима не зависит: у сжатого представления он слабый (W/"…-gzip") и описывает ресурс, а не конкретные байты, поэтому ответ из кеша, ответ из .gz и сжатие на лету взаимозаменяемы для клиентского кэша. Порядок такой: сначала .gz с диска, потом кеш в памяти, потом сжатие на лету.
log объект обязательный
Настройки логирования. Поля enabled и level обязательны, access — нет. Журнал идёт в syslog — читается через journalctl -t cwfr, а не из stdout.
"log": {
"enabled": true,
"level": "info",
"access": false
}enabled boolean
Включает или отключает логирование. При false все функции логирования игнорируются.
level строка
Минимальный уровень логирования. Сообщения с более низким приоритетом отфильтровываются. Допустимые значения (от наиболее критичных к наименее критичным):
emerg— система неработоспособна (0)alert— требуется немедленное действие (1)crit— критическое состояние (2)err/error— ошибки (3)warning/warn— предупреждения (4)notice— важные уведомления (5)info— информационные сообщения (6)debug— отладочные сообщения (7)
Любое другое значение — ошибка конфигурации.
Рекомендации
- Production:
infoилиnotice— баланс между информативностью и производительностью. - Development:
debug— максимально детальное логирование. - Critical systems:
warning/error— только важные события.
access boolean
Access-лог: одна запись на каждый отданный ответ — кто, что и с каким кодом запросил. Необязательный ключ, по умолчанию false.
Это журнал обращений, а не событий сервера, и от enabled он не зависит: записи о запросах без отладочного шума — обычный случай, а не экзотика. Чтобы journalctl -t cwfr не превратился в кашу, access-лог идёт в отдельный syslog-facility local7:
journalctl -t cwfr SYSLOG_FACILITY=23 -o cat # обращения, по строке на запрос
journalctl -t cwfr SYSLOG_FACILITY=1 # события сервераФормат фиксированный — Apache vhost_combined плюс длительность обработки в конце:
example.com 203.0.113.7 - - [08/Sep/2026:22:41:07 +0700] "GET /page?a=1 HTTP/2" 200 1234 "https://ref/" "Mozilla/5.0" 0.002Поля по порядку: вхост (значение Host), адрес клиента, два прочерка на месте identd и HTTP-пользователя, время, строка запроса, код ответа, размер тела, Referer, User-Agent, длительность в секундах. Для GoAccess:
log-format %v:%^ %h %^[%d:%t %^] "%r" %s %b "%R" "%u" %T
date-format %d/%b/%Y
time-format %H:%M:%SЗаписи одинаковы для HTTP/1.1, HTTP/2 и HTTP/3, и покрывают всё, что отдаёт вхост, — статику, ответы обработчиков, 304, 429 от rate limit, редиректы. Размер тела — это байты, реально ушедшие на провод: у сжатого ответа он сжатый, у Range — размер куска, у 304 — ноль. Строка запроса — та, что прислал клиент: у редиректа записывается исходный путь, а не цель.
Что в записи не попадает:
- запрос, который парсер отверг до конца чтения (битая строка запроса, неизвестный
Host), — метод и путь к этому моменту уже уничтожены, на их месте прочерки; - ответ, который не удалось дописать в сокет: оборванный ответ не считается отданным.
Доставка пачками
Записи копятся в буфере воркера и уходят в syslog пачкой — одна отправка на пачку, не на запрос. Это не оптимизация ради оптимизации: journald берёт плату за запись журнала, а не за байты, и одна отправка на ответ стоила 4.3 мкс — больше, чем весь остальной ответ. Пачка из шестидесяти записей стоит около 60 нс на запись.
Что из этого следует на практике:
- Одна запись журнала может содержать несколько строк.
journalctl -o cat— то, что скармливают анализатору, — печатает их как отдельные строки, ровно по одной на запрос; обычный выводjournalctlпоказывает продолжения с отступом. Каждая строка несёт своё время, поэтому от того, в какую пачку она попала, не зависит ничего. - Запись может отстать примерно на полсекунды. Буфер сбрасывается, когда заполнится, по таймеру воркера (тик — 500 мс) и при остановке. На тихом сервере это верхняя граница задержки; ни одна запись не теряется, в том числе при перезагрузке конфигурации.
- Порядок между воркерами — с точностью до пачки. Внутри одного соединения порядок точный: соединение обслуживает один воркер. То же свойство у пер-воркерных буферов nginx.
Цена
На синтетическом бенчмарке статики (wrk, keep-alive) включённый access-лог стоит около 5 % пропускной способности: 159.6k → 151.9k запросов в секунду. По профилю на воркер приходится 1.7 %, из них 0.6 % — сама доставка; остальное съедает journald в соседнем процессе.
До введения пачек — по отправке на запрос — то же самое стоило 24 % (159.6k → 121.6k), а на более быстрой машине доходило до половины. Батчинг системных вызовов (sendmmsg) здесь не помогает: дорога обработка каждой записи журналом, а не сам вызов.
С false цены нет вообще: флаг проверяется до того, как что-либо форматируется, и до чтения часов; на бенчмарке выключенный лог неотличим от сборки без него (158.9k против 159.6k).
modules массив строк
Разделяемые библиотеки приложения, которые cwfr и migrate загружают при старте. Необязательный ключ; порядок элементов — порядок загрузки.
"modules": ["/srv/myapp/lib/cwfr/libapp.so"]Это единственная точка, через которую приложение сообщает ядру то, чего оно не может знать само:
- имена middleware, на которые ссылаются
servers.*.http.middlewaresиservers.*.websockets.middlewares; - деструкторы для данных, которые приложение вешает на
httpctx_t/wsctx_tчерезhttpctx_set_user_data().
Раньше это были символы, разрешаемые на этапе компоновки, из-за чего код приложения приходилось вкомпилировать в libcwfr_framework.so. Теперь фреймворк собирается и устанавливается один раз, ничего не зная о приложении, а обработчики пишутся и пересобираются потом.
Контракт модуля
Библиотека обязана экспортировать int app_init(void) — возврат 0 прекращает запуск.
#include <stdio.h>
#include "model.h"
#include "httpcontext.h"
#include "wscontext.h"
#include "middleware_registry.h"
#include "httpmiddlewares.h"
int app_init(void) {
/* Один владелец на процесс: с несколькими модулями второй получит 0. */
if (!httpctx_set_user_data_free(model_free) || !wsctx_set_user_data_free(model_free))
return 0;
if (!middleware_registry_register("middleware_http_auth", (middleware_fn_p)middleware_http_auth)) {
fprintf(stderr, "app_init: failed to register middleware_http_auth\n");
return 0;
}
return 1;
}Не log_error()
app_init() вызывается до того, как разобранная конфигурация опубликована, поэтому логгер на этот момент ещё молчит (log_message() выходит, пока env() возвращает NULL). Пишите об ошибках в stderr — иначе сообщение потеряется, и от неудачного старта останется только ненулевой код возврата. Само ядро свои ошибки загрузки дублирует в stderr именно по этой причине.
Как загружается
Путь передаётся в dlopen() как есть — ровно так же, как поле "file" у маршрута. Абсолютный путь берётся буквально, относительный отсчитывается от текущего каталога процесса, а имя без / ищется по обычным правилам динамического загрузчика (LD_LIBRARY_PATH, RPATH, ldconfig). Относительные пути в сервисных юнитах — частый источник «файл не найден» и в норме не нужны.
Флаги — RTLD_NOW | RTLD_LOCAL:
RTLD_NOW— все неразрешённые символы модуля диагностируются здесь, с путём в сообщении, а не позже как срыв ленивого связывания в рабочем потоке;RTLD_LOCAL— символы модуля не попадают в глобальную область поиска, поэтому несколько модулей не склеиваются по одинаковым именам. Обработчикам это не мешает:.soобработчика записывает модуль вDT_NEEDED, и загрузчик находит уже загруженный экземпляр поSONAMEнезависимо от области видимости.
Порядок относительно остальной конфигурации
app_init() вызывается после разбора main и до разбора servers. Иначе быть не может: маршрут ссылается на middleware по имени, а имя появляется в реестре только после регистрации. Неизвестное имя — ошибка конфигурации, и сервер не поднимется.
migrate делает то же самое и в том же порядке: он тоже разбирает servers, а значит тоже нуждается в заполненном реестре.
Несколько модулей
Модулей может быть сколько угодно — они загружаются в порядке массива, и app_init() каждого вызывается сразу после его загрузки. Это штатная раскладка для монорепозитория, где приложение собрано из нескольких независимых частей:
"modules": [
"/srv/app/lib/cwfr/libcommon.so",
"/srv/app/lib/cwfr/libidentity.so",
"/srv/app/lib/cwfr/libscheduler.so"
]Что при этом общее на весь процесс, а что принадлежит модулю:
| Область | Конфликт | |
|---|---|---|
| Имена middleware | общий реестр процесса | повторное имя отклоняется, app_init() вернёт 0 |
Деструктор ctx->user_data | один на процесс | второй модуль с другой функцией получает 0 и сообщение |
Символы модуля (.so) | свои у каждого модуля | нет: модули загружаются с RTLD_LOCAL |
Изоляция символов существенна: два модуля вполне могут содержать по своей функции user_create(), и без неё второй молча связался бы с реализацией первого. RTLD_LOCAL этому мешает и ничего не ломает — обработчик находит свой модуль через DT_NEEDED, а не через глобальную область видимости.
Владелец ctx->user_data — один
httpctx_set_user_data_free() и wsctx_set_user_data_free() возвращают 0, если деструктор уже занят другой функцией, и печатают об этом. Повторная регистрация той же функции успешна — иначе app_init() нельзя было бы вызывать заново при перезагрузке.
Проверяйте возврат: без проверки конфликт останется сообщением в журнале, а сервер поднимется с чужим деструктором для половины запросов.
Перезагрузка конфигурации
При перезагрузке (SIGUSR1) реестр middleware очищается и app_init() вызывается заново, поэтому функция должна быть идемпотентной: повторная регистрация того же имени возвращает 0 и прекратит запуск, если писать её с накоплением состояния.
Вызывается она при этом дважды: сначала на проверочном проходе, который решает, годна ли новая конфигурация, потом на настоящем. Оба раза — с чистым реестром, так что дублей не возникает; но всё, что app_init() делает помимо регистрации, произойдёт два раза за перезагрузку.
Если модуль был пересобран, app_init() вызывается у нового экземпляра, и статические переменные у него свои: состояние между поколениями не переезжает. Всё, что должно пережить перезагрузку, храните вне модуля.
Библиотека принадлежит поколению конфигурации: пересобранная на месте, она загружается заново — копией, под SONAME этого поколения, — а прежняя выгружается, когда уходит последний поток прежнего поколения. Раньше этого момента её отображение снимать нельзя: рабочие потоки могут исполнять middleware из неё.
Пересобранный модуль подхватывается по SIGUSR1
Менять путь не нужно. Как это устроено, чего стоит и когда всё-таки не срабатывает — в Горячей перезагрузке → Модуль приложения.
Добавить в одной перезагрузке и новое middleware в app_init(), и маршрут, который на него ссылается, — можно: проверочный проход исполняет app_init() нового модуля, поэтому имена новой сборки ему видны.
Проверки и ошибки
Значение обязано быть массивом непустых строк; нарушение — ошибка конфигурации. Дальше каждый элемент проходит три проверки, и любая из них прекращает запуск с сообщением в stderr и в лог:
| Что не так | Сообщение |
|---|---|
| Файл не открывается | can't load module <путь>: <причина от dlerror> |
Нет app_init() | module <путь> exports no app_init() |
app_init() вернул 0 | app_init() failed in <путь> |
Если модуль не экспортирует app_init(), но экспортирует middlewares_init() — хук, который ядро больше не вызывает, — к сообщению добавляется подсказка о миграции. Без неё ошибка всплыла бы гораздо позже и в постороннем виде: failed to find middleware <имя> при разборе servers.
Первые две проверки выполняются и до перезагрузки, на этапе валидации новой конфигурации, — но только для модулей, ещё не загруженных в этом процессе, и без вызова app_init(). Так опечатка в пути отклоняет перезагрузку, пока старое поколение ещё обслуживает запросы. Отказ самого app_init() остаётся фатальным: это ошибка приложения, а не конфигурации, и проявляется она на первом же старте.
Без этого ключа
Сервер поднимется, но реестр middleware останется пустым, а ctx->user_data — без деструктора. Это рабочая конфигурация для раздачи статики или для маршрутов без прикладных middleware; как только конфигурация сошлётся на middleware по имени, старт прервётся.
См. Middleware, а также core/INSTALL.md — разделы «The application module» и «Building an application against an installed framework».
env объект
Единственный необязательный ключ в main. Хранилище «ключ-значение», из которого читаются и пользовательские настройки приложения, и все поведенческие параметры протоколов.
Копируются только скалярные значения: string, number, bool, null. Вложенные объекты и массивы молча отбрасываются — их нельзя прочитать ни одной функцией env_get_*.
Файл .env
Перед запуском ключи можно положить в файл .env рядом с config.json — по одному ключ=значение на строку:
# комментарий
refresh_token_expiration=15552000
feature_x_enabled=true
app_name=backend
password="p@ss # не комментарий" # комментарий после значенияПустые строки и строки с # в начале пропускается, префикс export допустим, пробелы вокруг ключа и значения обрезаются. Кавычки снимаются, и взятое в них значение всегда остаётся строкой (у значения без кавычек хвост после # отбрасывается как комментарий). Тип определяется автоматически: true/false читается как boolean, всё, что целиком число, — как number, остальное — строка; дальше ключ ничем не отличается от заданного в main.env.
Путь переопределяется ключом env_file. Если конфиг задаёт ключ и в main.env, и в .env, побеждает main.env.
env_file string main
Имя файла с переменными вместо .env. Относительный путь считается от каталога config.json:
"env_file": "secrets/.env.production"Файл не открылся или строка в нём не содержит = — сообщение уходит в журнал, запуск продолжается без этих ключей.
Пользовательские ключи
"env": {
"refresh_token_expiration": 15552000,
"feature_x_enabled": true,
"app_name": "backend"
}long long ttl = env_get_llong("refresh_token_expiration", 3600);
int enabled = env_get_bool("feature_x_enabled", 0);
const char* name = env_get_string("app_name", "default");Доступны: env_get_string, env_get_int, env_get_llong, env_get_bool, env_get_double, env_get_ldouble — каждое принимает ключ и значение по умолчанию. Ключа нет или у него неподходящий тип — возвращается умолчание, без ошибки.
Когда умолчание в вызове — не то, что нужно поведению (например, отсутствие ключа должно отличаться от явного значения), используйте проверяемые варианты: env_get_string_checked, env_get_llong_checked, env_get_bool_checked. Они возвращают статус: 0 — ключа нет, 1 — ключ есть и тип подходит (значение записано в выходной параметр), -1 — ключ есть, но тип не тот. Отдельные env_config_get_string_checked / env_config_get_llong_checked / env_config_get_bool_checked делают то же самое для явно переданного env_t*, а не для глобальной конфигурации — это удобно в коде, который работает с конфигурацией до её публикации или в тестах.
Ключи рантайма
Всё остальное, что настраивается в main.env, — это параметры самого сервера. Полный указатель; подробное описание каждого параметра — на связанных страницах.
| Группа | Ключи | Где описаны |
|---|---|---|
| Наблюдаемость | metrics | ниже |
| Сжатие статики | gzip_static, gzip_cache_size, gzip_cache_max_file | выше |
| HTTP/2: жизненный цикл | http2_idle_timeout_sec, http2_ping_interval_sec, http2_ping_ack_timeout_sec, http2_settings_ack_timeout_sec, http2_recv_window_initial, http2_recv_window_max, http2_write_quantum | HTTP/2 → Настройка |
| HTTP/2: защита от DoS | http2_max_header_list_size, http2_max_continuation_frames, http2_abort_rate, http2_abort_burst, http2_ctrl_rate, http2_ctrl_burst, http2_max_out_backlog | HTTP/2 → Защита |
| HTTP/3: эндпоинт и ресурсы | http3_max_connections, http3_buffer_memory_limit, http3_rx_batch, http3_so_rcvbuf, http3_so_sndbuf, http3_handshake_rate, http3_handshake_burst, http3_stateless_reset_rate, http3_stateless_reset_burst, http3_version_negotiation_rate, http3_version_negotiation_burst | HTTP/3 |
| HTTP/3: транспорт соединения | http3_idle_timeout_sec, http3_keepalive_sec, http3_max_udp_payload_size, http3_initial_max_data, http3_initial_max_stream_data, http3_max_streams_bidi, http3_max_streams_uni, http3_recv_window_max, http3_active_cid_limit, http3_ack_delay_ms | HTTP/3 |
| HTTP/3: перегрузка | http3_initcwnd_packets, http3_cc, http3_pacing, http3_amplification_factor | HTTP/3 |
| HTTP/3: проверка адреса | http3_retry, http3_retry_threshold, http3_new_token, http3_token_lifetime_sec | HTTP/3 |
| HTTP/3: протокол и защита | http3_max_field_section_size, http3_abort_rate, http3_abort_burst, http3_ctrl_rate, http3_ctrl_burst | HTTP/3 |
| HTTP/3: версии и 0-RTT | http3_version_2, http3_early_data | HTTP/3 |
| HTTP/3: диагностика | http3_qlog_dir, http3_qlog_connections | HTTP/3 |
Все они читаются один раз при загрузке конфигурации и перечитываются при перезагрузке. Неверный тип или значение вне диапазона — ошибка конфигурации, а не молчаливый откат к умолчанию: "http3_cc": "reno" или "http3_max_streams_uni": 1 останавливают запуск с сообщением, называющим ключ и допустимый диапазон.
metrics boolean
Включает счётчики конкурентности, блокировок, HTTP/2- и QUIC-статистики. По умолчанию false: выключенные счётчики не стоят ничего.
"env": { "metrics": true }Флаг только собирает данные — отдавать их наружу должен обработчик приложения. В примере приложения это app/routes/bench/metrics.c, повешенный на маршрут:
"/metrics": {
"GET": { "file": "<build>/exec/handlers/bench/lib_metrics.so", "function": "metrics" }
}GET /metrics?reset=1 отдаёт снимок и обнуляет счётчики. Маршрут стоит закрывать middleware — снимок описывает внутреннее состояние процесса.
Ключи, которых нет
main.buffer_size встречается в старых примерах config.json и не читается сервером: размер буфера соединения фиксирован в коде. Ключ безвреден, но менять поведение им нельзя — его можно удалить.
Секция migrations
"migrations": {
"source_directory": "<project>/app/migrations"
}Секция не используется
Ни cwfr, ни migrate не читают migrations.source_directory. Утилита migrate принимает пути позиционными аргументами:
./exec/migrate create add_users_table ../config.json ../app/migrations/s1
./exec/migrate up all ../config.json postgresql.p1 s1Секцию можно оставить как документацию к проекту или удалить — на поведение это не влияет. Подробности — в разделе Миграции.
Секция translations
Список доменов локализации (i18n). Необязательная.
"translations": [
{ "domain": "backend", "path": "/app/locale" }
]domain— имя текстового домена (обязательный, непустой)path— каталог с.mo-файлами (обязательный, непустой)
Базовая локаль каждого домена — en. Элемент с неверными полями пропускается с сообщением в журнал, остальные загружаются: битая запись в этой секции не останавливает запуск, в отличие от большинства других. Подробности — в разделе Интернационализация.
Секция task_manager
Список фоновых задач, загружаемых из .so и исполняемых планировщиком. Необязательная; массив.
Общие поля каждой задачи — name, type, file, function — обязательны; поля расписания зависят от type.
type | Поля расписания | Описание |
|---|---|---|
interval | interval | Запуск каждые N секунд (≥ 1) |
daily | hour, minute | Ежедневно в hh:mm |
weekly | weekday, hour, minute | Еженедельно в указанный день недели |
monthly | day, hour, minute | Ежемесячно в указанный день месяца |
weekday принимает sunday, monday, tuesday, wednesday, thursday, friday, saturday; hour — 0–23, minute — 0–59, day — 1–31. Неизвестный type или значение вне диапазона — ошибка конфигурации.
"task_manager": [
{
"name": "cleanup_expired_tokens",
"type": "interval",
"interval": 60,
"file": "/app/build/exec/handlers/tasks/libtasks.so",
"function": "cleanup_authorization_codes"
},
{
"name": "nightly_report",
"type": "daily",
"hour": 3,
"minute": 30,
"file": "/app/build/exec/handlers/tasks/libtasks.so",
"function": "send_report"
},
{
"name": "weekly_digest",
"type": "weekly",
"weekday": "monday",
"hour": 9,
"minute": 0,
"file": "/app/build/exec/handlers/tasks/libtasks.so",
"function": "send_digest"
},
{
"name": "monthly_invoice",
"type": "monthly",
"day": 1,
"hour": 0,
"minute": 15,
"file": "/app/build/exec/handlers/tasks/libtasks.so",
"function": "build_invoices"
}
]Подробности — в разделе Планировщик задач.
Секция servers
Обязательная и непустая. Каждый сервер (виртуальный хост) — именованная запись; имя (s1, s2, …) произвольно и нигде, кроме сообщений об ошибках, не используется.
Обязательные поля вхоста: domains, ip, port, root. Остальные — index, ratelimits, http, websockets, tls, http3 — необязательны.
domains массив строк обязательный
Имена, по которым вхост выбирается. Сравнение идёт с заголовком Host (или именем из SNI на TLS), без учёта регистра и после отбрасывания порта.
Шаблон переводится в punycode, затем — в регулярное выражение по нескольким правилам:
| В шаблоне | Значит |
|---|---|
example.com | Точное имя. Точка экранируется, поэтому a.b не матчит axb |
*.example.com | * вне скобок раскрывается в .* — но только в начале или в конце строки |
mail.* | То же самое с другого конца |
(api|www).example.com | Обычное регулярное выражение PCRE2: скобки, альтернатива, классы [...] работают как в регулярке |
(.1|.*)example.com | Внутри скобок точка и звёздочка — метасимволы, экранирование не применяется |
Шаблон автоматически привязывается к границам строки: ^ в начале и $ в конце добавляются, если их нет. Звёздочка в середине строки (a*b) — ошибка конфигурации, как и незакрытая скобка.
Шаблон без метасимволов сравнивается напрямую, минуя PCRE2, — это заметно быстрее, и именно поэтому точка экранируется, а не оставляется метасимволом: иначе ни одно реальное доменное имя не попало бы в быструю ветку.
Порт в domains не пишется
Порт отрезается от Host перед сравнением, поэтому запись вида "www.example.com:8080" не совпадёт никогда. Порт задаётся полем port.
Вхост опознаётся тройкой (адрес, домен, порт), и совпадение этой тройки у двух записей — ошибка конфигурации.
ip строка обязательный
IP-адрес для прослушивания — IPv4 или IPv6. Принимаются 127.0.0.1, 0.0.0.0, ::1, :: и форма в скобках [::1]. Адрес относится и к TCP (HTTP/1.1, HTTP/2, WebSocket), и к UDP-эндпоинту HTTP/3.
IPv6-сокет создаётся v6-only: он не принимает IPv4-трафик, потому что dual-stack сокет отдавал бы IPv4-адреса в форме v4-mapped, а это делает неоднозначным локальный адрес датаграммы и зависит от системного net.ipv6.bindv6only. Поэтому сайт, доступный по обеим семьям, — это две записи в servers с одинаковыми domains и port, различающиеся только ip:
"servers": {
"site_v4": { "domains": ["example.com"], "ip": "0.0.0.0", "port": 443, "...": "..." },
"site_v6": { "domains": ["example.com"], "ip": "::", "port": 443, "...": "..." }
}Уникальность виртуальных хостов проверяется по тройке «адрес + домен + порт», так что такая пара конфликтом не считается.
Неверный адрес (опечатка вроде 127.0.0.300 или ::1x) останавливает запуск с сообщением, называющим значение, — сервер не пытается его угадать.
port число обязательный
TCP-порт сервера (обычно 80 для HTTP, 443 для HTTPS). Он же по умолчанию становится UDP-портом HTTP/3.
root строка обязательный
Корневой каталог статических файлов. Должен существовать на момент запуска — иначе отказ. Завершающий слэш отбрасывается.
От этого каталога отсчитывается всё файловое: и статика, отдаваемая при промахе по маршрутам, и пути в static_file.
index строка
Имя индексного файла, отдаваемого для каталога. По умолчанию index.html. Одно имя, не список.
ratelimits объект
Именованные профили ограничения частоты запросов (token bucket). Каждый профиль задаёт burst (ёмкость бакета — пиковое число запросов) и rate (скорость восполнения токенов в секунду). Каждый разрешённый запрос расходует один токен; бакет пополняется по мере течения времени, а не сбрасывается на границе секунды.
"ratelimits": {
"default": { "burst": 100, "rate": 50 },
"strict": { "burst": 1, "rate": 1 },
"unlimited": { "burst": 1, "rate": 0 }
}Оба поля обязательны и должны быть целыми. Бакет свой у каждого IPv4-адреса клиента или IPv6-префикса /64. rate: 0 выключает профиль: такой ограничитель пропускает все запросы, и burst не учитывается. Бакета, который не восполняется («burst запросов, дальше отказ навсегда»), нет — самый строгий профиль { "burst": 1, "rate": 1 }, один запрос в секунду.
Сами профили ничего не ограничивают — их надо назначить: полем ratelimit в http, в websockets или у отдельного метода маршрута. Ссылка на несуществующее имя профиля — ошибка конфигурации.
Например, { "burst": 100, "rate": 50 } допускает до 100 запросов подряд из полного бакета, затем восполняет до 50 токенов в секунду. burst ограничивает запас, а rate — скорость его восстановления.
Клиент определяется по адресу соединения: целому IPv4-адресу или IPv6-префиксу /64. Бакеты находятся в памяти каждого worker-процесса и не объединяются между workers или экземплярами сервера. Одно имя профиля означает одинаковые настройки, а не общий бакет для всех его назначений: отдельные маршруты со своим профилем имеют отдельные лимитеры; маршруты без своего профиля используют лимитер http.ratelimit.
Поле ratelimit задаётся внутри метода, но лимитер хранится у всего маршрута и применяется ко всем его методам. Для методов одного маршрута указывайте одинаковый профиль; для разных лимитов используйте отдельные маршруты.
http объект
Конфигурация HTTP-маршрутов, middleware, заголовков и редиректов. Все пять вложенных ключей необязательны.
ratelimit строка
Имя профиля rate limiting по умолчанию для всех HTTP-маршрутов вхоста без собственного ratelimit и статики от root по запросу, не совпавшему ни с одним маршрутом.
Для файловых маршрутов из root и запросов, не совпавших ни с одним маршрутом, лимит проверяется до поиска файла. Несуществующие файлы тоже расходуют токены; при исчерпанном лимите ответ — 429 с Retry-After: 1.
Маршруты без собственного профиля и запросы к статике вне маршрутов делят один бакет на IPv4-адрес клиента или IPv6-префикс /64 в рамках лимитера вхоста в одном worker-процессе. Профиль маршрута имеет приоритет, включая rate: 0, который отключает ограничение для этого маршрута. Без назначенного профиля запросы не ограничиваются. Редиректы из http.redirects этот лимитер не проверяют.
middlewares массив строк
Список middleware, применяемых ко всем HTTP-маршрутам. Имена берутся из реестра приложения — их регистрирует app_init() в модуле, указанном в main.modules; неизвестное имя — ошибка конфигурации.
"middlewares": ["middleware_http_auth"]Подробности — в разделе Middleware.
routes объект
Маршруты HTTP-запросов. Ключ — путь, значение — объект МЕТОД → { обработчик }.
Поддерживаемые методы: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS. Другие (CONNECT, TRACE) в маршрутах не объявляются.
"routes": {
"/api/users": {
"GET": { "file": "handlers/models/lib_modeluser.so", "function": "list", "ratelimit": "strict" },
"POST": { "file": "handlers/models/lib_modeluser.so", "function": "create", "ratelimit": "strict" }
},
"/api/users/{id|\\d+}": {
"PATCH": { "file": "handlers/models/lib_modeluser.so", "function": "update" }
}
}Путь может быть литеральным, содержать именованные параметры ({id|\d+}) или быть регулярным выражением — но не то и другое сразу. Подробности синтаксиса — в разделе Маршрутизация.
Поля обработчика:
file— путь до.soс обработчиком. Библиотека загружается при старте и переиспользуется всеми маршрутами, которые на неё ссылаются. Пересобранная на месте, она подхватывается поSIGUSR1— путь менять не нужно (см. Горячая перезагрузка)function— имя экспортируемой функции. Резолвится при старте; не нашлась — отказstatic_file— путь к статическому файлу. Если задан,file/functionне требуются и не вызываются: маршрут отдаёт файл. Путь всегда разрешается относительноroot— ведущий/отбрасывается, а не означает корень файловой системы. Поддерживается подстановка групп захвата{1},{2}, … из регулярного выражения маршрута (то же написание, что уredirects), поэтому одним маршрутом можно отдавать целый каталог:json"/assets/(.*)": { "GET": { "static_file": "/assets/{1}", "cache_control": "public, max-age=31536000, immutable" } }storage— имя хранилища из секцииstorages, из которого берётсяstatic_file. Без него путь отсчитывается отroot, как раньше. Требуетstatic_fileи несовместим сfile/function; неизвестное имя — отказ старта. На storage-маршруте, в отличие от обычной статики, работают middleware, аcache_controlложится только на то, что хранилище отдало (200/206/304), — не на404,429или отказ middleware. Для S3-хранилища объект сначала запрашиваетсяHEAD, тело тянется порциями по 8 МБ (порция дополнительно зажимаетсяclient_max_body_size), аRangeклиента вырезает сам S3 — см. Отдача из хранилища:json"/videos/(.*)": { "GET": { "static_file": "{1}", "storage": "media" } }cache_control— заголовокCache-Controlдля того, чем отвечает маршрут: и дляstatic_file, и для обработчика. Без него каждый файловый ответ несётCache-Control: no-cache(перепроверка при каждом использовании) — безопасно, но заставляет клиента заново качать неизменяемые артефакты сборки. Ставьтеimmutable-кэширование на маршруты с файлами, в имени которых есть хеш содержимого, а страницы оставляйте на значении по умолчанию. Обработчик, выставивший свойCache-Control, сохраняет его — значение маршрута это умолчание, а не переопределение; а отсутствующийstatic_fileотвечает 404 без этого заголовкаratelimit— профиль rate limiting для всего маршрута, переопределяетhttp.ratelimit. Поле задаётся внутри метода, но действует на все методы маршрута; если разные методы задают разные профили, используется последний загруженный профиль. Действует на маршрутах с обработчиком и сstatic_file, включая файлы изrootи изstorage(см. Rate limiting для статических файлов)
Запрос, не совпавший ни с одним маршрутом, обслуживается как статика из root.
redirects объект
Правила редиректов. Ключ — путь или регулярное выражение, значение — цель; группы захвата подставляются через {1}, {2}, …:
"redirects": {
"/user": "/persons",
"/user(.*)/(\\d)": "/user-{1}-{2}",
"/section1/(\\d+)/section2/(\\d+)/section3": "/one/{1}/two/{2}/three"
}Редиректы проверяются до маршрутов.
Query-строка запроса переносится на цель, если у самой цели знака ? нет: /old?utm_source=ya уедет на /index.html?utm_source=ya. Цель со своими параметрами ("/old": "/new?b=2") остаётся как есть — они заданы осознанно, и склейка двух наборов была бы сюрпризом. Совпадение по-прежнему ищется по пути без query, поэтому (.*) в группу query не захватывает.
headers объект
Заголовки, которые вхост ставит на каждый свой ответ. Ключ — имя заголовка, значение — строка; пустое значение — ошибка конфигурации.
"headers": {
"X-Content-Type-Options": "nosniff",
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
"Referrer-Policy": "strict-origin-when-cross-origin"
}Это то место, где живут заголовки, задаваемые один раз на сайт. Обработчик до них не дотягивается: статику отдаёт ядро, а middleware работает на маршрутах — статика же отдаётся при промахе по ним. Здесь заголовок попадает на всё: на статику, на ответ обработчика, на send_default(404), на 304, — и одинаково в HTTP/1.1, HTTP/2 и HTTP/3.
Значение из конфигурации — умолчание: обработчик, выставивший свой Referrer-Policy, сохраняет своё значение, дубля не возникает. Это то же правило, что у cache_control у маршрута.
websockets объект
Конфигурация WebSocket. Все вложенные ключи — default, ratelimit, middlewares, routes — необязательны, но сам факт наличия секции значим.
"websockets": {
"default": { "file": "handlers/ws/lib_wsindex.so", "function": "default_" },
"ratelimit": "strict",
"middlewares": ["middleware_ws_auth"],
"routes": {
"/": { "GET": { "file": "handlers/ws/lib_wsindex.so", "function": "echo" } }
}
}default— обработчик кадров, не попавших ни в один маршрут. Без него используется встроенный обработчик по умолчаниюratelimit,middlewares— то же, что вhttp, но для WebSocket-трафикаroutes— маршруты; ключ метода здесь тожеGET
Секция определяет и HTTP/2
Вхост без секции websockets отвечает на Extended CONNECT (WebSocket поверх HTTP/2, RFC 8441) кодом 501 Not Implemented. Анонс возможности идёт на уровне соединения, а обслуживание — на уровне вхоста, поэтому пустая секция "websockets": {} — это осмысленное «здесь WebSocket разрешён».
Подробности — в разделах WebSocket: запросы и Рассылка.
tls объект
Настройки TLS/SSL. Все три поля обязательны, если секция присутствует; пустая строка не допускается.
"tls": {
"fullchain": "/path/to/fullchain.pem",
"private": "/path/to/privkey.pem",
"ciphers": "TLS_AES_256_GCM_SHA384 TLS_CHACHA20_POLY1305_SHA256 ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384"
}fullchain— цепочка сертификатов в PEMprivate— приватный ключ в PEMciphers— список шифров: сначала наборы TLS 1.3 через пробел, затем список TLS 1.2 через двоеточие
Выбор вхоста на TLS идёт по SNI; сертификат берётся у совпавшего вхоста. HTTP/2 требует шифра с AEAD и эфемерным обменом ключами (RFC 9113 §9.2.2) — соединение на слабом шифре получит GOAWAY(INADEQUATE_SECURITY), а HTTP/1.1 на нём же продолжит работать.
Подробности — в разделе SSL-сертификаты.
http3 объект
Включает HTTP/3 (поверх QUIC) для вхоста. Требует флага сборки -DINCLUDE_HTTP3=yes (OpenSSL ≥ 3.5) и секции tls — QUIC не имеет режима без шифрования; http3.enabled без tls останавливает запуск, как и enabled в сборке без поддержки HTTP/3. TCP при этом продолжает раздавать HTTP/1.1 и HTTP/2.
"http3": {
"enabled": true,
"port": 443,
"alt_svc": true,
"alt_svc_max_age": 86400
}| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
enabled | bool | false | Включает HTTP/3. Обязательно для работы h3 |
port | число | TCP-порт вхоста | UDP-порт для QUIC (1–65535) |
alt_svc | bool | true | Анонсировать h3 в заголовке Alt-Svc поверх HTTP/1.1 и HTTP/2 |
alt_svc_max_age | число | 86400 | Срок кеширования Alt-Svc (сек, ≥ 0) |
В Alt-Svc уезжает только порт (h3=":443"; ma=86400), без имени хоста: пустой хост по RFC 7838 значит «тот же самый», и это делает заголовок корректным для всех вхостов, делящих слушатель.
Поведение самого QUIC настраивается ключами http3_* в main.env — они процессные, а не по вхостам. Подробности — в разделе HTTP/3.
Секция databases
Конфигурация подключений к БД. Необязательная. Каждый драйвер — непустой массив хостов; в коде подключение адресуется как <драйвер>.<host_id> (например, postgresql.p1, redis.r1, sqlite.local).
Компилируются только включённые при сборке драйверы (-DINCLUDE_POSTGRESQL=yes и т. п.). Драйвер, названный в конфиге, но отсутствующий в сборке, пропускается с сообщением в журнал — запуск не останавливается, но обращения к нему в коде не найдут хост.
Во всех драйверах неизвестное поле — ошибка конфигурации, как и повторение поля дважды.
postgresql
"postgresql": [{
"host_id": "p1",
"ip": "127.0.0.1",
"port": 5432,
"dbname": "mydb",
"user": "dbuser",
"password": "dbpass",
"connection_timeout": 3,
"schema": "public"
}]| Поле | Тип | Обязательное | Описание |
|---|---|---|---|
host_id | строка | да | Идентификатор хоста; адресуется как postgresql.<host_id> |
ip | строка | да | Адрес сервера БД |
port | число | да | Порт |
dbname | строка | да | Имя базы |
user | строка | да | Пользователь |
password | строка | да | Пароль (может быть пустой строкой) |
connection_timeout | число | да | Таймаут подключения, сек |
schema | строка | нет | Схема, в которой ищутся таблицы. Не задана — current_schema() |
mysql
"mysql": [{
"host_id": "m1",
"ip": "127.0.0.1",
"port": 3306,
"dbname": "mydb",
"user": "dbuser",
"password": "dbpass",
"charset": "utf8mb4",
"connection_timeout": 5
}]| Поле | Тип | Обязательное | Описание |
|---|---|---|---|
host_id, ip, port, dbname, user, password | — | да | Как у PostgreSQL |
charset | строка | нет | Кодировка соединения. По умолчанию utf8mb4 |
connection_timeout | число | нет | Таймаут подключения, сек. 0 или отсутствие — таймаут не выставляется |
redis
"redis": [{
"host_id": "r1",
"ip": "127.0.0.1",
"port": 6379,
"dbindex": 0,
"user": "",
"password": ""
}]| Поле | Тип | Обязательное | Описание |
|---|---|---|---|
host_id, ip, port | — | да | Идентификатор и адрес |
dbindex | число | да | Номер базы Redis, 0–15 |
user | строка | нет | Пользователь (Redis ACL) |
password | строка | нет | Пароль |
sqlite
"sqlite": [{
"host_id": "local",
"path": "/var/lib/app/data.sqlite",
"journal_mode": "WAL",
"busy_timeout": 5000
}]| Поле | Тип | Обязательное | По умолчанию | Описание |
|---|---|---|---|---|
host_id | строка | да | — | Идентификатор хоста |
path | строка | да | — | Путь к файлу БД; ":memory:" — база в памяти |
journal_mode | строка | нет | WAL | Значение PRAGMA journal_mode |
busy_timeout | число | нет | 5000 | PRAGMA busy_timeout, мс; неотрицательное |
Подробности работы с БД — в разделе Базы данных.
Секция storages
Именованные файловые хранилища. Необязательная. Имя ключа — то, чем хранилище адресуется в коде и в sessions.storage_name. Тип задаётся полем type; неизвестный тип — ошибка конфигурации.
filesystem
"storages": {
"local": {
"type": "filesystem",
"root": "/var/www/storage"
}
}root обязателен и не может быть пустым.
s3
"storages": {
"remote": {
"type": "s3",
"access_id": "your_access_id",
"access_secret": "your_access_secret",
"protocol": "https",
"host": "s3.amazonaws.com",
"port": "443",
"bucket": "my-bucket",
"region": "us-east-1"
}
}Все поля обязательны и непусты
access_id, access_secret, protocol, host, port, bucket, region — каждое должно быть непустой строкой. В частности, "port": "" (встречается в старых примерах) останавливает запуск: порт указывается явно, строкой — "443" или "80".
Подробности — в разделе Хранилища.
Секция sessions
Именованный набор конфигураций сессий. Необязательная. Каждый ключ — имя сессии, используемое в коде (session_create("backend", data, ttl)).
Все сессии шифруются AES-256-GCM; ключ выводится из обязательного поля secret. Драйвер задаётся полем driver:
driver | Обязательное поле хранения | Описание |
|---|---|---|
filesystem | storage_name | Файлы в указанном хранилище (имя из секции storages) |
redis | host_id | Redis, адресуется как redis.<host_id> |
database | host_id | БД, адресуется как <драйвер>.<host_id> (например, postgresql.p1) |
"sessions": {
"backend": {
"driver": "filesystem",
"storage_name": "local",
"secret": "change-me"
},
"scheduler": {
"driver": "redis",
"host_id": "redis.r1",
"secret": "change-me"
},
"doc-editor": {
"driver": "database",
"host_id": "postgresql.p1",
"secret": "change-me"
}
}Неизвестный драйвер, отсутствующий secret или пустое поле хранения — ошибка конфигурации. Время жизни сессии в конфигурации не задаётся — оно передаётся при создании: session_create(name, data, duration).
Подробности — в разделе Сессии.
Секция mail
Конфигурация отправки email. Необязательная; отсутствие секции равносильно пустым значениям — письма уходят напрямую на MX получателя и без подписи.
"mail": {
"dkim_private": "/path/to/dkim_private.pem",
"dkim_selector": "mail",
"host": "example.com"
}| Поле | Описание |
|---|---|
dkim_private | Путь к файлу приватного ключа. Файл читается при загрузке конфигурации — недоступный путь останавливает запуск |
dkim_selector | DKIM-селектор (часть имени TXT-записи <selector>._domainkey.<host>) |
host | Домен, от имени которого подписываются письма |
relay | Объект: отправка через SMTP-релей вместо прямой доставки (см. ниже) |
Каждое поле по отдельности необязательно, но если оно есть — это непустая строка. dkim_private и dkim_selector при этом задаются вместе: одно без другого — ошибка конфигурации. Если не задано ни одно, DKIM не применяется и письма уходят без подписи.
mail.relay
Наличие объекта relay переключает доставку с прямой на релейную: письмо передаётся заданному SMTP-серверу с аутентификацией, MX-записи получателя не запрашиваются.
"mail": {
"host": "example.com",
"relay": {
"host": "smtp.mail.ru",
"port": 587,
"security": "starttls",
"user": "info@example.com",
"password": "...",
"auth": "auto",
"timeout": 15,
"verify": true
}
}| Поле | Обязательное | По умолчанию | Описание |
|---|---|---|---|
host | да | — | Имя или адрес сервера-релея. Резолвится через getaddrinfo() (A и AAAA) |
port | нет | по security: 587 / 465 / 25 | Порт, 1..65535 |
security | нет | starttls | starttls, tls (рукопожатие до баннера), none |
user | нет | — | Логин; без него AUTH не выполняется |
password | нет | — | Пароль. Не попадает в журнал |
auth | нет | auto | auto, plain, login, none |
timeout | нет | 30 | Таймаут операций сокета, секунды, 1..3600 |
verify | нет | true | Проверять сертификат и имя хоста релея |
Сочетание security: "none" с user допустимо, но пишет в журнал предупреждение — учётные данные уйдут открытым текстом. Остальные нарушения останавливают запуск:
| Конфигурация | Сообщение |
|---|---|
relay без host | mail.relay.host is required |
"host": "" | mail.relay.host must be not empty |
user без password | mail.relay.user without mail.relay.password |
password без user | mail.relay.password without mail.relay.user |
"security": "ssl" | mail.relay.security must be one of: starttls, tls, none |
"auth": "cram-md5" | mail.relay.auth must be one of: auto, plain, login, none |
"port": 70000 | mail.relay.port must be in range 1..65535 |
"timeout": 0 | mail.relay.timeout must be in range 1..3600 |
"verify": "yes" | mail.relay.verify must be bool — нужен true/false, не строка |
"relay": "smtp.mail.ru" | mail.relay must be object |
dkim_selector без dkim_private (и наоборот) | mail.dkim_private and mail.dkim_selector must be set together |
Готовые варианты конфигурации на каждый режим — в разделе Почта.
Секция mimetypes
Обязательная и непустая. Соответствие MIME-типов расширениям файлов: ключ — тип, значение — непустой массив расширений (без точки).
"mimetypes": {
"text/html": ["html", "htm", "shtml"],
"text/css": ["css"],
"application/json": ["json"],
"application/javascript": ["js"],
"image/png": ["png"],
"image/jpeg": ["jpeg", "jpg"],
"application/octet-stream": ["bin", "exe", "dll"]
}Строятся две таблицы: «тип → расширение» и «расширение → тип». В первую попадает только первое расширение массива — оно и будет каноническим для типа; во вторую — все. Поэтому расширение может встречаться у нескольких типов (побеждает последнее вхождение), а порядок внутри массива значим.
Файл, расширение которого здесь не описано, отдаётся без осмысленного Content-Type — таблицу стоит держать полной. Список из примера в репозитории повторяет набор nginx и годится как отправная точка.
Полный пример конфигурации
{
"main": {
"workers": 4,
"threads": 2,
"reload": "hard",
"modules": ["/app/build/exec/libapp.so"],
"env_file": "secrets/.env.production",
"client_max_body_size": 110485760,
"tmp": "/tmp",
"gzip": ["text/html", "text/css", "application/json", "application/javascript"],
"log": { "enabled": true, "level": "info", "access": true },
"env": {
"refresh_token_expiration": 15552000,
"metrics": true,
"gzip_static": true,
"gzip_cache_size": 33554432,
"gzip_cache_max_file": 1048576,
"http2_idle_timeout_sec": 60,
"http2_ping_interval_sec": 30,
"http3_idle_timeout_sec": 300,
"http3_keepalive_sec": 10
},
"body_store": {
"mode": "auto",
"file_threshold": 1048576
}
},
"translations": [
{ "domain": "backend", "path": "/app/locale" }
],
"task_manager": [
{
"name": "cleanup_expired_tokens",
"type": "interval",
"interval": 60,
"file": "/app/build/exec/handlers/tasks/libtasks.so",
"function": "cleanup_authorization_codes"
},
{
"name": "nightly_report",
"type": "daily",
"hour": 3,
"minute": 30,
"file": "/app/build/exec/handlers/tasks/libtasks.so",
"function": "send_report"
}
],
"servers": {
"site_v4": {
"domains": ["example.com", "*.example.com"],
"ip": "0.0.0.0",
"port": 443,
"root": "/var/www/html",
"index": "index.html",
"ratelimits": {
"default": { "burst": 100, "rate": 50 },
"strict": { "burst": 1, "rate": 1 },
"assets": { "burst": 200, "rate": 100 },
"downloads": { "burst": 10, "rate": 2 },
"unlimited": { "burst": 1, "rate": 0 }
},
"http": {
"ratelimit": "default",
"middlewares": ["middleware_http_auth"],
"routes": {
"/api/users": {
"GET": { "file": "/app/build/exec/handlers/models/lib_modeluser.so", "function": "list", "ratelimit": "strict" },
"POST": { "file": "/app/build/exec/handlers/models/lib_modeluser.so", "function": "create", "ratelimit": "strict" }
},
"/assets/(.*)": {
"GET": {
"static_file": "/assets/{1}",
"cache_control": "public, max-age=31536000, immutable",
"ratelimit": "assets"
}
},
"/downloads/(.*)": {
"GET": { "static_file": "downloads/{1}", "storage": "local", "ratelimit": "downloads" }
},
"/api/login": {
"POST": { "file": "/app/build/exec/handlers/models/lib_modeluser.so", "function": "login", "ratelimit": "strict" }
},
"/robots.txt": {
"GET": { "static_file": "/robots.txt", "ratelimit": "unlimited" }
}
},
"redirects": {
"/old": "/new",
"/user(.*)/(\\d)": "/user-{1}-{2}"
},
"headers": {
"X-Content-Type-Options": "nosniff",
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
"Referrer-Policy": "strict-origin-when-cross-origin"
}
},
"websockets": {
"default": { "file": "/app/build/exec/handlers/ws/lib_wsindex.so", "function": "default_" },
"ratelimit": "default",
"middlewares": ["middleware_ws_auth"],
"routes": {
"/ws": { "GET": { "file": "/app/build/exec/handlers/ws/lib_wsindex.so", "function": "connect" } }
}
},
"tls": {
"fullchain": "/etc/letsencrypt/live/example.com/fullchain.pem",
"private": "/etc/letsencrypt/live/example.com/privkey.pem",
"ciphers": "TLS_AES_256_GCM_SHA384 TLS_CHACHA20_POLY1305_SHA256"
},
"http3": {
"enabled": true,
"port": 443,
"alt_svc": true,
"alt_svc_max_age": 86400
}
},
"site_v6": {
"domains": ["example.com", "*.example.com"],
"ip": "::",
"port": 443,
"root": "/var/www/html",
"index": "index.html",
"tls": {
"fullchain": "/etc/letsencrypt/live/example.com/fullchain.pem",
"private": "/etc/letsencrypt/live/example.com/privkey.pem",
"ciphers": "TLS_AES_256_GCM_SHA384 TLS_CHACHA20_POLY1305_SHA256"
},
"http3": { "enabled": true }
}
},
"databases": {
"postgresql": [{
"host_id": "p1", "ip": "127.0.0.1", "port": 5432,
"dbname": "mydb", "user": "dbuser", "password": "dbpass",
"connection_timeout": 3,
"schema": "public"
}],
"mysql": [{
"host_id": "m1", "ip": "127.0.0.1", "port": 3306,
"dbname": "mydb", "user": "dbuser", "password": "dbpass",
"charset": "utf8mb4",
"connection_timeout": 5
}],
"redis": [{
"host_id": "r1", "ip": "127.0.0.1", "port": 6379,
"dbindex": 0, "user": "", "password": ""
}],
"sqlite": [{
"host_id": "local", "path": "/var/lib/app/data.sqlite",
"journal_mode": "WAL",
"busy_timeout": 5000
}]
},
"storages": {
"local": {
"type": "filesystem",
"root": "/var/www/storage"
},
"remote": {
"type": "s3",
"access_id": "your_access_id",
"access_secret": "your_access_secret",
"protocol": "https",
"host": "s3.amazonaws.com",
"port": "443",
"bucket": "my-bucket",
"region": "us-east-1"
}
},
"sessions": {
"backend": {
"driver": "filesystem",
"storage_name": "local",
"secret": "change-me"
},
"scheduler": {
"driver": "redis",
"host_id": "redis.r1",
"secret": "change-me"
},
"doc-editor": {
"driver": "database",
"host_id": "postgresql.p1",
"secret": "change-me"
}
},
"mail": {
"dkim_private": "/etc/dkim/private.pem",
"dkim_selector": "mail",
"host": "example.com"
},
"mimetypes": {
"text/html": ["html", "htm"],
"text/css": ["css"],
"application/json": ["json"],
"application/javascript": ["js"],
"image/png": ["png"],
"image/jpeg": ["jpeg", "jpg"],
"image/svg+xml": ["svg"],
"font/woff2": ["woff2"]
}
}Две ссылки в этом примере ведут за пределы конфигурации, и обе обязаны разрешиться, иначе сервер не поднимется:
middleware_http_authвhttp.middlewares— имя из реестра, который заполняетapp_init()модуля/app/build/exec/libapp.so, объявленного вmain.modules. Без этой строки вmodulesреестр пуст и старт прервётся сfailed to find middleware middleware_http_auth.fileу маршрутов и задач — пути к обработчикам, которые разрешаютсяdlopen()при старте; отсутствующий файл или неизвестнаяfunctionтоже прекращают запуск.
Значения secret показаны как заглушки. Их место — в файле, указанном через env_file, а не в самой конфигурации.