Файл конфигурации
Вся настройка приложения живёт в одном 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 | нет | DKIM-поля пустые, письма уходят без подписи |
migrations | нет | Ничего: секция рантаймом не читается |
Секция main
Обязательная. Все перечисленные ниже ключи, кроме env, обязательны — отсутствие любого из них останавливает запуск.
workers число обязательный
Количество воркеров, принимающих соединения и читающих/пишущих данные. Должно быть ≥ 1.
Воркеры — это потоки одного процесса, а не отдельные процессы. Поэтому всё, что описано как «на процесс» (бюджет памяти QUIC, кеш сжатых представлений, лимит QUIC-соединений), на их число не умножается.
threads число обязательный
Количество потоков, исполняющих обработчики и формирующих ответы. Должно быть ≥ 1. Отделены от воркеров намеренно: медленный обработчик не должен останавливать чтение сокетов.
reload soft | hard обязательный
Режим горячей перезагрузки (по SIGUSR1):
soft— перезагрузка с сохранением активных соединенийhard— перезагрузка с принудительным закрытием соединений
Значение по умолчанию в коде — soft, но ключ всё равно обязан присутствовать в файле.
client_max_body_size число обязательный
Максимальный размер тела запроса в байтах, ≥ 1.
Проверяется дважды и с разным исходом: заголовок Content-Length больше лимита — это 400 Bad Request ещё на разборе заголовков, тело не читается вовсе; тело, переросшее лимит по ходу приёма (chunked, HTTP/2, HTTP/3), — 413 Content Too Large. Тем же значением ограничены кадры WebSocket и ответы, которые принимает встроенный HTTP-клиент.
tmp строка обязательный
Каталог временных файлов: туда выгружаются большие тела запросов и загружаемые файлы. Без завершающего слэша — "/tmp/" считается ошибкой конфигурации, "/tmp" верно.
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 объект обязательный
Настройки логирования. Оба вложенных поля обязательны. Журнал идёт в syslog — читается через journalctl -t cwfr, а не из stdout.
"log": {
"enabled": true,
"level": "info"
}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— только важные события.
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 (скорость восполнения токенов в секунду); окно — 1 секунда.
"ratelimits": {
"one": { "burst": 1, "rate": 0 },
"strict":{ "burst": 15, "rate": 15 }
}Оба поля обязательны и должны быть целыми. rate: 0 означает бакет, который не восполняется: burst запросов, дальше отказ.
Сами профили ничего не ограничивают — их надо назначить: полем ratelimit в http, в websockets или у отдельного метода маршрута. Ссылка на несуществующее имя профиля — ошибка конфигурации.
http объект
Конфигурация HTTP-маршрутов, middleware и редиректов. Все четыре вложенных ключа необязательны.
ratelimit строка
Имя профиля rate limiting по умолчанию для всего HTTP-трафика вхоста.
middlewares массив строк
Список middleware, применяемых ко всем HTTP-маршрутам. Имена берутся из реестра приложения (app/middlewares/middlewarelist.c); неизвестное имя — ошибка конфигурации.
"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" }
},
"/api/users/{id|\\d+}": {
"PATCH": { "file": "handlers/models/lib_modeluser.so", "function": "update" }
}
}Путь может быть литеральным, содержать именованные параметры ({id|\d+}) или быть регулярным выражением — но не то и другое сразу. Подробности синтаксиса — в разделе Маршрутизация.
Поля обработчика:
file— путь до.soс обработчиком. Библиотека загружается при старте и переиспользуется всеми маршрутами, которые на неё ссылаютсяfunction— имя экспортируемой функции. Резолвится при старте; не нашлась — отказstatic_file— путь к статическому файлу. Если задан,file/functionне требуются и не вызываются: маршрут отдаёт файл. Путь всегда разрешается относительноroot— ведущий/отбрасывается, а не означает корень файловой системы. Поддерживается подстановка групп захвата{1},{2}, … из регулярного выражения маршрута (то же написание, что уredirects), поэтому одним маршрутом можно отдавать целый каталог:json"/assets/(.*)": { "GET": { "static_file": "/assets/{1}", "cache_control": "public, max-age=31536000, immutable" } }cache_control— заголовокCache-Controlдля того, чем отвечает маршрут: и дляstatic_file, и для обработчика. Без него каждый файловый ответ несётCache-Control: no-cache(перепроверка при каждом использовании) — безопасно, но заставляет клиента заново качать неизменяемые артефакты сборки. Ставьтеimmutable-кэширование на маршруты с файлами, в имени которых есть хеш содержимого, а страницы оставляйте на значении по умолчанию. Обработчик, выставивший свойCache-Control, сохраняет его — значение маршрута это умолчание, а не переопределение; а отсутствующийstatic_fileотвечает 404 без этого заголовкаratelimit— профиль rate limiting для этого метода этого маршрута, переопределяетhttp.ratelimit
Запрос, не совпавший ни с одним маршрутом, обслуживается как статика из root.
redirects объект
Правила редиректов. Ключ — путь или регулярное выражение, значение — цель; группы захвата подставляются через {1}, {2}, …:
"redirects": {
"/user": "/persons",
"/user(.*)/(\\d)": "/user-{1}-{2}",
"/section1/(\\d+)/section2/(\\d+)/section3": "/one/{1}/two/{2}/three"
}Редиректы проверяются до маршрутов.
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 с DKIM-подписью. Необязательная; отсутствие секции равносильно пустым значениям — письма уходят без подписи.
"mail": {
"dkim_private": "/path/to/dkim_private.pem",
"dkim_selector": "mail",
"host": "example.com"
}| Поле | Описание |
|---|---|
dkim_private | Путь к файлу приватного ключа. Файл читается при загрузке конфигурации — недоступный путь останавливает запуск |
dkim_selector | DKIM-селектор (часть имени TXT-записи <selector>._domainkey.<host>) |
host | Домен, от имени которого подписываются письма |
Каждое поле по отдельности необязательно, но если оно есть — это непустая строка. Подробности — в разделе Почта.
Секция 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",
"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" },
"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
}
},
"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": 15, "rate": 15 },
"strict": { "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" },
"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"
}
},
"/robots.txt": {
"GET": { "static_file": "/robots.txt" }
}
},
"redirects": {
"/old": "/new",
"/user(.*)/(\\d)": "/user-{1}-{2}"
}
},
"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"]
}
}