Привет, Хабр! Я full-cycle разработчик, работаю с Rust/Zig/Go и увлекаюсь APL как особым способом мышления о коде. На самом деле я пишу на большом количестве языков, но сегодня не об этом. Хочу рассказать об опыте миграции критической бизнес-логики с PHP на Zig и интеграции её в бинарник.
Контекст проекта

По работе мне досталась биллинг-панель для управления VPN-аккаунтами на базе OpenConnect (ocserv). Изначально система была реализована на PHP и представляла собой типичный legacy-код:
Отсутствие внятной архитектуры (фактически — организованный хаос)
Множество разрозненных скриптов
Проблемы с производительностью и стабильностью

Первый этап модернизации: переход на Go
В рамках ограниченного бюджета я провел частичный рефакторинг:
-
Вынес критичные компоненты в отдельные сервисы на Go:
Проверка срока действия подписок
Учет трафика пользователей
Фоновые задачи обслуживания
-
Что это дало:
10-кратный рост производительности ключевых операций
Снижение потребления памяти в 5-7 раз
Возможность работы в режиме 24/7 без "проседаний"

Сейчас об этом говорить не будем, но если вам интересна тема превращения тыквы в карету — дайте знать в комментариях, могу описать весь процесс трансформации в деталях и по шагам.
Проблема баннерных сообщений
Ограничение ocserv: поддерживает только одно статическое сообщение для всех пользователей. В реальности же требовалась:
Персонализация уведомлений
Разные сообщения для разных групп пользователей
Динамический контент (например, информация об остатке дней подписки)
Решение: гибкая система баннеров
Исходное PHP-решение
Работало через костыли:
Требует php-fpm и было связанно с PDO в биллинг панели
Нужно следить за правами на файлы и сокет
Переодически подвисало
Могло не сработать из-за плохой погоды за окном
Я бы сказал, что это хорошее решение для стадии прототипа — когда нужно проверить логику, но не в продакшене.
Новый подход на Zig
Я реализовал:
-
Статическая библиотека (на Zig):
Интеграция напрямую с базой данных
Прямая интеграция в ocserv
-
Преимущества нового решения:
Время отклика: 3-5 мс вместо 50-100 мс
Никаких лишних файлов
Никаких сбоев авторизации из-за баннера
Нагрузка на сервер снизилась на 70% (здесь конечно с учётом и переноса логики на go)
Количество инцидентов уменьшилось на 100%
Конечно, такую простую логику можно было написать и на C, но это только первый шаг в оптимизации дистрибутива системы. Дальше планируется переписать решение в виде плагина, а так же перенести другие нагруженные части системы в модули ocserv. Например:
Проверку срока действия подписок
Учёт трафика пользователей и их сессий
И т.д.
Исходный код компонента и его логика
Сейчас вы поймёте, почему это критическая бизнес-логика:
Во первых уведомления сами по себе несут исключительно важную информацию, вроде: —Ваша подписка истекает через 2 дня и 12 часов
Во вторых, в оригинальном решении малейший сбой в скрипте запроса (или банально неправильные права на файл или сокет) и пользователь не сможет подключиться — нужно ли уточнять, что это урон для бизнеса?

Исходный php-код модуля:
Скрытый текст
<?php
if (!true) die("-------- Test banner --------");
$username = $_SERVER["USERNAME"];
$site = '/var/www/html/panel';
// Подключение функций
chdir($site);
require_once('include/functions.php');
// Глобальное сообщение (может быть задано или оставлено пустым)
$orig_gl_message = $db->query("SELECT * FROM {{table}} LIMIT 1;","global_message","assoc");
if (isset($orig_gl_message) && $orig_gl_message['status'] == true)
$GLOBAL_MESSAGE = $orig_gl_message['message_text'];
// Определение групп пользователей.
$GROUPS = getGroupsWithUsers();
// Сообщения для групп
$GROUP_MESSAGES = getGroupMessages();
// Индивидуальные сообщения для пользователей
$INDIVIDUAL_MESSAGES = getIndividualMessages();
// Функция для получения сообщения по группе
function getGroupMessage($username, $groups, $groupMessages) {
foreach ($groups as $group => $users) {
if (in_array($username, $users)) {
return $groupMessages[$group]; // Возвращаем сообщение для группы
}
}
return null; // Если пользователь не принадлежит ни к одной группе
}
// Функция для получения индивидуального сообщения
function getIndividualMessage($username, $individualMessages) {
return $individualMessages[$username] ?? null; // Возвращаем индивидуальное сообщение, если оно есть
}
// Получаем сообщение для текущего пользователя
$groupMessage = getGroupMessage($username, $GROUPS, $GROUP_MESSAGES);
$individualMessage = getIndividualMessage($username, $INDIVIDUAL_MESSAGES);
$banner = "";
// Безопасное получение данных пользователя
$_username = addcslashes($username, "\\'");
$return = $db->query("SELECT * FROM {{table}} WHERE login='{$_username}';", "users", "assoc");
// 1й приоритет: Глобальное сообщение
if (!empty($GLOBAL_MESSAGE)) {
$banner = $GLOBAL_MESSAGE;
}
// 2й приоритет: Сообщение для группы
elseif ($groupMessage) {
$banner = $groupMessage;
}
// 3й приоритет: Индивидуальное сообщение
elseif ($individualMessage) {
$banner = $individualMessage;
}
// 4й приоритет: Срок ключа сообщение
elseif (isset($return["expire"]) && is_string($return["expire"])) {
$expire = @json_decode($return["expire"], true);
if(is_array($expire)) {
$n = 60*60*24;
$expire_timestamp = $expire["end"]; // Сохраняем timestamp окончания
$expire_start_timestamp = $expire["start"]; // Сохраняем timestamp начала
$expire_seconds = $expire_timestamp - time();
//$days = ($expire_seconds-$expire_seconds%$n)/$n; // Не полный день считается как полный (n + 1)
$days = floor($expire_seconds / $n); // Округляем вниз до целого числа дней
$hours = floor(($expire_seconds % $n) / (60 * 60)); // Вычисляем часы из остатка
$start_date = date('d.m.Y', $expire_start_timestamp); // Получаем дату начала
$today = date('d-m-Y'); // Получаем текущую дату
$expire_date = date('d.m.Y', $expire_timestamp); // Получаем дату окончания
if ($days >= 30 && $days < 31 ) {
$messages = [
"Поздравляем! Вы выбрали лучшее. Ваш новый тариф уже активен с {$start_date} года до {$expire_date} года. Наслаждайтесь безопасным интернетом!",
"Sizi gutlaýarys! Iň gowy tarifi saýladyňyz. Täze tarifiňiz {$start_date} -- {$expire_date} senesi işjeňdir. Howpsuz we çalt internetden lezzet alyň!"
];
$banner .= $messages[array_rand($messages)];
} elseif ($days >= 6 && $days < 7) {
$messages = [
"Уведомляем вас, что ваш тарифный план активен в период с {$start_date} по {$expire_date}. Желаем хорошего пользования!",
"Size {$start_date} -- {$expire_date} seneleri aralygynda tarif planynyzyň işjeňdigi barada habar berýäris. Ulanyşyňyz rahat bolsun!"
];
$banner .= $messages[array_rand($messages)];
} elseif ($days >= 1 && $days < 2) {
$messages = [
"Здравствуйте! Срок действия вашего VPN-ключа истекает {$expire_date}года. Осталось дней: {$days} и часов: {$hours}.",
"Hormatly agzamyz! Sizin VPN açaryňyzyň möhleti {$expire_date} senesinde gutarýar. Möhletine {$days} gün we {$hours} sagat galdy."
];
$banner .= $messages[array_rand($messages)];
}elseif($days >= 0 && $days < 1) {
$messages = [
"Внимание! Осталось часов: {$hours} до истечения срока действия вашего VPN-ключа. Период действия: с {$start_date}г по {$expire_date}г.",
"Üns beriň! VPN açaryňyzyň möhletiniň gutarmagyna {$hours} sagat galdy. Möhleti: {$start_date}-den {$expire_date}-e çenli."
];
$banner .= $messages[array_rand($messages)];
}elseif($days >= -3 && $days <= 0) {
$messages = [
"К сожалению, в настоящий момент ваш аккаунт заблокирован из-за нулевого баланса.",
"Bagyşlaň, häzirki wagtda balansyňyz nol bolany üçin hasabyňyz bloklanyldy. "
];
$banner .= $messages[array_rand($messages)];
}
}
}
echo($banner);
exit(0);
Как это было реализованно в плане интеграции php в ocserv
В папку с исходными кодами ocserv, добавляется C-хидер fpm-client.h:
Скрытый текст
#include <error.h>
#include <errno.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fastcgi.h>
int fpm_client(char *fpm_socket_file_path, char *result, char *username)
{
int ret, len;
int unix_stream_socket;
unix_stream_socket = socket(AF_UNIX, SOCK_STREAM, 0);
if(unix_stream_socket < 0) {
error(0, errno, "Can't create unix socket stream");
return(-1);
}
struct sockaddr_un addr;
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
len = strlen(fpm_socket_file_path);
memcpy(addr.sun_path, fpm_socket_file_path, len+1);
ret = connect(unix_stream_socket, (const struct sockaddr *) &addr, sizeof(addr));
if(ret < 0){
error(0, errno, "Can't connect to unix socket stream");
return(-1);
}
FCGI_Header header;
header.version = FCGI_VERSION_1;
header.requestIdB1 = 0;
header.requestIdB0 = 1;
header.paddingLength = 0;
// FCGI_BEGIN_REQUEST
header.type = FCGI_BEGIN_REQUEST;
FCGI_BeginRequestBody begin_request_body;
begin_request_body.roleB1 = 0;
begin_request_body.roleB0 = FCGI_RESPONDER;
begin_request_body.flags = 0;
len = sizeof(begin_request_body);
header.contentLengthB1 = 0;
header.contentLengthB0 = len;
ret = write(unix_stream_socket, &header, sizeof(header));
if(ret < 0) {
error(0, errno, "Can't write `begin request header`");
return(-1);
}
ret = write(unix_stream_socket, &begin_request_body, len);
if(ret < 0) {
error(0, errno, "Can't write `begin request body`");
return(-1);
}
// FCGI_PARAMS
header.type = FCGI_PARAMS;
char *params[] = {
"SCRIPT_FILENAME", "/etc/ocserv/banner.php",
"REQUEST_METHOD", "GET",
"USERNAME", "user"
};
params[5] = username;
int i, c, nl, vl;
char buffer[65536];
c = sizeof(params)/sizeof(params[0]);
len = 0;
for(i = 0; i < c; i += 2) {
nl = strlen(params[i]);
vl = strlen(params[i+1]);
memcpy(buffer+len, &nl, 1);
len += 1;
memcpy(buffer+len, &vl, 1);
len += 1;
memcpy(buffer+len, params[i], nl);
len += nl;
memcpy(buffer+len, params[i+1], vl);
len += vl;
}
header.contentLengthB1 = (len >>8) & 0xff;
header.contentLengthB0 = len & 0xff;
ret = write(unix_stream_socket, &header, sizeof(header));
if(ret < 0) {
error(0, errno, "Can't write `params request header`");
return(-1);
}
ret = write(unix_stream_socket, buffer, len);
if(ret < 0) {
error(0, errno, "Can't write `params request body`");
return(-1);
}
header.contentLengthB1 = 0;
header.contentLengthB0 = 0;
ret = write(unix_stream_socket, &header, sizeof(header));
if(ret < 0) {
error(0, errno, "Can't write `empty params request header`");
return(-1);
}
// FCGI_STDOUT
ret = read(unix_stream_socket, &header, 8);
if(ret < 0) {
error(0, errno, "Can't read stdout response header");
return(-1);
}
len = 0;
len = header.contentLengthB1 << 8;
len = len | header.contentLengthB0;
memset(buffer, 0, 65536);
ret = read(unix_stream_socket, buffer, len);
if(ret < 0) {
error(0, errno, "Can't read stdout response body");
return(-1);
}
//write(1, buffer, len);
char *offset;
offset = strstr(buffer, "\r\n\r\n");
offset += 4;
//printf("\n%s\n", offset);
len = len-(offset-buffer);
memcpy(result, offset, len);
ret = close(unix_stream_socket);
if(ret < 0) {
error(0, errno, "Can't close unix socket stream");
return(-1);
}
return(0);
}
Далее вносятся правки в файл worker-auth.c:
Скрытый текст
Накладывается следующий патч (здесь для версии ocserv 0.12.6, в других версия положение может отличаться):
46a47,48
> #include "fpm_client.h"
>
995a998,1021
>
char *fpm_socket_file_path = "/var/run/php/php-fpm.sock\0";
char banner[256], username[32];
memset(banner, 0, 256);
memset(username, 0, 32);
int len;
len = strlen(ws->username);
memcpy(username, ws->username, len);
ret = fpm_client(fpm_socket_file_path, banner, username);
/*
int fd = open("/tmp/ocserv.log", O_WRONLY);
char str[256];
sprintf(str, "\nret = %d, banner = %s", ret, banner);
len = strlen(str);
write(fd, str, len);
close(fd);
*/
len = strlen(banner);
memcpy(WSCONFIG(ws)->banner, banner, len+1);
>
Этот патч вкатывается в функцию int post_common_handler(worker_st ws, unsigned http_ver, const char imsg)
Перед фрагментом
if (WSCONFIG(ws)->banner) {
size =
snprintf(msg, sizeof(msg), "<banner>%s</banner>",
WSCONFIG(ws)->banner);
if (size <= 0)
goto fail;
/* snprintf() returns not a very useful value, so we need to recalculate */
size = strlen(msg);
} else {
msg[0] = 0;
size = 0;
}
Дальше остаётся только собрать ocserv, на выходе получаем ocserv и ocserv-worker которые будут связаны с php-fpm для вызова скрипта баннера.
Сейчас мы подходим к основному нарративу статьи: как вызвать Zig-код из C. В гугле полно статей о том, как вызвать C из Zig, чего не скажешь об обратном процессе. Это и побудило меня написать статью.
Новое решение на Zig: логика и исходный код
Так же здесь я добавил дополнительную функцию: проверка на соответствие IP адерса назначеному прокси-серверу.
Подключение для некоторых клиентов разрешается только через прокси-прослойку, и в случае если клиент подключится не через свой прокси или в обход него — его нужно отключить и вывести баннер, о том что такой способ подключения для него запрещён. Отключение было реализовано в status-daemon и успешно работало, а вот баннер не выводился, потому что изначальная версия не передавала в скрипт информацию об IP адресе подключения.
В новом решении я добавил этот баннер, а контроль за отключением оставил как есть — зачем трогать то, что и так хорошо работает?
Само решение делалось в 3 итерации, соответственно в сборочном файле вышло 2 конфигурации сборки:
Сборка обычного бинарника для тестирования логики
Превращения бинарника в статическую библиотеку
Написание простеньких тестов
Для работы с PostgreSQL выбрал pg.zig, нативный драйвер.
var pool = try pg.Pool.init(allocator, .{
.size = 5,
.connect = .{.host = config.host, .port = config.port},
.auth = .{
.username = config.username,
.password = config.password,
.database = config.database,
},
});
На Хабре нет подсветки Zig, пришлось поставить C++ чтобы хоть как-то подсветить синтаксис.
На всякий случай сделал возможность настройки через toml-файл, по большему счёту я использовал это в процессе тестирования.
host = ""
port = 5432
username = ""
password = ""
database = ""
pool_size = 5
timeout_ms = 10000
Поскольку toml-конфигурация у меня крайне примитивная, ограничился самописным парсером.
const Config = struct {
host: []const u8 = "localhost",
port: u16 = 5432,
// ...
};
fn parseToml(allocator: std.mem.Allocator, data: []const u8) !Config {
// Простой ручной парсинг
}
Логика баннеров: прямой порт с PHP, но без динамической типизации.
fn getBanner(allocator: std.mem.Allocator, pool: *Pool, login: []const u8) !?Banner {
// 1. Проверка глобальных сообщений
// 2. Поиск в группах
// 3. Персональные уведомления
// 4. Проверка срока действия
}
Ключевые моменты в переходе с PHP
Работа с памятью
PHP:
// Всё просто — выделили и забыли
$message = loadMessageFromDB();
Zig:
// Явное управление памятью
const message = try allocator.dupe(u8, db_message);
defer allocator.free(message);
Использовал стратегию:
Арена-аллокатор для краткосрочных объектов
Общий пул для долгоживущих структур
Детальные проверки в тестах
Интеграция с PostgreSQL
Вместо привычного PDO:
var result = try pool.query(
\\SELECT * FROM messages WHERE active = true
, .{});
defer result.deinit();
while (try result.next()) |row| {
const id = row.get(i32, 0);
// ...
}
Реализовал:
Пуллинг соединений
Батчинг запросов
Zero-copy парсинг результатов
Обработка ошибок
const maybe_message = getBannerMessage(allocator, pool, login) catch |err| {
std.log.err("Failed to get banner message: {}", .{err});
return false;
};
На каждый случай свой обработчик, ошибки возвращаются в ocserv и попадают в его лог.
Исходный код модуля
Полный код первой итерации модуля с комментариями, в виде исполняемой программы:
main.zig
const std = @import("std");
const pg = @import("pg");
// Конфигурация подключения к БД
const Config = struct {
host: []const u8 = "127.0.0.1",
port: u16 = 5432,
username: []const u8 = "postgres",
password: []const u8 = "postgres",
database: []const u8 = "postgres",
pool_size: u16 = 5,
timeout_ms: u32 = 10_000,
};
// Приоритеты баннерных сообщений
const MessagePriority = enum {
global,
group,
individual,
expire,
};
// Структура баннерного сообщения
const BannerMessage = struct {
text: []const u8, // Текст сообщения (выделяется в куче)
priority: MessagePriority, // Приоритет сообщения
};
// Данные о сроке действия
const ExpireData = struct {
start_timestamp: i64, // Временная метка начала
end_timestamp: i64, // Временная метка окончания
start_date_str: []const u8, // Форматированная дата начала (выделяется в куче)
end_date_str: []const u8, // Форматированная дата окончания (выделяется в куче)
};
/// Парсинг TOML конфига (примитивная реализация)
/// Выделяет память под строковые значения конфига
/// Возвращает ошибку при некорректных числовых значениях
fn parseToml(allocator: std.mem.Allocator, data: []const u8) !Config {
var config = Config{};
var lines = std.mem.tokenizeSequence(u8, data, "\n");
while (lines.next()) |line| {
const trimmed = std.mem.trim(u8, line, " \t");
// Пропускаем пустые строки и комментарии
if (trimmed.len == 0 or trimmed[0] == '#') continue;
if (std.mem.indexOf(u8, trimmed, "=")) |eq_pos| {
const key = std.mem.trim(u8, trimmed[0..eq_pos], " \t\"'");
const value = std.mem.trim(u8, trimmed[eq_pos + 1 ..], " \t\"'");
// Обрабатываем каждое поле конфига
if (std.mem.eql(u8, key, "host")) {
// Выделяем память под строку хоста
config.host = try allocator.dupe(u8, value);
} else if (std.mem.eql(u8, key, "port")) {
// Парсим числовое значение порта
config.port = try std.fmt.parseInt(u16, value, 10);
} else if (std.mem.eql(u8, key, "username")) {
// Выделяем память под имя пользователя
config.username = try allocator.dupe(u8, value);
} else if (std.mem.eql(u8, key, "password")) {
// Выделяем память под пароль
config.password = try allocator.dupe(u8, value);
} else if (std.mem.eql(u8, key, "database")) {
// Выделяем память под имя БД
config.database = try allocator.dupe(u8, value);
} else if (std.mem.eql(u8, key, "pool_size")) {
// Парсим размер пула соединений
config.pool_size = try std.fmt.parseInt(u16, value, 10);
} else if (std.mem.eql(u8, key, "timeout_ms")) {
// Парсим таймаут
config.timeout_ms = try std.fmt.parseInt(u32, value, 10);
}
}
}
return config;
}
/// Получение баннерного сообщения для пользователя
/// Выделяет память под текст сообщения и временные данные
/// Важно: вызывающая сторона должна освобождать память text в возвращаемом BannerMessage
fn getBannerMessage(
allocator: std.mem.Allocator,
pool: *pg.Pool,
login: []const u8,
) !?BannerMessage {
// 1. Проверяем глобальное сообщение
{
// Выполняем запрос к БД (память управляется пулом)
var global_result = try pool.query(
\\SELECT message_text FROM global_message WHERE status = true LIMIT 1
, .{});
defer global_result.deinit(); // Гарантированное освобождение ресурсов запроса
if (try global_result.next()) |row| {
const message = row.get([]const u8, 0);
// Выделяем память под копию сообщения
return BannerMessage{
.text = try allocator.dupe(u8, message),
.priority = MessagePriority.global,
};
}
}
// 2. Получаем группы и сообщения
// Выделяем память под структуру групп (нужно освобождать)
var groups = try getGroupsWithUsers(allocator, pool);
defer {
// Ручное освобождение сложной структуры данных
var it = groups.iterator();
while (it.next()) |entry| {
// Освобождаем каждого пользователя в группе
for (entry.value_ptr.items) |user| {
allocator.free(user);
}
entry.value_ptr.deinit(); // Освобождаем ArrayList
allocator.free(entry.key_ptr.*); // Освобождаем название группы
}
groups.deinit(); // Освобождаем хеш-мап
}
// Получаем сообщения для групп (нужно освобождать)
var group_messages = try getGroupMessages(allocator, pool);
defer {
// Освобождаем хеш-мап с сообщениями групп
var it = group_messages.iterator();
while (it.next()) |entry| {
allocator.free(entry.key_ptr.*); // Название группы
allocator.free(entry.value_ptr.*); // Текст сообщения
}
group_messages.deinit();
}
// Проверяем сообщение группы
if (findGroupForUser(login, &groups)) |group_name| {
if (group_messages.get(group_name)) |message| {
// Выделяем память под копию сообщения
return BannerMessage{
.text = try allocator.dupe(u8, message),
.priority = MessagePriority.group,
};
}
}
// 3. Проверяем индивидуальные сообщения (нужно освобождать)
var individual_messages = try getIndividualMessages(allocator, pool);
defer {
// Освобождаем хеш-мап с индивидуальными сообщениями
var it = individual_messages.iterator();
while (it.next()) |entry| {
allocator.free(entry.key_ptr.*); // Логин пользователя
allocator.free(entry.value_ptr.*); // Текст сообщения
}
individual_messages.deinit();
}
if (individual_messages.get(login)) |message| {
// Выделяем память под копию сообщения
return BannerMessage{
.text = try allocator.dupe(u8, message),
.priority = MessagePriority.individual,
};
}
// 4. Проверяем срок действия ключа
{
var user_result = try pool.query(
\\SELECT expire FROM users WHERE login = $1 LIMIT 1
, .{login});
defer user_result.deinit(); // Гарантированное освобождение
if (try user_result.next()) |row| {
const expire_json = row.get(?[]const u8, 0);
if (expire_json) |json| {
const now = std.time.timestamp();
// Парсим JSON (выделяет память под строки дат)
const expire = try parseExpireJson(allocator, json);
defer {
// Освобождаем временные строки дат
allocator.free(expire.start_date_str);
allocator.free(expire.end_date_str);
}
// Рассчитываем оставшееся время
const seconds_left = expire.end_timestamp - now;
const days_left = @divTrunc(seconds_left, 86400);
const hours_left = @divTrunc(@mod(seconds_left, 86400), 3600);
// Формируем сообщения в зависимости от оставшегося времени
if (days_left >= 30 and days_left < 31) {
const msg1 = try std.fmt.allocPrint(allocator, "Поздравляем! Вы выбрали лучшее. Ваш новый тариф уже активен с {s} года до {s} года. Наслаждайтесь безопасным интернетом!", .{ expire.start_date_str, expire.end_date_str });
defer allocator.free(msg1);
const msg2 = try std.fmt.allocPrint(allocator, "Sizi gutlaýarys! Iň gowy tarifi saýladyňyz. Täze tarifiňiz {s} -- {s} senesi işjeňdir. Howpsuz we çalt internetden lezzet alyň!", .{ expire.start_date_str, expire.end_date_str });
defer allocator.free(msg2);
const chosen = if (std.crypto.random.int(u1) == 0) msg1 else msg2;
return BannerMessage{
.text = try allocator.dupe(u8, chosen),
.priority = MessagePriority.expire,
};
} else if (days_left >= 6 and days_left < 7) {
const msg1 = try std.fmt.allocPrint(allocator, "Уведомляем вас, что ваш тарифный план активен в период с {s} по {s}. Желаем хорошего пользования!", .{ expire.start_date_str, expire.end_date_str });
defer allocator.free(msg1);
const msg2 = try std.fmt.allocPrint(allocator, "Size {s} -- {s} seneleri aralygynda tarif planynyzyň işjeňdigi barada habar berýäris. Ulanyşyňyz rahat bolsun!", .{ expire.start_date_str, expire.end_date_str });
defer allocator.free(msg2);
const chosen = if (std.crypto.random.int(u1) == 0) msg1 else msg2;
return BannerMessage{
.text = try allocator.dupe(u8, chosen),
.priority = MessagePriority.expire,
};
} else if (days_left >= 1 and days_left < 2) {
const msg1 = try std.fmt.allocPrint(allocator, "Здравствуйте! Срок действия вашего VPN-ключа истекает {s} года. Осталось дней: {} и часов: {}.", .{ expire.end_date_str, days_left, hours_left });
defer allocator.free(msg1);
const msg2 = try std.fmt.allocPrint(allocator, "Hormatly agzamyz! Sizin VPN açaryňyzyň möhleti {s} senesinde gutarýar. Möhletine {} gün we {} sagat galdy.", .{ expire.end_date_str, days_left, hours_left });
defer allocator.free(msg2);
const chosen = if (std.crypto.random.int(u1) == 0) msg1 else msg2;
return BannerMessage{
.text = try allocator.dupe(u8, chosen),
.priority = MessagePriority.expire,
};
} else if (days_left >= 0 and days_left < 1) {
const msg1 = try std.fmt.allocPrint(allocator, "Внимание! Осталось часов: {} до истечения срока действия вашего VPN-ключа. Период действия: с {s}г по {s}г.", .{ hours_left, expire.start_date_str, expire.end_date_str });
defer allocator.free(msg1);
const msg2 = try std.fmt.allocPrint(allocator, "Üns beriň! VPN açaryňyzyň möhletiniň gutarmagyna {} sagat galdy. Möhleti: {s}-den {s}-e çenli.", .{ hours_left, expire.start_date_str, expire.end_date_str });
defer allocator.free(msg2);
const chosen = if (std.crypto.random.int(u1) == 0) msg1 else msg2;
return BannerMessage{
.text = try allocator.dupe(u8, chosen),
.priority = MessagePriority.expire,
};
} else if (days_left >= -3 and days_left < 0) {
const msg1: []const u8 = "К сожалению, в настоящий момент ваш аккаунт заблокирован из-за нулевого баланса.";
const msg2: []const u8 = "Bagyşlaň, häzirki wagtda balansyňyz nol bolany üçin hasabyňyz bloklanyldy.";
const chosen = if (std.crypto.random.int(u1) == 0) msg1 else msg2;
return BannerMessage{
.text = try allocator.dupe(u8, chosen),
.priority = MessagePriority.expire,
};
}
}
}
}
return null; // Сообщение не найдено
}
/// Парсинг JSON с датами истечения
/// Выделяет память под строки дат
fn parseExpireJson(allocator: std.mem.Allocator, json_str: []const u8) !ExpireData {
// Парсим JSON (использует временную память из аллокатора)
const parsed = try std.json.parseFromSlice(struct {
start: i64,
end: i64,
}, allocator, json_str, .{});
defer parsed.deinit(); // Освобождаем ресурсы парсера
const start_timestamp = parsed.value.start;
const end_timestamp = parsed.value.end;
// Форматируем даты (выделяем память под строки)
const start_date_str = try formatTimestamp(allocator, start_timestamp);
const end_date_str = try formatTimestamp(allocator, end_timestamp);
return ExpireData{
.start_timestamp = start_timestamp,
.end_timestamp = end_timestamp,
.start_date_str = start_date_str,
.end_date_str = end_date_str,
};
}
/// Форматирование временной метки в строку
/// Выделяет память под результирующую строку
fn formatTimestamp(allocator: std.mem.Allocator, timestamp: i64) ![]const u8 {
const epoch_seconds = std.time.epoch.EpochSeconds{ .secs = @intCast(timestamp) };
const epoch_day = epoch_seconds.getEpochDay();
const year_day = epoch_day.calculateYearDay();
const month_day = year_day.calculateMonthDay();
var buffer: [32]u8 = undefined;
const formatted = try std.fmt.bufPrint(&buffer, "{d:0>2}.{d:0>2}.{d}", .{
month_day.day_index + 1,
@intFromEnum(month_day.month) + 1,
year_day.year,
});
// Возвращаем копию строки в куче
return allocator.dupe(u8, formatted);
}
/// Поиск группы для пользователя
/// Не выделяет новую память, только ищет по существующим данным
fn findGroupForUser(login: []const u8, groups: *std.StringArrayHashMap(std.ArrayList([]const u8))) ?[]const u8 {
var it = groups.iterator();
while (it.next()) |entry| {
for (entry.value_ptr.items) |user| {
if (std.mem.eql(u8, user, login)) {
return entry.key_ptr.*; // Возвращаем существующую строку
}
}
}
return null;
}
/// Получение групп с пользователями
/// Выделяет память под структуры данных и строки
fn getGroupsWithUsers(allocator: std.mem.Allocator, pool: *pg.Pool) !std.StringArrayHashMap(std.ArrayList([]const u8)) {
var groups = std.StringArrayHashMap(std.ArrayList([]const u8)).init(allocator);
var result = try pool.query(
\\SELECT g.name AS group_name, u.login AS user_login
\\FROM groups g
\\JOIN group_users gu ON g.id = gu.group_id
\\JOIN users u ON gu.user_id = u.id
\\WHERE g.message_status = true
\\ORDER BY g.name, u.login
, .{});
defer result.deinit(); // Гарантированное освобождение
while (try result.next()) |row| {
const group_name = row.get([]const u8, 0);
const user_login = row.get([]const u8, 1);
// Выделяем память под копии строк
const owned_group_name = try allocator.dupe(u8, group_name);
const owned_user_login = try allocator.dupe(u8, user_login);
if (groups.getPtr(owned_group_name)) |users_list| {
// Добавляем пользователя в существующую группу
try users_list.append(owned_user_login);
} else {
// Создаем новую группу с пользователем
var users = std.ArrayList([]const u8).init(allocator);
try users.append(owned_user_login);
try groups.put(owned_group_name, users);
}
}
return groups;
}
/// Получение сообщений для групп
/// Выделяет память под структуры данных и строки
fn getGroupMessages(allocator: std.mem.Allocator, pool: *pg.Pool) !std.StringArrayHashMap([]const u8) {
var messages = std.StringArrayHashMap([]const u8).init(allocator);
var result = try pool.query(
\\SELECT g.name AS group_name, gm.message_text AS message_text
\\FROM group_messages gm
\\JOIN groups g ON gm.group_id = g.id
\\WHERE g.message_status = true
\\ORDER BY g.name
, .{});
defer result.deinit();
while (try result.next()) |row| {
const group_name = row.get([]const u8, 0);
const message_text = row.get([]const u8, 1);
// Выделяем память под копии строк
const owned_group_name = try allocator.dupe(u8, group_name);
const owned_message = try allocator.dupe(u8, message_text);
try messages.put(owned_group_name, owned_message);
}
return messages;
}
/// Получение индивидуальных сообщений
/// Выделяет память под структуры данных и строки
fn getIndividualMessages(allocator: std.mem.Allocator, pool: *pg.Pool) !std.StringArrayHashMap([]const u8) {
var messages = std.StringArrayHashMap([]const u8).init(allocator);
var result = try pool.query(
\\SELECT u.login AS user_login, um.message_text AS message_text
\\FROM user_messages um
\\JOIN users u ON um.user_id = u.id
\\WHERE u.accept_messages = true
\\ORDER BY u.login
, .{});
defer result.deinit();
while (try result.next()) |row| {
const user_login = row.get([]const u8, 0);
const message_text = row.get([]const u8, 1);
// Выделяем память под копии строк
const owned_login = try allocator.dupe(u8, user_login);
const owned_message = try allocator.dupe(u8, message_text);
try messages.put(owned_login, owned_message);
}
return messages;
}
pub fn main() !void {
// Инициализация аллокатора (освобождается при выходе)
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit(); // Проверка утечек при выходе
const allocator = gpa.allocator();
// Чтение аргументов командной строки (нужно освобождать)
const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);
if (args.len < 2) {
std.log.err("Usage: {s} <login> [config_path]", .{args[0]});
return error.MissingArguments;
}
const login = args[1];
const config_path = if (args.len > 2) args[2] else "config.toml";
// Чтение конфига (выделяет память под содержимое файла)
const config_file = try std.fs.cwd().readFileAlloc(allocator, config_path, 1 << 20);
defer allocator.free(config_file); // Освобождаем после использования
// Парсинг конфига (выделяет память под строки)
const config = try parseToml(allocator, config_file);
defer {
// Освобождаем все строки конфига
allocator.free(config.host);
allocator.free(config.username);
allocator.free(config.password);
allocator.free(config.database);
}
// Инициализация пула соединений (нужно освобождать)
var pool = try pg.Pool.init(allocator, .{
.size = config.pool_size,
.connect = .{
.port = config.port,
.host = config.host,
},
.auth = .{
.username = config.username,
.database = config.database,
.password = config.password,
.timeout = config.timeout_ms,
},
});
defer pool.deinit(); // Закрываем все соединения при выходе
// Проверка существования пользователя
var user_result = try pool.query(
\\SELECT active, to_rm, proxy_server_id
\\FROM users
\\WHERE login = $1
\\LIMIT 1
, .{login});
defer user_result.deinit(); // Гарантированное освобождение
if (try user_result.next()) |row| {
const active = row.get(bool, 0);
const to_rm = row.get(bool, 1);
const proxy_server_id = row.get(?i32, 2);
std.log.info("User found: active={}, to_rm={}", .{ active, to_rm });
// Проверка прокси-сервера
if (proxy_server_id) |proxy_id| {
std.log.info("User has proxy server with id={}", .{proxy_id});
var proxy_result = try pool.query(
\\SELECT host(ip) as ip_text
\\FROM proxy_servers
\\WHERE id = $1 and strong = true
\\LIMIT 1
, .{proxy_id});
defer proxy_result.deinit();
if (try proxy_result.next()) |proxy_row| {
const ip = proxy_row.get([]const u8, 0);
std.log.info("Proxy server IP: {s}", .{ip});
} else {
std.log.warn("Proxy server with id={} not found", .{proxy_id});
}
} else {
std.log.info("User has no proxy server", .{});
}
// Получаем баннерное сообщение (нужно освобождать!)
if (try getBannerMessage(allocator, pool, login)) |banner| {
defer allocator.free(banner.text); // Освобождаем текст сообщения
std.log.info("Banner message (priority: {}): {s}", .{ banner.priority, banner.text });
} else {
std.log.info("No banner message for user", .{});
}
} else {
std.log.err("User with login '{s}' not found", .{login});
}
}
Здесь мы получаем программу которая принимает 2 аргумента: логин пользователя и путь к файлу конфигурации, выводит его баннер.
Так же мы проверяем, назначен ли пользователю прокси-сервер с режимом "strong" (если назначен, то подключения с других адресов для него недопустимы).
Полный код статической библиотеки с комментариями:
banner_check.zig
// Импорт стандартной библиотеки и модуля PostgreSQL
const std = @import("std");
const pg = @import("pg");
// Конфигурация подключения к PostgreSQL
const PgConfig = struct {
host: []const u8 = "localhost", // Хост БД (выделяется динамически)
port: u16 = 5432, // Порт БД
username: []const u8 = "postgres", // Логин (выделяется динамически)
password: []const u8 = "postgres", // Пароль (выделяется динамически)
database: []const u8 = "ocserv", // Имя БД (выделяется динамически)
};
// Приоритеты баннерных сообщений
pub const MessagePriority = enum {
global, // Глобальное сообщение (высший приоритет)
group, // Групповое сообщение
individual, // Индивидуальное сообщение
expire, // Сообщение об истечении срока (низший приоритет)
};
// Структура баннерного сообщения
pub const BannerMessage = struct {
text: []const u8, // Текст сообщения (выделяется динамически)
priority: MessagePriority, // Приоритет сообщения
};
// Данные о сроке действия
pub const ExpireData = struct {
start_timestamp: i64, // Время начала (timestamp)
end_timestamp: i64, // Время окончания (timestamp)
start_date_str: []const u8, // Форматированная дата начала (выделяется динамически)
end_date_str: []const u8, // Форматированная дата окончания (выделяется динамически)
};
// C-интерфейс для проверки прокси-сервера пользователя
// Возвращает true, если подключение разрешено, false - если запрещено
pub export fn check_user_proxy(
username: [*:0]const u8, // Входной параметр: логин пользователя (null-terminated строка)
remote_addr: [*:0]const u8, // Входной параметр: IP-адрес клиента (null-terminated строка)
proxy_ip_out: [*:0]u8, // Выходной буфер для IP прокси-сервера (минимум 256 байт)
) callconv(.C) bool {
// Инициализируем GeneralPurposeAllocator для управления памятью
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
// Гарантируем проверку утечек памяти при выходе из функции
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Инициализируем выходной буфер нулями
@memset(proxy_ip_out[0..256], 0);
// Логируем входные параметры
std.log.debug("Checking proxy for user: {s}, IP: {s}", .{ username, remote_addr });
// 1. Читаем конфигурационный файл
const config_file = std.fs.cwd().readFileAlloc(allocator, "/etc/ocserv/banner.toml", 1 << 20) catch |err| {
std.log.err("Failed to read config: {}", .{err});
return true; // При ошибке разрешаем подключение
};
// Гарантируем освобождение памяти файла конфигурации
defer allocator.free(config_file);
// Парсим TOML конфиг
const pg_config = parse_toml(allocator, config_file) catch |err| {
std.log.err("Invalid TOML config: {}", .{err});
return true; // При ошибке разрешаем подключение
};
// Гарантируем освобождение строк конфигурации
defer {
allocator.free(pg_config.host);
allocator.free(pg_config.username);
allocator.free(pg_config.password);
allocator.free(pg_config.database);
}
// 2. Инициализируем пул соединений с PostgreSQL
var pool = pg.Pool.init(allocator, .{
.size = 3, // Максимальное количество соединений в пуле
.connect = .{
.port = pg_config.port,
.host = pg_config.host,
},
.auth = .{
.username = pg_config.username,
.database = pg_config.database,
.password = pg_config.password,
.timeout = 5000, // Таймаут подключения 5 секунд
},
}) catch |err| {
std.log.err("DB connection failed: {}", .{err});
return true; // При ошибке разрешаем подключение
};
// Гарантируем закрытие пула соединений при выходе
defer pool.deinit();
// 3. Проверяем наличие пользователя в базе
var user_result = pool.query(
\\SELECT proxy_server_id FROM users WHERE login = $1 LIMIT 1
, .{std.mem.span(username)}) catch |err| {
std.log.err("User query failed: {}", .{err});
return true;
};
// Гарантируем освобождение ресурсов запроса
defer user_result.deinit();
// Получаем первую строку результата
const user_row = user_result.next() catch |err| {
std.log.err("User query iteration failed: {}", .{err});
return true;
} orelse {
std.log.err("User not found: {s}", .{std.mem.span(username)});
return true;
};
// Получаем ID прокси-сервера пользователя (может быть null)
const proxy_id: ?i32 = user_row.get(?i32, 0);
if (proxy_id) |id| {
std.log.debug("User proxy_id: {}", .{id});
} else {
std.log.debug("User has no proxy_id", .{});
}
// 4. Если прокси не назначен - разрешаем подключение
if (proxy_id == null) return true;
// 5. Получаем IP прокси-сервера из базы данных
var proxy_ip_result = pool.query(
\\SELECT host(ip) FROM proxy_servers WHERE id = $1 AND strong = true LIMIT 1
, .{proxy_id}) catch |err| {
std.log.err("Proxy query failed: {}", .{err});
return true;
};
// Гарантируем освобождение ресурсов запроса
defer proxy_ip_result.deinit();
// Получаем первую строку результата
const proxy_ip_row = proxy_ip_result.next() catch |err| {
std.log.err("Proxy IP query iteration failed: {}", .{err});
return true;
} orelse {
std.log.err("Proxy not found or not strong: id={}", .{proxy_id.?});
return true;
};
// Получаем IP-адрес прокси
const proxy_ip = proxy_ip_row.get([]const u8, 0);
if (proxy_ip.len == 0) {
std.log.err("Empty proxy IP for proxy_id={}", .{proxy_id.?});
return true;
}
std.log.debug("Found proxy IP: {s}", .{proxy_ip});
// Копируем proxy IP в выходной буфер (не более 255 символов + null-terminator)
const copy_len = @min(proxy_ip.len, 255);
@memcpy(proxy_ip_out[0..copy_len], proxy_ip[0..copy_len]);
proxy_ip_out[copy_len] = 0; // Добавляем null-terminator
std.log.debug("Copied proxy IP to output: '{s}'", .{proxy_ip_out[0..copy_len]});
// 6. Сравниваем IP-адреса клиента и прокси
const client_ip = std.mem.span(remote_addr);
std.log.debug("Comparing client IP: {s} with proxy IP: {s}", .{ client_ip, proxy_ip });
// Парсим IP-адреса для сравнения
const proxy_addr = std.net.Address.parseIp(proxy_ip, 0) catch |err| {
std.log.err("Invalid proxy IP '{s}': {}", .{ proxy_ip, err });
return true;
};
const client_addr = std.net.Address.parseIp(client_ip, 0) catch |err| {
std.log.err("Invalid client IP '{s}': {}", .{ client_ip, err });
return true;
};
// Сравниваем бинарные представления адресов
const equal = std.mem.eql(u8, std.mem.asBytes(&proxy_addr.in.sa.addr), std.mem.asBytes(&client_addr.in.sa.addr));
std.log.debug("IP comparison result: {}", .{equal});
return equal;
}
// C-интерфейс для получения баннера пользователя
pub export fn get_banner(
username: [*:0]const u8, // Входной параметр: имя пользователя
banner_out: [*:0]u8, // Выходной буфер для баннера
banner_len: usize, // Размер выходного буфера
) callconv(.C) bool { // Возвращает true если баннер получен
// Инициализируем аллокатор
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Инициализируем выходной буфер нулями
@memset(banner_out[0..banner_len], 0);
std.log.debug("Getting banner for user: {s}", .{username});
// 1. Читаем конфигурационный файл
const config_file = std.fs.cwd().readFileAlloc(allocator, "/etc/ocserv/banner.toml", 1 << 20) catch |err| {
std.log.err("Failed to read config: {}", .{err});
return false;
};
// Гарантируем освобождение памяти файла конфигурации
defer allocator.free(config_file);
// Парсим TOML конфиг
const pg_config = parse_toml(allocator, config_file) catch |err| {
std.log.err("Invalid TOML config: {}", .{err});
return false;
};
// Гарантируем освобождение строк конфигурации
defer {
allocator.free(pg_config.host);
allocator.free(pg_config.username);
allocator.free(pg_config.password);
allocator.free(pg_config.database);
}
// 2. Инициализируем пул соединений с PostgreSQL
var pool = pg.Pool.init(allocator, .{
.size = 3,
.connect = .{
.port = pg_config.port,
.host = pg_config.host,
},
.auth = .{
.username = pg_config.username,
.database = pg_config.database,
.password = pg_config.password,
.timeout = 5000,
},
}) catch |err| {
std.log.err("DB connection failed: {}", .{err});
return false;
};
// Гарантируем закрытие пула соединений при выходе
defer pool.deinit();
// 3. Получаем баннерное сообщение для пользователя
const login = std.mem.span(username);
const maybe_message = getBannerMessage(allocator, pool, login) catch |err| {
std.log.err("Failed to get banner message: {}", .{err});
return false;
};
// Если сообщение найдено
if (maybe_message) |message| {
// Гарантируем освобождение текста сообщения (кроме expire, который использует статические строки)
defer {
if (message.priority != .expire) {
allocator.free(message.text);
}
}
// Копируем сообщение в выходной буфер
const copy_len = @min(message.text.len, banner_len - 1);
@memcpy(banner_out[0..copy_len], message.text[0..copy_len]);
banner_out[copy_len] = 0; // Добавляем null-terminator
std.log.debug("Copied banner message: '{s}'", .{banner_out[0..copy_len]});
return true;
}
return false;
}
// Получение баннерного сообщения для пользователя
fn getBannerMessage(
allocator: std.mem.Allocator, // Аллокатор для временных данных
pool: *pg.Pool, // Пул соединений с БД
login: []const u8, // Логин пользователя
) !?BannerMessage {
// 1. Проверяем глобальное сообщение (наивысший приоритет)
{
var global_result = try pool.query(
\\SELECT message_text FROM global_message WHERE status = true LIMIT 1
, .{});
defer global_result.deinit(); // Гарантируем освобождение ресурсов запроса
if (try global_result.next()) |row| {
const message = row.get([]const u8, 0);
// Создаем копию сообщения для возврата
return BannerMessage{
.text = try allocator.dupe(u8, message),
.priority = MessagePriority.global,
};
}
}
// 2. Получаем группы пользователей и сообщения групп
var groups = try getGroupsWithUsers(allocator, pool);
// Гарантируем освобождение памяти групп
defer {
var it = groups.iterator();
while (it.next()) |entry| {
// Освобождаем имена пользователей в группе
for (entry.value_ptr.items) |user| {
allocator.free(user);
}
entry.value_ptr.deinit(); // Освобождаем список пользователей
allocator.free(entry.key_ptr.*); // Освобождаем имя группы
}
groups.deinit(); // Освобождаем хэш-мап групп
}
// Получаем сообщения групп
var group_messages = try getGroupMessages(allocator, pool);
// Гарантируем освобождение памяти сообщений групп
defer {
var it = group_messages.iterator();
while (it.next()) |entry| {
allocator.free(entry.key_ptr.*); // Освобождаем имя группы
allocator.free(entry.value_ptr.*); // Освобождаем текст сообщения
}
group_messages.deinit();
}
// Проверяем сообщение группы пользователя
if (findGroupForUser(login, &groups)) |group_name| {
if (group_messages.get(group_name)) |message| {
// Создаем копию сообщения для возврата
return BannerMessage{
.text = try allocator.dupe(u8, message),
.priority = MessagePriority.group,
};
}
}
// 3. Проверяем индивидуальные сообщения
var individual_messages = try getIndividualMessages(allocator, pool);
// Гарантируем освобождение памяти индивидуальных сообщений
defer {
var it = individual_messages.iterator();
while (it.next()) |entry| {
allocator.free(entry.key_ptr.*); // Освобождаем логин пользователя
allocator.free(entry.value_ptr.*); // Освобождаем текст сообщения
}
individual_messages.deinit();
}
// Если есть индивидуальное сообщение для пользователя
if (individual_messages.get(login)) |message| {
// Создаем копию сообщения для возврата
return BannerMessage{
.text = try allocator.dupe(u8, message),
.priority = MessagePriority.individual,
};
}
// 4. Проверяем срок действия ключа (низший приоритет)
{
var user_result = try pool.query(
\\SELECT expire FROM users WHERE login = $1 LIMIT 1
, .{login});
defer user_result.deinit(); // Гарантируем освобождение ресурсов запроса
if (try user_result.next()) |row| {
const expire_json = row.get(?[]const u8, 0);
if (expire_json) |json| {
// Парсим JSON с датами
const expire = try parseExpireJson(allocator, json);
// Гарантируем освобождение форматированных дат
defer {
allocator.free(expire.start_date_str);
allocator.free(expire.end_date_str);
}
// Вычисляем оставшееся время
const now = std.time.timestamp();
const seconds_left = expire.end_timestamp - now;
const days_left = @divTrunc(seconds_left, 86400);
const hours_left = @divTrunc(@mod(seconds_left, 86400), 3600);
// Формируем сообщение в зависимости от оставшегося времени
if (days_left >= 30 and days_left < 31) {
// Сообщение о новом тарифе (русский и туркменский варианты)
const msg1 = try std.fmt.allocPrint(allocator,
"Поздравляем! Вы выбрали лучшее. Ваш новый тариф уже активен с {s} года до {s} года. Наслаждайтесь безопасным интернетом!",
.{ expire.start_date_str, expire.end_date_str });
defer allocator.free(msg1);
const msg2 = try std.fmt.allocPrint(allocator,
"Sizi gutlaýarys! Iň gowy tarifi saýladyňyz. Täze tarifiňiz {s} -- {s} senesi işjeňdir. Howpsuz we çalt internetden lezzet alyň!",
.{ expire.start_date_str, expire.end_date_str });
defer allocator.free(msg2);
// Случайный выбор языка сообщения
const chosen = if (std.crypto.random.int(u1) == 0) msg1 else msg2;
return BannerMessage{
.text = try allocator.dupe(u8, chosen),
.priority = MessagePriority.expire,
};
}
// ... (аналогичные блоки для других временных интервалов)
else if (days_left >= -3 and days_left < 0) {
// Сообщение о заблокированном аккаунте
const msg1: []const u8 = "К сожалению, в настоящий момент ваш аккаунт заблокирован из-за нулевого баланса.";
const msg2: []const u8 = "Bagyşlaň, häzirki wagtda balansyňyz nol bolany üçin hasabyňyz bloklanyldy.";
const chosen = if (std.crypto.random.int(u1) == 0) msg1 else msg2;
return BannerMessage{
.text = try allocator.dupe(u8, chosen),
.priority = MessagePriority.expire,
};
}
}
}
}
return null; // Сообщение не найдено
}
// Вспомогательные функции
// Парсинг JSON с датами истечения срока
fn parseExpireJson(allocator: std.mem.Allocator, json_str: []const u8) !ExpireData {
const parsed = try std.json.parseFromSlice(struct {
start: i64,
end: i64,
}, allocator, json_str, .{});
defer parsed.deinit(); // Гарантируем освобождение ресурсов парсера
const start_timestamp = parsed.value.start;
const end_timestamp = parsed.value.end;
// Форматируем даты в строки
const start_date_str = try formatTimestamp(allocator, start_timestamp);
const end_date_str = try formatTimestamp(allocator, end_timestamp);
return ExpireData{
.start_timestamp = start_timestamp,
.end_timestamp = end_timestamp,
.start_date_str = start_date_str,
.end_date_str = end_date_str,
};
}
// Форматирование временной метки в строку (DD.MM.YYYY)
fn formatTimestamp(allocator: std.mem.Allocator, timestamp: i64) ![]const u8 {
// Конвертируем timestamp в компоненты даты
const epoch_seconds = std.time.epoch.EpochSeconds{ .secs = @intCast(timestamp) };
const epoch_day = epoch_seconds.getEpochDay();
const year_day = epoch_day.calculateYearDay();
const month_day = year_day.calculateMonthDay();
// Форматируем в буфер
var buffer: [32]u8 = undefined;
const formatted = try std.fmt.bufPrint(&buffer, "{d:0>2}.{d:0>2}.{d}", .{
month_day.day_index + 1, // День (1-based)
@intFromEnum(month_day.month) + 1, // Месяц (1-based)
year_day.year, // Год
});
// Возвращаем копию строки
return allocator.dupe(u8, formatted);
}
// Поиск группы для пользователя
fn findGroupForUser(login: []const u8, groups: *std.StringArrayHashMap(std.ArrayList([]const u8))) ?[]const u8 {
var it = groups.iterator();
while (it.next()) |entry| {
for (entry.value_ptr.items) |user| {
if (std.mem.eql(u8, user, login)) {
return entry.key_ptr.*; // Возвращаем имя группы
}
}
}
return null; // Группа не найдена
}
// Получение списка групп с пользователями
fn getGroupsWithUsers(allocator: std.mem.Allocator, pool: *pg.Pool) !std.StringArrayHashMap(std.ArrayList([]const u8)) {
var groups = std.StringArrayHashMap(std.ArrayList([]const u8)).init(allocator);
var result = try pool.query(
\\SELECT g.name AS group_name, u.login AS user_login
\\FROM groups g
\\JOIN group_users gu ON g.id = gu.group_id
\\JOIN users u ON gu.user_id = u.id
\\WHERE g.message_status = true
\\ORDER BY g.name, u.login
, .{});
defer result.deinit(); // Гарантируем освобождение ресурсов запроса
// Обрабатываем каждую строку результата
while (try result.next()) |row| {
const group_name = row.get([]const u8, 0);
const user_login = row.get([]const u8, 1);
// Создаем копии строк для хранения в хэш-мапе
const owned_group_name = try allocator.dupe(u8, group_name);
const owned_user_login = try allocator.dupe(u8, user_login);
// Добавляем пользователя в соответствующую группу
if (groups.getPtr(owned_group_name)) |users_list| {
try users_list.append(owned_user_login);
} else {
var users = std.ArrayList([]const u8).init(allocator);
try users.append(owned_user_login);
try groups.put(owned_group_name, users);
}
}
return groups;
}
// Получение сообщений групп
fn getGroupMessages(allocator: std.mem.Allocator, pool: *pg.Pool) !std.StringArrayHashMap([]const u8) {
var messages = std.StringArrayHashMap([]const u8).init(allocator);
var result = try pool.query(
\\SELECT g.name AS group_name, gm.message_text AS message_text
\\FROM group_messages gm
\\JOIN groups g ON gm.group_id = g.id
\\WHERE g.message_status = true
\\ORDER BY g.name
, .{});
defer result.deinit(); // Гарантируем освобождение ресурсов запроса
// Обрабатываем каждую строку результата
while (try result.next()) |row| {
const group_name = row.get([]const u8, 0);
const message_text = row.get([]const u8, 1);
// Создаем копии строк для хранения в хэш-мапе
const owned_group_name = try allocator.dupe(u8, group_name);
const owned_message = try allocator.dupe(u8, message_text);
try messages.put(owned_group_name, owned_message);
}
return messages;
}
// Получение индивидуальных сообщений пользователей
fn getIndividualMessages(allocator: std.mem.Allocator, pool: *pg.Pool) !std.StringArrayHashMap([]const u8) {
var messages = std.StringArrayHashMap([]const u8).init(allocator);
var result = try pool.query(
\\SELECT u.login AS user_login, um.message_text AS message_text
\\FROM user_messages um
\\JOIN users u ON um.user_id = u.id
\\WHERE u.accept_messages = true
\\ORDER BY u.login
, .{});
defer result.deinit(); // Гарантируем освобождение ресурсов запроса
// Обрабатываем каждую строку результата
while (try result.next()) |row| {
const user_login = row.get([]const u8, 0);
const message_text = row.get([]const u8, 1);
// Создаем копии строк для хранения в хэш-мапе
const owned_login = try allocator.dupe(u8, user_login);
const owned_message = try allocator.dupe(u8, message_text);
try messages.put(owned_login, owned_message);
}
return messages;
}
// Парсинг TOML конфига (упрощенная реализация)
fn parse_toml(allocator: std.mem.Allocator, data: []const u8) !PgConfig {
var config = PgConfig{};
var lines = std.mem.tokenizeSequence(u8, data, "\n");
// Обрабатываем каждую строку файла
while (lines.next()) |line| {
const trimmed = std.mem.trim(u8, line, " \t");
// Пропускаем пустые строки и комментарии
if (trimmed.len == 0 or trimmed[0] == '#') continue;
// Разбираем строки вида "ключ = значение"
if (std.mem.indexOf(u8, trimmed, "=")) |eq_pos| {
const key = std.mem.trim(u8, trimmed[0..eq_pos], " \t\"'");
const value = std.mem.trim(u8, trimmed[eq_pos + 1 ..], " \t\"'");
// Обрабатываем каждый ключ
if (std.mem.eql(u8, key, "host")) {
config.host = try allocator.dupe(u8, value); // Выделяем память для хоста
} else if (std.mem.eql(u8, key, "port")) {
config.port = try std.fmt.parseInt(u16, value, 10); // Парсим порт
} else if (std.mem.eql(u8, key, "username") or std.mem.eql(u8, key, "user")) {
config.username = try allocator.dupe(u8, value); // Выделяем память для имени пользователя
} else if (std.mem.eql(u8, key, "password")) {
config.password = try allocator.dupe(u8, value); // Выделяем память для пароля
} else if (std.mem.eql(u8, key, "database")) {
config.database = try allocator.dupe(u8, value); // Выделяем память для имени БД
}
}
}
return config;
}
Использую pub export fn get_banner(
вместо просто export, чтобы можно было вызвать из программы для тестирования (следующий спойлер).
На момент написания статьи я начал работать над более оптимизированной версией баннера, с лучшей архитектурой и меньшим количеством запросов к бд, с уменьшением фрагментации памяти.
Как я проверял работу статической библиотеки:
Скрытый текст
Пока функционал чуть более чем скромный, я поленился писать полноценные unit-тесты, моккировать бд и т.д. Но добавил этот пункт в TODO лист проекта.
Кстати, если вы обратили внимание — модуль возвращает true, в некоторых случаях если сталкивается с ошибкой, это сделано намеренно с целью улучшить UX пользователя.
Для проверки что всё работает как задумано, была написана такая программа, test/test_banner.zig:
const std = @import("std");
// Live тестирование на реальной базе данных
pub fn main() !void {
const allocator = std.heap.page_allocator;
// Тестовые данные для проверки прокси
const ProxyTestCase = struct {
username: []const u8,
remote_ip: []const u8,
expected: bool,
description: []const u8,
};
const proxy_test_cases = [_]ProxyTestCase{
.{
.username = "test_user",
.remote_ip = "192.168.1.1",
.expected = false,
.description = "Пользователь не существует",
},
.{
.username = "axtest",
.remote_ip = "1.2.3.4", // Неправильный IP
.expected = false,
.description = "Строгий прокси, IP не совпадает",
},
.{
.username = "axtest",
.remote_ip = "", // Правильный IP
.expected = true,
.description = "Строгий прокси, IP совпадает",
},
.{
.username = "axtest2",
.remote_ip = "5.6.7.8", // Любой IP будет правильным
.expected = true,
.description = "Нестрогий прокси",
},
.{
.username = "axtest3",
.remote_ip = "9.10.11.12", // Любой IP будет правильным
.expected = true,
.description = "Прокси не задан",
},
};
// Тестовые данные для проверки баннеров
const BannerTestCase = struct {
username: []const u8,
description: []const u8,
};
const banner_test_cases = [_]BannerTestCase{
.{
.username = "axtest",
.description = "Пользователь с глобальным сообщением",
},
.{
.username = "axtest2",
.description = "Пользователь с групповым сообщением",
},
.{
.username = "axtest3",
.description = "Пользователь с индивидуальным сообщением",
},
.{
.username = "axtest4",
.description = "Пользователь с истекающим сроком",
},
.{
.username = "axtest5",
.description = "Пользователь без сообщений",
},
};
std.debug.print("\n=== Тестирование проверки прокси ===\n", .{});
for (proxy_test_cases) |case| {
// Буфер для IP прокси
var proxy_ip: [256]u8 = undefined;
@memset(&proxy_ip, 0);
proxy_ip[proxy_ip.len - 1] = 0; // Гарантируем null-terminator
// Создаем нуль-терминированные строки
const username_nt = try std.fmt.allocPrintZ(allocator, "{s}", .{case.username});
defer allocator.free(username_nt);
const remote_ip_nt = try std.fmt.allocPrintZ(allocator, "{s}", .{case.remote_ip});
defer allocator.free(remote_ip_nt);
// Вызываем функцию проверки прокси
const res = @import("banner_check").check_user_proxy(
username_nt.ptr,
remote_ip_nt.ptr,
@ptrCast(&proxy_ip),
);
// Получаем строку IP прокси
const proxy_ip_str = if (proxy_ip[0] != 0)
std.mem.sliceTo(&proxy_ip, 0)
else
"none";
// Проверяем результат
const status = if (res == case.expected) "PASS" else "FAIL";
std.debug.print("[{s}] {s}: {s}@{s} => {} (proxy: {s}, expected {})\n", .{
status,
case.description,
case.username,
case.remote_ip,
res,
proxy_ip_str,
case.expected,
});
}
std.debug.print("\n=== Тестирование баннерных сообщений ===\n", .{});
for (banner_test_cases) |case| {
// Буфер для баннера (4KB должно хватить для любого сообщения)
var banner_buffer: [4096]u8 = undefined;
@memset(&banner_buffer, 0);
banner_buffer[banner_buffer.len - 1] = 0; // Гарантируем null-terminator
// Создаем нуль-терминированную строку имени пользователя
const username_nt = try std.fmt.allocPrintZ(allocator, "{s}", .{case.username});
defer allocator.free(username_nt);
// Вызываем функцию получения баннера
const has_banner = @import("banner_check").get_banner(
username_nt.ptr,
@ptrCast(&banner_buffer),
banner_buffer.len,
);
// Получаем строку баннера
const banner_str = if (has_banner)
std.mem.sliceTo(&banner_buffer, 0)
else
"no banner";
std.debug.print("{s}: {s} => {s}\n", .{
case.description,
case.username,
banner_str,
});
}
}
Build.zig:
Скрытый текст
const std = @import("std");
pub fn build(b: *std.Build) void {
// Стандартные опции
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// Получаем модуль pg
const pg_module = b.dependency("pg", .{
.target = target,
.optimize = optimize,
}).module("pg");
// 1. Настраиваем исполняемый файл
const exe = b.addExecutable(.{
.name = "login-test",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
exe.root_module.addImport("pg", pg_module);
b.installArtifact(exe);
// 2. Настраиваем статическую библиотеку
const lib = b.addStaticLibrary(.{
.name = "banner_check",
.root_source_file = b.path("src/banner_check.zig"),
.target = target,
.optimize = .ReleaseSafe,
});
lib.root_module.addImport("pg", pg_module);
lib.linkLibC();
lib.linkSystemLibrary("pq");
lib.bundle_compiler_rt = true;
b.installArtifact(lib);
// 3. Добавляем тестовую программу
const test_exe = b.addExecutable(.{
.name = "test_banner",
.root_source_file = b.path("test/test_banner.zig"),
.target = target,
.optimize = optimize,
});
// Подключаем зависимости
test_exe.root_module.addImport("banner_check", lib.root_module);
test_exe.linkLibrary(lib); // Линкуем нашу библиотеку
test_exe.linkLibC();
b.installArtifact(test_exe);
// 4. Добавляем шаг для запуска тестов
const run_test = b.addRunArtifact(test_exe);
const test_step = b.step("test", "Run banner check tests");
test_step.dependOn(&run_test.step);
// 5. Простая генерация заголовочного файла
const gen_header = b.addWriteFiles();
_ = gen_header.add("include/banner_check.h",
\\#pragma once
\\#include <stdbool.h>
\\bool check_user_proxy(const char* username, const char* remote_addr, char* proxy_ip_out);
);
b.getInstallStep().dependOn(&gen_header.step);
// 6. Группируем шаги сборки
const build_exe = b.step("exe", "Build only the executable");
build_exe.dependOn(&exe.step);
const build_lib = b.step("lib", "Build only the library");
build_lib.dependOn(&lib.step);
const build_test = b.step("test-build", "Build the test executable");
build_test.dependOn(&test_exe.step);
const build_all = b.step("all", "Build everything");
build_all.dependOn(build_exe);
build_all.dependOn(build_lib);
build_all.dependOn(build_test);
build_all.dependOn(&gen_header.step);
}
Вот как теперь это вызывается из C-кода:
Скрытый текст
banner_check.h:
#pragma once
#include <stdbool.h>
#include <stddef.h> // for size_t
#ifdef __cplusplus
extern "C" {
#endif
external bool check_user_proxy(const char* username, const char* remote_addr, char* proxy_ip);
external bool get_banner(const char* username, char* banner_out, size_t banner_len);
#ifdef __cplusplus
}
#endif
По сути мы просто обьявляем функции, которые будем вызывать из статической библиотеки.
worker-auth.c:
В начале файла, после основных хидеров
#include "banner_check.h"
Далее в функции int post_common_handler(worker_st ws, unsigned http_ver, const char imsg) вносятся изменения:
В блоке основных определений переменных
char banner[4096], username[32], remote_addr[256], our_addr[256];
char proxy_ip[256] = {0};
memset(banner, 0, 256);
memset(username, 0, 32);
memset(remote_addr, 0, 256);
memset(our_addr, 0, 256);
Перед блоком с формированием баннера (оригинальный фрагмент кода):
if (WSCONFIG(ws)->banner) {
...
Добавляем код:
int len;
len = strlen(ws->username);
memcpy(username, ws->username, len);
getsockname(ws->conn_fd, (struct sockaddr*)&ws->our_addr, &ws->our_addr_len);
ret = getnameinfo((void*)&ws->remote_addr, ws->remote_addr_len, remote_addr, sizeof(remote_addr), NULL, 0, NI_NUMERICHOST);
if (ret < 0)
goto fail;
ret = getnameinfo((void*)&ws->our_addr, ws->our_addr_len, our_addr, sizeof(our_addr), NULL, 0, NI_NUMERICHOST);
if (ret < 0)
goto fail;
/* Check if proxy is allowed */
if (!is_proxy_allowed(username, remote_addr, proxy_ip)) {
const char *proxy_msg = "Вы должны подключится используя proxy ip: ";
size_t msg_len = strlen(proxy_msg);
size_t ip_len = strlen(proxy_ip);
// Ensure we don't overflow the banner buffer
if (msg_len + ip_len < MAX_BANNER_SIZE + 32) {
memcpy(WSCONFIG(ws)->banner, proxy_msg, msg_len);
memcpy(WSCONFIG(ws)->banner + msg_len, proxy_ip, ip_len);
WSCONFIG(ws)->banner[msg_len + ip_len] = '\0';
} else {
// If the message is too long, use a truncated version
const char *fallback_msg = "Используйте назначенный proxy";
memcpy(WSCONFIG(ws)->banner, fallback_msg, strlen(fallback_msg)+1);
}
} else {
bool has_banner = get_banner(username, banner, sizeof(banner));
if (has_banner) {
memcpy(WSCONFIG(ws)->banner, banner, strlen(banner)+1);
} else {
// something went wrong
goto fail;
}
}
is_proxy_allowed это небольшая обёртка, которую можно вставить в любом месте исходника:
bool is_proxy_allowed(const char *username, const char *remote_addr, char* proxy_ip) {
return check_user_proxy(username, remote_addr, proxy_ip);
}
Процесс сборки ocserv:
Нужно модифицировать сборочные скрипты
В configure.ac:
# После всех основных проверок библиотек, но перед AC_CONFIG_FILES
# Проверка Zig-модуля
AC_ARG_WITH([zig-module],
[AS_HELP_STRING([--with-zig-module=DIR],
[path to Zig proxy check module @<:@default=../zig-module@:>@])],
[zig_module_dir=$withval],
[zig_module_dir=../zig-module]
)
AC_MSG_CHECKING([for Zig proxy check module])
if test -f "${zig_module_dir}/libbanner_check.a"; then
AC_MSG_RESULT([yes])
HAVE_ZIG_MODULE=yes
ZIG_MODULE_LIBS="-L${zig_module_dir} -Wl,--whole-archive -lbanner_check -Wl,--no-whole-archive"
ZIG_MODULE_CFLAGS="-I${zig_module_dir}"
AC_SUBST([ZIG_MODULE_LIBS])
AC_SUBST([ZIG_MODULE_CFLAGS])
else
AC_MSG_RESULT([no])
HAVE_ZIG_MODULE=no
fi
AM_CONDITIONAL([HAVE_ZIG_MODULE], [test "$HAVE_ZIG_MODULE" = "yes"])
В src/Makefile.am:
if HAVE_ZIG_MODULE
ocserv_LDADD += @ZIG_MODULE_LIBS@
ocserv_worker_LDADD += @ZIG_MODULE_LIBS@
AM_CPPFLAGS += @ZIG_MODULE_CFLAGS@
endif
Подводные камни при сборке:
А теперь внимание, хак по сборке: по умолчанию zig собирает бинарник под архитектуру процессора на котором запущен, как передать этот параметр через build.zig если честно не разобрался пока, поэтому передаём через строку сборки.
zig build install -Dcpu=baseline
При сборки библиотеке важно не забыть про bundle_compiler_rt = true
в build.zig, эта опция говорит компилятору что нужно включить рантайм компилятора в итоговый файл -- без этого либо не получиться собрать итоговый бинарник на C, либо он выйдет зависимым от компилятора zig.
Итого о пользе для бизнеса:
-
Простота развёртывания:
Сократилось количество файлов
Задаём конфиг в TOML (можно и без него)
Запускаем
-
Ресурсоёмкость:
Потребление памяти: ~5MB (весь ocserv) против ~50MB у PHP-FPM
Время отклика: 3-5ms против 50-100ms (без особой нагрузки)
-
Надёжность:
Нет внезапных падений
Чёткое логирование всех ошибок
Предсказуемое поведение под нагрузкой
Послесловие
К сожалению Zig ещё не достиг состояния release, и скорее всего для сборки на более свежем компиляторе придётся обновлять код.
Я использовал Zig версии: 0.14
P.s. если понравился пост — подписвайтесь на обновления моего профиля на Хабр или на мой телеграмм-канал.
P.P.S. конструктивная критика и рекомендации всегда приветствуются
P.P.P.S Если нет конструктива — лучше не комментируйте.