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

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 объект обязательный

Настройки логирования. Оба вложенных поля обязательны. Журнал идёт в syslog — читается через journalctl -t cwfr, а не из stdout.

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

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 (скорость восполнения токенов в секунду); окно — 1 секунда.

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

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

json
"redirects": {
    "/user": "/persons",
    "/user(.*)/(\\d)": "/user-{1}-{2}",
    "/section1/(\\d+)/section2/(\\d+)/section3": "/one/{1}/two/{2}/three"
}

Редиректы проверяются до маршрутов.

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

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

Каждое поле по отдельности необязательно, но если оно есть — это непустая строка. Подробности — в разделе Почта.

Секция 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",
        "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"]
    }
}

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