Skip to content

Файл конфигурации ​

Вся настройка приложения живёт в одном JSON-файле; отдельных .env нет. Путь передаётся флагом -c:

bash
./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 ​

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

json
"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 КБ), поэтому клиент, не просивший сжатия, за неё не платит.

json
"env": { "gzip_static": true }

Сервер такие файлы не создаёт — он только отдаёт уже лежащие рядом, а писать их должна сборка. Если она этого не умеет, подойдёт постобработка готового каталога:

bash
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_size0 (выключено)Общий бюджет памяти под сжатые представления, в байтах
gzip_cache_max_file1048576Наибольший исходный файл, который кладётся в кеш, в байтах
json
"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.

json
"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:

bash
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 загружают при старте. Необязательный ключ; порядок элементов — порядок загрузки.

json
"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 прекращает запуск.

c
#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() каждого вызывается сразу после его загрузки. Это штатная раскладка для монорепозитория, где приложение собрано из нескольких независимых частей:

json
"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() вернул 0app_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 — по одному ключ=значение на строку:

dotenv
# комментарий
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:

json
"env_file": "secrets/.env.production"

Файл не открылся или строка в нём не содержит = — сообщение уходит в журнал, запуск продолжается без этих ключей.

Пользовательские ключи ​

json
"env": {
    "refresh_token_expiration": 15552000,
    "feature_x_enabled": true,
    "app_name": "backend"
}
c
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_quantumHTTP/2 → Настройка
HTTP/2: защита от DoShttp2_max_header_list_size, http2_max_continuation_frames, http2_abort_rate, http2_abort_burst, http2_ctrl_rate, http2_ctrl_burst, http2_max_out_backlogHTTP/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_burstHTTP/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_msHTTP/3
HTTP/3: перегрузкаhttp3_initcwnd_packets, http3_cc, http3_pacing, http3_amplification_factorHTTP/3
HTTP/3: проверка адресаhttp3_retry, http3_retry_threshold, http3_new_token, http3_token_lifetime_secHTTP/3
HTTP/3: протокол и защитаhttp3_max_field_section_size, http3_abort_rate, http3_abort_burst, http3_ctrl_rate, http3_ctrl_burstHTTP/3
HTTP/3: версии и 0-RTThttp3_version_2, http3_early_dataHTTP/3
HTTP/3: диагностикаhttp3_qlog_dir, http3_qlog_connectionsHTTP/3

Все они читаются один раз при загрузке конфигурации и перечитываются при перезагрузке. Неверный тип или значение вне диапазона — ошибка конфигурации, а не молчаливый откат к умолчанию: "http3_cc": "reno" или "http3_max_streams_uni": 1 останавливают запуск с сообщением, называющим ключ и допустимый диапазон.

metrics boolean ​

Включает счётчики конкурентности, блокировок, HTTP/2- и QUIC-статистики. По умолчанию false: выключенные счётчики не стоят ничего.

json
"env": { "metrics": true }

Флаг только собирает данные — отдавать их наружу должен обработчик приложения. В примере приложения это app/routes/bench/metrics.c, повешенный на маршрут:

json
"/metrics": {
    "GET": { "file": "<build>/exec/handlers/bench/lib_metrics.so", "function": "metrics" }
}

GET /metrics?reset=1 отдаёт снимок и обнуляет счётчики. Маршрут стоит закрывать middleware — снимок описывает внутреннее состояние процесса.

Ключи, которых нет ​

main.buffer_size встречается в старых примерах config.json и не читается сервером: размер буфера соединения фиксирован в коде. Ключ безвреден, но менять поведение им нельзя — его можно удалить.

Секция migrations ​

json
"migrations": {
    "source_directory": "<project>/app/migrations"
}

Секция не используется

Ни cwfr, ни migrate не читают migrations.source_directory. Утилита migrate принимает пути позиционными аргументами:

bash
./exec/migrate create add_users_table ../config.json ../app/migrations/s1
./exec/migrate up all ../config.json postgresql.p1 s1

Секцию можно оставить как документацию к проекту или удалить — на поведение это не влияет. Подробности — в разделе Миграции.

Секция translations ​

Список доменов локализации (i18n). Необязательная.

json
"translations": [
    { "domain": "backend", "path": "/app/locale" }
]
  • domain — имя текстового домена (обязательный, непустой)
  • path — каталог с .mo-файлами (обязательный, непустой)

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

Секция task_manager ​

Список фоновых задач, загружаемых из .so и исполняемых планировщиком. Необязательная; массив.

Общие поля каждой задачи — name, type, file, function — обязательны; поля расписания зависят от type.

typeПоля расписанияОписание
intervalintervalЗапуск каждые N секунд (≥ 1)
dailyhour, minuteЕжедневно в hh:mm
weeklyweekday, hour, minuteЕженедельно в указанный день недели
monthlyday, hour, minuteЕжемесячно в указанный день месяца

weekday принимает sunday, monday, tuesday, wednesday, thursday, friday, saturday; hour — 0–23, minute — 0–59, day — 1–31. Неизвестный type или значение вне диапазона — ошибка конфигурации.

json
"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:

json
"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 (скорость восполнения токенов в секунду). Каждый разрешённый запрос расходует один токен; бакет пополняется по мере течения времени, а не сбрасывается на границе секунды.

json
"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; неизвестное имя — ошибка конфигурации.

json
"middlewares": ["middleware_http_auth"]

Подробности — в разделе Middleware.

routes объект ​

Маршруты HTTP-запросов. Ключ — путь, значение — объект МЕТОД → { обработчик }.

Поддерживаемые методы: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS. Другие (CONNECT, TRACE) в маршрутах не объявляются.

json
"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}, …:

json
"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 объект ​

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

json
"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 — необязательны, но сам факт наличия секции значим.

json
"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. Все три поля обязательны, если секция присутствует; пустая строка не допускается.

json
"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 — цепочка сертификатов в PEM
  • private — приватный ключ в PEM
  • ciphers — список шифров: сначала наборы 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.

json
"http3": {
    "enabled": true,
    "port": 443,
    "alt_svc": true,
    "alt_svc_max_age": 86400
}
ПараметрТипПо умолчаниюОписание
enabledboolfalseВключает HTTP/3. Обязательно для работы h3
portчислоTCP-порт вхостаUDP-порт для QUIC (1–65535)
alt_svcbooltrueАнонсировать 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 ​

json
"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 ​

json
"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 ​

json
"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 ​

json
"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числонет5000PRAGMA busy_timeout, мс; неотрицательное

Подробности работы с БД — в разделе Базы данных.

Секция storages ​

Именованные файловые хранилища. Необязательная. Имя ключа — то, чем хранилище адресуется в коде и в sessions.storage_name. Тип задаётся полем type; неизвестный тип — ошибка конфигурации.

filesystem ​

json
"storages": {
    "local": {
        "type": "filesystem",
        "root": "/var/www/storage"
    }
}

root обязателен и не может быть пустым.

s3 ​

json
"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Обязательное поле храненияОписание
filesystemstorage_nameФайлы в указанном хранилище (имя из секции storages)
redishost_idRedis, адресуется как redis.<host_id>
databasehost_idБД, адресуется как <драйвер>.<host_id> (например, postgresql.p1)
json
"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 получателя и без подписи.

json
"mail": {
    "dkim_private": "/path/to/dkim_private.pem",
    "dkim_selector": "mail",
    "host": "example.com"
}
ПолеОписание
dkim_privateПуть к файлу приватного ключа. Файл читается при загрузке конфигурации — недоступный путь останавливает запуск
dkim_selectorDKIM-селектор (часть имени TXT-записи <selector>._domainkey.<host>)
hostДомен, от имени которого подписываются письма
relayОбъект: отправка через SMTP-релей вместо прямой доставки (см. ниже)

Каждое поле по отдельности необязательно, но если оно есть — это непустая строка. dkim_private и dkim_selector при этом задаются вместе: одно без другого — ошибка конфигурации. Если не задано ни одно, DKIM не применяется и письма уходят без подписи.

mail.relay ​

Наличие объекта relay переключает доставку с прямой на релейную: письмо передаётся заданному SMTP-серверу с аутентификацией, MX-записи получателя не запрашиваются.

json
"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нетstarttlsstarttls, tls (рукопожатие до баннера), none
userнет—Логин; без него AUTH не выполняется
passwordнет—Пароль. Не попадает в журнал
authнетautoauto, plain, login, none
timeoutнет30Таймаут операций сокета, секунды, 1..3600
verifyнетtrueПроверять сертификат и имя хоста релея

Сочетание security: "none" с user допустимо, но пишет в журнал предупреждение — учётные данные уйдут открытым текстом. Остальные нарушения останавливают запуск:

КонфигурацияСообщение
relay без hostmail.relay.host is required
"host": ""mail.relay.host must be not empty
user без passwordmail.relay.user without mail.relay.password
password без usermail.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": 70000mail.relay.port must be in range 1..65535
"timeout": 0mail.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-типов расширениям файлов: ключ — тип, значение — непустой массив расширений (без точки).

json
"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 и годится как отправная точка.

Полный пример конфигурации ​

json
{
    "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, а не в самой конфигурации.

Выпущено под лицензией MIT.