commit fe3bf8a309faf6b0b4e33b1cf1216679c714e087 Author: Magnus Root Date: Tue Feb 24 12:08:59 2026 +0300 Work version diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..862c15e --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Binaries +magos +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Credentials +*.env + +# Output of the go coverage tool +*.out + +# Go workspace file +go.work + +# Dependency directories +vendor/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Config with sensitive data +config.yaml +config.yml +*.local.yaml +*.local.yml + +# Temporary files +*.tmp +*.temp diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..e327149 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,1232 @@ +# Архитектура Magos + +## Обзор системы + +**Magos** - это система управления WAF (Web Application Firewall) контейнерами на базе Nginx/Angie, которая: + +- Автоматически разворачивает и управляет Docker-контейнерами с Nginx +- Настраивает балансировщик Angie для распределения трафика +- Интегрируется с API ServicePipe для получения конфигурации +- Поддерживает graceful shutdown при масштабировании и миграции +- Обеспечивает изоляцию клиентов через отдельные контейнеры + +### Ключевые особенности: + +- **Многоклиентовая архитектура** - каждый клиент имеет свои контейнеры +- **Динамическое управление портами** - автоматическое выделение портов из пулов +- **Кэширование данных** - минимизация обращений к API +- **Failover БД** - автоматическое переключение между primary/secondary +- **Graceful draining** - безопасное удаление контейнеров без потери трафика + +--- + +## Компоненты системы + +### 1. Основные файлы программы + +``` +main.go # Точка входа, основной цикл обработки +config.go # Управление конфигурацией +db.go # Работа с PostgreSQL +api.go # Интеграция с ServicePipe API +types.go # Структуры данных +utils.go # Утилиты и вспомогательные функции +``` + +### 2. Управление контейнерами + +``` +containers_part1.go # Создание и настройка контейнеров +containers_part2.go # Управление жизненным циклом контейнеров +drain.go # Graceful shutdown и миграция +``` + +### 3. Генерация конфигураций + +``` +nginx.go # Генерация конфигов Nginx и Angie +ports.go # Управление портами +``` + +--- + +## Архитектура данных + +### База данных PostgreSQL + +#### Таблица `instances_new` +Связь hostname → instance (waf_instance) + +```sql +hostname | instance +------------|------------ +waf-host-01 | instance-a +waf-host-01 | instance-b +waf-host-02 | instance-c +``` + +#### Таблица `client_info` +Основная информация о клиентах + +```sql +Поля: +- client_title # Уникальный идентификатор клиента +- containers_count # Количество контейнеров (1-20) +- ptaf_config # Дополнительная конфигурация PTAF +- fluent_bit_port # Порт для FluentBit логирования +- waf_instance # Instance для привязки к хосту +``` + +**Важно:** `waf_instance` определяет на каком хосте должен работать клиент + +#### Таблица `apps_settings` +Настройки каждого L7 ресурса (домена) + +```sql +Основные поля: +- l7resourceid # ID ресурса из ServicePipe API +- client_title # Связь с client_info +- waf_enabled # Включен ли WAF +- mode # auto/manual/disabled + +Порты: +- custom_input_http_ports[] # HTTP порты для прослушивания (Angie) +- custom_input_https_ports[] # HTTPS порты для прослушивания (Angie) +- custom_output_http_ports[] # HTTP порты для проксирования к origin +- custom_output_https_ports[] # HTTPS порты для проксирования к origin + +Кастомизация Nginx: +- upstream_nginx_custom # Кастомные upstream директивы +- server_nginx_custom # Кастомный server блок +- location_nginx_custom # Кастомные location директивы +- server_directives_nginx_custom # Дополнительные server директивы + +Кастомизация Angie: +- upstream_angie_custom # Кастомные upstream директивы +- server_angie_custom # Кастомный server блок +- location_angie_custom # Кастомные location директивы +- server_directives_angie_custom # Дополнительные server директивы +- custom_angie_ssl # Полная замена SSL директив + +Специальные настройки: +- sni # 0 = keepalive on, 1 = keepalive off +- ssl_enabled # true/false (по умолчанию true) +``` + +#### Таблица `sp_info` (кэш API данных) +Кэширует данные из ServicePipe API + +```sql +Поля: +- sid # l7resourceid +- domain_name # Основной домен +- aliases # JSON массив алиасов +- origins # JSON массив origin серверов +- updated_at # Timestamp обновления +``` + +**TTL кэша:** 10 минут + +#### Таблица `manual_info` +Ручное управление (альтернатива API) + +```sql +Поля: +- sid # l7resourceid +- domain_name # Основной домен +- aliases # JSON массив алиасов +- origins # JSON массив origin серверов +``` + +**Примечание:** Данные из `manual_info` не имеют TTL и всегда актуальны + +#### Таблица `ips` +Настройки IP сетей + +```sql +Поля: +- id (always = 1) +- antibot_networks[] # Массив CIDR для antibot +- waf_networks[] # Массив CIDR для WAF (фильтруются из origins) +``` + +--- + +## Процесс работы + +### 1. Инициализация + +```go +main() → initConfig() → connectToDB() +``` + +**Шаги:** +1. Подключение к БД (primary → secondary fallback) +2. Загрузка IP сетей из таблицы `ips` +3. Получение hostname текущего сервера +4. Определение instances для этого хоста + +### 2. Основной цикл обработки + +``` +┌─────────────────────────────────────────┐ +│ Для каждого instance на этом хосте │ +└─────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ Получить всех клиентов для instance │ +│ (getClientInfoByInstance) │ +└─────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ Для каждого клиента: │ +│ 1. Получить apps_settings │ +│ 2. Собрать данные о ресурсах │ +│ 3. Выделить порты │ +│ 4. Сгенерировать конфиги │ +│ 5. Управление контейнерами │ +└─────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ Глобальная проверка drain-контейнеров │ +│ (processAllDrainingContainersGlobally) │ +└─────────────────────────────────────────┘ +``` + +### 3. Сбор данных о ресурсах + +Для каждого ресурса определяется режим работы через поле `mode`: + +#### Режим `auto` (по умолчанию) +``` +1. Проверка кэша sp_info (TTL 10 мин) + ├─ Данные свежие → использовать + └─ Данные устарели → API запрос +2. API запросы (с retry × 3): + ├─ GET /l7/resource/{id}/global → domain_name + ├─ GET /l7/alias/global?l7ResourceId={id} → aliases + └─ GET /l7/origin/global?l7ResourceId={id} → origins +3. Фильтрация origins (удалить WAF IP) +4. Сохранение в sp_info +``` + +#### Режим `manual` +``` +1. Чтение из таблицы manual_info +2. Фильтрация origins (удалить WAF IP) +3. API и кэш игнорируются +``` + +#### Режим `disabled` +``` +Ресурс полностью пропускается +``` + +--- + +## Управление портами + +### Диапазоны портов + +```go +const ( + HTTPPortStart = 18000 // HTTP: 18000-20999 (3000 портов) + HTTPPortEnd = 20999 + HTTPSPortStart = 21000 // HTTPS: 21000-23999 (3000 портов) + HTTPSPortEnd = 23999 +) +``` + +### Алгоритм выделения портов + +``` +┌────────────────────────────────────────┐ +│ 1. Получить занятые порты (ss -tuln) │ +└────────────────────────────────────────┘ + ↓ +┌────────────────────────────────────────┐ +│ 2. Загрузить сохраненные порты │ +│ из .ports.json (если есть) │ +└────────────────────────────────────────┘ + ↓ +┌────────────────────────────────────────┐ +│ 3. Исключить порты клиента │ +│ (для переиспользования) │ +└────────────────────────────────────────┘ + ↓ +┌────────────────────────────────────────┐ +│ 4. Для каждого ресурса: │ +│ - Определить нужные порты из │ +│ custom_input_http_ports[] или │ +│ custom_input_https_ports[] │ +│ - Выделить свободные Docker порты │ +│ - Повторить для каждого контейнера │ +└────────────────────────────────────────┘ + ↓ +┌────────────────────────────────────────┐ +│ 5. Сохранить в .ports.json │ +└────────────────────────────────────────┘ +``` + +### Структура портов + +```go +type PortMapping struct { + HTTPPorts map[int]int // map[customPort]dockerPort + HTTPSPorts map[int]int // map[customPort]dockerPort +} +``` + +**Пример:** +```json +{ + "container_num": 1, + "resources": [ + { + "l7resourceid": 12345, + "http_ports": { + "80": 18000, + "8080": 18001 + }, + "https_ports": { + "443": 21000, + "8443": 21001 + } + } + ] +} +``` + +--- + +## Graceful Shutdown и миграция + +### Типы drain операций + +#### 1. Масштабирование вниз (Scale Down) +**Timeout:** 90 секунд + +``` +Триггер: containers_count уменьшен +Процесс: +1. Контейнер помечается маркером /tmp/ptaf-drain-{name} +2. Ожидание 90 секунд +3. Удаление контейнера +``` + +#### 2. Миграция между хостами +**Timeout:** 3600 секунд (1 час) + +``` +Триггер: waf_instance изменен +Процесс: +1. Старый хост помечает контейнеры маркером (isMigration=true) +2. Новый хост создает контейнеры +3. Через 1 час старый хост удаляет контейнеры +4. Проверка актуальности перед удалением +``` + +### Структура маркеров + +```bash +# Контейнеры +/tmp/ptaf-drain-ptaf_client-name_001 + +# Angie конфиги +/tmp/ptaf-angie-drain-client-name +``` + +**Формат маркера:** +``` +{timestamp}:{migration_flag} +1738845600:1 # migration +1738845600:0 # scale down +``` + +### Процесс проверки drain + +``` +processDrainingContainers() + ↓ +Для каждого контейнера с маркером: + ├─ Прошло времени < timeout? + │ └─ Логировать оставшееся время + └─ Прошло времени >= timeout? + ├─ Migration? + │ ├─ Проверить актуальность (checkHostBelongsToInstance) + │ ├─ Клиент вернулся? → отменить удаление + │ └─ Клиент ушел? → удалить контейнер + └─ Scale down? + └─ Удалить контейнер +``` + +--- + +## Генерация конфигураций + +### Структура файлов + +``` +/home/install/ptaf/{client-title}/ +├── {client-title}-ptaf-agent001/ +│ ├── docker-compose.yml +│ └── .ports.json +├── {client-title}-ptaf-agent002/ +│ ├── docker-compose.yml +│ └── .ports.json +└── ... + +/home/install/conf/ptaf-nginx/{client-title}/ +├── ptaf-agent001/ +│ ├── nginx.conf +│ ├── mime.types +│ └── conf.d/ +│ ├── {client-title}_12345.conf +│ ├── {client-title}_67890.conf +│ └── ... +└── ... + +/var/log/ptaf_nginx/{client-title}/ +└── ptaf-agent001/ + ├── access.log + └── error.log + +/etc/angie/http.d/ +└── angie-ptaf-{client-title}.conf +``` + +### 1. Генерация nginx.conf + +**Функция:** `generateNginxConf()` + +**Содержимое:** +```nginx +env POD_IP={extracted_or_127.0.0.1}; + +user nginx; +worker_processes {config.WorkerProcesses}; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections {config.WorkerConnections}; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Logging + log_format main '$remote_addr - $remote_user [$time_local] ...'; + access_log /var/log/nginx/access.log main; + + # Performance + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + + # PTAF config (если указан) + {ptafConfig} + + # Include resource configs + include /etc/nginx/conf.d/*.conf; +} +``` + +**Особенности:** +- POD_IP извлекается из существующего конфига (для сохранения при обновлении) +- ptafConfig берется из `client_info.ptaf_config` + +### 2. Генерация конфигов ресурсов (Nginx) + +**Функция:** `generateNginxResourceConfig()` + +#### Структура конфига ресурса: + +```nginx +# Upstream блоки для каждого output порта +upstream {clientTitle}_{l7ResourceID}_http_{outputPort} { + least_conn; + {для каждого origin:} + server {origin.IP}:{outputPort} weight={origin.Weight} max_fails=3 fail_timeout=30s; + + # Custom upstream или дефолтные keepalive (если sni != 1) + keepalive 60; + keepalive_timeout 70s; +} + +upstream {clientTitle}_{l7ResourceID}_https_{outputPort} { + least_conn; + {для каждого origin:} + server {origin.IP}:{outputPort} weight={origin.Weight} max_fails=3 fail_timeout=30s; + + # Custom upstream или дефолтные keepalive (если sni != 1) + keepalive 60; + keepalive_timeout 70s; +} + +# Server блоки для каждого input порта +server { + listen {dockerPort}; + server_name {serverName} {aliases с www.}; + + # Custom server directives (если есть) + {server_directives_nginx_custom} + + # Custom location или дефолтный + location / { + proxy_pass http://{upstream_name}; + proxy_http_version 1.1; + proxy_set_header Connection ""; + # ... стандартные proxy заголовки + } +} + +server { + listen {dockerPort} ssl; + server_name {serverName} {aliases с www.}; + + # SSL сертификаты + ssl_certificate /opt/ptaf/ssl/{sanitizedServerName}.crt; + ssl_certificate_key /opt/ptaf/ssl/{sanitizedServerName}.key; + + # Custom server directives (если есть) + {server_directives_nginx_custom} + + # Custom location или дефолтный + location / { + proxy_pass https://{upstream_name}; + proxy_http_version 1.1; + proxy_set_header Connection ""; + # ... стандартные proxy заголовки + } +} + +# Custom SERVER_BLOCK блоки (если есть) +server { + listen {customPort}; + server_name {customServerName} {customAliases}; + {customContent} +} +``` + +#### Логика кастомизации: + +1. **Custom Upstream** (`upstream_nginx_custom`): + - Если заполнено → заменяет keepalive директивы + - Если пусто → используются дефолтные (keepalive 60 + keepalive_timeout 70s) + - Исключение: если `sni = 1`, keepalive отключен + +2. **Custom Server** (`server_nginx_custom`): + - Полная замена всего server блока + - Используется вместо генерации стандартных блоков + +3. **Custom Location** (`location_nginx_custom`): + - Заменяет дефолтный location / + - proxy_pass корректируется автоматически + +4. **Custom Server Directives** (`server_directives_nginx_custom`): + - Добавляются внутрь server блока + - Поддержка [SERVER_BLOCK:PORT:DOMAIN] синтаксиса + +5. **SERVER_BLOCK директивы**: + ``` + [SERVER_BLOCK:8080:example.com:alias1.com:alias2.com] + location / { + proxy_pass http://backend; + } + [/SERVER_BLOCK] + ``` + - Создают отдельные server блоки + - Домены из SERVER_BLOCK исключаются из основных блоков + +### 3. Генерация конфига Angie + +**Функция:** `generateAngieConfig()` + +#### Структура конфига Angie: + +```nginx +# Upstream блоки для контейнеров +upstream angie_{clientTitle}_{l7ResourceID}_http_{customPort} { + least_conn; + {для каждого контейнера:} + server 127.0.0.1:{dockerPort} weight=1 max_fails=3 fail_timeout=30s; + + # Custom upstream или дефолтные keepalive (если sni != 1) + keepalive 60; + keepalive_timeout 70s; +} + +upstream angie_{clientTitle}_{l7ResourceID}_https_{customPort} { + least_conn; + {для каждого контейнера:} + server 127.0.0.1:{dockerPort} weight=1 max_fails=3 fail_timeout=30s; + + # Custom upstream или дефолтные keepalive (если sni != 1) + keepalive 60; + keepalive_timeout 70s; +} + +# Server блоки +server { + listen {customPort}; + server_name {serverName} {aliases с www.}; + + # Custom server directives (если есть) + {server_directives_angie_custom} + + # Custom location или дефолтный + location / { + proxy_pass http://angie_{upstream_name}; + proxy_http_version 1.1; + proxy_set_header Connection ""; + # ... стандартные proxy заголовки + } +} + +server { + listen {customPort} ssl; + server_name {serverName} {aliases с www.}; + + # SSL сертификаты + ssl_certificate /etc/ssl/certs/{sanitizedServerName}.crt; + ssl_certificate_key /etc/ssl/private/{sanitizedServerName}.key; + + # Custom SSL или дефолтный + {если custom_angie_ssl заполнен:} + {custom_angie_ssl} + {иначе:} + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # Custom server directives (если есть) + {server_directives_angie_custom} + + # Custom location или дефолтный + location / { + proxy_pass http://angie_{upstream_name}; + proxy_http_version 1.1; + proxy_set_header Connection ""; + # ... стандартные proxy заголовки + } +} + +# Custom SERVER_BLOCK блоки (если есть) +server { + listen {customPort}; + server_name {customServerName} {customAliases}; + {customContent} +} +``` + +#### Особенности Angie конфига: + +1. **Балансировка между контейнерами:** + - Upstream указывает на локальные Docker порты (127.0.0.1:18xxx) + - Количество серверов = containers_count + +2. **SSL настройки:** + - `custom_angie_ssl` **полностью заменяет** дефолтные директивы + - Если не заполнено → используются безопасные дефолты + +3. **Санитизация доменов:** + - В Angie: `_` заменяется на `-` + - В Nginx: домены без изменений + - Причина: совместимость и единообразие + +4. **mTLS поддержка:** + - Автоматическая замена путей `/etc/ssl/` → `/opt/ptaf/conf/main/` + - Применяется в custom директивах + +### 4. Генерация docker-compose.yml + +**Функция:** `generateDockerCompose()` + +```yaml +version: '3.8' + +services: + ptaf_{clientTitle}_{containerNum:03d}: + image: {config.DockerImage} + container_name: ptaf_{clientTitle}_{containerNum:03d} + restart: unless-stopped + + ports: + # Для каждого ресурса и каждого custom порта: + - "{dockerPort}:{customPort}" # HTTP + - "{dockerPort}:{customPort}" # HTTPS + + volumes: + - /home/install/conf/ptaf-nginx/{clientTitle}/ptaf-agent{containerNum:03d}:/etc/nginx:ro + - /var/log/ptaf_nginx/{clientTitle}/ptaf-agent{containerNum:03d}:/var/log/nginx + - /home/install/ssl:/opt/ptaf/ssl:ro + - /home/install/mtls:/opt/ptaf/conf/main:ro + + networks: + - ptaf-network + + logging: + driver: "fluentd" + options: + fluentd-address: "127.0.0.1:{fluentBitPort}" + tag: "ptaf.{clientTitle}.agent{containerNum:03d}" + fluentd-async: "true" + fluentd-max-retries: "3" + fluentd-retry-wait: "1s" + +networks: + ptaf-network: + driver: bridge +``` + +**Важно:** +- Порты сортируются для детерминированного вывода +- Volume с конфигами монтируется read-only +- SSL сертификаты общие для всех контейнеров +- FluentBit для централизованного логирования + +--- + +## API интеграция + +### ServicePipe API + +**Base URL:** `https://api.servicepipe.ru/api/v1/` + +#### Endpoints: + +1. **Получение ресурса:** +```http +GET /l7/resource/{l7ResourceID}/global +Authorization: Bearer {token} + +Response: +{ + "data": { + "result": { + "l7ResourceId": 12345, + "l7ResourceName": "example.com", + ... + } + } +} +``` + +2. **Получение aliases:** +```http +GET /l7/alias/global?limit=1000&l7ResourceId={l7ResourceID} +Authorization: Bearer {token} + +Response: +{ + "data": { + "result": { + "items": [ + { + "id": 1, + "domain": "www.example.com", + "l7ResourceId": 12345, + ... + } + ] + } + } +} +``` + +3. **Получение origins:** +```http +GET /l7/origin/global?limit=1000&l7ResourceId={l7ResourceID} +Authorization: Bearer {token} + +Response: +{ + "data": { + "result": { + "items": [ + { + "id": 1, + "ip": "192.168.1.100", + "weight": 100, + "mode": "active", + ... + } + ] + } + } +} +``` + +### Retry механизм + +```go +maxRetries := 3 +для attempt := 1 до maxRetries: + попытка запроса + если успех: + return результат + если ошибка: + waitTime = attempt² секунд // 1s, 4s, 9s + sleep(waitTime) + +return ошибка +``` + +### Кэширование + +**Стратегия:** +1. Проверка `sp_info` (TTL 10 минут) +2. Если данные свежие → использовать +3. Если устарели или отсутствуют → API запрос + сохранение + +**Преимущества:** +- Снижение нагрузки на API +- Устойчивость к временным сбоям API +- Быстрая работа при повторных запусках + +--- + +## Схема взаимодействия + +### Полный flow обработки клиента + +``` +┌─────────────────────────────────────────────────────────────┐ +│ НАЧАЛО ОБРАБОТКИ │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 1. Получение данных из БД │ +│ - client_info (containers_count, waf_instance) │ +│ - apps_settings (все ресурсы для client_title) │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 2. Для каждого ресурса (apps_settings): │ +│ ┌──────────────────────────────────────────┐ │ +│ │ mode = disabled? → SKIP │ │ +│ │ mode = manual? → manual_info │ │ +│ │ mode = auto? → sp_info cache или API │ │ +│ └──────────────────────────────────────────┘ │ +│ Результат: ResourceData { │ +│ L7ResourceID, ServerName, Aliases, Origins │ +│ } │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 3. Выделение портов (PortAllocator) │ +│ - Сканирование занятых портов (ss -tuln) │ +│ - Попытка загрузки .ports.json │ +│ - Выделение свободных портов из пулов │ +│ - Сохранение в .ports.json │ +│ │ +│ Результат: map[l7ResourceID][]PortMapping │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 4. Генерация конфигов для каждого контейнера │ +│ Для containerNum := 1 до containers_count: │ +│ ├─ nginx.conf (основной конфиг) │ +│ ├─ mime.types │ +│ └─ conf.d/*.conf (для каждого ресурса) │ +│ │ +│ Отслеживание изменений: containerChanges[containerNum] │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 5. Генерация docker-compose.yml для каждого контейнера │ +│ - Маппинг портов │ +│ - Volumes │ +│ - Logging (FluentBit) │ +│ │ +│ Отслеживание изменений: composeChanges[containerNum] │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 6. Управление контейнерами │ +│ Для containerNum := 1 до containers_count: │ +│ ┌──────────────────────────────────────────┐ │ +│ │ Контейнер существует? │ │ +│ │ ├─ НЕТ → docker-compose up -d │ │ +│ │ └─ ДА: │ │ +│ │ ├─ composeChanges? → recreate │ │ +│ │ ├─ !running? → start │ │ +│ │ └─ containerChanges? │ │ +│ │ ├─ nginx -t (test) │ │ +│ │ └─ nginx -s reload │ │ +│ └──────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 7. Генерация Angie конфига │ +│ /etc/angie/http.d/angie-ptaf-{clientTitle}.conf │ +│ - Upstream для всех контейнеров │ +│ - Server блоки для всех портов │ +│ - SSL настройки │ +│ │ +│ Если изменился → angie -t && angie -s reload │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 8. Обработка лишних контейнеров (drain) │ +│ Для containerNum := containers_count+1 до MAX: │ +│ ├─ Контейнер существует? │ +│ │ ├─ Уже помечен для drain? │ +│ │ │ └─ Проверить timeout → удалить │ +│ │ └─ Не помечен → пометить для drain │ +│ └─ Контейнер не существует → очистить маркер │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ ЗАВЕРШЕНИЕ ОБРАБОТКИ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Схема миграции клиента + +``` +СТАРЫЙ ХОСТ (waf-host-01) НОВЫЙ ХОСТ (waf-host-02) + │ │ + │ waf_instance изменен в БД │ + │ client_title: waf-02 (было: waf-01) │ + ↓ ↓ +┌───────────────────┐ ┌───────────────────┐ +│ Проверка │ │ Проверка │ +│ принадлежности: │ │ принадлежности: │ +│ НЕ принадлежит │ │ Принадлежит │ +└───────────────────┘ └───────────────────┘ + ↓ ↓ +┌───────────────────┐ ┌───────────────────┐ +│ Пометка всех │ │ Создание новых │ +│ контейнеров для │ │ контейнеров │ +│ drain (migration) │ │ ├─ Выделить порты │ +│ timeout: 1 час │ │ ├─ Генерация │ +└───────────────────┘ │ │ конфигов │ + ↓ │ └─ docker-compose │ +┌───────────────────┐ │ up -d │ +│ Пометка Angie │ └───────────────────┘ +│ конфига для │ ↓ +│ удаления │ ┌───────────────────┐ +└───────────────────┘ │ Генерация Angie │ + ↓ │ конфига │ +┌───────────────────┐ │ ├─ angie -t │ +│ Ожидание 1 час... │ │ └─ angie -s │ +│ │ │ reload │ +│ [трафик все еще │ └───────────────────┘ +│ обрабатывается] │ ↓ +└───────────────────┘ ┌───────────────────┐ + ↓ │ Новые контейнеры │ +┌───────────────────┐ │ принимают трафик │ +│ Через 1 час: │ └───────────────────┘ +│ - Проверка │ +│ актуальности │ +│ - Клиент вернулся?│ +│ → ОТМЕНА │ +│ - Клиент ушел? │ +│ → УДАЛЕНИЕ │ +└───────────────────┘ + ↓ +┌───────────────────┐ +│ Удаление: │ +│ ├─ docker-compose │ +│ │ down │ +│ ├─ rm -rf dirs │ +│ └─ rm angie.conf │ +└───────────────────┘ +``` + +### Схема масштабирования + +``` +containers_count: 3 → 2 (уменьшение) + +КОНТЕЙНЕР 1 КОНТЕЙНЕР 2 КОНТЕЙНЕР 3 + │ │ │ + │ │ │ + ↓ ↓ ↓ +┌────────┐ ┌────────┐ ┌────────┐ +│ Обновл.│ │ Обновл.│ │ Помечен│ +│ конфиг │ │ конфиг │ │ drain │ +│ и порты│ │ и порты│ │timeout:│ +└────────┘ └────────┘ │ 90 сек │ + │ │ └────────┘ + │ │ │ + ↓ ↓ ↓ +┌────────┐ ┌────────┐ [Ожидание] +│ reload │ │ reload │ [активных] +│ nginx │ │ nginx │ [соединений] +└────────┘ └────────┘ ↓ + │ │ ┌────────┐ + │ │ │ Через │ + ↓ ↓ │ 90 сек │ +[Обрабатывают] [Обрабатывают] └────────┘ + трафик трафик ↓ + ┌────────┐ + │ docker-│ + │ compose│ + │ down │ + └────────┘ + ↓ + ┌────────┐ + │ Удален │ + └────────┘ +``` + +--- + +## Константы и лимиты + +### Производительность + +```go +DefaultWorkerProcesses = 4 // Nginx worker processes +DefaultWorkerConnections = 64000 // Соединений на worker +``` + +### Ограничения + +```go +MaxContainersPerClient = 20 // Максимум контейнеров на клиента +``` + +### Timeouts + +```go +ContainerDrainTimeout = 90 // Секунд для scale down +MigrationDrainTimeout = 3600 // Секунд для миграции (1 час) +APITimeout = 120 // Секунд для API запросов +CacheTTL = 600 // Секунд (10 минут) для sp_info +``` + +### Retry + +```go +MaxAPIRetries = 3 // Попыток для API запросов +RetryWaitFormula = attempt² // 1s, 4s, 9s +``` + +--- + +## Безопасность + +### Фильтрация IP адресов + +**WAF Networks** - исключаются из origins: +``` +109.238.89.0/24 +89.20.63.0/24 +``` + +Причина: предотвращение routing loops (WAF не должен проксировать на себя) + +### Antibot Networks + +``` +185.66.85.0/24 +185.66.86.0/24 +185.35.5.0/24 +185.35.6.0/24 +212.67.26.0/24 ++ +сети заказчиков у которых свой АнтиДДОС +``` + +Используется для идентификации antibot трафика + +### SSL/TLS + +**Дефолтные настройки (Angie):** +```nginx +ssl_protocols TLSv1.2 TLSv1.3; +ssl_ciphers HIGH:!aNULL:!MD5; +ssl_prefer_server_ciphers on; +ssl_session_cache shared:SSL:10m; +ssl_session_timeout 10m; +``` + +**Кастомизация через `custom_angie_ssl`** - полная замена дефолтов + +### mTLS + +**Автоматическая замена путей:** +``` +/etc/ssl/certs/ → /opt/ptaf/conf/main/ +/etc/ssl/private/ → /opt/ptaf/conf/main/ +``` + +Применяется во всех custom директивах, содержащих `proxy_ssl_certificate` + +--- + +## Мониторинг и логирование + +### Логирование контейнеров + +**FluentBit интеграция:** +```yaml +logging: + driver: "fluentd" + options: + fluentd-address: "127.0.0.1:{fluentBitPort}" + tag: "ptaf.{clientTitle}.agent{containerNum:03d}" + fluentd-async: "true" + fluentd-max-retries: "3" + fluentd-retry-wait: "1s" +``` + +### Структура логов + +``` +/var/log/ptaf_nginx/{client-title}/ptaf-agent001/ +├── access.log # HTTP access logs +└── error.log # Nginx error logs +``` + +### Логирование программы + +**Уровни:** +- `✓` - Успешная операция +- `○` - Информация (без изменений) +- `⚠` - Предупреждение +- `→` - Действие/переход +- `└─` - Детали операции + +**Примеры:** +``` +✓ nginx.conf изменен: /path/to/nginx.conf +○ nginx.conf не изменился: /path/to/nginx.conf +⚠ Данные в кеше устарели (возраст: 15.3 мин) +→ Запуск нового контейнера client-ptaf-agent001 +└─ Используются кастомные SSL директивы для Angie +``` + +--- + +## Оптимизации + +### 1. Кэширование API данных +- TTL: 10 минут +- Снижение нагрузки на API +- Устойчивость к сбоям + +### 2. Переиспользование портов +- Сохранение в `.ports.json` +- Проверка актуальности (список ресурсов, порядок) +- Минимизация изменений docker-compose + +### 3. Детерминированная генерация +- Хеширование содержимого (SHA256) +- Запись только при изменениях +- Сортировка портов для стабильного вывода + +### 4. Graceful reload +- `nginx -t` перед reload +- Минимизация перезапусков контейнеров +- Изолированный reload (по контейнерам) + +### 5. Batch обработка +- Все клиенты instance обрабатываются за один запуск +- Одно подключение к БД +- Один reload Angie для всех изменений + +--- + +## Troubleshooting + +### Проблема: Контейнер не запускается + +**Проверить:** +1. `docker logs ptaf_{client}_{num}` +2. Наличие конфигов в `/home/install/conf/ptaf-nginx/` +3. Занятость портов: `ss -tuln | grep {port}` +4. Права на `/var/log/ptaf_nginx/` (должны быть 0777) + +### Проблема: Nginx не перезагружается + +**Проверить:** +1. `docker exec ptaf_{client}_{num} nginx -t` +2. Синтаксис в `conf.d/*.conf` +3. Логи: `/var/log/ptaf_nginx/{client}/ptaf-agent{num}/error.log` + +### Проблема: Порты конфликтуют + +**Действия:** +1. Удалить `.ports.json` +2. Перезапустить программу (порты будут перевыделены) +3. Проверить диапазоны: 18000-20999 (HTTP), 21000-23999 (HTTPS) + +### Проблема: Клиент не мигрирует + +**Проверить:** +1. `waf_instance` в `client_info` +2. Записи в `instances_new` +3. Наличие маркеров `/tmp/ptaf-drain-*` +4. Логи программы на обоих хостах + +### Проблема: API данные не обновляются + +**Действия:** +1. Проверить `sp_info.updated_at` +2. Если > 10 минут → должен быть API запрос +3. Проверить `mode` в `apps_settings`: + - `disabled` → пропускается + - `manual` → использует `manual_info` + - `auto` → кэш или API + +--- + +## Расширение системы + +### Добавление нового клиента + +```sql +-- 1. Добавить в client_info +INSERT INTO client_info (client_title, containers_count, waf_instance) +VALUES ('new-client', 2, 'instance-a'); + +-- 2. Добавить ресурсы в apps_settings +INSERT INTO apps_settings (l7resourceid, client_title, waf_enabled, mode) +VALUES (12345, 'new-client', true, 'auto'); + +-- 3. (Опционально) Для ручного режима добавить в manual_info +INSERT INTO manual_info (sid, domain_name, aliases, origins) +VALUES (12345, 'example.com', '["www.example.com"]', '[{"ip":"192.168.1.100","weight":100,"mode":"active"}]'); +``` + +### Добавление нового instance + +```sql +INSERT INTO instances_new (hostname, instance) +VALUES ('waf-host-03', 'instance-d'); +``` + +### Кастомизация портов + +```sql +UPDATE apps_settings +SET + custom_input_http_ports = ARRAY[80, 8080], + custom_input_https_ports = ARRAY[443, 8443], + custom_output_http_ports = ARRAY[80], + custom_output_https_ports = ARRAY[443] +WHERE l7resourceid = 12345; +``` + +### Переопределение SSL + +```sql +UPDATE apps_settings +SET custom_angie_ssl = 'ssl_protocols TLSv1.3; +ssl_ciphers ECDHE-RSA-AES256-GCM-SHA384; +ssl_prefer_server_ciphers off; +ssl_session_cache shared:SSL:50m; +ssl_session_timeout 1d; +ssl_stapling on; +ssl_stapling_verify on;' +WHERE l7resourceid = 12345; +``` diff --git a/DATABASE_SCHEMA.md b/DATABASE_SCHEMA.md new file mode 100644 index 0000000..8c7b868 --- /dev/null +++ b/DATABASE_SCHEMA.md @@ -0,0 +1,912 @@ +# Magos - Схема базы данных + +--- + +## Обзор + +Magos работает с PostgreSQL базой данных `waf_info`, которая содержит конфигурации WAF платформы. Программа использует 5 основных таблиц для получения информации о хостах, клиентах, приложениях и backend серверах. + +### Используемые таблицы + +1. **instances** - информация о хостах/серверах +2. **client_info** - данные клиентов и контейнеров +3. **apps_settings** - настройки приложений и кастомные директивы +4. **origins** - backend серверы (получаются через REST API) +5. **aliases** - дополнительные домены (получаются через REST API) +6. **ips** - IP адреса для WAF и antibot сетей + +--- + +## Подключение к БД + +### Параметры подключения + +```bash +DB_USER=login +DB_PASSWORD=password +DB_NAME=waf_info +DB_PORT=5432 +PRIMARY_DB_HOST=10.10.10.8 +SECONDARY_DB_HOST=10.10.10.5 +``` + +### Failover логика + +1. Попытка подключения к Primary DB (10.10.10.8) +2. Если неудача → подключение к Secondary DB (10.10.10.5) +3. Если обе недоступны → программа останавливается + +**Функция:** `connectToDB()` + +--- + +## Таблица: instances + +### Назначение + +Хранит информацию о хостах/серверах в кластере PTAF. + +### Структура + +| Поле | Тип | Описание | Пример | +|------|-----|----------|--------| +| `hostname` | TEXT | Hostname сервера | `host01.example.com` | +| `instance` | TEXT | Идентификатор инстанса | `waf-prod-01` | + +### Используемые запросы + +#### 1. Получение instance по hostname + +```sql +SELECT instance +FROM instances +WHERE hostname = $1; +``` + +**Функция:** `getInstanceByHostname(hostname string)` + +**Использование:** При старте программы для определения какой это инстанс. + +**Пример:** +```go +hostname := "host01.example.com" +instance, err := getInstanceByHostname(db, hostname) +// instance = "waf-prod-01" +``` + +--- + +#### 2. Проверка принадлежности хоста к инстансу + +```sql +SELECT COUNT(*) +FROM instances +WHERE hostname = $1 AND instance = $2; +``` + +**Функция:** `checkHostBelongsToInstance(hostname, instance string)` + +**Использование:** Валидация что хост принадлежит данному инстансу. + +**Пример:** +```go +hostname := "host01.example.com" +instance := "waf-prod-01" +err := checkHostBelongsToInstance(db, hostname, instance) +// err == nil если хост принадлежит инстансу +``` + +--- + +### Примеры данных + +```sql +INSERT INTO instances (hostname, instance) VALUES +('host01.example.com', 'waf-prod-01'), +('host02.example.com', 'waf-prod-01'), +('host03.example.com', 'waf-prod-02'); +``` + +--- + +## Таблица: client_info + +### Назначение + +Хранит информацию о клиентах: количество контейнеров, привязка к инстансу. + +### Структура + +| Поле | Тип | Описание | Пример | +|------|-----|----------|--------| +| `client_title` | TEXT | Идентификатор клиента | `CLIENT001` | +| `containers_count` | INTEGER | Количество контейнеров | `3` | +| `ptaf_config` | TEXT | Конфигурация PTAF (JSON) | `{"key": "value"}` | +| `fluent_bit_port` | INTEGER | Порт Fluent Bit | `24224` | +| `waf_instance` | TEXT | Instance для apps_settings | `waf-prod-01` | + +### Используемые запросы + +#### 1. Получение клиентов по instance + +```sql +SELECT + containers_count, + ptaf_config, + client_title, + fluent_bit_port, + waf_instance +FROM client_info +WHERE waf_instance = $1; +``` + +**Функция:** `getClientInfoByInstance(instance string)` + +**Использование:** Получение всех клиентов для данного инстанса. + +**Пример:** +```go +instance := "waf-prod-01" +clients, err := getClientInfoByInstance(db, instance) +// clients = [{ContainersCount: 3, ClientTitle: "CLIENT001", ...}, ...] +``` + +--- + +#### 2. Получение клиента по client_title + +```sql +SELECT + containers_count, + ptaf_config, + client_title, + fluent_bit_port, + waf_instance +FROM client_info +WHERE client_title = $1; +``` + +**Функция:** `getClientInfoByClientTitle(clientTitle string)` + +**Использование:** Получение информации о конкретном клиенте. + +--- + +### Примеры данных + +```sql +INSERT INTO client_info +(client_title, containers_count, ptaf_config, fluent_bit_port, waf_instance) +VALUES +('CLIENT001', 3, '{"version": "1.0"}', 24224, 'waf-prod-01'), +('CLIENT002', 5, '{"version": "1.0"}', 24225, 'waf-prod-01'), +('CLIENT003', 2, '{"version": "1.0"}', 24226, 'waf-prod-02'); +``` + +--- + +### Ключевое поле: containers_count + +**Назначение:** Определяет сколько Docker контейнеров нужно создать для клиента. + +**Логика:** +- `containers_count = 3` → создать ptaf_client_001, ptaf_client_002, ptaf_client_003 +- При увеличении → создаются новые контейнеры +- При уменьшении → лишние контейнеры помечаются на graceful shutdown (90s) + +**Пример изменения:** +```sql +-- Увеличить до 5 контейнеров +UPDATE client_info +SET containers_count = 5 +WHERE client_title = 'CLIENT001'; + +-- Manager автоматически создаст ptaf_client_004 и ptaf_client_005 +``` + +--- + +## Таблица: apps_settings + +### Назначение + +Основная таблица с настройками приложений и кастомными директивами Nginx/Angie. + +### Структура + +| Поле | Тип | Описание | Значение по умолчанию | +|------|-----|----------|----------------------| +| `l7resourceid` | INTEGER | ID ресурса | Первичный ключ | +| `client_title` | TEXT | Клиент (FK) | `CLIENT001` | +| `waf_enabled` | BOOLEAN | WAF включен | `true` | +| `sni` | INTEGER | SNI режим (0=keepalive on, 1=keepalive off) | `0` | +| `custom_input_http_ports` | INTEGER[] | HTTP порты для прослушивания | `{80, 8080}` | +| `custom_input_https_ports` | INTEGER[] | HTTPS порты для прослушивания | `{443, 8443}` | +| `custom_output_http_ports` | INTEGER[] | HTTP порты к backend | `{80}` | +| `custom_output_https_ports` | INTEGER[] | HTTPS порты к backend | `{443}` | +| `upstream_angie_custom` | TEXT | Кастомные upstream директивы (Angie) | `NULL` | +| `upstream_nginx_custom` | TEXT | Кастомные upstream директивы (Nginx) | `NULL` | +| `server_angie_custom` | TEXT | Полная замена server блока (Angie) | `NULL` | +| `server_nginx_custom` | TEXT | Полная замена server блока (Nginx) | `NULL` | +| `server_directives_angie_custom` | TEXT | Кастомные server директивы (Angie) | `NULL` | +| `server_directives_nginx_custom` | TEXT | Кастомные server директивы (Nginx) | `NULL` | +| `location_angie_custom` | TEXT | Кастомные location блоки (Angie) | `NULL` | +| `location_nginx_custom` | TEXT | Кастомные location блоки (Nginx) | `NULL` | + +--- + +### Используемые запросы + +#### Получение настроек по client_title + +```sql +SELECT + l7resourceid, + waf_enabled, + custom_input_http_ports, + custom_input_https_ports, + custom_output_http_ports, + custom_output_https_ports, + upstream_angie_custom, + server_nginx_custom, + server_angie_custom, + client_title, + upstream_nginx_custom, + location_angie_custom, + location_nginx_custom, + server_directives_angie_custom, + server_directives_nginx_custom, + sni +FROM apps_settings +WHERE client_title = $1 +ORDER BY l7resourceid ASC; +``` + +**Функция:** `getAppsSettingsByClientTitle(clientTitle string)` + +**Использование:** Получение всех настроек приложений для клиента. + +--- + +### Примеры данных + +```sql +INSERT INTO apps_settings +(l7resourceid, client_title, waf_enabled, sni, + custom_input_http_ports, custom_input_https_ports, + custom_output_http_ports, custom_output_https_ports) +VALUES +(12323, 'CLIENT001', true, 0, '{80}', '{443}', '{80}', '{443}'), +(12324, 'CLIENT001', true, 0, '{80,8080}', '{443,8443}', '{80}', '{443}'), +(15000, 'CLIENT002', true, 0, '{80}', '{443}', '{80}', '{443}'); +``` + +--- + +### Кастомные поля (подробно) + +#### upstream_angie_custom / upstream_nginx_custom + +**Режим:** REPLACE (полная замена keepalive директив) + +**NULL значение:** +```nginx +upstream backend { + server 127.0.0.1:21000; + + # Default upstream directives + keepalive 60; + keepalive_timeout 70s; +} +``` + +**НЕ NULL значение:** +```nginx +upstream backend { + server 127.0.0.1:21000; + + # Custom upstream directives (replace defaults) + keepalive 200; + keepalive_timeout 90s; + least_conn; +} +``` + +**Пример SQL:** +```sql +-- Изменить балансировку на least_conn +UPDATE apps_settings +SET upstream_angie_custom = 'keepalive 60; +keepalive_timeout 70s; +least_conn;' +WHERE l7resourceid = 12323; + +-- Вернуть дефолты +UPDATE apps_settings +SET upstream_angie_custom = NULL +WHERE l7resourceid = 12323; +``` + +--- + +#### server_directives_angie_custom / server_directives_nginx_custom + +**Режим:** REPLACE (замена else блока в server) + +**NULL значение:** +```nginx +server { + listen 443 ssl; + + # Default directives + keepalive_timeout 80s; + send_timeout 60s; + proxy_connect_timeout 60s; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + + large_client_header_buffers 4 128k; + proxy_buffers 4 32k; + proxy_buffer_size 16k; + proxy_busy_buffers_size 32k; +} +``` + +**НЕ NULL значение:** +```nginx +server { + listen 443 ssl; + + # Custom directives (replace defaults) + keepalive_timeout 120s; + client_max_body_size 100m; + # Нужно указать ВСЕ директивы, включая буферы! +} +``` + +**Пример SQL:** +```sql +-- Увеличить таймауты +UPDATE apps_settings +SET server_directives_angie_custom = 'keepalive_timeout 120s; +send_timeout 90s; +proxy_connect_timeout 90s; +proxy_read_timeout 180s; +proxy_send_timeout 180s; + +large_client_header_buffers 4 128k; +proxy_buffers 4 32k; +proxy_buffer_size 16k;' +WHERE l7resourceid = 12323; +``` + +--- + +#### server_angie_custom / server_nginx_custom + +**Режим:** SERVER_BLOCK (полная замена всего server блока) + +**NULL значение:** Используется стандартный шаблон server блока + +**НЕ NULL значение:** Полная замена server блока + +**Пример SQL:** +```sql +-- Создать полностью кастомный server блок +UPDATE apps_settings +SET server_angie_custom = 'server { + listen 9999; + server_name special.example.com; + + location / { + return 200 "Custom response"; + } +}' +WHERE l7resourceid = 12323; +``` + +--- + +#### location_angie_custom / location_nginx_custom + +**Режим:** APPEND (добавление к дефолтному location) + +**NULL значение:** Только стандартный `location / { proxy_pass }` + +**НЕ NULL значение:** Добавляются дополнительные location блоки + +**Пример SQL:** +```sql +-- Добавить location для API +UPDATE apps_settings +SET location_nginx_custom = 'location /api { + proxy_pass http://api.backend.com; + proxy_set_header X-API-Key secret; +} + +location /static { + alias /var/www/static; +}' +WHERE l7resourceid = 12323; +``` + +--- + +#### sni (Server Name Indication) + +**Значения:** +- `0` - Keepalive ВКЛЮЧЕН (дефолт) +- `1` - Keepalive ОТКЛЮЧЕН + +**Когда sni = 1:** +- Директивы `keepalive` и `keepalive_timeout` НЕ добавляются в upstream +- Используется для SNI passthrough (прямая передача без терминации SSL) + +**Пример SQL:** +```sql +-- Отключить keepalive для SNI passthrough +UPDATE apps_settings +SET sni = 1 +WHERE l7resourceid = 12323; +``` + +--- + +#### custom_input_*_ports и custom_output_*_ports + +**custom_input_http_ports / custom_input_https_ports:** +- Порты на которых Angie и Nginx контейнеры будут слушать +- Angie слушает на этих портах (80, 443, 8080, 8443) +- Nginx контейнеры слушают на этих же портах + +**custom_output_http_ports / custom_output_https_ports:** +- Порты к которым Nginx контейнеры будут проксировать на backend +- Обычно {80} для HTTP и {443} для HTTPS +- Могут быть нестандартные порты (8080, 8443) + +**Пример:** +```sql +-- Приложение слушает на портах 80, 8080 (HTTP) и 443, 8443 (HTTPS) +-- Backend работает на порту 443 +UPDATE apps_settings +SET + custom_input_http_ports = '{80, 8080}', + custom_input_https_ports = '{443, 8443}', + custom_output_http_ports = '{80}', + custom_output_https_ports = '{443}' +WHERE l7resourceid = 12323; +``` + +**Результат:** +```nginx +# Angie +server { + listen 80; # custom_input_http_ports[0] + listen 8080; # custom_input_http_ports[1] + listen 443 ssl; # custom_input_https_ports[0] + listen 8443 ssl; # custom_input_https_ports[1] +} + +# Nginx upstream +upstream backend { + server 185.22.65.148:443; # custom_output_https_ports[0] +} +``` + +--- + +## Таблица: ips + +### Назначение + +Хранит IP адреса для WAF и antibot сетей (белые списки). + +### Структура + +| Поле | Тип | Описание | +|------|-----|----------| +| `antibot_networks` | TEXT[] | IP сети для antibot | +| `waf_networks` | TEXT[] | IP сети для WAF | + +### Используемые запросы + +```sql +SELECT antibot_networks, waf_networks +FROM ips; +``` + +**Функция:** `loadIPsFromDB()` + +**Использование:** При старте программы для загрузки IP сетей. + +**Пример данных:** +```sql +INSERT INTO ips (antibot_networks, waf_networks) VALUES +( + '{172.16.0.0/12, 10.0.0.0/8}', + '{172.16.0.0/12, 10.100.0.0/16}' +); +``` + +**Использование в конфигах:** +```nginx +server { + # Доверенные IP для получения реального IP клиента + set_real_ip_from 172.16.0.0/12; + set_real_ip_from 10.100.0.0/16; + real_ip_header X-Forwarded-For; +} +``` + +--- + +## REST API (origins и aliases) + +### Origins (Backend серверы) + +**Не хранятся в БД**, получаются через REST API. + +**Эндпоинт:** `/api/waf-proxy/get-apps-and-resourses` + +**Функция:** `makeAPIRequest()`, `getResourceDataFromCache()` + +**Пример ответа:** +```json +{ + "data": { + "result": { + "items": [ + { + "id": 1, + "ip": "185.22.65.148", + "weight": 100, + "mode": "active", + "comment": "Primary backend" + }, + { + "id": 2, + "ip": "185.22.65.149", + "weight": 50, + "mode": "backup", + "comment": "Backup backend" + } + ] + } + } +} +``` + +**Использование в конфигах:** +```nginx +upstream backend { + server 185.22.65.148:443 weight=100; + server 185.22.65.149:443 weight=50 backup; +} +``` + +--- + +### Aliases (Дополнительные домены) + +**Не хранятся в БД**, получаются через REST API. + +**Эндпоинт:** `/api/waf-proxy/get-apps-and-resourses` + +**Пример ответа:** +```json +{ + "data": { + "result": { + "items": [ + { + "id": 1, + "alias": "www.example.com" + }, + { + "id": 2, + "alias": "app.example.com" + } + ] + } + } +} +``` + +**Использование в конфигах:** +```nginx +server { + listen 443 ssl; + server_name example.com www.example.com app.example.com; +} +``` + +--- + +## Связи между таблицами + +``` +instances + ↓ (hostname → instance) +client_info (waf_instance) + ↓ (client_title) +apps_settings (client_title) + ↓ (l7resourceid через API) +Origins (от REST API) +Aliases (от REST API) +``` + +**Поток данных:** + +1. Program запускается на хосте с hostname = `host01.example.com` +2. Запрос к `instances` → получаем `instance = "waf-prod-01"` +3. Запрос к `client_info` WHERE `waf_instance = "waf-prod-01"` → получаем клиентов +4. Для каждого клиента запрос к `apps_settings` WHERE `client_title = "CLIENT001"` +5. Для каждого ресурса API запрос → получаем origins и aliases +6. Генерируем конфиги на основе всех данных + +--- + +## Примеры типичных запросов + +### Получить все ресурсы клиента + +```sql +SELECT + a.l7resourceid, + a.custom_input_https_ports, + a.upstream_angie_custom, + c.containers_count +FROM apps_settings a +JOIN client_info c ON a.client_title = c.client_title +WHERE a.client_title = 'CLIENT001' +ORDER BY a.l7resourceid; +``` + +--- + +### Изменить количество контейнеров + +```sql +-- Увеличить до 5 +UPDATE client_info +SET containers_count = 5 +WHERE client_title = 'CLIENT001'; + +-- Проверка +SELECT client_title, containers_count +FROM client_info +WHERE client_title = 'CLIENT001'; +``` + +--- + +### Добавить кастомную балансировку + +```sql +UPDATE apps_settings +SET upstream_nginx_custom = 'keepalive 60; +keepalive_timeout 70s; +least_conn;' +WHERE l7resourceid = 12323; + +-- Проверка +SELECT l7resourceid, upstream_nginx_custom +FROM apps_settings +WHERE l7resourceid = 12323; +``` + +--- + +### Включить SNI passthrough + +```sql +UPDATE apps_settings +SET sni = 1 +WHERE l7resourceid = 12323; + +-- Проверка +SELECT l7resourceid, sni +FROM apps_settings +WHERE sni = 1; +``` + +--- + +### Добавить дополнительные порты + +```sql +UPDATE apps_settings +SET + custom_input_https_ports = '{443, 8443, 9443}', + custom_output_https_ports = '{443, 8443, 9443}' +WHERE l7resourceid = 12323; + +-- Проверка +SELECT l7resourceid, custom_input_https_ports, custom_output_https_ports +FROM apps_settings +WHERE l7resourceid = 12323; +``` + +--- + +## Индексы (рекомендуемые) + +```sql +-- Для быстрого поиска по hostname +CREATE INDEX idx_instances_hostname ON instances(hostname); + +-- Для быстрого поиска клиентов по instance +CREATE INDEX idx_client_info_waf_instance ON client_info(waf_instance); +CREATE INDEX idx_client_info_client_title ON client_info(client_title); + +-- Для быстрого поиска настроек по client_title +CREATE INDEX idx_apps_settings_client_title ON apps_settings(client_title); +CREATE INDEX idx_apps_settings_l7resourceid ON apps_settings(l7resourceid); +``` + +--- + +## Миграции и изменения схемы + +### Добавление нового кастомного поля + +```sql +-- Добавить новое поле для header манипуляций +ALTER TABLE apps_settings +ADD COLUMN headers_custom TEXT DEFAULT NULL; + +-- Обновить код для чтения этого поля +``` + +--- + +### Изменение типа поля + +```sql +-- Изменить тип sni на boolean (если сейчас integer) +ALTER TABLE apps_settings +ALTER COLUMN sni TYPE BOOLEAN +USING (sni::integer = 0); +``` + +--- + +## Best Practices + +### 1. NULL vs пустая строка + +**Правильно:** +```sql +UPDATE apps_settings +SET upstream_angie_custom = NULL -- NULL для дефолтов +WHERE l7resourceid = 12323; +``` + +**Неправильно:** +```sql +UPDATE apps_settings +SET upstream_angie_custom = '' -- Пустая строка может вызвать ошибки +WHERE l7resourceid = 12323; +``` + +--- + +### 2. Массивы портов + +**Правильно:** +```sql +UPDATE apps_settings +SET custom_input_https_ports = '{443, 8443}' -- PostgreSQL array +WHERE l7resourceid = 12323; +``` + +**Неправильно:** +```sql +UPDATE apps_settings +SET custom_input_https_ports = '443,8443' -- Строка, не массив +WHERE l7resourceid = 12323; +``` + +--- + +### 3. Многострочные кастомные директивы + +**Правильно:** +```sql +UPDATE apps_settings +SET server_directives_angie_custom = 'keepalive_timeout 80s; +send_timeout 60s; +proxy_buffers 4 32k;' +WHERE l7resourceid = 12323; +``` + +**Также правильно (с экранированием):** +```sql +UPDATE apps_settings +SET server_directives_angie_custom = E'keepalive_timeout 80s;\nsend_timeout 60s;' +WHERE l7resourceid = 12323; +``` + +--- + +## Мониторинг БД + +### Проверка соединения + +```sql +-- Проверить активные соединения +SELECT + datname, + usename, + client_addr, + state, + query_start +FROM pg_stat_activity +WHERE datname = 'waf_info'; +``` + +--- + +### Проверка данных + +```sql +-- Количество клиентов на каждом instance +SELECT waf_instance, COUNT(*) as clients_count +FROM client_info +GROUP BY waf_instance; + +-- Количество ресурсов на клиента +SELECT client_title, COUNT(*) as resources_count +FROM apps_settings +GROUP BY client_title; + +-- Клиенты с кастомными upstream директивами +SELECT client_title, l7resourceid +FROM apps_settings +WHERE upstream_angie_custom IS NOT NULL + OR upstream_nginx_custom IS NOT NULL; +``` + +--- + +## Резервное копирование + +```bash +# Дамп всей БД +pg_dump -h 10.100.10.8 -U install -d waf_info > waf_info_backup.sql + +# Дамп только структуры +pg_dump -h 10.100.10.8 -U install -d waf_info --schema-only > schema.sql + +# Дамп только данных +pg_dump -h 10.100.10.8 -U install -d waf_info --data-only > data.sql + +# Восстановление +psql -h 10.100.10.8 -U install -d waf_info < waf_info_backup.sql +``` + +--- + +## Итого + +### Основные таблицы + +| Таблица | Записей (примерно) | Частота изменений | +|---------|-------------------|-------------------| +| instances | 10-50 | Редко (при добавлении хостов) | +| client_info | 50-500 | Редко (при добавлении клиентов) | +| apps_settings | 500-5000 | Часто (изменение настроек) | +| ips | 1 | Очень редко | + +### Ключевые связи + +- `instances.instance` → `client_info.waf_instance` +- `client_info.client_title` → `apps_settings.client_title` +- `apps_settings.l7resourceid` → REST API (origins, aliases) + +### Кастомизация + +Все кастомные поля в `apps_settings`: +- 8 полей для кастомных директив +- 4 режима: REPLACE (upstream, server_directives), SERVER_BLOCK, APPEND (location) +- NULL = дефолты, NOT NULL = кастом diff --git a/README.md b/README.md new file mode 100644 index 0000000..25b6712 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# Magos + +Программа для автоматизированного управления конфигурациями WAF. + +## Описание + +Программа выполняет следующие задачи: + +1. Подключается к PostgreSQL БД (primary/secondary) +2. Определяет instance текущего хоста по hostname +3. Получает биндинги для данного instance из таблицы `apps_settings` +4. Группирует биндинги по `client_title` +5. Для каждого клиента: + - Получает данные из БД или через API ServicePipe (server_name, aliases, origins) + - Выделяет свободные порты для контейнеров + - Генерирует конфигурационные файлы Angie + - Создает директории для контейнеров + - Генерирует конфигурационные файлы Nginx для контейнеров + - Генерирует docker-compose.yml файлы + - Запускает контейнеры через docker-compose + +## Подробности + +Изучить как работает программа можно в файле ARCHITECTURE.md. + +Посмотреть зависимость работы программы от БД в файле DATABASE_SCHEMA.md diff --git a/api.go b/api.go new file mode 100644 index 0000000..1cde3ab --- /dev/null +++ b/api.go @@ -0,0 +1,364 @@ +package main + +import ( + "database/sql" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" +) + +// apiClient — переиспользуемый HTTP-клиент для всех API запросов. +// http.Client безопасен для конкурентного использования и переиспользует соединения. +var apiClient = &http.Client{ + Timeout: 120 * time.Second, +} + +// makeAPIRequest выполняет GET запрос к API с авторизацией и повторными попытками +func makeAPIRequest(url, token string) ([]byte, error) { + maxRetries := 3 + var lastErr error + + for attempt := 1; attempt <= maxRetries; attempt++ { + body, err := doAPIRequest(url, token) + if err == nil { + if attempt > 1 { + log.Printf(" ✓ Запрос успешен после %d попыток", attempt) + } + return body, nil + } + + lastErr = err + if attempt < maxRetries { + waitTime := time.Duration(attempt*attempt) * time.Second // 1s, 4s, 9s + log.Printf(" ⚠ Попытка %d/%d не удалась: %v, повтор через %v", attempt, maxRetries, err, waitTime) + time.Sleep(waitTime) + } + } + + return nil, fmt.Errorf("запрос не удался после %d попыток: %w", maxRetries, lastErr) +} + +// doAPIRequest выполняет одиночный API запрос (без retry) +func doAPIRequest(url, token string) ([]byte, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("ошибка создания запроса: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := apiClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("ошибка чтения ответа: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API вернул код %d, URL: %s, ответ: %s", resp.StatusCode, url, string(body)) + } + + return body, nil +} + +// getResourceDataFromCache пытается получить данные из таблицы sp_info +func getResourceDataFromCache(db *sql.DB, config Config, l7ResourceID int) (serverName string, aliases []string, origins []OriginItem, found bool, err error) { + query := ` + SELECT + domain_name, + aliases, + origins, + updated_at, + EXTRACT(EPOCH FROM (NOW() - updated_at)) / 60 AS age_minutes + FROM sp_info + WHERE sid = $1 + ` + + var domainName sql.NullString + var aliasesJSON sql.NullString + var originsJSON sql.NullString + var updatedAt sql.NullTime + var ageMinutes sql.NullFloat64 + + err = db.QueryRow(query, l7ResourceID).Scan(&domainName, &aliasesJSON, &originsJSON, &updatedAt, &ageMinutes) + if err != nil { + if err == sql.ErrNoRows { + return "", nil, nil, false, nil // Данных нет в кеше + } + return "", nil, nil, false, fmt.Errorf("ошибка чтения из sp_info: %w", err) + } + + // Проверяем свежесть данных (не старее 10 минут) + if !ageMinutes.Valid { + log.Printf(" ⚠ updated_at не заполнен в кеше, обращаюсь к API") + return "", nil, nil, false, nil + } + + age := ageMinutes.Float64 + if age < 0 { + age = 0 + } + + if age > 10 { + log.Printf(" ⚠ Данные в кеше устарели (возраст: %.1f мин), обращаюсь к API", age) + return "", nil, nil, false, nil + } + + log.Printf(" ✓ Данные из кеша актуальны (возраст: %.1f мин)", age) + + if domainName.Valid { + serverName = domainName.String + } + + aliases, err = parseAliasesJSON(aliasesJSON) + if err != nil { + return "", nil, nil, false, fmt.Errorf("ошибка парсинга aliases из sp_info: %w", err) + } + + origins, err = parseOriginsJSON(originsJSON, config.WAFNetworks) + if err != nil { + return "", nil, nil, false, fmt.Errorf("ошибка парсинга origins из sp_info: %w", err) + } + + return serverName, aliases, origins, true, nil +} + +// getResourceDataFromManualInfo получает данные из таблицы manual_info (ручное управление) +// В отличие от sp_info, не проверяет timestamp - данные всегда актуальны +func getResourceDataFromManualInfo(db *sql.DB, config Config, l7ResourceID int) (serverName string, aliases []string, origins []OriginItem, err error) { + query := ` + SELECT + domain_name, + aliases, + origins + FROM manual_info + WHERE sid = $1 + ` + + var domainName sql.NullString + var aliasesJSON sql.NullString + var originsJSON sql.NullString + + err = db.QueryRow(query, l7ResourceID).Scan(&domainName, &aliasesJSON, &originsJSON) + if err != nil { + if err == sql.ErrNoRows { + return "", nil, nil, fmt.Errorf("данные не найдены в manual_info для sid=%d", l7ResourceID) + } + return "", nil, nil, fmt.Errorf("ошибка чтения из manual_info: %w", err) + } + + if domainName.Valid { + serverName = domainName.String + } + + aliases, err = parseAliasesJSON(aliasesJSON) + if err != nil { + return "", nil, nil, fmt.Errorf("ошибка парсинга aliases из manual_info: %w", err) + } + + origins, err = parseOriginsJSON(originsJSON, config.WAFNetworks) + if err != nil { + return "", nil, nil, fmt.Errorf("ошибка парсинга origins из manual_info: %w", err) + } + + return serverName, aliases, origins, nil +} + +// parseAliasesJSON парсит JSON массив алиасов из sql.NullString +func parseAliasesJSON(aliasesJSON sql.NullString) ([]string, error) { + if !aliasesJSON.Valid || aliasesJSON.String == "" || aliasesJSON.String == "null" { + return nil, nil + } + + var aliases []string + if err := json.Unmarshal([]byte(aliasesJSON.String), &aliases); err != nil { + return nil, err + } + return aliases, nil +} + +// parseOriginsJSON парсит JSON массив origins из sql.NullString, фильтруя WAF IP +func parseOriginsJSON(originsJSON sql.NullString, wafNetworks []string) ([]OriginItem, error) { + if !originsJSON.Valid || originsJSON.String == "" { + return nil, nil + } + + var originsData []struct { + IP string `json:"ip"` + Mode string `json:"mode"` + Weight int `json:"weight"` + } + if err := json.Unmarshal([]byte(originsJSON.String), &originsData); err != nil { + return nil, err + } + + var origins []OriginItem + for _, o := range originsData { + if !isIPInWAFNetworks(o.IP, wafNetworks) { + origins = append(origins, OriginItem{ + IP: o.IP, + Weight: o.Weight, + Mode: o.Mode, + }) + } + } + return origins, nil +} + +// getServerName получает имя сервера (l7ResourceName) через API +func getServerName(config Config, l7ResourceID int) (string, error) { + url := fmt.Sprintf("https://api.servicepipe.ru/api/v1/l7/resource/%d/global", l7ResourceID) + body, err := makeAPIRequest(url, config.APIToken) + if err != nil { + return "", err + } + + var response ResourceResponse + if err := json.Unmarshal(body, &response); err != nil { + return "", fmt.Errorf("ошибка парсинга JSON: %w", err) + } + + return response.Data.Result.L7ResourceName, nil +} + +// getAliases получает список aliases через API +func getAliases(config Config, l7ResourceID int) ([]string, error) { + url := fmt.Sprintf("https://api.servicepipe.ru/api/v1/l7/alias/global?limit=1000&l7ResourceId=%d", l7ResourceID) + body, err := makeAPIRequest(url, config.APIToken) + if err != nil { + return nil, err + } + + var response AliasResponse + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("ошибка парсинга JSON: %w", err) + } + + var aliases []string + for _, item := range response.Data.Result.Items { + aliases = append(aliases, item.Domain) + } + + return aliases, nil +} + +// getOrigins получает список origins через API, исключая WAF IP +func getOrigins(config Config, l7ResourceID int) ([]OriginItem, error) { + url := fmt.Sprintf("https://api.servicepipe.ru/api/v1/l7/origin/global?limit=1000&l7ResourceId=%d", l7ResourceID) + body, err := makeAPIRequest(url, config.APIToken) + if err != nil { + return nil, err + } + + var response OriginResponse + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("ошибка парсинга JSON: %w", err) + } + + var origins []OriginItem + for _, item := range response.Data.Result.Items { + if !isIPInWAFNetworks(item.IP, config.WAFNetworks) { + origins = append(origins, OriginItem{ + IP: item.IP, + Weight: item.Weight, + Mode: item.Mode, + }) + } + } + + return origins, nil +} + +// getInputHTTPPorts возвращает список HTTP INPUT портов из AppsSettings или дефолтные +func getInputHTTPPorts(settings AppsSettings) []int { + if len(settings.CustomInputHTTPPorts) > 0 { + return int64ArrayToIntSlice(settings.CustomInputHTTPPorts) + } + return []int{80} +} + +// getInputHTTPSPorts возвращает список HTTPS INPUT портов из AppsSettings или дефолтные +func getInputHTTPSPorts(settings AppsSettings) []int { + if len(settings.CustomInputHTTPSPorts) > 0 { + return int64ArrayToIntSlice(settings.CustomInputHTTPSPorts) + } + return []int{443} +} + +// getOutputHTTPPorts возвращает список HTTP OUTPUT портов из AppsSettings или дефолтные +func getOutputHTTPPorts(settings AppsSettings) []int { + if len(settings.CustomOutputHTTPPorts) > 0 { + return int64ArrayToIntSlice(settings.CustomOutputHTTPPorts) + } + return []int{80} +} + +// getOutputHTTPSPorts возвращает список HTTPS OUTPUT портов из AppsSettings или дефолтные +func getOutputHTTPSPorts(settings AppsSettings) []int { + if len(settings.CustomOutputHTTPSPorts) > 0 { + return int64ArrayToIntSlice(settings.CustomOutputHTTPSPorts) + } + return []int{443} +} + +// int64ArrayToIntSlice конвертирует pq.Int64Array в []int +func int64ArrayToIntSlice(arr []int64) []int { + result := make([]int, len(arr)) + for i, v := range arr { + result[i] = int(v) + } + return result +} + +// writeCustomUpstreamOrDefault записывает кастомные или дефолтные upstream-директивы +func writeCustomUpstreamOrDefault(w stringWriter, customUpstream sql.NullString, sni sql.NullInt64) { + hasCustomUpstream := customUpstream.Valid && customUpstream.String != "" + + if hasCustomUpstream { + w.WriteString("\n # Custom upstream directives (replace defaults)\n") + lines := strings.Split(strings.TrimSpace(customUpstream.String), "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed != "" { + w.WriteString(" " + trimmed + "\n") + } + } + } else { + // Если нет custom - используем дефолтные keepalive директивы (только если sni != 1) + if !sni.Valid || sni.Int64 != 1 { + w.WriteString("\n # Default upstream directives\n") + w.WriteString(" keepalive 60;\n") + w.WriteString(" keepalive_timeout 70s;\n") + } + } +} + +// stringWriter — интерфейс для записи строк (совпадает с *os.File и *strings.Builder) +type stringWriter interface { + WriteString(s string) (int, error) +} + +// getMaxFails возвращает max_fails из AppsSettings или дефолтное значение 5 +func getMaxFails(settings AppsSettings) int { + if settings.MaxFails.Valid && settings.MaxFails.Int64 >= 0 { + return int(settings.MaxFails.Int64) + } + return 5 // дефолт +} + +// getFailTimeout возвращает fail_timeout из AppsSettings или дефолтное значение 15 +func getFailTimeout(settings AppsSettings) int { + if settings.FailTimeout.Valid && settings.FailTimeout.Int64 >= 0 { + return int(settings.FailTimeout.Int64) + } + return 15 // дефолт +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..4462ed4 --- /dev/null +++ b/config.go @@ -0,0 +1,225 @@ +package main + +import ( + "database/sql" + "fmt" + "log" + "net" + "os" + "strconv" + + "github.com/joho/godotenv" + "github.com/lib/pq" +) + +// Константы для graceful shutdown +const ( + // Время ожидания перед удалением контейнера при масштабировании вниз (в секундах) + // Должно быть больше чем keepalive_timeout в nginx (обычно 60-75 секунд) + ContainerDrainTimeout = 90 + + // Время ожидания перед удалением контейнера при миграции между хостами (в секундах) + // Увеличенное время для безопасного переключения трафика на новый хост + MigrationDrainTimeout = 3600 // 1 час + + // Максимальное количество контейнеров на одного клиента + MaxContainersPerClient = 20 +) + +// Константы диапазонов портов для Docker +const ( + HTTPPortStart = 18000 // Начало диапазона HTTP портов + HTTPPortEnd = 20999 // Конец диапазона HTTP портов (3000 портов) + HTTPSPortStart = 21000 // Начало диапазона HTTPS портов + HTTPSPortEnd = 23999 // Конец диапазона HTTPS портов (3000 портов) +) + +// Пути к файлам конфигурации +const ( + EnvFilePath = "/etc/magos/magos.env" +) + +// Константы конфигурации по умолчанию (используются как fallback если env не загружен) +const ( + DefaultDBName = "waf_info" + DefaultDBPort = 5432 + DefaultPrimaryDBHost = "10.100.10.8" + DefaultSecondaryDBHost = "10.100.13.5" + DefaultDockerImage = "ptaf-core-nginx-agent:release-4.1.6.409465" + DefaultWorkerProcesses = 4 + DefaultWorkerConnections = 64000 +) + +// Конфигурация приложения +type Config struct { + DBUser string + DBPassword string + DBName string + DBPort int + PrimaryDBHost string + SecondaryDBHost string + APIToken string + AntibotNetworks []string + WAFNetworks []string + WorkerProcesses int + WorkerConnections int + DockerImage string +} + +// loadEnvFile загружает переменные окружения из файла /etc/magos/magos.env +func loadEnvFile() error { + if err := godotenv.Load(EnvFilePath); err != nil { + return fmt.Errorf("ошибка загрузки %s: %w", EnvFilePath, err) + } + log.Printf("✓ Загружен файл конфигурации: %s", EnvFilePath) + return nil +} + +// getEnv возвращает значение переменной окружения или дефолтное значение +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +// getEnvInt возвращает int значение переменной окружения или дефолтное значение +func getEnvInt(key string, defaultValue int) int { + if value := os.Getenv(key); value != "" { + if intVal, err := strconv.Atoi(value); err == nil { + return intVal + } + log.Printf("⚠ Некорректное значение %s=%s, используется дефолт %d", key, value, defaultValue) + } + return defaultValue +} + +// getDefaultConfig возвращает конфигурацию из env-файла или дефолтные значения +func getDefaultConfig() Config { + // Пытаемся загрузить env файл (не критично если не получится) + if err := loadEnvFile(); err != nil { + log.Printf("⚠ %v", err) + log.Printf(" Используются дефолтные значения для DB_USER, DB_PASSWORD, API_TOKEN") + } + + return Config{ + DBUser: getEnv("DB_USER", ""), + DBPassword: getEnv("DB_PASSWORD", ""), + DBName: getEnv("DB_NAME", DefaultDBName), + DBPort: getEnvInt("DB_PORT", DefaultDBPort), + PrimaryDBHost: getEnv("PRIMARY_DB_HOST", DefaultPrimaryDBHost), + SecondaryDBHost: getEnv("SECONDARY_DB_HOST", DefaultSecondaryDBHost), + APIToken: getEnv("API_TOKEN", ""), + AntibotNetworks: []string{ + "185.66.85.0/24", + "185.66.86.0/24", + "185.35.5.0/24", + "185.35.6.0/24", + "212.67.26.0/24", + }, + WAFNetworks: []string{ + "109.238.89.0/24", + "89.20.63.0/24", + }, + WorkerProcesses: getEnvInt("WORKER_PROCESSES", DefaultWorkerProcesses), + WorkerConnections: getEnvInt("WORKER_CONNECTIONS", DefaultWorkerConnections), + DockerImage: getEnv("DOCKER_IMAGE", DefaultDockerImage), + } +} + +// initConfig инициализирует конфигурацию, загружая IP списки из БД +func initConfig(db *sql.DB) (Config, error) { + // Читаем IP адреса и сети из таблицы ips + antibotNetworks, wafNetworks, err := loadIPsFromDB(db) + if err != nil { + log.Printf("⚠ Не удалось загрузить IP настройки из БД: %v", err) + log.Printf(" Используются значения по умолчанию") + return getDefaultConfig(), nil + } + + // Загружаем env файл для получения credentials + if err := loadEnvFile(); err != nil { + log.Printf("⚠ %v", err) + log.Printf(" Используются дефолтные значения для DB_USER, DB_PASSWORD, API_TOKEN") + } + + // Формируем Config с настройками из env + IP из БД + config := Config{ + DBUser: getEnv("DB_USER", ""), + DBPassword: getEnv("DB_PASSWORD", ""), + DBName: getEnv("DB_NAME", DefaultDBName), + DBPort: getEnvInt("DB_PORT", DefaultDBPort), + PrimaryDBHost: getEnv("PRIMARY_DB_HOST", DefaultPrimaryDBHost), + SecondaryDBHost: getEnv("SECONDARY_DB_HOST", DefaultSecondaryDBHost), + APIToken: getEnv("API_TOKEN", ""), + DockerImage: getEnv("DOCKER_IMAGE", DefaultDockerImage), + WorkerProcesses: getEnvInt("WORKER_PROCESSES", DefaultWorkerProcesses), + WorkerConnections: getEnvInt("WORKER_CONNECTIONS", DefaultWorkerConnections), + AntibotNetworks: antibotNetworks, + WAFNetworks: wafNetworks, + } + + // Валидация критичных параметров + if config.DBUser == "" { + log.Printf("⚠ DB_USER не установлен в %s", EnvFilePath) + } + if config.DBPassword == "" { + log.Printf("⚠ DB_PASSWORD не установлен в %s", EnvFilePath) + } + if config.APIToken == "" { + log.Printf("⚠ API_TOKEN не установлен в %s", EnvFilePath) + } + + log.Printf("✓ IP конфигурация загружена из таблицы ips") + log.Printf(" - Antibot Networks: %d сетей", len(config.AntibotNetworks)) + log.Printf(" - WAF Networks: %d сетей", len(config.WAFNetworks)) + + return config, nil +} + +// loadIPsFromDB загружает списки IP адресов и сетей из таблицы ips +func loadIPsFromDB(db *sql.DB) (antibotNetworks []string, wafNetworks []string, err error) { + query := ` + SELECT antibot_networks, waf_networks + FROM ips + WHERE id = 1 + ` + + // PostgreSQL массивы читаем через pq.Array + var antibotNetworksArray, wafNetworksArray []string + + err = db.QueryRow(query).Scan( + pq.Array(&antibotNetworksArray), + pq.Array(&wafNetworksArray), + ) + + if err != nil { + if err == sql.ErrNoRows { + return nil, nil, fmt.Errorf("записи с id=1 не найдено в таблице ips") + } + return nil, nil, fmt.Errorf("ошибка чтения из таблицы ips: %w", err) + } + + return antibotNetworksArray, wafNetworksArray, nil +} + +// isIPInWAFNetworks проверяет, входит ли IP адрес в одну из WAF подсетей +func isIPInWAFNetworks(ipStr string, wafNetworks []string) bool { + ip := net.ParseIP(ipStr) + if ip == nil { + return false + } + + for _, cidr := range wafNetworks { + _, ipNet, err := net.ParseCIDR(cidr) + if err != nil { + continue + } + + if ipNet.Contains(ip) { + return true + } + } + + return false +} diff --git a/containers_part1.go b/containers_part1.go new file mode 100644 index 0000000..f330d03 --- /dev/null +++ b/containers_part1.go @@ -0,0 +1,194 @@ +package main + +import ( + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "time" +) + +func setupContainers(config Config, clientTitle string, clientInfo *ClientInfo, resources []ResourceData, resourcePortMap map[int][]PortMapping) error { + log.Printf("Настройка контейнеров для %s", clientTitle) + baseDir := filepath.Join("/home/install/ptaf", clientTitle) + containerChanges := make(map[int]bool) + composeChanges := make(map[int]bool) + + for containerNum := 1; containerNum <= clientInfo.ContainersCount; containerNum++ { + containerName := fmt.Sprintf("%s-ptaf-agent%03d", clientTitle, containerNum) + containerDir := filepath.Join(baseDir, containerName) + log.Printf(" Обработка контейнера: %s", containerName) + + dirs := []string{ + containerDir, + filepath.Join("/home/install/conf/ptaf-nginx", clientTitle, fmt.Sprintf("ptaf-agent%03d", containerNum), "conf.d"), + } + for _, dir := range dirs { + existed := false + if _, err := os.Stat(dir); err == nil { + existed = true + } + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("ошибка создания директории %s: %w", dir, err) + } + if !existed { + log.Printf(" ✓ Создана директория: %s", dir) + } + } + + logDir := filepath.Join("/var/log/ptaf_nginx", clientTitle, fmt.Sprintf("ptaf-agent%03d", containerNum)) + logDirExisted := false + if _, err := os.Stat(logDir); err == nil { + logDirExisted = true + } + if err := os.MkdirAll(logDir, 0777); err != nil { + return fmt.Errorf("ошибка создания директории логов %s: %w", logDir, err) + } + if err := os.Chmod(logDir, 0777); err != nil { + return fmt.Errorf("ошибка установки прав для директории логов %s: %w", logDir, err) + } + if !logDirExisted { + log.Printf(" ✓ Создана директория логов: %s (права: 0777)", logDir) + } + + nginxConfPath := filepath.Join("/home/install/conf/ptaf-nginx", clientTitle, fmt.Sprintf("ptaf-agent%03d", containerNum), "nginx.conf") + existingPodIP := "" + if info, err := os.Stat(nginxConfPath); err == nil { + if info.IsDir() { + log.Printf(" ⚠ nginx.conf является директорией, удаляем...") + os.RemoveAll(nginxConfPath) + } else { + existingPodIP = extractPodIPFromConfig(nginxConfPath) + } + } + + ptafConfig := "" + if clientInfo.PTAFConfig.Valid { + ptafConfig = clientInfo.PTAFConfig.String + } + + changed, err := generateNginxConf(nginxConfPath, config, ptafConfig, existingPodIP) + if err != nil { + return fmt.Errorf("ошибка генерации nginx.conf: %w", err) + } + if changed { + log.Printf(" ✓ nginx.conf изменен: %s", nginxConfPath) + containerChanges[containerNum] = true + } else { + log.Printf(" ○ nginx.conf не изменился: %s", nginxConfPath) + } + + mimeTypesPath := filepath.Join("/home/install/conf/ptaf-nginx", clientTitle, fmt.Sprintf("ptaf-agent%03d", containerNum), "mime.types") + if info, err := os.Stat(mimeTypesPath); err == nil { + if info.IsDir() { + log.Printf(" ⚠ mime.types является директорией, удаляем...") + os.RemoveAll(mimeTypesPath) + } + } + changed, err = generateMimeTypes(mimeTypesPath) + if err != nil { + return fmt.Errorf("ошибка генерации mime.types: %w", err) + } + if changed { + log.Printf(" ✓ mime.types изменен: %s", mimeTypesPath) + containerChanges[containerNum] = true + } + + confDPath := filepath.Join("/home/install/conf/ptaf-nginx", clientTitle, fmt.Sprintf("ptaf-agent%03d", containerNum), "conf.d") + containerPorts := []PortMapping{} + for _, res := range resources { + ports, ok := resourcePortMap[res.L7ResourceID] + if !ok || containerNum-1 >= len(ports) { + return fmt.Errorf("не найдены порты для l7ResourceID %d контейнер %d", res.L7ResourceID, containerNum) + } + containerPorts = append(containerPorts, ports[containerNum-1]) + confFile := filepath.Join(confDPath, fmt.Sprintf("%s_%d.conf", clientTitle, res.L7ResourceID)) + oldHash := "" + if _, err := os.Stat(confFile); err == nil { + oldHash, _ = getFileHash(confFile) + } + err := generateNginxResourceConfig(confFile, config, res, ports[containerNum-1]) + if err != nil { + return fmt.Errorf("ошибка генерации конфига для ресурса: %w", err) + } + newHash, _ := getFileHash(confFile) + if oldHash != "" && oldHash != newHash { + log.Printf(" ✓ Конфиг ресурса изменен: %s", confFile) + containerChanges[containerNum] = true + } else if oldHash == "" { + log.Printf(" ✓ Создан конфиг ресурса: %s", confFile) + containerChanges[containerNum] = true + } else { + log.Printf(" ○ Конфиг ресурса не изменился: %s", confFile) + } + } + + composeFile := filepath.Join(containerDir, "docker-compose.yml") + log.Printf(" → DEBUG порты контейнера %d:", containerNum) + for idx, pm := range containerPorts { + log.Printf(" PortMapping[%d]: HTTP=%v, HTTPS=%v", idx, pm.HTTPPorts, pm.HTTPSPorts) + } + changed, err = generateDockerCompose(composeFile, config, clientTitle, clientInfo, containerNum, resources, containerPorts) + if err != nil { + return fmt.Errorf("ошибка генерации docker-compose.yml: %w", err) + } + if changed { + log.Printf(" ✓ docker-compose.yml изменен: %s", composeFile) + composeChanges[containerNum] = true + } + if err := saveContainerPorts(containerDir, containerNum, resources, containerPorts); err != nil { + log.Printf(" ⚠ Ошибка сохранения портов: %v", err) + } + } + + log.Printf(" Запуск и обновление контейнеров для %s", clientTitle) + for containerNum := 1; containerNum <= clientInfo.ContainersCount; containerNum++ { + containerName := fmt.Sprintf("%s-ptaf-agent%03d", clientTitle, containerNum) + containerDir := filepath.Join(baseDir, containerName) + composeFile := filepath.Join(containerDir, "docker-compose.yml") + containerFullName := fmt.Sprintf("ptaf_%s_%03d", clientTitle, containerNum) + containerExists, containerRunning := checkContainerStatus(containerFullName) + + if containerExists { + if composeChanges[containerNum] { + recreateContainer(containerFullName, composeFile, containerDir, clientTitle, resources, resourcePortMap, containerNum) + continue + } + if !containerRunning { + startContainer(containerFullName, composeFile, containerDir) + } + if containerChanges[containerNum] { + log.Printf(" Конфигурация изменилась, проверяем и перезагружаем nginx в %s", containerFullName) + if err := testNginxConfig(containerFullName); err != nil { + log.Printf(" ⚠ Ошибка проверки конфигурации в %s: %v", containerFullName, err) + continue + } + if err := reloadNginx(containerFullName); err != nil { + log.Printf(" ⚠ Ошибка перезагрузки nginx в %s: %v", containerFullName, err) + continue + } + log.Printf(" ✓ Nginx успешно перезагружен в контейнере %s", containerFullName) + } else { + log.Printf(" ○ Конфигурация не изменилась, проверка и перезагрузка не требуются для %s", containerFullName) + } + } else { + log.Printf(" Запуск нового контейнера %s", containerName) + cmd := exec.Command("docker-compose", "-f", composeFile, "up", "-d") + cmd.Dir = containerDir + output, _ := cmd.CombinedOutput() + log.Printf(" → Вывод docker-compose: %s", string(output)) + time.Sleep(5 * time.Second) + _, isRunning := checkContainerStatus(containerFullName) + if !isRunning { + log.Printf(" ⚠ Контейнер %s не запустился. Попробуйте вручную: cd %s && docker-compose up -d", containerFullName, containerDir) + continue + } + log.Printf(" ✓ Контейнер %s успешно запущен", containerName) + } + } + + log.Printf(" Проверка лишних контейнеров для %s", clientTitle) + processDrainingContainers(clientTitle, clientInfo.ContainersCount) + return nil +} diff --git a/containers_part2.go b/containers_part2.go new file mode 100644 index 0000000..b447931 --- /dev/null +++ b/containers_part2.go @@ -0,0 +1,676 @@ +package main + +import ( + "fmt" + "log" + "math/rand" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "text/template" + "time" +) + +func recreateContainer(containerFullName, composeFile, containerDir, clientTitle string, resources []ResourceData, resourcePortMap map[int][]PortMapping, containerNum int) { + log.Printf(" docker-compose.yml изменился, пересоздаём контейнер %s...", containerFullName) + portsToFree := make([]int, 0) + for _, res := range resources { + if containerPorts, ok := resourcePortMap[res.L7ResourceID]; ok { + if containerNum-1 < len(containerPorts) { + for _, dockerPort := range containerPorts[containerNum-1].HTTPPorts { + portsToFree = append(portsToFree, dockerPort) + } + for _, dockerPort := range containerPorts[containerNum-1].HTTPSPorts { + portsToFree = append(portsToFree, dockerPort) + } + } + } + } + log.Printf(" → Останавливаем старый контейнер...") + cmd := exec.Command("docker-compose", "-f", composeFile, "down") + cmd.Dir = containerDir + output, err := cmd.CombinedOutput() + if err != nil { + log.Printf(" ⚠ Ошибка остановки контейнера %s: %v, output: %s", containerFullName, err, string(output)) + } + + log.Printf(" → Ожидаем остановки контейнера...") + containerStillExists := false + for i := 0; i < 30; i++ { + exists, _ := checkContainerStatus(containerFullName) + if !exists { + break + } + containerStillExists = true + time.Sleep(1 * time.Second) + } + if containerStillExists { + exists, _ := checkContainerStatus(containerFullName) + if exists { + log.Printf(" → Контейнер не остановился, принудительное удаление...") + if err := forceRemoveContainer(containerFullName); err != nil { + log.Printf(" ⚠ Не удалось принудительно удалить контейнер: %v", err) + return + } + } + } + + if len(portsToFree) > 0 { + log.Printf(" → Ожидаем освобождения %d портов...", len(portsToFree)) + allPortsFree := false + for attempt := 0; attempt < 180; attempt++ { // Увеличено с 60 до 180 секунд + usedPorts, err := getUsedPorts() + if err != nil { + log.Printf(" ⚠ Ошибка проверки портов: %v", err) + time.Sleep(1 * time.Second) + continue + } + portsBusy := 0 + busyPortsList := make([]int, 0) + for _, port := range portsToFree { + if usedPorts[port] { + portsBusy++ + busyPortsList = append(busyPortsList, port) + } + } + if portsBusy == 0 { + allPortsFree = true + log.Printf(" ✓ Все порты освобождены (попытка %d)", attempt+1) + break + } + if attempt%5 == 0 && attempt > 0 { + log.Printf(" → Ещё заняты %d портов: %v (попытка %d/180)", portsBusy, busyPortsList, attempt+1) // Обновлено с 60 на 180 + } + time.Sleep(1 * time.Second) + } + if !allPortsFree { + log.Printf(" ⚠ Порты не освободились за 180 секунд!") // Обновлено с 60 на 180 + log.Printf(" → Проверьте вручную: sudo lsof -i :%d", portsToFree[0]) + return + } + } + time.Sleep(2 * time.Second) + + log.Printf(" → Запускаем новый контейнер...") + cmd = exec.Command("docker-compose", "-f", composeFile, "up", "-d") + cmd.Dir = containerDir + output, err = cmd.CombinedOutput() + log.Printf(" → Вывод docker-compose: %s", string(output)) + if err != nil && strings.Contains(string(output), "address already in use") { + log.Printf(" ⚠ Порт всё ещё занят после ожидания освобождения!") + log.Printf(" → Проверьте: docker ps -a | grep %s", clientTitle) + log.Printf(" → Найдите процесс: sudo lsof -i | grep LISTEN | grep 127.0.0.1") + return + } + time.Sleep(5 * time.Second) + _, isRunning := checkContainerStatus(containerFullName) + if !isRunning { + log.Printf(" ⚠ Контейнер %s не запустился. Попробуйте вручную: cd %s && docker-compose up -d", containerFullName, containerDir) + return + } + log.Printf(" ✓ Контейнер %s пересоздан с новыми портами", containerFullName) +} + +func startContainer(containerFullName, composeFile, containerDir string) { + log.Printf(" Контейнер %s остановлен, запускаем...", containerFullName) + cmd := exec.Command("docker-compose", "-f", composeFile, "up", "-d") + cmd.Dir = containerDir + output, _ := cmd.CombinedOutput() + log.Printf(" → Вывод docker-compose: %s", string(output)) + time.Sleep(5 * time.Second) + _, isRunning := checkContainerStatus(containerFullName) + if !isRunning { + log.Printf(" ⚠ Контейнер %s не запустился. Попробуйте вручную: cd %s && docker-compose up -d", containerFullName, containerDir) + return + } + log.Printf(" ✓ Контейнер %s запущен", containerFullName) +} + +func checkContainerStatus(containerName string) (exists bool, running bool) { + cmd := exec.Command("docker", "ps", "-a", "--filter", fmt.Sprintf("name=^%s$", containerName), "--format", "{{.Names}}") + output, err := cmd.Output() + if err != nil { + return false, false + } + if strings.TrimSpace(string(output)) == "" { + return false, false + } + cmd = exec.Command("docker", "ps", "--filter", fmt.Sprintf("name=^%s$", containerName), "--format", "{{.Names}}") + output, err = cmd.Output() + if err != nil { + return true, false + } + running = strings.TrimSpace(string(output)) != "" + return true, running +} + +func forceRemoveContainer(containerName string) error { + cmd := exec.Command("docker", "stop", "-t", "10", containerName) + output, err := cmd.CombinedOutput() + if err != nil { + log.Printf(" ⚠ Ошибка остановки контейнера: %v, output: %s", err, string(output)) + } + cmd = exec.Command("docker", "rm", "-f", containerName) + output, err = cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("ошибка удаления контейнера: %w, output: %s", err, string(output)) + } + return nil +} + +func testNginxConfig(containerName string) error { + log.Printf(" Проверка конфигурации nginx в контейнере %s", containerName) + cmd := exec.Command("docker", "exec", containerName, "/opt/ptaf/bin/ptaf-nginx", "-t", "-c", "/opt/ptaf/conf/nginx.conf") + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("конфигурация невалидна: %w, output: %s", err, string(output)) + } + log.Printf(" Конфигурация валидна: %s", strings.TrimSpace(string(output))) + return nil +} + +func reloadNginx(containerName string) error { + log.Printf(" Перезагрузка nginx в контейнере %s", containerName) + cmd := exec.Command("docker", "exec", containerName, "/opt/ptaf/bin/ptaf-nginx", "-s", "reload") + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("ошибка перезагрузки: %w, output: %s", err, string(output)) + } + log.Printf(" Nginx перезагружен успешно") + return nil +} + +func markContainerForDrain(containerName string, isMigration bool) error { + timestamp := time.Now().Unix() + markerFile := fmt.Sprintf("/tmp/ptaf-drain-%s", containerName) + content := fmt.Sprintf("%d,%t", timestamp, isMigration) + return os.WriteFile(markerFile, []byte(content), 0644) +} + +func getContainerDrainInfo(containerName string) (timestamp int64, isMigration bool, found bool) { + markerFile := fmt.Sprintf("/tmp/ptaf-drain-%s", containerName) + data, err := os.ReadFile(markerFile) + if err != nil { + return 0, false, false + } + content := strings.TrimSpace(string(data)) + parts := strings.Split(content, ",") + if len(parts) < 1 { + return 0, false, false + } + ts, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return 0, false, false + } + migration := false + if len(parts) >= 2 { + migration = parts[1] == "true" + } + return ts, migration, true +} + +func markAngieConfigForDrain(clientTitle string) error { + markerFile := fmt.Sprintf("/tmp/ptaf-angie-drain-%s", clientTitle) + return os.WriteFile(markerFile, []byte(fmt.Sprintf("%d", time.Now().Unix())), 0644) +} + +func getAngieConfigDrainTimestamp(clientTitle string) (int64, bool) { + markerFile := fmt.Sprintf("/tmp/ptaf-angie-drain-%s", clientTitle) + data, err := os.ReadFile(markerFile) + if err != nil { + return 0, false + } + timestamp, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64) + if err != nil { + return 0, false + } + return timestamp, true +} + +func stopAndRemoveContainer(containerName, composeFile, containerDir string) error { + log.Printf(" Остановка контейнера %s", containerName) + cmd := exec.Command("docker-compose", "-f", composeFile, "down", "-v") + cmd.Dir = containerDir + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("ошибка остановки контейнера: %w, output: %s", err, string(output)) + } + os.Remove(fmt.Sprintf("/tmp/ptaf-drain-%s", containerName)) + log.Printf(" ✓ Контейнер %s остановлен и удалён", containerName) + return nil +} + +func removeContainerFiles(clientTitle string, containerNum int) error { + baseDir := filepath.Join("/home/install/ptaf", clientTitle) + containerName := fmt.Sprintf("%s-ptaf-agent%03d", clientTitle, containerNum) + for _, dir := range []string{ + filepath.Join(baseDir, containerName), + filepath.Join("/home/install/conf/ptaf-nginx", clientTitle, fmt.Sprintf("ptaf-agent%03d", containerNum)), + filepath.Join("/var/log/ptaf_nginx", clientTitle, fmt.Sprintf("ptaf-agent%03d", containerNum)), + } { + if _, err := os.Stat(dir); err == nil { + log.Printf(" Удаление директории: %s", dir) + if err := os.RemoveAll(dir); err != nil { + log.Printf(" ⚠ Ошибка удаления %s: %v", dir, err) + } else { + log.Printf(" ✓ Удалена директория: %s", dir) + } + } + } + return nil +} + +func cleanupClientDirectoryIfEmpty(clientTitle string) error { + clientConfDir := fmt.Sprintf("/home/install/conf/ptaf-nginx/%s", clientTitle) + if _, err := os.Stat(clientConfDir); os.IsNotExist(err) { + return nil + } + entries, err := os.ReadDir(clientConfDir) + if err != nil { + return fmt.Errorf("ошибка чтения директории %s: %w", clientConfDir, err) + } + hasContainers := false + for _, entry := range entries { + if entry.IsDir() && strings.HasPrefix(entry.Name(), "ptaf-agent") { + hasContainers = true + break + } + } + if !hasContainers { + log.Printf(" ✓ У клиента %s не осталось контейнеров, удаляем директории клиента", clientTitle) + os.RemoveAll(clientConfDir) + ptafDir := fmt.Sprintf("/home/install/ptaf/%s", clientTitle) + if _, err := os.Stat(ptafDir); err == nil { + os.RemoveAll(ptafDir) + } + hashFile := fmt.Sprintf("/etc/angie/http.d/angie-ptaf-%s.conf.hash", clientTitle) + if _, err := os.Stat(hashFile); err == nil { + os.Remove(hashFile) + } + } + return nil +} + +func reloadAngie() { + log.Printf(" Перезагрузка Angie...") + cmd := exec.Command("systemctl", "reload", "angie") + if err := cmd.Run(); err != nil { + log.Printf(" ⚠ Ошибка перезагрузки Angie: %v", err) + } else { + log.Printf(" ✓ Angie перезагружен") + } +} + +// generateNginxConf генерирует основной nginx.conf для PTAF +func generateNginxConf(filePath string, config Config, ptafConfig string, existingPodIP string) (bool, error) { + var randomIP string + if existingPodIP != "" { + randomIP = existingPodIP + log.Printf(" → Используется существующий POD_IP: %s", randomIP) + } else { + randomIP = fmt.Sprintf("135.%d.%d.%d", + rand.Intn(254)+1, + rand.Intn(254)+1, + rand.Intn(254)+1) + log.Printf(" → Сгенерирован новый POD_IP: %s", randomIP) + } + + pathParts := strings.Split(filePath, "/") + clientTitle := "" + containerNum := "001" + for i, part := range pathParts { + if part == "ptaf-nginx" && i+1 < len(pathParts) { + clientTitle = pathParts[i+1] + } + if strings.HasPrefix(part, "ptaf-agent") { + containerNum = strings.TrimPrefix(part, "ptaf-agent") + } + } + + hostname := fmt.Sprintf("ptaf-%s-agent%s", clientTitle, containerNum) + log.Printf(" → clientTitle=%s, containerNum=%s, hostname=%s", clientTitle, containerNum, hostname) + + content := fmt.Sprintf(`#user ptaf; +master_process on; +worker_processes %d; +worker_rlimit_nofile 1048576; +daemon off; +load_module /opt/ptaf/lib/ngx_wrapper.so; +env POD_IP=%s; +env PTAF_LOG_LEVEL=INFO; +env DEBUG=false; +env HOSTNAME=%s; +error_log stderr notice; +pid /opt/ptaf/logs/nginx.pid; + +events { + worker_connections %d; + accept_mutex off; +} + +http { + include /opt/ptaf/conf/mime.types; + default_type application/octet-stream; + + log_format waf escape=json + '{' + '"SID":$http_x_sid,' + '"server_name":"$server_name",' + '"app_name":"$host",' + '"proxyed_to":"$upstream_addr",' + '"upstream_status":"$upstream_status",' + '"upstream_response_length":"$upstream_response_length",' + '"upstream_response_time":"$upstream_response_time",' + '"upstream_bytes_sent":"$upstream_bytes_sent",' + '"server_port":$server_port,' + '"server_addr":"$server_addr",' + '"server_protocol":"$server_protocol",' + '"remote_addr":"$remote_addr",' + '"response_status_code":$status,' + '"response_body_bytes_sent":$body_bytes_sent,' + '"http_x_forwarded_for":"$http_x_forwarded_for",' + '"uri":"$uri",' + '"http_user_agent":"$http_user_agent",' + '"query_string":"$query_string",' + '"request_method":"$request_method",' + '"request_time":$request_time,' + '"@timestamp":"$time_iso8601"' + '}'; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + log_format main_upstream '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for" $upstream_response_time'; + + access_log /var/log/nginx/%s_access.log waf; + + client_max_body_size 0; + sendfile on; + gzip on; + server_names_hash_bucket_size 512; + server_tokens off; + ptaf_fallback 503; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + send_timeout 60s; + keepalive_timeout 80s; + keepalive_requests 1000; + proxy_buffer_size 8k; + proxy_buffers 8 8k; + proxy_busy_buffers_size 16k; + proxy_connect_timeout 60s; + resolver_timeout 30s; + + geo $dollar { + default "$"; + } + + map $http_upgrade $proxy_http_connection { + default upgrade; + '' ''; + } + + # Map для условного Connection header (используется в server блоках) + map $http_upgrade $proxy_connection { + default upgrade; + '' ''; + } + + # Map для определения backend host из X-Original-Host заголовка + map $http_x_original_host $backend_host { + "" $host; + default $http_x_original_host; + } + + ptaf_config %s; + + include /opt/ptaf/conf/conf.d/*.conf; +} +`, config.WorkerProcesses, randomIP, hostname, config.WorkerConnections, clientTitle, ptafConfig) + + return writeFileIfChanged(filePath, content) +} + +// generateMimeTypes генерирует файл mime.types +func generateMimeTypes(filePath string) (bool, error) { + content := `types { + text/html html htm shtml; + text/css css; + text/xml xml; + image/gif gif; + image/jpeg jpeg jpg; + application/javascript js; + application/atom+xml atom; + application/rss+xml rss; + + text/mathml mml; + text/plain txt; + text/vnd.sun.j2me.app-descriptor jad; + text/vnd.wap.wml wml; + text/x-component htc; + + image/avif avif; + image/png png; + image/svg+xml svg svgz; + image/tiff tif tiff; + image/vnd.wap.wbmp wbmp; + image/webp webp; + image/x-icon ico; + image/x-jng jng; + image/x-ms-bmp bmp; + + font/woff woff; + font/woff2 woff2; + + application/java-archive jar war ear; + application/json json; + application/mac-binhex40 hqx; + application/msword doc; + application/pdf pdf; + application/postscript ps eps ai; + application/rtf rtf; + application/vnd.apple.mpegurl m3u8; + application/vnd.google-earth.kml+xml kml; + application/vnd.google-earth.kmz kmz; + application/vnd.ms-excel xls; + application/vnd.ms-fontobject eot; + application/vnd.ms-powerpoint ppt; + application/vnd.oasis.opendocument.graphics odg; + application/vnd.oasis.opendocument.presentation odp; + application/vnd.oasis.opendocument.spreadsheet ods; + application/vnd.oasis.opendocument.text odt; + application/vnd.openxmlformats-officedocument.presentationml.presentation pptx; + application/vnd.openxmlformats-officedocument.spreadsheetml.sheet xlsx; + application/vnd.openxmlformats-officedocument.wordprocessingml.document docx; + application/vnd.wap.wmlc wmlc; + application/wasm wasm; + application/x-7z-compressed 7z; + application/x-cocoa cco; + application/x-java-archive-diff jardiff; + application/x-java-jnlp-file jnlp; + application/x-makeself run; + application/x-perl pl pm; + application/x-pilot prc pdb; + application/x-rar-compressed rar; + application/x-redhat-package-manager rpm; + application/x-sea sea; + application/x-shockwave-flash swf; + application/x-stuffit sit; + application/x-tcl tcl tk; + application/x-x509-ca-cert der pem crt; + application/x-xpinstall xpi; + application/xhtml+xml xhtml; + application/xspf+xml xspf; + application/zip zip; + + application/octet-stream bin exe dll; + application/octet-stream deb; + application/octet-stream dmg; + application/octet-stream iso img; + application/octet-stream msi msp msm; + + audio/midi mid midi kar; + audio/mpeg mp3; + audio/ogg ogg; + audio/x-m4a m4a; + audio/x-realaudio ra; + + video/3gpp 3gpp 3gp; + video/mp2t ts; + video/mp4 mp4; + video/mpeg mpeg mpg; + video/quicktime mov; + video/webm webm; + video/x-flv flv; + video/x-m4v m4v; + video/x-mng mng; + video/x-ms-asf asx asf; + video/x-ms-wmv wmv; + video/x-msvideo avi; +} +` + return writeFileIfChanged(filePath, content) +} + +// generateDockerCompose генерирует docker-compose.yml файл +func generateDockerCompose(filePath string, config Config, clientTitle string, clientInfo *ClientInfo, containerNum int, resources []ResourceData, portMappings []PortMapping) (bool, error) { + type ComposeData struct { + ClientTitle string + ContainerNum int + DockerImage string + PTAFConfig string + WorkerProcesses int + WorkerConnections int + FluentBitEnabled bool + FluentBitPort int + HasMTLS bool + SortedHTTPPorts []SortedPortMapping + SortedHTTPSPorts []SortedPortMapping + } + + hasMTLS := false + for _, res := range resources { + if res.AppsSettings.LocationNginxCustom.Valid && hasMTLSCertificates(res.AppsSettings.LocationNginxCustom.String) { + hasMTLS = true + break + } + if res.AppsSettings.ServerNginxCustom.Valid && hasMTLSCertificates(res.AppsSettings.ServerNginxCustom.String) { + hasMTLS = true + break + } + } + + var allHTTPPorts []SortedPortMapping + var allHTTPSPorts []SortedPortMapping + + for _, pm := range portMappings { + var httpKeys []int + for customPort := range pm.HTTPPorts { + httpKeys = append(httpKeys, customPort) + } + sort.Ints(httpKeys) + for _, customPort := range httpKeys { + allHTTPPorts = append(allHTTPPorts, SortedPortMapping{CustomPort: customPort, DockerPort: pm.HTTPPorts[customPort]}) + } + + var httpsKeys []int + for customPort := range pm.HTTPSPorts { + httpsKeys = append(httpsKeys, customPort) + } + sort.Ints(httpsKeys) + for _, customPort := range httpsKeys { + allHTTPSPorts = append(allHTTPSPorts, SortedPortMapping{CustomPort: customPort, DockerPort: pm.HTTPSPorts[customPort]}) + } + } + + sort.Slice(allHTTPPorts, func(i, j int) bool { return allHTTPPorts[i].DockerPort < allHTTPPorts[j].DockerPort }) + sort.Slice(allHTTPSPorts, func(i, j int) bool { return allHTTPSPorts[i].DockerPort < allHTTPSPorts[j].DockerPort }) + + data := ComposeData{ + ClientTitle: clientTitle, + ContainerNum: containerNum, + DockerImage: config.DockerImage, + PTAFConfig: "", + WorkerProcesses: config.WorkerProcesses, + WorkerConnections: config.WorkerConnections, + FluentBitEnabled: false, + HasMTLS: hasMTLS, + SortedHTTPPorts: allHTTPPorts, + SortedHTTPSPorts: allHTTPSPorts, + } + + if clientInfo.PTAFConfig.Valid { + data.PTAFConfig = clientInfo.PTAFConfig.String + } + if clientInfo.FluentBitPort.Valid { + data.FluentBitEnabled = true + data.FluentBitPort = int(clientInfo.FluentBitPort.Int64) + } + + tmpl := `services: + ptaf-{{.ClientTitle}}-agent{{printf "%03d" .ContainerNum}}: + image: '{{.DockerImage}}' + restart: unless-stopped + shm_size: '1gb' + container_name: ptaf_{{.ClientTitle}}_{{printf "%03d" .ContainerNum}} + hostname: ptaf_{{.ClientTitle}}_{{printf "%03d" .ContainerNum}} + environment: + - CONNECTION_STRING={{.PTAFConfig}} + - WORKER_PROCESSES={{.WorkerProcesses}} + - WORKER_CONNECTIONS={{.WorkerConnections}} + - INCLUDE_CUSTOM_CONFIG=True + - PTAF_CONFIGURATOR_DISABLED=1 +{{- if .FluentBitEnabled}} + - EVENT_SENDER_ENABLED=1 + - EVENT_SENDER_HOST=172.17.0.1 + - EVENT_SENDER_PORT={{.FluentBitPort}} + - EVENT_SENDER_TIMEOUT=3 + - EVENT_WEB_UI_URL=https://af.ptcloud.ru/events/attacks/%s/general/ +{{- end}} + sysctls: + kernel.sem: "32000 1024000000 500 32000" + ports: +{{- range .SortedHTTPPorts}} + - 127.0.0.1:{{.DockerPort}}:{{.CustomPort}}/tcp +{{- end}} +{{- range .SortedHTTPSPorts}} + - 127.0.0.1:{{.DockerPort}}:{{.CustomPort}}/tcp +{{- end}} + volumes: + - /var/log/ptaf_nginx/{{.ClientTitle}}/ptaf-agent{{printf "%03d" .ContainerNum}}:/var/log/nginx + - /home/install/conf/ptaf-nginx/{{.ClientTitle}}/ptaf-agent{{printf "%03d" .ContainerNum}}/conf.d:/opt/ptaf/conf/conf.d + - /home/install/conf/ptaf-nginx/{{.ClientTitle}}/ptaf-agent{{printf "%03d" .ContainerNum}}/nginx.conf:/opt/ptaf/conf/nginx.conf + - /home/install/conf/ptaf-nginx/{{.ClientTitle}}/ptaf-agent{{printf "%03d" .ContainerNum}}/mime.types:/opt/ptaf/conf/mime.types +{{- if .HasMTLS}} + - /etc/ssl/mtls:/opt/ptaf/conf/main +{{- end}} + networks: + - ptaf-{{.ClientTitle}}-net + logging: + driver: "json-file" + options: + max-size: "100m" + max-file: "5" +networks: + ptaf-{{.ClientTitle}}-net: + driver: bridge +` + + t, err := template.New("compose").Parse(tmpl) + if err != nil { + return false, fmt.Errorf("ошибка парсинга шаблона: %w", err) + } + + var buf strings.Builder + if err = t.Execute(&buf, data); err != nil { + return false, fmt.Errorf("ошибка генерации файла: %w", err) + } + + return writeFileIfChanged(filePath, buf.String()) +} diff --git a/db.go b/db.go new file mode 100644 index 0000000..86f077f --- /dev/null +++ b/db.go @@ -0,0 +1,228 @@ +package main + +import ( + "database/sql" + "fmt" + "log" + "strings" + + _ "github.com/lib/pq" +) + +// connectToDB подключается к базе данных PostgreSQL +// Сначала пытается подключиться к primary, если не удалось - к secondary +func connectToDB(config Config) *sql.DB { + log.Println("Попытка подключения к первичной БД...") + + connStr := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + config.PrimaryDBHost, config.DBPort, config.DBUser, config.DBPassword, config.DBName) + + db, err := sql.Open("postgres", connStr) + if err == nil { + err = db.Ping() + if err == nil { + log.Printf("Успешное подключение к первичной БД: %s", config.PrimaryDBHost) + return db + } + } + + log.Printf("Не удалось подключиться к первичной БД: %v", err) + log.Println("Попытка подключения к вторичной БД...") + + connStr = fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + config.SecondaryDBHost, config.DBPort, config.DBUser, config.DBPassword, config.DBName) + + db, err = sql.Open("postgres", connStr) + if err != nil { + log.Fatalf("Ошибка открытия соединения с вторичной БД: %v", err) + } + + err = db.Ping() + if err != nil { + log.Fatalf("Ошибка подключения к вторичной БД: %v", err) + } + + log.Printf("Успешное подключение к вторичной БД: %s", config.SecondaryDBHost) + return db +} + +// getInstancesByHostname получает все instances для hostname из таблицы instances +// Возвращает массив instances, т.к. один hostname может быть связан с несколькими instance +func getInstancesByHostname(db *sql.DB, hostname string) ([]string, error) { + query := "SELECT instance FROM instances_new WHERE hostname = $1 ORDER BY instance" + + rows, err := db.Query(query, hostname) + if err != nil { + return nil, fmt.Errorf("ошибка выполнения запроса для hostname %s: %w", hostname, err) + } + defer rows.Close() + + var instances []string + for rows.Next() { + var instance string + if err := rows.Scan(&instance); err != nil { + return nil, fmt.Errorf("ошибка чтения instance: %w", err) + } + instances = append(instances, instance) + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("ошибка обработки результатов: %w", err) + } + + if len(instances) == 0 { + return nil, fmt.Errorf("instances не найдены для hostname %s", hostname) + } + + return instances, nil +} + +// checkHostBelongsToInstance проверяет принадлежит ли хост к указанному instance +func checkHostBelongsToInstance(db *sql.DB, hostname, instance string) (bool, error) { + query := ` + SELECT COUNT(*) + FROM instances_new + WHERE hostname = $1 AND instance = $2 + ` + + var count int + err := db.QueryRow(query, hostname, instance).Scan(&count) + if err != nil { + return false, fmt.Errorf("ошибка проверки принадлежности хоста: %w", err) + } + + return count > 0, nil +} + +// getClientInfoByInstance получает всех клиентов для заданного waf_instance +func getClientInfoByInstance(db *sql.DB, instance string) ([]ClientInfo, error) { + query := ` + SELECT containers_count, ptaf_config, client_title, fluent_bit_port, waf_instance + FROM client_info + WHERE waf_instance = $1 + ` + + rows, err := db.Query(query, instance) + if err != nil { + if strings.Contains(err.Error(), "does not exist") { + return nil, fmt.Errorf("ошибка структуры таблицы client_info: %w (возможно изменилась схема БД)", err) + } + return nil, fmt.Errorf("ошибка выполнения запроса: %w", err) + } + defer rows.Close() + + var clientInfoList []ClientInfo + for rows.Next() { + var ci ClientInfo + var wafInstance sql.NullString + err := rows.Scan( + &ci.ContainersCount, + &ci.PTAFConfig, + &ci.ClientTitle, + &ci.FluentBitPort, + &wafInstance, + ) + if err != nil { + return nil, fmt.Errorf("ошибка сканирования строки: %w", err) + } + + if wafInstance.Valid { + ci.WAFInstance = wafInstance.String + } + + clientInfoList = append(clientInfoList, ci) + } + + return clientInfoList, nil +} + +// getAppsSettingsByClientTitle получает apps_settings для заданного client_title +func getAppsSettingsByClientTitle(db *sql.DB, clientTitle string) ([]AppsSettings, error) { + query := ` + SELECT + l7resourceid, waf_enabled, waf_vendor, + custom_input_http_ports, custom_input_https_ports, + custom_output_http_ports, custom_output_https_ports, + upstream_angie_custom, server_nginx_custom, + server_angie_custom, client_title, + upstream_nginx_custom, + location_angie_custom, location_nginx_custom, + server_directives_angie_custom, server_directives_nginx_custom, + sni, + mode, + ssl_enabled, + custom_angie_ssl, + max_fails, + fail_timeout + FROM apps_settings + WHERE client_title = $1 + ORDER BY l7resourceid ASC + ` + + rows, err := db.Query(query, clientTitle) + if err != nil { + if strings.Contains(err.Error(), "does not exist") { + return nil, fmt.Errorf("ошибка структуры таблицы apps_settings: %w (возможно изменилась схема БД)", err) + } + return nil, fmt.Errorf("ошибка выполнения запроса: %w", err) + } + defer rows.Close() + + var appsSettingsList []AppsSettings + for rows.Next() { + var a AppsSettings + err := rows.Scan( + &a.L7ResourceID, &a.WAFEnabled, &a.WAFVendor, + &a.CustomInputHTTPPorts, &a.CustomInputHTTPSPorts, + &a.CustomOutputHTTPPorts, &a.CustomOutputHTTPSPorts, + &a.UpstreamAngieCustom, &a.ServerNginxCustom, + &a.ServerAngieCustom, &a.ClientTitle, + &a.UpstreamNginxCustom, + &a.LocationAngieCustom, &a.LocationNginxCustom, + &a.ServerDirectivesAngieCustom, &a.ServerDirectivesNginxCustom, + &a.SNI, + &a.Mode, + &a.SSLEnabled, + &a.CustomAngieSSL, + &a.MaxFails, + &a.FailTimeout, + ) + if err != nil { + return nil, fmt.Errorf("ошибка сканирования строки: %w", err) + } + appsSettingsList = append(appsSettingsList, a) + } + + return appsSettingsList, nil +} + +// getClientInfoByClientTitle получает информацию о клиенте из таблицы client_info +func getClientInfoByClientTitle(db *sql.DB, clientTitle string) (*ClientInfo, error) { + query := ` + SELECT containers_count, ptaf_config, client_title, fluent_bit_port, waf_instance + FROM client_info + WHERE client_title = $1 + ` + + var clientInfo ClientInfo + var wafInstance sql.NullString + err := db.QueryRow(query, clientTitle).Scan( + &clientInfo.ContainersCount, + &clientInfo.PTAFConfig, + &clientInfo.ClientTitle, + &clientInfo.FluentBitPort, + &wafInstance, + ) + if err != nil { + if strings.Contains(err.Error(), "does not exist") { + return nil, fmt.Errorf("ошибка структуры таблицы client_info: %w (возможно изменилась схема БД)", err) + } + return nil, fmt.Errorf("ошибка получения данных из client_info: %w", err) + } + + if wafInstance.Valid { + clientInfo.WAFInstance = wafInstance.String + } + + return &clientInfo, nil +} diff --git a/drain.go b/drain.go new file mode 100644 index 0000000..7b7627f --- /dev/null +++ b/drain.go @@ -0,0 +1,265 @@ +package main + +import ( + "database/sql" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "time" +) + +var _ = sql.ErrNoRows // ensure import + +func processDrainingAngieConfigs() { + currentTime := time.Now().Unix() + timeout := int64(MigrationDrainTimeout) + hostname, err := os.Hostname() + if err != nil { + log.Printf("⚠ Ошибка получения hostname: %v", err) + return + } + tempConfig := getDefaultConfig() + db := connectToDB(tempConfig) + defer db.Close() + + matches, err := filepath.Glob("/tmp/ptaf-angie-drain-*") + if err != nil { + log.Printf("⚠ Ошибка поиска маркеров Angie: %v", err) + return + } + + for _, markerFile := range matches { + parts := strings.Split(filepath.Base(markerFile), "-") + if len(parts) < 4 { + continue + } + clientTitle := strings.Join(parts[3:], "-") + drainTimestamp, found := getAngieConfigDrainTimestamp(clientTitle) + if !found { + continue + } + elapsedTime := currentTime - drainTimestamp + remainingTime := timeout - elapsedTime + angieConfigPath := fmt.Sprintf("/etc/angie/http.d/angie-ptaf-%s.conf", clientTitle) + + if elapsedTime >= timeout { + log.Printf(" Конфиг Angie для %s завершил drain (прошло %d сек), проверяю актуальность...", clientTitle, elapsedTime) + clientInfo, err := getClientInfoByClientTitle(db, clientTitle) + if err == nil && clientInfo.WAFInstance != "" { + belongsNow, checkErr := checkHostBelongsToInstance(db, hostname, clientInfo.WAFInstance) + if checkErr == nil && belongsNow { + log.Printf(" ⚠ Клиент %s снова принадлежит этому хосту, ОТМЕНЯЮ удаление конфига", clientTitle) + os.Remove(markerFile) + continue + } + } + log.Printf(" → Клиент %s не принадлежит этому хосту, удаляю конфиг", clientTitle) + if _, err := os.Stat(angieConfigPath); err == nil { + if err := os.Remove(angieConfigPath); err != nil { + log.Printf(" ⚠ Ошибка удаления конфига Angie: %v", err) + } else { + log.Printf(" ✓ Конфиг Angie удалён: %s", angieConfigPath) + os.Remove(markerFile) + reloadAngie() + } + } else { + log.Printf(" Конфиг Angie для %s уже не существует, удаляю маркер", clientTitle) + os.Remove(markerFile) + } + } else { + log.Printf(" ○ Конфиг Angie для %s в процессе drain [миграция] (осталось %d сек)", clientTitle, remainingTime) + } + } +} + +func processAllDrainingContainersGlobally(db *sql.DB, hostname string) { + log.Println("\n--- Глобальная проверка клиентов и drain-контейнеров ---") + entries, err := os.ReadDir("/home/install/ptaf") + if err != nil { + log.Printf("⚠ Не удалось прочитать директорию: %v", err) + return + } + + processedClients := 0 + markedForDrain := 0 + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + clientTitle := entry.Name() + log.Printf(" → Проверка клиента: %s", clientTitle) + + clientInfo, err := getClientInfoByClientTitle(db, clientTitle) + if err != nil { + log.Printf(" ⚠ Ошибка получения данных клиента из БД: %v", err) + checkAndProcessExistingDrainMarkers(clientTitle, &processedClients) + continue + } + + shouldBeHere := false + skipDrainCheck := false + if clientInfo.WAFInstance != "" { + belongsToInstance, err := checkHostBelongsToInstance(db, hostname, clientInfo.WAFInstance) + if err != nil { + log.Printf(" ⚠ Ошибка проверки принадлежности хоста: %v", err) + } else { + shouldBeHere = belongsToInstance + } + } else { + log.Printf(" ℹ waf_instance не указан (NULL), ресурс может быть на любом хосте") + shouldBeHere = true + skipDrainCheck = true + } + + if !shouldBeHere && !skipDrainCheck { + log.Printf(" ⚠ Клиент %s не должен быть на этом хосте (waf_instance='%s')", clientTitle, clientInfo.WAFInstance) + hasExistingMarkers := false + for containerNum := 1; containerNum <= MaxContainersPerClient; containerNum++ { + if _, err := os.Stat(fmt.Sprintf("/tmp/ptaf-drain-ptaf_%s_%03d", clientTitle, containerNum)); err == nil { + hasExistingMarkers = true + break + } + } + if !hasExistingMarkers { + log.Printf(" → Помечаю контейнеры для миграции") + for containerNum := 1; containerNum <= MaxContainersPerClient; containerNum++ { + containerFullName := fmt.Sprintf("ptaf_%s_%03d", clientTitle, containerNum) + exists, _ := checkContainerStatus(containerFullName) + if exists { + if err := markContainerForDrain(containerFullName, true); err != nil { + log.Printf(" ⚠ Ошибка пометки контейнера %s: %v", containerFullName, err) + } else { + log.Printf(" ✓ Контейнер %s помечен для drain (миграция)", containerFullName) + markedForDrain++ + } + } + } + angieConfigPath := fmt.Sprintf("/etc/angie/http.d/angie-ptaf-%s.conf", clientTitle) + if _, err := os.Stat(angieConfigPath); err == nil { + if err := markAngieConfigForDrain(clientTitle); err != nil { + log.Printf(" ⚠ Ошибка пометки Angie конфига: %v", err) + } else { + log.Printf(" ✓ Angie конфиг помечен для удаления") + } + } + } + processDrainingContainers(clientTitle, 0) + processedClients++ + } else { + log.Printf(" ✓ Клиент должен быть здесь, проверяю устаревшие drain-маркеры") + checkAndProcessExistingDrainMarkers(clientTitle, &processedClients) + } + } + + if markedForDrain > 0 { + log.Printf("\n✓ Помечено для миграции: %d контейнеров", markedForDrain) + } + if processedClients > 0 { + log.Printf("✓ Обработано клиентов: %d", processedClients) + } else { + log.Println(" Нет клиентов требующих обработки") + } + processDrainingAngieConfigs() +} + +func checkAndProcessExistingDrainMarkers(clientTitle string, processedCount *int) { + hasAnyDrainMarkers := false + for containerNum := 1; containerNum <= MaxContainersPerClient; containerNum++ { + if _, err := os.Stat(fmt.Sprintf("/tmp/ptaf-drain-ptaf_%s_%03d", clientTitle, containerNum)); err == nil { + hasAnyDrainMarkers = true + break + } + } + if !hasAnyDrainMarkers { + if _, err := os.Stat(fmt.Sprintf("/tmp/ptaf-angie-drain-%s", clientTitle)); err == nil { + hasAnyDrainMarkers = true + } + } + if hasAnyDrainMarkers { + log.Printf(" → Найдены drain-маркеры, обрабатываю") + processDrainingContainers(clientTitle, 0) + *processedCount++ + } +} + +func processDrainingContainers(clientTitle string, maxContainerNum int) { + baseDir := filepath.Join("/home/install/ptaf", clientTitle) + currentTime := time.Now().Unix() + hostname, err := os.Hostname() + if err != nil { + log.Printf("⚠ Ошибка получения hostname: %v", err) + return + } + tempConfig := getDefaultConfig() + db := connectToDB(tempConfig) + defer db.Close() + + for containerNum := maxContainerNum + 1; containerNum <= MaxContainersPerClient; containerNum++ { + containerName := fmt.Sprintf("%s-ptaf-agent%03d", clientTitle, containerNum) + containerFullName := fmt.Sprintf("ptaf_%s_%03d", clientTitle, containerNum) + containerDir := filepath.Join(baseDir, containerName) + composeFile := filepath.Join(containerDir, "docker-compose.yml") + exists, _ := checkContainerStatus(containerFullName) + if !exists { + markerFile := fmt.Sprintf("/tmp/ptaf-drain-%s", containerFullName) + if _, err := os.Stat(markerFile); err == nil { + log.Printf(" Удаляю файл-маркер для несуществующего контейнера %s", containerFullName) + os.Remove(markerFile) + } + continue + } + drainTimestamp, isMigration, isDraining := getContainerDrainInfo(containerFullName) + if isDraining { + var timeout int64 + timeoutName := "масштабирование" + if isMigration { + timeout = int64(MigrationDrainTimeout) + timeoutName = "миграция" + } else { + timeout = int64(ContainerDrainTimeout) + } + elapsedTime := currentTime - drainTimestamp + remainingTime := timeout - elapsedTime + if elapsedTime >= timeout { + if isMigration { + log.Printf(" Контейнер %s завершил drain [миграция] (прошло %d сек), проверяю актуальность...", containerFullName, elapsedTime) + clientInfo, err := getClientInfoByClientTitle(db, clientTitle) + if err == nil && clientInfo.WAFInstance != "" { + belongsNow, checkErr := checkHostBelongsToInstance(db, hostname, clientInfo.WAFInstance) + if checkErr == nil && belongsNow { + log.Printf(" ⚠ Клиент %s снова принадлежит этому хосту, ОТМЕНЯЮ удаление контейнера %s", clientTitle, containerFullName) + os.Remove(fmt.Sprintf("/tmp/ptaf-drain-%s", containerFullName)) + continue + } + } + log.Printf(" → Клиент %s не принадлежит этому хосту, удаляю контейнер %s", clientTitle, containerFullName) + } else { + log.Printf(" Контейнер %s завершил drain [%s] (прошло %d сек), удаляем", containerFullName, timeoutName, elapsedTime) + } + if err := stopAndRemoveContainer(containerFullName, composeFile, containerDir); err != nil { + log.Printf(" ⚠ Ошибка удаления контейнера %s: %v", containerFullName, err) + continue + } + if err := removeContainerFiles(clientTitle, containerNum); err != nil { + log.Printf(" ⚠ Ошибка удаления файлов контейнера %s: %v", containerFullName, err) + } else { + if err := cleanupClientDirectoryIfEmpty(clientTitle); err != nil { + log.Printf(" ⚠ Ошибка очистки директорий клиента: %v", err) + } + } + } else { + log.Printf(" ○ Контейнер %s в процессе drain [%s] (осталось %d сек)", containerFullName, timeoutName, remainingTime) + } + } else { + log.Printf(" Контейнер %s больше не нужен, помечаем для drain", containerFullName) + if err := markContainerForDrain(containerFullName, false); err != nil { + log.Printf(" ⚠ Ошибка пометки контейнера для drain: %v", err) + } else { + log.Printf(" ✓ Контейнер %s помечен для drain, удаление через %d сек", containerFullName, ContainerDrainTimeout) + } + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7faa539 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module waf_automata + +go 1.22 + +require github.com/lib/pq v1.10.9 + +require github.com/joho/godotenv v1.5.1 // indirect diff --git a/main.go b/main.go new file mode 100644 index 0000000..1398d1c --- /dev/null +++ b/main.go @@ -0,0 +1,299 @@ +package main + +import ( + "database/sql" + "fmt" + "log" + "net" + "os" + "path/filepath" + "strings" + "time" +) + +func main() { + // ════════════════════════════════════════════════════════════ + // КРИТИЧЕСКОЕ ИСПРАВЛЕНИЕ: Lock-файл для предотвращения параллельного запуска + // ════════════════════════════════════════════════════════════ + lockFile := "/var/lock/ptaf-main.lock" + + f, err := os.OpenFile(lockFile, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0600) + if err != nil { + if os.IsExist(err) { + log.Fatal("❌ Программа уже запущена! Lock файл существует: ", lockFile) + } + log.Fatal("❌ Не удалось создать lock файл: ", err) + } + + // Записываем PID в lock файл + pid := os.Getpid() + f.WriteString(fmt.Sprintf("%d\n", pid)) + + // Гарантированно удаляем lock при выходе + defer func() { + f.Close() + os.Remove(lockFile) + log.Printf("✓ Lock файл удален") + }() + + log.Printf("✓ Lock файл создан: %s (PID: %d)", lockFile, pid) + // ════════════════════════════════════════════════════════════ + + log.Println("=== Запуск программы управления конфигурациями PTAF ===") + + hostname, err := os.Hostname() + if err != nil { + log.Fatalf("Ошибка получения hostname: %v", err) + } + log.Printf("Hostname текущего хоста: %s", hostname) + + tempConfig := getDefaultConfig() + db := connectToDB(tempConfig) + defer db.Close() + + config, err := initConfig(db) + if err != nil { + log.Fatalf("Ошибка загрузки конфигурации: %v", err) + } + + instanceNames, err := getInstancesByHostname(db, hostname) + if err != nil { + log.Fatalf("Ошибка получения instances для hostname %s: %v", hostname, err) + } + + if len(instanceNames) == 1 { + log.Printf("Instance для хоста: %s", instanceNames[0]) + } else { + log.Printf("Instances для хоста (%d): %s", len(instanceNames), strings.Join(instanceNames, ", ")) + } + + // Глобальная обработка drain-контейнеров и миграций + processAllDrainingContainersGlobally(db, hostname) + + // Аллокатор портов (заменяет глобальную переменную) + portAllocator := NewPortAllocator() + + totalClients := 0 + for _, instanceName := range instanceNames { + count := processInstance(db, config, hostname, instanceName, portAllocator) + totalClients += count + } + + if totalClients == 0 { + log.Println("\nНет активных клиентов для обработки на текущем хосте") + } else { + log.Printf("\nВсего обработано клиентов: %d (на %d instance)", totalClients, len(instanceNames)) + } + + // Финальная проверка drain-конфигов Angie + log.Println("\n--- Проверка отложенных удалений Angie конфигов ---") + processDrainingAngieConfigs() + + log.Println("\n=== Программа завершена успешно ===") +} + +// processInstance обрабатывает один instance и возвращает количество обработанных клиентов +func processInstance(db *sql.DB, config Config, hostname, instanceName string, portAllocator *PortAllocator) int { + log.Printf("\n=== Обработка instance: %s ===", instanceName) + + clientInfoList, err := getClientInfoByInstance(db, instanceName) + if err != nil { + log.Printf("⚠ Ошибка получения клиентов для instance %s: %v", instanceName, err) + return 0 + } + + if len(clientInfoList) == 0 { + log.Printf("Нет активных клиентов для instance %s", instanceName) + return 0 + } + + log.Printf("Найдено клиентов для instance %s: %d", instanceName, len(clientInfoList)) + + for _, clientInfo := range clientInfoList { + processClient(db, config, hostname, instanceName, clientInfo, portAllocator) + } + + log.Printf("\n=== Instance %s обработан ===", instanceName) + return len(clientInfoList) +} + +// processClient обрабатывает одного клиента. Возвращает true если клиент был обработан. +// Роутер по waf_vendor: направляет клиента в PTAF или SW процессор +func processClient(db *sql.DB, config Config, hostname, instanceName string, clientInfo ClientInfo, portAllocator *PortAllocator) bool { + log.Printf("\n--- Обработка клиента: %s (instance: %s) ---", clientInfo.ClientTitle, instanceName) + + appsSettingsList, err := getAppsSettingsByClientTitle(db, clientInfo.ClientTitle) + if err != nil { + log.Printf("Ошибка получения apps_settings для %s: %v", clientInfo.ClientTitle, err) + return false + } + + if len(appsSettingsList) == 0 { + log.Printf("⚠ Нет apps_settings для клиента %s, пропускаем", clientInfo.ClientTitle) + return false + } + + // ════════════════════════════════════════════════════════════ + // ТОЧКА ВЕТВЛЕНИЯ ПО WAF VENDOR + // ════════════════════════════════════════════════════════════ + vendor := determineVendor(appsSettingsList) + log.Printf("WAF Vendor: %s", vendor) + + switch vendor { + case "ptaf": + return processPTAFClient(db, config, hostname, instanceName, clientInfo, appsSettingsList, portAllocator) + case "sw": + return processSWClient(db, config, hostname, instanceName, clientInfo, appsSettingsList, portAllocator) + default: + log.Printf("⚠ Неизвестный или не указан waf_vendor для клиента %s, пропускаем", clientInfo.ClientTitle) + log.Printf(" Ожидается: 'ptaf' или 'sw' в поле waf_vendor таблицы apps_settings") + return false + } +} + +// determineVendor определяет vendor из списка apps_settings +// Все ресурсы клиента должны иметь одинаковый waf_vendor +func determineVendor(appsSettingsList []AppsSettings) string { + for _, apps := range appsSettingsList { + if apps.WAFVendor.Valid && apps.WAFVendor.String != "" { + v := strings.ToLower(strings.TrimSpace(apps.WAFVendor.String)) + if v == "ptaf" || v == "sw" { + return v + } + } + } + return "" +} + +// REMOVED: старая логика processClient перенесена в ptaf/ptaf_processor.go +// Ниже идут вспомогательные функции которые используются обоими vendor + +// processClient_REMOVED_PLACEHOLDER - вся логика обработки теперь в vendor-specific модулях +// Этот блок оставлен для ясности что код был перенесён, не удалён +// handleClientMigration обрабатывает миграцию клиента с текущего хоста +func handleClientMigration(clientInfo ClientInfo, hostname string) { + log.Printf("⚠ Клиент %s должен быть на instance '%s', текущий хост '%s' не принадлежит этому instance", + clientInfo.ClientTitle, clientInfo.WAFInstance, hostname) + log.Printf(" Инициирую удаление контейнеров с текущего хоста (миграция)") + + for containerNum := 1; containerNum <= MaxContainersPerClient; containerNum++ { + containerFullName := fmt.Sprintf("ptaf_%s_%03d", clientInfo.ClientTitle, containerNum) + exists, _ := checkContainerStatus(containerFullName) + if exists { + log.Printf(" → Помечаю контейнер %s для drain (миграция, таймаут %d сек)", + containerFullName, MigrationDrainTimeout) + if err := markContainerForDrain(containerFullName, true); err != nil { + log.Printf(" ⚠ Ошибка пометки: %v", err) + } + } + } + + angieConfigPath := fmt.Sprintf("/etc/angie/http.d/angie-ptaf-%s.conf", clientInfo.ClientTitle) + if _, err := os.Stat(angieConfigPath); err == nil { + log.Printf(" → Помечаю конфиг Angie для удаления (миграция, таймаут %d сек)", MigrationDrainTimeout) + if err := markAngieConfigForDrain(clientInfo.ClientTitle); err != nil { + log.Printf(" ⚠ Ошибка пометки конфига: %v", err) + } else { + log.Printf(" ✓ Конфиг Angie помечен для удаления") + } + } + + processDrainingContainers(clientInfo.ClientTitle, 0) + processDrainingAngieConfigs() + + log.Printf(" Контейнеры и конфиг будут удалены через %d сек после завершения соединений", MigrationDrainTimeout) +} + +// cleanStaleDrainMarkers очищает устаревшие drain-маркеры если клиент вернулся на хост +func cleanStaleDrainMarkers(clientInfo ClientInfo) { + log.Printf(" → Проверка и очистка устаревших маркеров drain для %s", clientInfo.ClientTitle) + cleanedContainers := 0 + cleanedAngieMarker := false + + for containerNum := 1; containerNum <= MaxContainersPerClient; containerNum++ { + containerFullName := fmt.Sprintf("ptaf_%s_%03d", clientInfo.ClientTitle, containerNum) + markerFile := fmt.Sprintf("/tmp/ptaf-drain-%s", containerFullName) + + if _, err := os.Stat(markerFile); err == nil { + _, isMigration, found := getContainerDrainInfo(containerFullName) + if found && isMigration { + if err := os.Remove(markerFile); err == nil { + log.Printf(" ✓ Удалён устаревший маркер drain для контейнера %s (миграция отменена)", containerFullName) + cleanedContainers++ + } + } else if found && !isMigration { + if containerNum <= clientInfo.ContainersCount { + if err := os.Remove(markerFile); err == nil { + log.Printf(" ✓ Удалён устаревший маркер drain для контейнера %s (масштабирование отменено)", containerFullName) + cleanedContainers++ + } + } + } + } + } + + angieMarkerFile := fmt.Sprintf("/tmp/ptaf-angie-drain-%s", clientInfo.ClientTitle) + if _, err := os.Stat(angieMarkerFile); err == nil { + if err := os.Remove(angieMarkerFile); err == nil { + log.Printf(" ✓ Удалён устаревший маркер drain для конфига Angie (миграция отменена)") + cleanedAngieMarker = true + } + } + + if cleanedContainers > 0 || cleanedAngieMarker { + log.Printf(" ✓ Очищено устаревших маркеров: контейнеры=%d, конфиг Angie=%v", cleanedContainers, cleanedAngieMarker) + } else { + log.Printf(" ✓ Устаревших маркеров drain не найдено") + } +} + +// fetchResourcesData REMOVED: логика перенесена в vendor-specific модули +// - PTAF: ptaf/ptaf_processor.go → fetchPTAFResourcesData() +// - SW: sw/sw_processor.go → fetchSWResourcesData() (TODO) + +// resolvePortsForClient загружает сохранённые порты или выделяет новые +func resolvePortsForClient(clientInfo ClientInfo, resourcesData []ResourceData, portAllocator *PortAllocator) (map[int][]PortMapping, error) { + log.Printf("Чтение сохранённых портов для %s", clientInfo.ClientTitle) + savedPortMap := make(map[int][]PortMapping) + allPortsFound := true + + for containerNum := 1; containerNum <= clientInfo.ContainersCount; containerNum++ { + containerName := fmt.Sprintf("%s-ptaf-agent%03d", clientInfo.ClientTitle, containerNum) + containerDir := filepath.Join("/home/install/ptaf", clientInfo.ClientTitle, containerName) + + if portMappings, found := loadContainerPorts(containerDir, resourcesData); found { + if containerNum == 1 { + for _, res := range resourcesData { + savedPortMap[res.L7ResourceID] = make([]PortMapping, clientInfo.ContainersCount) + } + } + for idx, res := range resourcesData { + if idx < len(portMappings) { + savedPortMap[res.L7ResourceID][containerNum-1] = portMappings[idx] + } + } + } else { + log.Printf(" → Файл портов для контейнера %d не найден или невалиден", containerNum) + allPortsFound = false + break + } + } + + if allPortsFound && len(savedPortMap) > 0 { + log.Printf(" → Используются сохранённые порты из .ports.json файлов (%d контейнеров)", clientInfo.ContainersCount) + return savedPortMap, nil + } + + log.Printf(" → Выделение новых портов для %d ресурсов × %d контейнеров", len(resourcesData), clientInfo.ContainersCount) + return portAllocator.allocatePortsForResources(resourcesData, clientInfo.ContainersCount, nil) +} + +// checkPortListening проверяет что порт слушает на указанном адресе +func checkPortListening(host string, port int) bool { + conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), 2*time.Second) + if err != nil { + return false + } + conn.Close() + return true +} diff --git a/nginx.go b/nginx.go new file mode 100644 index 0000000..7876ba9 --- /dev/null +++ b/nginx.go @@ -0,0 +1,833 @@ +package main + +import ( + "database/sql" + "fmt" + "log" + "os" + "path/filepath" + "strings" +) + +// generateAngieConfigsWithoutReload генерирует конфиги Angie БЕЗ автоматического reload +func generateAngieConfigsWithoutReload(config Config, clientTitle string, resources []ResourceData, resourcePortMap map[int][]PortMapping) (bool, error) { + log.Printf("Генерация конфигов Angie для %s", clientTitle) + + configDir := "/etc/angie/http.d" + configFile := filepath.Join(configDir, fmt.Sprintf("angie-ptaf-%s.conf", clientTitle)) + hashFile := configFile + ".hash" + + if err := os.MkdirAll(configDir, 0755); err != nil { + return false, fmt.Errorf("ошибка создания директории %s: %w", configDir, err) + } + + tmpFile := configFile + ".tmp" + f, err := os.Create(tmpFile) + if err != nil { + return false, fmt.Errorf("ошибка создания временного файла %s: %w", tmpFile, err) + } + defer f.Close() + + // Заголовок + f.WriteString("# angie configuration file\n") + f.WriteString("# inserts into http location\n\n") + + // Map для WebSocket + f.WriteString("# Map для автоматического определения Connection header\n") + f.WriteString("# Для WebSocket: Connection: upgrade, для HTTP: Connection: '' (пустое для keepalive)\n") + f.WriteString("map $http_upgrade $proxy_connection {\n") + f.WriteString(" default upgrade;\n") + f.WriteString(" '' '';\n") + f.WriteString("}\n\n") + + for _, res := range resources { + containerPorts, ok := resourcePortMap[res.L7ResourceID] + if !ok { + os.Remove(tmpFile) + return false, fmt.Errorf("не найдены порты для l7ResourceID %d", res.L7ResourceID) + } + + err := writeAngieResourceConfig(f, config, res, containerPorts) + if err != nil { + os.Remove(tmpFile) + return false, fmt.Errorf("ошибка генерации конфига для l7ResourceID %d: %w", res.L7ResourceID, err) + } + } + + f.Close() + + newHash, err := getFileHash(tmpFile) + if err != nil { + os.Remove(tmpFile) + return false, fmt.Errorf("ошибка вычисления SHA256: %w", err) + } + + oldHashBytes, err := os.ReadFile(hashFile) + oldHash := "" + if err == nil { + oldHash = strings.TrimSpace(string(oldHashBytes)) + } + + if oldHash == newHash { + log.Printf(" ℹ Конфигурация Angie не изменилась (SHA256: %s), reload не требуется", newHash[:12]) + os.Remove(tmpFile) + return false, nil + } + + if err := os.Rename(tmpFile, configFile); err != nil { + os.Remove(tmpFile) + return false, fmt.Errorf("ошибка замены конфига: %w", err) + } + + if err := os.WriteFile(hashFile, []byte(newHash), 0644); err != nil { + log.Printf(" ⚠ Не удалось сохранить хеш: %v", err) + } + + if oldHash == "" { + log.Printf(" ✓ Конфиг Angie создан: %s (БЕЗ reload)", configFile) + } else { + log.Printf(" ✓ Конфиг Angie обновлён: %s (SHA256: %s → %s) (БЕЗ reload)", configFile, oldHash[:12], newHash[:12]) + } + + return true, nil +} + +// generateAngieConfigs генерирует конфигурационные файлы Angie с автоматическим reload +func generateAngieConfigs(config Config, clientTitle string, resources []ResourceData, resourcePortMap map[int][]PortMapping) error { + changed, err := generateAngieConfigsWithoutReload(config, clientTitle, resources, resourcePortMap) + if err != nil { + return err + } + + if changed { + reloadAngie() // Существующая функция из containers_part2.go (без возврата error) + } + + return nil +} + +// writeAngieResourceConfig записывает конфигурацию для одного ресурса в Angie +func writeAngieResourceConfig(f *os.File, config Config, res ResourceData, containerPorts []PortMapping) error { + allDomains := []string{res.ServerName} + allDomains = append(allDomains, res.Aliases...) + processedDomains := processDomainsWithWWW(allDomains) + + // Парсим кастомные SERVER_BLOCK + var customServerBlocks []CustomServerBlock + var serverDirectivesWithoutBlocks string + + if res.AppsSettings.ServerAngieCustom.Valid && strings.TrimSpace(res.AppsSettings.ServerAngieCustom.String) != "" { + serverDirectivesRaw := strings.TrimSpace(res.AppsSettings.ServerAngieCustom.String) + // Для Angie НЕ санитизируем домены в SERVER_BLOCK (принимаем my_site.com как есть) + customServerBlocks = parseCustomServerBlocks(serverDirectivesRaw, false) + + if len(customServerBlocks) > 0 { + log.Printf(" → Найдено %d кастомных SERVER_BLOCK директив для Angie l7ResourceID %d", + len(customServerBlocks), res.L7ResourceID) + for _, block := range customServerBlocks { + log.Printf(" → SERVER_BLOCK для Angie порта %d: %s + %d alias(es)", + block.Port, block.ServerName, len(block.Aliases)) + } + serverDirectivesWithoutBlocks = stripServerBlocks(serverDirectivesRaw) + } else { + serverDirectivesWithoutBlocks = serverDirectivesRaw + } + } + + serverBlockDomainsByPort, allServerBlockDomains := collectServerBlockDomains(customServerBlocks) + + if len(allServerBlockDomains) > 0 { + log.Printf(" → Домены в SERVER_BLOCK для ЛЮБЫХ портов Angie: %v", getMapKeys(allServerBlockDomains)) + } + + hasCustomLocation := res.AppsSettings.LocationAngieCustom.Valid && strings.TrimSpace(res.AppsSettings.LocationAngieCustom.String) != "" + customLocationStr := "" + if hasCustomLocation { + customLocationStr = strings.TrimSpace(res.AppsSettings.LocationAngieCustom.String) + } + + httpPortsToListen := getInputHTTPPorts(res.AppsSettings) + httpsPortsToListen := getInputHTTPSPorts(res.AppsSettings) + + // Upstream блоки для HTTPS + for _, customPort := range httpsPortsToListen { + upstreamName := fmt.Sprintf("secure%d_%d_%s", res.L7ResourceID, customPort, res.ServerName) + f.WriteString(fmt.Sprintf("upstream %s {\n", upstreamName)) + for _, ports := range containerPorts { + if dockerPort, ok := ports.HTTPSPorts[customPort]; ok { + f.WriteString(fmt.Sprintf(" server 127.0.0.1:%d weight=50;\n", dockerPort)) + } + } + writeCustomUpstreamOrDefault(f, res.AppsSettings.UpstreamAngieCustom, res.AppsSettings.SNI) + f.WriteString("}\n\n") + } + + // Upstream блоки для HTTP + for _, customPort := range httpPortsToListen { + upstreamName := fmt.Sprintf("unsecure%d_%d_%s", res.L7ResourceID, customPort, res.ServerName) + f.WriteString(fmt.Sprintf("upstream %s {\n", upstreamName)) + for _, ports := range containerPorts { + if dockerPort, ok := ports.HTTPPorts[customPort]; ok { + f.WriteString(fmt.Sprintf(" server 127.0.0.1:%d weight=50;\n", dockerPort)) + } + } + writeCustomUpstreamOrDefault(f, res.AppsSettings.UpstreamAngieCustom, res.AppsSettings.SNI) + f.WriteString("}\n\n") + } + + // Upstream блоки для кастомных SERVER_BLOCK в Angie. + // В nginx upstream для SERVER_BLOCK генерируется отдельным циклом (строки ~577), + // здесь реализуем аналогичную логику для Angie. + // Логика выбора docker-порта зеркалит resolveOutputPort из nginx: + // 1. Если block.Port совпадает с одним из httpsPortsToListen — берём его docker-порт + // 2. Иначе фоллбэк на httpsPortsToListen[0] — детерминировано, без итерации по map + if len(customServerBlocks) > 0 { + log.Printf(" → Генерация upstream'ов для %d кастомных SERVER_BLOCK (Angie) l7ResourceID %d", + len(customServerBlocks), res.L7ResourceID) + for _, block := range customServerBlocks { + var upstreamName string + if block.IsHTTPS { + upstreamName = fmt.Sprintf("secure%d_%d_%s", res.L7ResourceID, block.Port, block.ServerName) + f.WriteString(fmt.Sprintf("upstream %s {\n", upstreamName)) + // Определяем input-порт для поиска docker-порта: точное совпадение или фоллбэк на [0] + resolvedHTTPSPort := block.Port + foundHTTPS := false + for _, p := range httpsPortsToListen { + if p == block.Port { + foundHTTPS = true + break + } + } + if !foundHTTPS && len(httpsPortsToListen) > 0 { + resolvedHTTPSPort = httpsPortsToListen[0] + log.Printf(" └─ SERVER_BLOCK порт %d не в custom_input_https_ports, фоллбэк на %d", + block.Port, resolvedHTTPSPort) + } + for _, ports := range containerPorts { + if dockerPort, ok := ports.HTTPSPorts[resolvedHTTPSPort]; ok { + f.WriteString(fmt.Sprintf(" server 127.0.0.1:%d weight=50;\n", dockerPort)) + } + } + } else { + upstreamName = fmt.Sprintf("unsecure%d_%d_%s", res.L7ResourceID, block.Port, block.ServerName) + f.WriteString(fmt.Sprintf("upstream %s {\n", upstreamName)) + resolvedHTTPPort := block.Port + foundHTTP := false + for _, p := range httpPortsToListen { + if p == block.Port { + foundHTTP = true + break + } + } + if !foundHTTP && len(httpPortsToListen) > 0 { + resolvedHTTPPort = httpPortsToListen[0] + log.Printf(" └─ SERVER_BLOCK порт %d не в custom_input_http_ports, фоллбэк на %d", + block.Port, resolvedHTTPPort) + } + for _, ports := range containerPorts { + if dockerPort, ok := ports.HTTPPorts[resolvedHTTPPort]; ok { + f.WriteString(fmt.Sprintf(" server 127.0.0.1:%d weight=50;\n", dockerPort)) + } + } + } + writeCustomUpstreamOrDefault(f, res.AppsSettings.UpstreamAngieCustom, res.AppsSettings.SNI) + f.WriteString("}\n\n") + log.Printf(" └─ Angie upstream для SERVER_BLOCK: %s", upstreamName) + } + } + + // Server блоки для HTTP + for _, httpPort := range httpPortsToListen { + defaultDomains := filterDomainsForPort(httpPort, processedDomains, serverBlockDomainsByPort, allServerBlockDomains) + if len(defaultDomains) > 0 { + log.Printf(" → Angie HTTP порт %d: default server с %d доменами", httpPort, len(defaultDomains)) + upstreamName := fmt.Sprintf("unsecure%d_%d_%s", res.L7ResourceID, httpPort, res.ServerName) + + f.WriteString("server {\n") + f.WriteString(fmt.Sprintf(" listen %d;\n", httpPort)) + f.WriteString(fmt.Sprintf(" server_name %s;\n\n", strings.Join(defaultDomains, " "))) + f.WriteString(fmt.Sprintf(" access_log /var/log/angie/%d_%s_access.log waf;\n", res.L7ResourceID, res.ServerName)) + f.WriteString(fmt.Sprintf(" error_log /var/log/angie/%d_%s_error.log error;\n\n", res.L7ResourceID, res.ServerName)) + + writeNetworkSettings(f, config) + writeStandardServerSettings(f, getCustomServerDirectives(res.AppsSettings.ServerDirectivesAngieCustom)) + + if hasCustomLocation { + log.Printf(" └─ Добавление location_angie_custom (отдельный блок) для l7ResourceID %d (HTTP, порт %d)", res.L7ResourceID, httpPort) + correctUpstream := fmt.Sprintf("http://%s", upstreamName) + writeCustomLocationBlock(f, customLocationStr, correctUpstream, res.L7ResourceID, "HTTP", httpPort) + } + + f.WriteString(" location / {\n") + if res.AppsSettings.ServerAngieCustom.Valid && serverDirectivesWithoutBlocks != "" { + log.Printf(" └─ Добавление server_angie_custom для l7ResourceID %d (HTTP location /)", res.L7ResourceID) + writeIndentedLines(f, serverDirectivesWithoutBlocks) + } + f.WriteString(fmt.Sprintf(" proxy_pass http://%s;\n", upstreamName)) + f.WriteString(" }\n") + f.WriteString("}\n\n") + } else { + log.Printf(" → Angie HTTP порт %d: default server НЕ создан (нет доменов после исключения SERVER_BLOCK)", httpPort) + } + } + + // Server блоки для HTTPS + for _, httpsPort := range httpsPortsToListen { + defaultDomains := filterDomainsForPort(httpsPort, processedDomains, serverBlockDomainsByPort, allServerBlockDomains) + if len(defaultDomains) > 0 { + sslEnabled := true + if res.AppsSettings.SSLEnabled.Valid { + sslEnabled = res.AppsSettings.SSLEnabled.Bool + } + + if sslEnabled { + log.Printf(" → Angie HTTPS порт %d: default server с %d доменами (SSL включен)", httpsPort, len(defaultDomains)) + } else { + log.Printf(" → Angie HTTPS порт %d: default server с %d доменами (SSL отключен, plain HTTP)", httpsPort, len(defaultDomains)) + } + + upstreamName := fmt.Sprintf("secure%d_%d_%s", res.L7ResourceID, httpsPort, res.ServerName) + + f.WriteString("server {\n") + if sslEnabled { + f.WriteString(fmt.Sprintf(" listen %d ssl;\n", httpsPort)) + } else { + f.WriteString(fmt.Sprintf(" listen %d;\n", httpsPort)) + } + f.WriteString(fmt.Sprintf(" server_name %s;\n\n", strings.Join(defaultDomains, " "))) + f.WriteString(fmt.Sprintf(" access_log /var/log/angie/%d_%s_access.log waf;\n", res.L7ResourceID, res.ServerName)) + f.WriteString(fmt.Sprintf(" error_log /var/log/angie/%d_%s_error.log error;\n\n", res.L7ResourceID, res.ServerName)) + + if sslEnabled { + writeAngieSSLSettings(f, res.AppsSettings.CustomAngieSSL) + } + + writeNetworkSettings(f, config) + writeStandardServerSettings(f, getCustomServerDirectives(res.AppsSettings.ServerDirectivesAngieCustom)) + + if hasCustomLocation { + log.Printf(" └─ Добавление location_angie_custom (отдельный блок) для l7ResourceID %d (HTTPS, порт %d)", res.L7ResourceID, httpsPort) + correctUpstream := fmt.Sprintf("http://%s", upstreamName) + writeCustomLocationBlock(f, customLocationStr, correctUpstream, res.L7ResourceID, "HTTPS", httpsPort) + } + + f.WriteString(" location / {\n") + if res.AppsSettings.ServerAngieCustom.Valid && serverDirectivesWithoutBlocks != "" { + log.Printf(" └─ Добавление server_angie_custom для l7ResourceID %d (HTTPS location /)", res.L7ResourceID) + writeIndentedLines(f, serverDirectivesWithoutBlocks) + } + f.WriteString(fmt.Sprintf(" proxy_pass http://%s;\n", upstreamName)) + f.WriteString(" }\n") + f.WriteString("}\n\n") + } else { + log.Printf(" → Angie HTTPS порт %d: default server НЕ создан (нет доменов после исключения SERVER_BLOCK)", httpsPort) + } + } + + // Кастомные SERVER_BLOCK + if len(customServerBlocks) > 0 { + log.Printf(" → Генерация %d кастомных server блоков для Angie l7ResourceID %d", + len(customServerBlocks), res.L7ResourceID) + for _, block := range customServerBlocks { + aliasInfo := "" + if len(block.Aliases) > 0 { + aliasInfo = fmt.Sprintf(" + %d alias(es)", len(block.Aliases)) + } + log.Printf(" └─ Angie Server блок: порт %d, server_name %s%s", + block.Port, block.ServerName, aliasInfo) + generateAngieCustomServerBlock(f, config, res, block, containerPorts, customLocationStr) + } + } + + return nil +} + +// writeNetworkSettings записывает WAF и Antibot networks для Angie +func writeNetworkSettings(f *os.File, config Config) { + f.WriteString(" # waf networks\n") + for _, net := range config.WAFNetworks { + f.WriteString(fmt.Sprintf(" set_real_ip_from %s;\n", net)) + } + f.WriteString(" # antibot networks\n") + for _, net := range config.AntibotNetworks { + f.WriteString(fmt.Sprintf(" set_real_ip_from %s;\n", net)) + } + f.WriteString("\n real_ip_header X-Forwarded-For;\n") + f.WriteString(" real_ip_recursive on;\n\n") +} + +// writeAngieSSLSettings записывает SSL настройки для Angie +func writeAngieSSLSettings(f *os.File, customSSL sql.NullString) { + if customSSL.Valid && strings.TrimSpace(customSSL.String) != "" { + log.Printf(" └─ Использование custom_angie_ssl (кастомные SSL настройки)") + lines := strings.Split(strings.TrimSpace(customSSL.String), "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed != "" { + f.WriteString(" " + trimmed + "\n") + } + } + f.WriteString("\n") + } else { + f.WriteString(" ssl_certificate /etc/ssl/certs/sp.crt;\n") + f.WriteString(" ssl_certificate_key /etc/ssl/private/sp.key;\n") + f.WriteString(" ssl_protocols TLSv1.3 TLSv1.2;\n") + f.WriteString(" ssl_prefer_server_ciphers on;\n\n") + } +} + +// writeStandardServerSettings записывает стандартные настройки сервера (Angie) +func writeStandardServerSettings(f *os.File, customDirectives string) { + settings := ` server_tokens off; + sendfile on; + gzip on; + client_max_body_size 0; + + proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504; + proxy_http_version 1.1; + + js_set $safe_host main.sanitizeHost; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Connection $proxy_connection; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Host $safe_host; + proxy_set_header X-Original-Host $host; + proxy_pass_header Date; + proxy_pass_header Server; +` + f.WriteString(settings) + writeCustomOrDefaultDirectives(f, customDirectives) +} + +// writeNginxStandardSettings записывает стандартные настройки сервера (Nginx) +func writeNginxStandardSettings(f *os.File, customDirectives string) { + settings := ` server_tokens off; + sendfile on; + gzip on; + client_max_body_size 0; + + proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504; + proxy_http_version 1.1; + + #proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Connection $proxy_connection; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Host $backend_host; + proxy_pass_header Date; + proxy_pass_header Server; +` + f.WriteString(settings) + writeCustomOrDefaultDirectives(f, customDirectives) +} + +// writeCustomOrDefaultDirectives записывает кастомные или дефолтные директивы таймаутов/буферов +func writeCustomOrDefaultDirectives(f *os.File, customDirectives string) { + if customDirectives != "" { + f.WriteString("\n # Custom server directives (override defaults)\n") + lines := strings.Split(strings.TrimSpace(customDirectives), "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed != "" { + f.WriteString(" " + trimmed + "\n") + } + } + } else { + f.WriteString("\n # Default timeouts and buffers\n") + f.WriteString(" proxy_connect_timeout 60s;\n") + f.WriteString(" proxy_read_timeout 120s;\n") + f.WriteString(" proxy_send_timeout 120s;\n") + f.WriteString(" send_timeout 60s;\n") + f.WriteString(" keepalive_timeout 80s;\n") + f.WriteString(" keepalive_requests 1000;\n") + f.WriteString(" resolver_timeout 30s;\n") + f.WriteString(" large_client_header_buffers 4 128k;\n") + f.WriteString(" proxy_buffers 4 32k;\n") + f.WriteString(" proxy_buffer_size 16k;\n") + f.WriteString(" proxy_busy_buffers_size 32k;\n") + } +} + +// generateAngieCustomServerBlock генерирует кастомный server блок для Angie +func generateAngieCustomServerBlock(f *os.File, config Config, res ResourceData, block CustomServerBlock, containerPorts []PortMapping, customLocationStr string) { + var upstreamName string + if block.IsHTTPS { + upstreamName = fmt.Sprintf("secure%d_%d_%s", res.L7ResourceID, block.Port, res.ServerName) + } else { + upstreamName = fmt.Sprintf("unsecure%d_%d_%s", res.L7ResourceID, block.Port, res.ServerName) + } + + f.WriteString("server {\n") + + sslEnabled := true + if res.AppsSettings.SSLEnabled.Valid { + sslEnabled = res.AppsSettings.SSLEnabled.Bool + } + + if block.IsHTTPS { + if sslEnabled { + f.WriteString(fmt.Sprintf(" listen %d ssl;\n", block.Port)) + writeAngieSSLSettings(f, res.AppsSettings.CustomAngieSSL) + } else { + f.WriteString(fmt.Sprintf(" listen %d;\n", block.Port)) + } + } else { + f.WriteString(fmt.Sprintf(" listen %d;\n", block.Port)) + } + + if len(block.Aliases) > 0 { + f.WriteString(fmt.Sprintf(" server_name %s %s;\n\n", + block.ServerName, strings.Join(block.Aliases, " "))) + } else { + f.WriteString(fmt.Sprintf(" server_name %s;\n\n", block.ServerName)) + } + + f.WriteString(fmt.Sprintf(" access_log /var/log/angie/%d_%s_port%d_access.log waf;\n", + res.L7ResourceID, sanitizeServerName(block.ServerName), block.Port)) + f.WriteString(fmt.Sprintf(" error_log /var/log/angie/%d_%s_port%d_error.log error;\n\n", + res.L7ResourceID, sanitizeServerName(block.ServerName), block.Port)) + + writeNetworkSettings(f, config) + writeStandardServerSettings(f, getCustomServerDirectives(res.AppsSettings.ServerDirectivesAngieCustom)) + + // Кастомные location блоки из location_angie_custom (перед location /) + if customLocationStr != "" { + correctUpstream := fmt.Sprintf("http://%s", upstreamName) + log.Printf(" └─ Добавление location_angie_custom в SERVER_BLOCK порт %d: %s", block.Port, block.ServerName) + writeCustomLocationBlock(f, customLocationStr, correctUpstream, res.L7ResourceID, "SERVER_BLOCK", block.Port) + } + + f.WriteString(" location / {\n") + lines := strings.Split(block.Content, "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed != "" { + f.WriteString(" " + trimmed + "\n") + } + } + f.WriteString(fmt.Sprintf(" proxy_pass http://%s;\n", upstreamName)) + f.WriteString(" }\n") + f.WriteString("}\n\n") +} + +// writeUpstreamServers записывает серверы для upstream блока nginx (с origins) +func writeUpstreamServers(f *os.File, origins []OriginItem, outputPort int, settings AppsSettings) { + allBackup := true + for _, origin := range origins { + if strings.ToLower(origin.Mode) != "backup" { + allBackup = false + break + } + } + + for _, origin := range origins { + serverLine := fmt.Sprintf(" server %s:%d", origin.IP, outputPort) + if origin.Weight > 0 { + serverLine += fmt.Sprintf(" weight=%d", origin.Weight) + } + if strings.ToLower(origin.Mode) == "backup" && !allBackup { + serverLine += " backup" + } + maxFails := getMaxFails(settings) + failTimeout := getFailTimeout(settings) + serverLine += fmt.Sprintf(" max_fails=%d fail_timeout=%ds", maxFails, failTimeout) + serverLine += ";\n" + f.WriteString(serverLine) + } +} + +// generateNginxResourceConfig генерирует конфигурацию nginx для ресурса +func generateNginxResourceConfig(filepath string, config Config, res ResourceData, ports PortMapping) error { + allDomains := []string{res.ServerName} + allDomains = append(allDomains, res.Aliases...) + allDomains = sanitizeDomainsForNginx(allDomains) + processedDomains := processDomainsWithWWW(allDomains) + + serverNameForProcessing := processedDomains[0] + aliasesForProcessing := processedDomains[1:] + + f, err := os.Create(filepath) + if err != nil { + return err + } + defer f.Close() + + f.WriteString("# nginx configuration file\n") + f.WriteString("# inserts into http location\n\n") + + // Парсим SERVER_BLOCK + var customServerBlocks []CustomServerBlock + var serverDirectivesWithoutBlocks string + + if res.AppsSettings.ServerNginxCustom.Valid && strings.TrimSpace(res.AppsSettings.ServerNginxCustom.String) != "" { + serverDirectivesRaw := strings.TrimSpace(res.AppsSettings.ServerNginxCustom.String) + // Для Nginx санитизируем домены в SERVER_BLOCK (ожидаем my-site.com от Angie) + customServerBlocks = parseCustomServerBlocks(serverDirectivesRaw, true) + + if len(customServerBlocks) > 0 { + log.Printf(" → Найдено %d кастомных SERVER_BLOCK директив для l7ResourceID %d", + len(customServerBlocks), res.L7ResourceID) + for _, block := range customServerBlocks { + log.Printf(" → SERVER_BLOCK для порта %d: %s + %d alias(es)", + block.Port, block.ServerName, len(block.Aliases)) + } + serverDirectivesWithoutBlocks = stripServerBlocks(serverDirectivesRaw) + } else { + serverDirectivesWithoutBlocks = serverDirectivesRaw + } + serverDirectivesWithoutBlocks = replaceMTLSPaths(serverDirectivesWithoutBlocks) + } + + serverBlockDomainsByPort, allServerBlockDomains := collectServerBlockDomains(customServerBlocks) + + if len(allServerBlockDomains) > 0 { + log.Printf(" → Домены в SERVER_BLOCK для ЛЮБЫХ портов: %v", getMapKeys(allServerBlockDomains)) + } + + // Функция фильтрации для nginx (использует serverName + aliases отдельно) + filterNginxDomainsForPort := func(port int) []string { + var defaultDomains []string + allDoms := append([]string{serverNameForProcessing}, aliasesForProcessing...) + for _, domain := range allDoms { + if serverBlockDomainsByPort[port][domain] { + continue + } + if port == 80 || port == 443 { + defaultDomains = append(defaultDomains, domain) + } else { + if !allServerBlockDomains[domain] { + defaultDomains = append(defaultDomains, domain) + } + } + } + return defaultDomains + } + + // Location directives + var locationDirectives string + if res.AppsSettings.LocationNginxCustom.Valid && strings.TrimSpace(res.AppsSettings.LocationNginxCustom.String) != "" { + locationDirectives = replaceMTLSPaths(strings.TrimSpace(res.AppsSettings.LocationNginxCustom.String)) + } + + httpsPortsToListen := getInputHTTPSPorts(res.AppsSettings) + httpPortsToListen := getInputHTTPPorts(res.AppsSettings) + httpsOutputPorts := getOutputHTTPSPorts(res.AppsSettings) + httpOutputPorts := getOutputHTTPPorts(res.AppsSettings) + + hasCustomNginxLocation := locationDirectives != "" + isFullNginxLocationBlock := hasCustomNginxLocation && strings.Contains(locationDirectives, "location ") + + // Upstream блоки для HTTPS + for idx, inputPort := range httpsPortsToListen { + outputPort := httpsOutputPorts[0] + if idx < len(httpsOutputPorts) { + outputPort = httpsOutputPorts[idx] + } + upstreamName := fmt.Sprintf("secure%d_%d_%s", res.L7ResourceID, inputPort, res.ServerName) + f.WriteString(fmt.Sprintf("upstream %s {\n", upstreamName)) + writeUpstreamServers(f, res.Origins, outputPort, res.AppsSettings) + writeCustomUpstreamOrDefault(f, res.AppsSettings.UpstreamNginxCustom, res.AppsSettings.SNI) + f.WriteString("}\n\n") + } + + // Upstream блоки для HTTP + for idx, inputPort := range httpPortsToListen { + outputPort := httpOutputPorts[0] + if idx < len(httpOutputPorts) { + outputPort = httpOutputPorts[idx] + } + upstreamName := fmt.Sprintf("unsecure%d_%d_%s", res.L7ResourceID, inputPort, res.ServerName) + f.WriteString(fmt.Sprintf("upstream %s {\n", upstreamName)) + writeUpstreamServers(f, res.Origins, outputPort, res.AppsSettings) + writeCustomUpstreamOrDefault(f, res.AppsSettings.UpstreamNginxCustom, res.AppsSettings.SNI) + f.WriteString("}\n\n") + } + + // Upstream для SERVER_BLOCK + if len(customServerBlocks) > 0 { + log.Printf(" → Генерация upstream'ов для %d кастомных server блоков", len(customServerBlocks)) + for _, block := range customServerBlocks { + outputPort := resolveOutputPort(block, httpsPortsToListen, httpsOutputPorts, httpPortsToListen, httpOutputPorts) + prefix := map[bool]string{true: "secure", false: "unsecure"}[block.IsHTTPS] + upstreamName := fmt.Sprintf("%s%d_%d_%s", prefix, res.L7ResourceID, block.Port, block.ServerName) + f.WriteString(fmt.Sprintf("upstream %s {\n", upstreamName)) + writeUpstreamServers(f, res.Origins, outputPort, res.AppsSettings) + writeCustomUpstreamOrDefault(f, res.AppsSettings.UpstreamNginxCustom, res.AppsSettings.SNI) + f.WriteString("}\n\n") + } + } + + // Server блоки HTTP + for _, httpPort := range httpPortsToListen { + defaultDomains := filterNginxDomainsForPort(httpPort) + if len(defaultDomains) > 0 { + log.Printf(" → HTTP порт %d: default server с %d доменами", httpPort, len(defaultDomains)) + upstreamName := fmt.Sprintf("unsecure%d_%d_%s", res.L7ResourceID, httpPort, res.ServerName) + + f.WriteString("server {\n") + f.WriteString(fmt.Sprintf(" listen %d;\n", httpPort)) + f.WriteString(fmt.Sprintf(" server_name %s;\n\n", strings.Join(defaultDomains, " "))) + f.WriteString(fmt.Sprintf(" access_log /var/log/nginx/%d_%s_access.log waf;\n", res.L7ResourceID, res.ServerName)) + f.WriteString(fmt.Sprintf(" error_log /var/log/nginx/%d_%s_error.log error;\n\n", res.L7ResourceID, res.ServerName)) + f.WriteString(" set_real_ip_from 172.16.0.0/12;\n") + f.WriteString(" real_ip_header X-Forwarded-For;\n") + f.WriteString(" real_ip_recursive on;\n\n") + writeNginxStandardSettings(f, getCustomServerDirectives(res.AppsSettings.ServerDirectivesNginxCustom)) + + if hasCustomNginxLocation && isFullNginxLocationBlock { + log.Printf(" └─ Добавление location_nginx_custom (отдельный блок) для l7ResourceID %d (HTTP, порт %d)", res.L7ResourceID, httpPort) + writeCustomLocationBlock(f, locationDirectives, fmt.Sprintf("http://%s", upstreamName), res.L7ResourceID, "HTTP", httpPort) + } + + f.WriteString(" location / {\n") + if serverDirectivesWithoutBlocks != "" { + writeIndentedLines(f, serverDirectivesWithoutBlocks) + } + if hasCustomNginxLocation && !isFullNginxLocationBlock { + writeIndentedLines(f, locationDirectives) + } + f.WriteString(fmt.Sprintf(" proxy_pass http://%s;\n", upstreamName)) + f.WriteString(" }\n") + f.WriteString("}\n\n") + } else { + log.Printf(" → HTTP порт %d: default server НЕ создан (нет доменов после исключения SERVER_BLOCK)", httpPort) + } + } + + // Server блоки HTTPS + for _, httpsPort := range httpsPortsToListen { + defaultDomains := filterNginxDomainsForPort(httpsPort) + if len(defaultDomains) > 0 { + log.Printf(" → HTTPS порт %d: default server с %d доменами", httpsPort, len(defaultDomains)) + upstreamName := fmt.Sprintf("secure%d_%d_%s", res.L7ResourceID, httpsPort, res.ServerName) + + f.WriteString("server {\n") + f.WriteString(fmt.Sprintf(" listen %d;\n", httpsPort)) + f.WriteString(fmt.Sprintf(" server_name %s;\n\n", strings.Join(defaultDomains, " "))) + f.WriteString(fmt.Sprintf(" access_log /var/log/nginx/%d_%s_access.log waf;\n", res.L7ResourceID, res.ServerName)) + f.WriteString(fmt.Sprintf(" error_log /var/log/nginx/%d_%s_error.log error;\n\n", res.L7ResourceID, res.ServerName)) + f.WriteString(" proxy_ssl_server_name on;\n") + f.WriteString(" proxy_ssl_name $host;\n\n") + f.WriteString(" set_real_ip_from 172.16.0.0/12;\n") + f.WriteString(" real_ip_header X-Forwarded-For;\n") + f.WriteString(" real_ip_recursive on;\n\n") + writeNginxStandardSettings(f, getCustomServerDirectives(res.AppsSettings.ServerDirectivesNginxCustom)) + + if hasCustomNginxLocation && isFullNginxLocationBlock { + log.Printf(" └─ Добавление location_nginx_custom (отдельный блок) для l7ResourceID %d (HTTPS, порт %d)", res.L7ResourceID, httpsPort) + writeCustomLocationBlock(f, locationDirectives, fmt.Sprintf("https://%s", upstreamName), res.L7ResourceID, "HTTPS", httpsPort) + } + + f.WriteString(" location / {\n") + if serverDirectivesWithoutBlocks != "" { + writeIndentedLines(f, serverDirectivesWithoutBlocks) + } + if hasCustomNginxLocation && !isFullNginxLocationBlock { + writeIndentedLines(f, locationDirectives) + } + f.WriteString(fmt.Sprintf(" proxy_pass https://%s;\n", upstreamName)) + f.WriteString(" }\n") + f.WriteString("}\n") + } else { + log.Printf(" → HTTPS порт %d: default server НЕ создан (нет доменов после исключения SERVER_BLOCK)", httpsPort) + } + } + + // Кастомные SERVER_BLOCK + if len(customServerBlocks) > 0 { + log.Printf(" → Генерация %d кастомных server блоков для l7ResourceID %d", + len(customServerBlocks), res.L7ResourceID) + for _, block := range customServerBlocks { + aliasInfo := "" + if len(block.Aliases) > 0 { + aliasInfo = fmt.Sprintf(" + %d alias(es)", len(block.Aliases)) + } + log.Printf(" └─ Server блок: порт %d, server_name %s%s", block.Port, block.ServerName, aliasInfo) + outputPort := resolveOutputPort(block, httpsPortsToListen, httpsOutputPorts, httpPortsToListen, httpOutputPorts) + generateCustomServerBlock(f, res, block, outputPort, locationDirectives) + } + } + + return nil +} + +// resolveOutputPort определяет OUTPUT порт для SERVER_BLOCK +func resolveOutputPort(block CustomServerBlock, httpsInput, httpsOutput, httpInput, httpOutput []int) int { + if block.IsHTTPS { + for idx, inputPort := range httpsInput { + if inputPort == block.Port && idx < len(httpsOutput) { + return httpsOutput[idx] + } + } + if len(httpsOutput) > 0 { + return httpsOutput[0] + } + return 443 + } + + for idx, inputPort := range httpInput { + if inputPort == block.Port && idx < len(httpOutput) { + return httpOutput[idx] + } + } + if len(httpOutput) > 0 { + return httpOutput[0] + } + return 80 +} + +// generateCustomServerBlock генерирует отдельный server блок для nginx из SERVER_BLOCK +func generateCustomServerBlock(f *os.File, res ResourceData, block CustomServerBlock, outputPort int, locationDirectives string) { + protocol := "http" + if block.IsHTTPS { + protocol = "https" + } + + prefix := map[bool]string{true: "secure", false: "unsecure"}[block.IsHTTPS] + upstreamName := fmt.Sprintf("%s%d_%d_%s", prefix, res.L7ResourceID, block.Port, block.ServerName) + + f.WriteString("server {\n") + f.WriteString(fmt.Sprintf(" listen %d;\n", block.Port)) + + if len(block.Aliases) > 0 { + f.WriteString(fmt.Sprintf(" server_name %s %s;\n\n", + block.ServerName, strings.Join(block.Aliases, " "))) + } else { + f.WriteString(fmt.Sprintf(" server_name %s;\n\n", block.ServerName)) + } + + f.WriteString(fmt.Sprintf(" access_log /var/log/nginx/%d_%s_port%d_access.log waf;\n", + res.L7ResourceID, sanitizeServerName(block.ServerName), block.Port)) + f.WriteString(fmt.Sprintf(" error_log /var/log/nginx/%d_%s_port%d_error.log error;\n\n", + res.L7ResourceID, sanitizeServerName(block.ServerName), block.Port)) + + f.WriteString(" set_real_ip_from 172.16.0.0/12;\n") + f.WriteString(" real_ip_header X-Forwarded-For;\n") + f.WriteString(" real_ip_recursive on;\n\n") + + writeNginxStandardSettings(f, getCustomServerDirectives(res.AppsSettings.ServerDirectivesNginxCustom)) + + // Кастомные location блоки из location_nginx_custom (перед location /) + if locationDirectives != "" && strings.Contains(locationDirectives, "location ") { + correctUpstream := fmt.Sprintf("%s://%s", protocol, upstreamName) + log.Printf(" └─ Добавление location_nginx_custom в SERVER_BLOCK порт %d: %s", block.Port, block.ServerName) + writeCustomLocationBlock(f, locationDirectives, correctUpstream, res.L7ResourceID, "SERVER_BLOCK", block.Port) + } + + f.WriteString(" location / {\n") + lines := strings.Split(block.Content, "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed != "" { + f.WriteString(" " + trimmed + "\n") + } + } + // Если locationDirectives содержит директивы без location-блока — вставляем в location / + if locationDirectives != "" && !strings.Contains(locationDirectives, "location ") { + writeIndentedLines(f, locationDirectives) + } + f.WriteString(fmt.Sprintf(" proxy_pass %s://%s;\n", protocol, upstreamName)) + f.WriteString(" }\n") + f.WriteString("}\n\n") +} diff --git a/ports.go b/ports.go new file mode 100644 index 0000000..fcffd8c --- /dev/null +++ b/ports.go @@ -0,0 +1,270 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" +) + +// PortAllocator отслеживает порты, выделенные в текущем запуске программы. +// Заменяет глобальную переменную allocatedPortsInCurrentRun. +type PortAllocator struct { + allocated map[int]bool +} + +// NewPortAllocator создаёт новый аллокатор портов +func NewPortAllocator() *PortAllocator { + return &PortAllocator{allocated: make(map[int]bool)} +} + +// markAsUsed помечает порт как занятый в текущем запуске. +// Используется при загрузке сохранённых портов из .ports.json, +// чтобы другие клиенты/контейнеры не получили тот же порт. +func (pa *PortAllocator) markAsUsed(port int) { + pa.allocated[port] = true +} + +// allocatePortsForResources выделяет порты для всех ресурсов и контейнеров. +// excludePorts — порты, которые нужно исключить из проверки занятости (может быть nil). +// Возвращает map[l7ResourceID][]PortMapping где каждый элемент массива — порты для одного контейнера. +func (pa *PortAllocator) allocatePortsForResources(resourcesData []ResourceData, containersCount int, excludePorts map[int]bool) (map[int][]PortMapping, error) { + // Получаем список занятых портов в системе + usedPorts, err := getUsedPorts() + if err != nil { + return nil, fmt.Errorf("ошибка получения занятых портов: %w", err) + } + + // Исключаем порты клиента (если передано) для переиспользования + for port := range excludePorts { + delete(usedPorts, port) + } + + // Объединяем с портами из текущего запуска + for port := range pa.allocated { + usedPorts[port] = true + } + + resourcePortMap := make(map[int][]PortMapping) + currentHTTPPort := HTTPPortStart + currentHTTPSPort := HTTPSPortStart + + for _, res := range resourcesData { + var portMappingsForResource []PortMapping + + httpPortsNeeded := getInputHTTPPorts(res.AppsSettings) + httpsPortsNeeded := getInputHTTPSPorts(res.AppsSettings) + + for containerNum := 0; containerNum < containersCount; containerNum++ { + portMapping := PortMapping{ + HTTPPorts: make(map[int]int), + HTTPSPorts: make(map[int]int), + } + + // Выделяем Docker порты для HTTP + for _, customPort := range httpPortsNeeded { + for usedPorts[currentHTTPPort] && currentHTTPPort <= HTTPPortEnd { + currentHTTPPort++ + } + if currentHTTPPort > HTTPPortEnd { + return nil, fmt.Errorf("не хватает свободных HTTP портов (диапазон %d-%d исчерпан)", HTTPPortStart, HTTPPortEnd) + } + + portMapping.HTTPPorts[customPort] = currentHTTPPort + usedPorts[currentHTTPPort] = true + pa.allocated[currentHTTPPort] = true + currentHTTPPort++ + } + + // Выделяем Docker порты для HTTPS + for _, customPort := range httpsPortsNeeded { + for usedPorts[currentHTTPSPort] && currentHTTPSPort <= HTTPSPortEnd { + currentHTTPSPort++ + } + if currentHTTPSPort > HTTPSPortEnd { + return nil, fmt.Errorf("не хватает свободных HTTPS портов (диапазон %d-%d исчерпан)", HTTPSPortStart, HTTPSPortEnd) + } + + portMapping.HTTPSPorts[customPort] = currentHTTPSPort + usedPorts[currentHTTPSPort] = true + pa.allocated[currentHTTPSPort] = true + currentHTTPSPort++ + } + + portMappingsForResource = append(portMappingsForResource, portMapping) + } + + resourcePortMap[res.L7ResourceID] = portMappingsForResource + + // Логирование + totalHTTPPorts := len(httpPortsNeeded) * containersCount + totalHTTPSPorts := len(httpsPortsNeeded) * containersCount + log.Printf(" → Ресурс %d: выделено %d HTTP портов и %d HTTPS портов (%d контейнеров × %d+%d портов)", + res.L7ResourceID, totalHTTPPorts, totalHTTPSPorts, containersCount, len(httpPortsNeeded), len(httpsPortsNeeded)) + } + + return resourcePortMap, nil +} + +// getUsedPorts возвращает map занятых портов +func getUsedPorts() (map[int]bool, error) { + usedPorts := make(map[int]bool) + + cmd := exec.Command("ss", "-tuln") + output, err := cmd.Output() + if err != nil { + return usedPorts, fmt.Errorf("ошибка выполнения ss: %w", err) + } + + lines := strings.Split(string(output), "\n") + for _, line := range lines { + fields := strings.Fields(line) + if len(fields) < 5 { + continue + } + + localAddr := fields[4] + parts := strings.Split(localAddr, ":") + if len(parts) < 2 { + continue + } + + port := 0 + fmt.Sscanf(parts[len(parts)-1], "%d", &port) + if (port >= HTTPPortStart && port <= HTTPPortEnd) || (port >= HTTPSPortStart && port <= HTTPSPortEnd) { + usedPorts[port] = true + } + } + + return usedPorts, nil +} + +// saveContainerPorts сохраняет порты контейнера в файл .ports.json +func saveContainerPorts(containerDir string, containerNum int, resources []ResourceData, portMappings []PortMapping) error { + portsFile := filepath.Join(containerDir, ".ports.json") + + savedPorts := SavedContainerPorts{ + ContainerNum: containerNum, + Resources: make([]SavedResourcePorts, 0), + } + + for idx, res := range resources { + if idx >= len(portMappings) { + continue + } + + savedPorts.Resources = append(savedPorts.Resources, SavedResourcePorts{ + L7ResourceID: res.L7ResourceID, + HTTPPorts: portMappings[idx].HTTPPorts, + HTTPSPorts: portMappings[idx].HTTPSPorts, + }) + } + + data, err := json.MarshalIndent(savedPorts, "", " ") + if err != nil { + return fmt.Errorf("ошибка сериализации портов: %w", err) + } + + if err := os.WriteFile(portsFile, data, 0644); err != nil { + return fmt.Errorf("ошибка записи файла портов: %w", err) + } + + return nil +} + +// loadContainerPorts загружает порты контейнера из файла .ports.json +func loadContainerPorts(containerDir string, resources []ResourceData) ([]PortMapping, bool) { + portsFile := filepath.Join(containerDir, ".ports.json") + + data, err := os.ReadFile(portsFile) + if err != nil { + return nil, false + } + + var savedPorts SavedContainerPorts + if err := json.Unmarshal(data, &savedPorts); err != nil { + log.Printf(" ⚠ Ошибка парсинга %s: %v", portsFile, err) + return nil, false + } + + if len(savedPorts.Resources) != len(resources) { + log.Printf(" ⚠ Количество ресурсов изменилось (было %d, стало %d), перевыделяем порты", + len(savedPorts.Resources), len(resources)) + return nil, false + } + + for idx, savedRes := range savedPorts.Resources { + if savedRes.L7ResourceID != resources[idx].L7ResourceID { + log.Printf(" ⚠ Порядок ресурсов изменился (позиция %d: было %d, стало %d), перевыделяем порты", + idx, savedRes.L7ResourceID, resources[idx].L7ResourceID) + return nil, false + } + } + + // Проверка: изменились ли кастомные порты? + for idx, savedRes := range savedPorts.Resources { + currentRes := resources[idx] + + // Получаем текущие кастомные порты из БД + currentHTTPPorts := getInputHTTPPorts(currentRes.AppsSettings) + currentHTTPSPorts := getInputHTTPSPorts(currentRes.AppsSettings) + + // Проверяем HTTP порты + if !portsMatch(savedRes.HTTPPorts, currentHTTPPorts) { + log.Printf(" ⚠ Кастомные HTTP порты изменились для ресурса %d, перевыделяем порты", + currentRes.L7ResourceID) + log.Printf(" Было: %v, Стало: %v", getMapKeysInt(savedRes.HTTPPorts), currentHTTPPorts) + return nil, false + } + + // Проверяем HTTPS порты + if !portsMatch(savedRes.HTTPSPorts, currentHTTPSPorts) { + log.Printf(" ⚠ Кастомные HTTPS порты изменились для ресурса %d, перевыделяем порты", + currentRes.L7ResourceID) + log.Printf(" Было: %v, Стало: %v", getMapKeysInt(savedRes.HTTPSPorts), currentHTTPSPorts) + return nil, false + } + } + + portMappings := make([]PortMapping, len(savedPorts.Resources)) + for idx, savedRes := range savedPorts.Resources { + portMappings[idx] = PortMapping{ + HTTPPorts: savedRes.HTTPPorts, + HTTPSPorts: savedRes.HTTPSPorts, + } + } + + return portMappings, true +} + +// portsMatch проверяет совпадают ли кастомные порты в сохраненном маппинге +// с текущими кастомными портами из БД +func portsMatch(savedMapping map[int]int, currentCustomPorts []int) bool { + // Проверяем что количество совпадает + if len(savedMapping) != len(currentCustomPorts) { + return false + } + + // Проверяем что все текущие кастомные порты есть в сохраненном маппинге + for _, customPort := range currentCustomPorts { + if _, exists := savedMapping[customPort]; !exists { + return false + } + } + + return true +} + +// getMapKeysInt возвращает ключи из map[int]int в виде отсортированного слайса +func getMapKeysInt(m map[int]int) []int { + keys := make([]int, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Ints(keys) + return keys +} diff --git a/ptaf_processor.go b/ptaf_processor.go new file mode 100644 index 0000000..6ee22d5 --- /dev/null +++ b/ptaf_processor.go @@ -0,0 +1,273 @@ +package main + +import ( + "database/sql" + "fmt" + "log" + "net" + "strings" + "time" +) + +// processPTAFClient обрабатывает клиента для PTAF vendor +// Вся логика из processClient() перенесена сюда без изменений +func processPTAFClient( + db *sql.DB, + config Config, + hostname, instanceName string, + clientInfo ClientInfo, + appsSettingsList []AppsSettings, + portAllocator *PortAllocator, +) bool { + log.Printf("Количество контейнеров: %d", clientInfo.ContainersCount) + if clientInfo.PTAFConfig.Valid { + log.Printf("PTAF Config: установлен (скрыт для безопасности)") + } else { + log.Printf("PTAF Config: не установлен") + } + if clientInfo.FluentBitPort.Valid { + log.Printf("Fluent Bit Port: %d", clientInfo.FluentBitPort.Int64) + } + + // Проверяем принадлежность хоста к instance клиента + belongsToInstance := false + if clientInfo.WAFInstance != "" { + var err error + belongsToInstance, err = checkHostBelongsToInstance(db, hostname, clientInfo.WAFInstance) + if err != nil { + log.Printf("⚠ Ошибка проверки принадлежности хоста для %s: %v", clientInfo.ClientTitle, err) + return false + } + } + + // Если хост НЕ принадлежит instance — инициируем миграцию + if clientInfo.WAFInstance != "" && !belongsToInstance { + handleClientMigration(clientInfo, hostname) + return false + } + + // Хост принадлежит instance — обрабатываем + if clientInfo.WAFInstance == "" { + log.Printf("ℹ Клиент %s не привязан к конкретному instance, обрабатываю", clientInfo.ClientTitle) + } else { + log.Printf("✓ Клиент %s принадлежит instance '%s', обрабатываю", clientInfo.ClientTitle, clientInfo.WAFInstance) + } + + // Автоочистка устаревших маркеров drain + cleanStaleDrainMarkers(clientInfo) + + // Получение данных ресурсов + resourcesData := fetchPTAFResourcesData(db, config, appsSettingsList) + if len(resourcesData) == 0 { + log.Printf("⚠ Нет ресурсов с данными для клиента %s, пропускаем", clientInfo.ClientTitle) + return false + } + + // Выделение портов + resourcePortMap, err := resolvePortsForClient(clientInfo, resourcesData, portAllocator) + if err != nil { + log.Printf("Ошибка выделения портов для клиента %s: %v", clientInfo.ClientTitle, err) + return false + } + + // ════════════════════════════════════════════════════════════ + // КРИТИЧЕСКОЕ ИСПРАВЛЕНИЕ: Изменен порядок операций + // БЫЛО: generateAngieConfigs (с reload) → setupContainers + // СТАЛО: generateAngieConfigs (БЕЗ reload) → setupContainers → проверка портов → reload Angie + // ════════════════════════════════════════════════════════════ + + // 1. Генерация конфигурации Angie БЕЗ автоматического reload + angieChanged, err := generateAngieConfigsWithoutReload(config, clientInfo.ClientTitle, resourcesData, resourcePortMap) + if err != nil { + log.Printf("Ошибка генерации конфигов Angie для %s: %v", clientInfo.ClientTitle, err) + return false + } + + // 2. Создание директорий и конфигов для контейнеров (пересоздание если нужно) + err = setupContainers(config, clientInfo.ClientTitle, &clientInfo, resourcesData, resourcePortMap) + if err != nil { + log.Printf("Ошибка настройки контейнеров для %s: %v", clientInfo.ClientTitle, err) + return false + } + + // 3. Reload Angie только ПОСЛЕ того как все контейнеры готовы + if angieChanged { + log.Printf(" → Конфиг Angie изменился, проверяем готовность контейнеров перед reload") + + // Проверяем что все порты контейнеров слушают + allPortsReady := true + totalPorts := 0 + readyPorts := 0 + + for _, res := range resourcesData { + containerPorts, ok := resourcePortMap[res.L7ResourceID] + if !ok { + continue + } + + for _, ports := range containerPorts { + // Проверяем HTTPS порты + for customPort, dockerPort := range ports.HTTPSPorts { + totalPorts++ + if checkPortListening("127.0.0.1", dockerPort) { + readyPorts++ + } else { + log.Printf(" ⚠ Порт %d (custom %d) для l7ResourceID %d не слушает!", + dockerPort, customPort, res.L7ResourceID) + allPortsReady = false + } + } + + // Проверяем HTTP порты + for customPort, dockerPort := range ports.HTTPPorts { + totalPorts++ + if checkPortListening("127.0.0.1", dockerPort) { + readyPorts++ + } else { + log.Printf(" ⚠ Порт %d (custom %d) для l7ResourceID %d не слушает!", + dockerPort, customPort, res.L7ResourceID) + allPortsReady = false + } + } + } + } + + log.Printf(" → Портов готово: %d/%d", readyPorts, totalPorts) + + if !allPortsReady { + log.Printf("⚠ Не все порты готовы, пропускаем reload Angie для безопасности") + log.Printf(" → Angie продолжит работать со старой конфигурацией") + log.Printf(" → При следующем запуске программы будет повторена попытка") + } else { + log.Printf(" ✓ Все порты слушают, безопасно перезагрузить Angie") + reloadAngie() // Функция из containers_part2.go (не возвращает error) + } + } else { + log.Printf(" ℹ Конфиг Angie не изменился, reload не требуется") + } + // ════════════════════════════════════════════════════════════ + + log.Printf("Клиент %s успешно обработан", clientInfo.ClientTitle) + return true +} + +// fetchPTAFResourcesData получает данные для каждого ресурса из кеша или API +// Проверяет что waf_vendor = 'ptaf' +func fetchPTAFResourcesData(db *sql.DB, config Config, appsSettingsList []AppsSettings) []ResourceData { + var resourcesData []ResourceData + + for _, appsSettings := range appsSettingsList { + if !appsSettings.WAFEnabled.Valid || !appsSettings.WAFEnabled.Bool { + log.Printf("Пропускаем l7ResourceID %d: waf_enabled = false", appsSettings.L7ResourceID) + continue + } + + // Проверяем waf_vendor - обрабатываем только ресурсы PTAF + if appsSettings.WAFVendor.Valid && strings.ToLower(strings.TrimSpace(appsSettings.WAFVendor.String)) != "ptaf" { + log.Printf("Пропускаем l7ResourceID %d: waf_vendor = '%s' (требуется 'ptaf')", appsSettings.L7ResourceID, appsSettings.WAFVendor.String) + continue + } + + if !appsSettings.WAFVendor.Valid || appsSettings.WAFVendor.String == "" { + log.Printf("Пропускаем l7ResourceID %d: waf_vendor не указан (требуется 'ptaf')", appsSettings.L7ResourceID) + continue + } + + mode := "auto" + if appsSettings.Mode.Valid { + mode = strings.ToLower(strings.TrimSpace(appsSettings.Mode.String)) + } + + if mode != "auto" && mode != "manual" { + log.Printf("Пропускаем l7ResourceID %d: mode = '%s' (требуется 'auto' или 'manual')", appsSettings.L7ResourceID, mode) + continue + } + + if mode == "manual" { + log.Printf("l7ResourceID %d: mode = 'manual', waf_vendor = 'ptaf' ✓", appsSettings.L7ResourceID) + } else if appsSettings.Mode.Valid { + log.Printf("l7ResourceID %d: mode = 'auto', waf_vendor = 'ptaf' ✓", appsSettings.L7ResourceID) + } else { + log.Printf("l7ResourceID %d: mode = NULL (обрабатываем как 'auto'), waf_vendor = 'ptaf' ✓", appsSettings.L7ResourceID) + } + + log.Printf("Получение данных для l7ResourceID: %d", appsSettings.L7ResourceID) + + var serverName string + var aliases []string + var origins []OriginItem + var err error + + if mode == "manual" { + serverName, aliases, origins, err = getResourceDataFromManualInfo(db, config, appsSettings.L7ResourceID) + if err != nil { + log.Printf("Ошибка получения данных из manual_info для l7ResourceID %d: %v", appsSettings.L7ResourceID, err) + continue + } + log.Printf(" ✓ Данные получены из manual_info (waf_vendor: ptaf)") + } else { + var found bool + serverName, aliases, origins, found, err = getResourceDataFromCache(db, config, appsSettings.L7ResourceID) + + if found && err == nil { + log.Printf(" Server Name: %s", serverName) + log.Printf(" Aliases: %v", aliases) + log.Printf(" Origins: %d шт.", len(origins)) + } else { + if err != nil { + log.Printf(" ⚠ Ошибка чтения кеша: %v, запрашиваю API", err) + } else { + log.Printf(" ℹ Данных нет в кеше, запрашиваю API") + } + + serverName, err = getServerName(config, appsSettings.L7ResourceID) + if err != nil { + log.Printf("Ошибка получения server_name для l7ResourceID %d: %v", appsSettings.L7ResourceID, err) + continue + } + log.Printf(" Server Name: %s", serverName) + + aliases, err = getAliases(config, appsSettings.L7ResourceID) + if err != nil { + log.Printf("Ошибка получения aliases для l7ResourceID %d: %v", appsSettings.L7ResourceID, err) + continue + } + log.Printf(" Aliases: %v", aliases) + + origins, err = getOrigins(config, appsSettings.L7ResourceID) + if err != nil { + log.Printf("Ошибка получения origins для l7ResourceID %d: %v", appsSettings.L7ResourceID, err) + continue + } + log.Printf(" Origins: %d шт.", len(origins)) + + // Сохранение в кеш (опционально, можно закомментировать если функция отсутствует) + // if err := saveResourceDataToCache(db, appsSettings.L7ResourceID, serverName, aliases, origins); err != nil { + // log.Printf(" ⚠ Ошибка сохранения в кеш: %v", err) + // } + } + } + + resourcesData = append(resourcesData, ResourceData{ + L7ResourceID: appsSettings.L7ResourceID, + ServerName: serverName, + Aliases: aliases, + Origins: origins, + AppsSettings: appsSettings, + }) + } + + return resourcesData +} + +// checkPortListening проверяет что порт слушает на указанном адресе +// Функция уже есть в main.go, но для совместимости оставим здесь тоже +// (Go позволяет дублирование функций в одном package если они приватные) +func checkPortListeningPTAF(host string, port int) bool { + conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), 2*time.Second) + if err != nil { + return false + } + conn.Close() + return true +} diff --git a/sw_processor.go b/sw_processor.go new file mode 100644 index 0000000..056a2ce --- /dev/null +++ b/sw_processor.go @@ -0,0 +1,810 @@ +package main + +import ( + "database/sql" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// SW константы +const ( + SWNginxBinary = "solidwall-nginx" + SWConfigDir = "/etc/solidwall-nginx" + SWSiteAvailableDir = "/etc/solidwall-nginx/site-available" + SWSiteEnabledDir = "/etc/solidwall-nginx/site-enabled" + SWStaticDir = "/etc/solidwall-nginx/static" + SWLogDir = "/var/log/solidwall" + SWInternalHTTPPort = 8000 + SWInternalHTTPSPort = 9000 + SWSSLCertPath = "ssl/cert.pem" + SWSSLKeyPath = "ssl/key.pem" +) + +// processSWClient обрабатывает клиента для SW vendor +func processSWClient( + db *sql.DB, + config Config, + hostname, instanceName string, + clientInfo ClientInfo, + appsSettingsList []AppsSettings, + portAllocator *PortAllocator, +) bool { + log.Printf("═══════════════════════════════════════════════════════════") + log.Printf("SW VENDOR: обработка клиента %s", clientInfo.ClientTitle) + log.Printf("═══════════════════════════════════════════════════════════") + + // Проверяем принадлежность хоста к instance клиента (аналогично PTAF) + belongsToInstance := false + if clientInfo.WAFInstance != "" { + var err error + belongsToInstance, err = checkHostBelongsToInstance(db, hostname, clientInfo.WAFInstance) + if err != nil { + log.Printf("⚠ Ошибка проверки принадлежности хоста для %s: %v", clientInfo.ClientTitle, err) + return false + } + } + + // Если хост НЕ принадлежит instance — пропускаем (миграция для SW не реализована) + if clientInfo.WAFInstance != "" && !belongsToInstance { + log.Printf("⚠ Клиент %s должен быть на instance '%s', текущий хост '%s' не принадлежит этому instance", + clientInfo.ClientTitle, clientInfo.WAFInstance, hostname) + log.Printf(" SW не поддерживает автоматическую миграцию, пропускаем") + return false + } + + // Хост принадлежит instance — обрабатываем + if clientInfo.WAFInstance == "" { + log.Printf("ℹ Клиент %s не привязан к конкретному instance, обрабатываю", clientInfo.ClientTitle) + } else { + log.Printf("✓ Клиент %s принадлежит instance '%s', обрабатываю", clientInfo.ClientTitle, clientInfo.WAFInstance) + } + + // Получение данных ресурсов (фильтр по waf_vendor = 'sw') + resourcesData := fetchSWResourcesData(db, config, appsSettingsList) + if len(resourcesData) == 0 { + log.Printf("⚠ Нет ресурсов с данными для клиента %s, пропускаем", clientInfo.ClientTitle) + return false + } + + log.Printf("✓ Получено ресурсов для SW: %d", len(resourcesData)) + + // Создание static файлов если отсутствуют + if err := ensureSWStaticFiles(); err != nil { + log.Printf("⚠ Ошибка создания static файлов: %v", err) + // Не критично, продолжаем + } + + // Генерация конфигов для каждого ресурса + configsChanged := false + for _, res := range resourcesData { + changed, err := generateSWConfig(config, clientInfo.ClientTitle, res) + if err != nil { + log.Printf("⚠ Ошибка генерации конфига для l7ResourceID %d: %v", res.L7ResourceID, err) + continue + } + if changed { + configsChanged = true + } + } + + // Reload nginx если были изменения + if configsChanged { + log.Printf("→ Конфиги SW изменились, выполняю проверку и reload") + if err := reloadSWNginx(); err != nil { + log.Printf("⚠ Ошибка reload SW nginx: %v", err) + return false + } + log.Printf("✓ SW nginx успешно перезагружен") + } else { + log.Printf("ℹ Конфиги SW не изменились, reload не требуется") + } + + log.Printf("✓ Клиент %s (SW) успешно обработан", clientInfo.ClientTitle) + return true +} + +// fetchSWResourcesData получает данные для каждого ресурса из кеша или API +// Проверяет что waf_vendor = 'sw' +func fetchSWResourcesData(db *sql.DB, config Config, appsSettingsList []AppsSettings) []ResourceData { + var resourcesData []ResourceData + + for _, appsSettings := range appsSettingsList { + if !appsSettings.WAFEnabled.Valid || !appsSettings.WAFEnabled.Bool { + log.Printf("Пропускаем l7ResourceID %d: waf_enabled = false", appsSettings.L7ResourceID) + continue + } + + // Проверяем waf_vendor - обрабатываем только ресурсы SW + if appsSettings.WAFVendor.Valid && strings.ToLower(strings.TrimSpace(appsSettings.WAFVendor.String)) != "sw" { + log.Printf("Пропускаем l7ResourceID %d: waf_vendor = '%s' (требуется 'sw')", appsSettings.L7ResourceID, appsSettings.WAFVendor.String) + continue + } + + if !appsSettings.WAFVendor.Valid || appsSettings.WAFVendor.String == "" { + log.Printf("Пропускаем l7ResourceID %d: waf_vendor не указан (требуется 'sw')", appsSettings.L7ResourceID) + continue + } + + mode := "auto" + if appsSettings.Mode.Valid { + mode = strings.ToLower(strings.TrimSpace(appsSettings.Mode.String)) + } + + if mode != "auto" && mode != "manual" { + log.Printf("Пропускаем l7ResourceID %d: mode = '%s' (требуется 'auto' или 'manual')", appsSettings.L7ResourceID, mode) + continue + } + + log.Printf("l7ResourceID %d: mode = '%s', waf_vendor = 'sw' ✓", appsSettings.L7ResourceID, mode) + log.Printf("Получение данных для l7ResourceID: %d", appsSettings.L7ResourceID) + + var serverName string + var aliases []string + var origins []OriginItem + var err error + + if mode == "manual" { + serverName, aliases, origins, err = getResourceDataFromManualInfo(db, config, appsSettings.L7ResourceID) + if err != nil { + log.Printf("Ошибка получения данных из manual_info для l7ResourceID %d: %v", appsSettings.L7ResourceID, err) + continue + } + log.Printf(" ✓ Данные получены из manual_info (waf_vendor: sw)") + } else { + var found bool + serverName, aliases, origins, found, err = getResourceDataFromCache(db, config, appsSettings.L7ResourceID) + + if found && err == nil { + log.Printf(" Server Name: %s", serverName) + log.Printf(" Aliases: %v", aliases) + log.Printf(" Origins: %d шт.", len(origins)) + } else { + if err != nil { + log.Printf(" ⚠ Ошибка чтения кеша: %v, запрашиваю API", err) + } else { + log.Printf(" ℹ Данных нет в кеше, запрашиваю API") + } + + serverName, err = getServerName(config, appsSettings.L7ResourceID) + if err != nil { + log.Printf("Ошибка получения server_name для l7ResourceID %d: %v", appsSettings.L7ResourceID, err) + continue + } + log.Printf(" Server Name: %s", serverName) + + aliases, err = getAliases(config, appsSettings.L7ResourceID) + if err != nil { + log.Printf("Ошибка получения aliases для l7ResourceID %d: %v", appsSettings.L7ResourceID, err) + continue + } + log.Printf(" Aliases: %v", aliases) + + origins, err = getOrigins(config, appsSettings.L7ResourceID) + if err != nil { + log.Printf("Ошибка получения origins для l7ResourceID %d: %v", appsSettings.L7ResourceID, err) + continue + } + log.Printf(" Origins: %d шт.", len(origins)) + } + } + + resourcesData = append(resourcesData, ResourceData{ + L7ResourceID: appsSettings.L7ResourceID, + ServerName: serverName, + Aliases: aliases, + Origins: origins, + AppsSettings: appsSettings, + }) + } + + return resourcesData +} + +// generateSWConfig генерирует nginx конфиг для одного ресурса SW +func generateSWConfig(config Config, clientTitle string, res ResourceData) (bool, error) { + configFilename := fmt.Sprintf("solidwall-%s-%d.conf", sanitizeFilename(clientTitle), res.L7ResourceID) + configPath := filepath.Join(SWSiteAvailableDir, configFilename) + enabledPath := filepath.Join(SWSiteEnabledDir, configFilename) + + log.Printf(" → Генерация конфига SW: %s", configFilename) + + // Создаём временный файл + tmpPath := configPath + ".tmp" + f, err := os.Create(tmpPath) + if err != nil { + return false, fmt.Errorf("создание временного файла: %w", err) + } + defer f.Close() + + // Генерация содержимого конфига + if err := writeSWConfigContent(f, config, res); err != nil { + os.Remove(tmpPath) + return false, fmt.Errorf("запись конфига: %w", err) + } + + f.Close() + + // Проверяем изменился ли конфиг + changed := true + if oldContent, err := os.ReadFile(configPath); err == nil { + if newContent, err := os.ReadFile(tmpPath); err == nil { + changed = string(oldContent) != string(newContent) + } + } + + // Заменяем старый конфиг новым + if err := os.Rename(tmpPath, configPath); err != nil { + os.Remove(tmpPath) + return false, fmt.Errorf("замена конфига: %w", err) + } + + // Создаём симлинк в site-enabled если не существует + if _, err := os.Lstat(enabledPath); os.IsNotExist(err) { + relPath := fmt.Sprintf("../site-available/%s", configFilename) + if err := os.Symlink(relPath, enabledPath); err != nil { + log.Printf(" ⚠ Ошибка создания симлинка: %v", err) + } else { + log.Printf(" ✓ Создан симлинк в site-enabled") + } + } + + if changed { + log.Printf(" ✓ Конфиг %s изменён", configFilename) + } else { + log.Printf(" ℹ Конфиг %s не изменился", configFilename) + } + + return changed, nil +} + +// writeSWConfigContent записывает содержимое SW nginx конфига +func writeSWConfigContent(f *os.File, config Config, res ResourceData) error { + // Собираем все домены (server_name + aliases с www) + allDomains := []string{res.ServerName} + allDomains = append(allDomains, res.Aliases...) + processedDomains := processDomainsWithWWW(allDomains) + domainsStr := strings.Join(processedDomains, " ") + + // Получаем input порты (что слушаем извне) + httpInputPorts := getInputHTTPPorts(res.AppsSettings) + httpsInputPorts := getInputHTTPSPorts(res.AppsSettings) + + // Получаем output порты (куда проксируем к origin) + httpOutputPorts := getOutputHTTPPorts(res.AppsSettings) + httpsOutputPorts := getOutputHTTPSPorts(res.AppsSettings) + + // Проверяем наличие SERVER_BLOCK в server_nginx_custom + var customServerBlocks []CustomServerBlock + var serverDirectivesWithoutBlocks string + + if res.AppsSettings.ServerNginxCustom.Valid && strings.TrimSpace(res.AppsSettings.ServerNginxCustom.String) != "" { + serverDirectivesRaw := strings.TrimSpace(res.AppsSettings.ServerNginxCustom.String) + // Для SW не санитизируем домены (работаем как есть) + customServerBlocks = parseCustomServerBlocks(serverDirectivesRaw, false) + + if len(customServerBlocks) > 0 { + log.Printf(" → Найдено %d кастомных SERVER_BLOCK директив для SW", len(customServerBlocks)) + serverDirectivesWithoutBlocks = stripServerBlocks(serverDirectivesRaw) + } else { + serverDirectivesWithoutBlocks = serverDirectivesRaw + } + } + + // location_nginx_custom + var locationDirectives string + var isFullLocationBlock bool + if res.AppsSettings.LocationNginxCustom.Valid && strings.TrimSpace(res.AppsSettings.LocationNginxCustom.String) != "" { + locationDirectives = strings.TrimSpace(res.AppsSettings.LocationNginxCustom.String) + isFullLocationBlock = strings.Contains(locationDirectives, "location ") + log.Printf(" → Обнаружен location_nginx_custom (полный блок: %v)", isFullLocationBlock) + } + + f.WriteString("# SolidWall WAF configuration\n") + f.WriteString(fmt.Sprintf("# Client: %s, Resource ID: %d\n", res.ServerName, res.L7ResourceID)) + f.WriteString(fmt.Sprintf("# Generated: %s\n\n", time.Now().Format("2006-01-02 15:04:05"))) + + // ════════════════════════════════════════════════════════════ + // UPSTREAM БЛОКИ + // ════════════════════════════════════════════════════════════ + + // HTTPS upstreams (может быть несколько если custom_output_https_ports) + for idx, outputPort := range httpsOutputPorts { + upstreamName := fmt.Sprintf("secure%d", res.L7ResourceID) + if len(httpsOutputPorts) > 1 { + upstreamName = fmt.Sprintf("secure%d_%d", res.L7ResourceID, outputPort) + } + + f.WriteString(fmt.Sprintf("upstream %s {\n", upstreamName)) + writeSWUpstreamServers(f, res.Origins, outputPort, res.AppsSettings) + + // Кастомные upstream директивы или дефолтные keepalive + if idx == 0 { // Применяем кастом только к первому upstream + writeSWCustomUpstreamOrDefault(f, res.AppsSettings) + } else { + writeSWDefaultUpstreamSettings(f, res.AppsSettings) + } + + f.WriteString("}\n\n") + } + + // HTTP upstreams + for idx, outputPort := range httpOutputPorts { + upstreamName := fmt.Sprintf("unsecure%d", res.L7ResourceID) + if len(httpOutputPorts) > 1 { + upstreamName = fmt.Sprintf("unsecure%d_%d", res.L7ResourceID, outputPort) + } + + f.WriteString(fmt.Sprintf("upstream %s {\n", upstreamName)) + writeSWUpstreamServers(f, res.Origins, outputPort, res.AppsSettings) + + // Кастомные upstream директивы или дефолтные keepalive + if idx == 0 { + writeSWCustomUpstreamOrDefault(f, res.AppsSettings) + } else { + writeSWDefaultUpstreamSettings(f, res.AppsSettings) + } + + f.WriteString("}\n\n") + } + + // ════════════════════════════════════════════════════════════ + // ПУБЛИЧНЫЕ SERVER БЛОКИ (принимают трафик от клиентов) + // ════════════════════════════════════════════════════════════ + + // HTTP servers (порты из custom_input_http_ports или дефолт 80) + for _, inputPort := range httpInputPorts { + writeSWPublicHTTPServer(f, config, res, inputPort, domainsStr, serverDirectivesWithoutBlocks, locationDirectives, isFullLocationBlock) + } + + // HTTPS servers (порты из custom_input_https_ports или дефолт 443) + for _, inputPort := range httpsInputPorts { + writeSWPublicHTTPSServer(f, config, res, inputPort, domainsStr, serverDirectivesWithoutBlocks, locationDirectives, isFullLocationBlock) + } + + // ════════════════════════════════════════════════════════════ + // ВНУТРЕННИЕ SERVER БЛОКИ (проксируют на origins) + // ════════════════════════════════════════════════════════════ + + // Определяем какой upstream использовать (если несколько output портов) + httpUpstreamName := fmt.Sprintf("unsecure%d", res.L7ResourceID) + if len(httpOutputPorts) > 1 { + httpUpstreamName = fmt.Sprintf("unsecure%d_%d", res.L7ResourceID, httpOutputPorts[0]) + } + + httpsUpstreamName := fmt.Sprintf("secure%d", res.L7ResourceID) + if len(httpsOutputPorts) > 1 { + httpsUpstreamName = fmt.Sprintf("secure%d_%d", res.L7ResourceID, httpsOutputPorts[0]) + } + + writeSWInternalHTTPServer(f, res, domainsStr, httpUpstreamName) + writeSWInternalHTTPSServer(f, res, domainsStr, httpsUpstreamName) + + // ════════════════════════════════════════════════════════════ + // КАСТОМНЫЕ SERVER_BLOCK (если есть) + // ════════════════════════════════════════════════════════════ + + if len(customServerBlocks) > 0 { + log.Printf(" → Генерация %d кастомных SERVER_BLOCK для SW", len(customServerBlocks)) + for _, block := range customServerBlocks { + generateSWCustomServerBlock(f, config, res, block, locationDirectives) + } + } + + return nil +} + +// writeSWUpstreamServers записывает серверы в upstream блок +func writeSWUpstreamServers(f *os.File, origins []OriginItem, port int, settings AppsSettings) { + allBackup := true + for _, origin := range origins { + if strings.ToLower(origin.Mode) != "backup" { + allBackup = false + break + } + } + + for _, origin := range origins { + serverLine := fmt.Sprintf(" server %s:%d", origin.IP, port) + if origin.Weight > 0 { + serverLine += fmt.Sprintf(" weight=%d", origin.Weight) + } + maxFails := getMaxFails(settings) + failTimeout := getFailTimeout(settings) + serverLine += fmt.Sprintf(" max_fails=%d fail_timeout=%ds", maxFails, failTimeout) + if strings.ToLower(origin.Mode) == "backup" && !allBackup { + serverLine += " backup" + } + serverLine += ";\n" + f.WriteString(serverLine) + } +} + +// writeSWCustomUpstreamOrDefault записывает кастомные или дефолтные upstream директивы +func writeSWCustomUpstreamOrDefault(f *os.File, settings AppsSettings) { + if settings.UpstreamNginxCustom.Valid && strings.TrimSpace(settings.UpstreamNginxCustom.String) != "" { + log.Printf(" └─ Использование upstream_nginx_custom") + lines := strings.Split(strings.TrimSpace(settings.UpstreamNginxCustom.String), "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed != "" { + f.WriteString(" " + trimmed + "\n") + } + } + } else { + writeSWDefaultUpstreamSettings(f, settings) + } +} + +// writeSWDefaultUpstreamSettings записывает дефолтные upstream настройки +func writeSWDefaultUpstreamSettings(f *os.File, settings AppsSettings) { + // Проверяем SNI (0 = keepalive включен, 1 = отключен) + keepaliveEnabled := true + if settings.SNI.Valid && settings.SNI.Int64 == 1 { + keepaliveEnabled = false + log.Printf(" └─ Keepalive отключен (sni = 1)") + } + + if keepaliveEnabled { + f.WriteString(" keepalive 60;\n") + f.WriteString(" keepalive_timeout 70s;\n") + } +} + +// writeSWPublicHTTPServer пишет публичный HTTP server блок +func writeSWPublicHTTPServer(f *os.File, config Config, res ResourceData, port int, domainsStr string, serverDirectives string, locationDirectives string, isFullLocationBlock bool) { + f.WriteString("# Proxying HTTP requests to SolidWall WAF\n") + f.WriteString("server {\n") + f.WriteString(" include static/server.conf;\n") + f.WriteString(fmt.Sprintf(" listen %d;\n", port)) + f.WriteString(fmt.Sprintf(" server_name %s;\n\n", domainsStr)) + + writeSWRealIPConfig(f, config) + writeSWStandardSettings(f, res.AppsSettings) + + f.WriteString(fmt.Sprintf(" set $BACKEND http://127.0.0.1:%d;\n", SWInternalHTTPPort)) + + // Кастомные location блоки (полные) перед location / + if isFullLocationBlock { + log.Printf(" └─ Добавление location_nginx_custom (отдельный блок) для HTTP порт %d", port) + writeIndentedLines(f, locationDirectives) + f.WriteString("\n") + } + + f.WriteString(" location / {\n") + + // server_nginx_custom директивы внутри location / (если есть и не SERVER_BLOCK) + if serverDirectives != "" { + log.Printf(" └─ Добавление server_nginx_custom в location / (HTTP порт %d)", port) + writeIndentedLines(f, serverDirectives) + } + + // location_nginx_custom директивы внутри location / (если это не полный блок) + if !isFullLocationBlock && locationDirectives != "" { + log.Printf(" └─ Добавление location_nginx_custom (директивы) в location / (HTTP порт %d)", port) + writeIndentedLines(f, locationDirectives) + } + + f.WriteString(" include static/waf.conf;\n") + f.WriteString(" }\n") + f.WriteString("}\n\n") +} + +// writeSWPublicHTTPSServer пишет публичный HTTPS server блок +func writeSWPublicHTTPSServer(f *os.File, config Config, res ResourceData, port int, domainsStr string, serverDirectives string, locationDirectives string, isFullLocationBlock bool) { + f.WriteString("# Proxying HTTPS requests to SolidWall WAF\n") + f.WriteString("server {\n") + f.WriteString(" include static/server.conf;\n") + f.WriteString(fmt.Sprintf(" listen %d ssl;\n", port)) + f.WriteString(fmt.Sprintf(" server_name %s;\n\n", domainsStr)) + + f.WriteString(fmt.Sprintf(" ssl_certificate %s;\n", SWSSLCertPath)) + f.WriteString(fmt.Sprintf(" ssl_certificate_key %s;\n", SWSSLKeyPath)) + f.WriteString(" ssl_protocols TLSv1.3 TLSv1.2;\n") + f.WriteString(" ssl_prefer_server_ciphers on;\n\n") + + writeSWRealIPConfig(f, config) + writeSWStandardSettings(f, res.AppsSettings) + + f.WriteString(fmt.Sprintf(" set $BACKEND http://127.0.0.1:%d;\n", SWInternalHTTPSPort)) + + // Кастомные location блоки (полные) перед location / + if isFullLocationBlock { + log.Printf(" └─ Добавление location_nginx_custom (отдельный блок) для HTTPS порт %d", port) + writeIndentedLines(f, locationDirectives) + f.WriteString("\n") + } + + f.WriteString(" location / {\n") + + // server_nginx_custom директивы внутри location / + if serverDirectives != "" { + log.Printf(" └─ Добавление server_nginx_custom в location / (HTTPS порт %d)", port) + writeIndentedLines(f, serverDirectives) + } + + // location_nginx_custom директивы внутри location / (если это не полный блок) + if !isFullLocationBlock && locationDirectives != "" { + log.Printf(" └─ Добавление location_nginx_custom (директивы) в location / (HTTPS порт %d)", port) + writeIndentedLines(f, locationDirectives) + } + + f.WriteString(" include static/waf.conf;\n") + f.WriteString(" }\n") + f.WriteString("}\n\n") +} + +// writeSWInternalHTTPServer пишет внутренний HTTP → Origin server блок +func writeSWInternalHTTPServer(f *os.File, res ResourceData, domainsStr string, upstreamName string) { + f.WriteString("# Proxying HTTP requests to origin\n") + f.WriteString("server {\n") + f.WriteString(fmt.Sprintf(" listen 127.0.0.1:%d;\n", SWInternalHTTPPort)) + f.WriteString(fmt.Sprintf(" server_name %s;\n\n", domainsStr)) + + f.WriteString(" set_real_ip_from 127.0.0.1;\n") + f.WriteString(" real_ip_header X-Forwarded-For;\n") + f.WriteString(" real_ip_recursive on;\n\n") + + writeSWStandardSettings(f, res.AppsSettings) + + f.WriteString(" proxy_set_header CDN-Loop \"WAF\";\n\n") + f.WriteString(fmt.Sprintf(" error_log %s/%d_error.log error;\n\n", SWLogDir, res.L7ResourceID)) + + f.WriteString(" location / {\n") + f.WriteString(fmt.Sprintf(" proxy_pass http://%s;\n", upstreamName)) + f.WriteString(" }\n") + f.WriteString("}\n\n") +} + +// writeSWInternalHTTPSServer пишет внутренний HTTPS → Origin server блок +func writeSWInternalHTTPSServer(f *os.File, res ResourceData, domainsStr string, upstreamName string) { + f.WriteString("# Proxying HTTPS requests to origin\n") + f.WriteString("server {\n") + f.WriteString(fmt.Sprintf(" listen 127.0.0.1:%d;\n", SWInternalHTTPSPort)) + f.WriteString(fmt.Sprintf(" server_name %s;\n\n", domainsStr)) + + f.WriteString(" proxy_ssl_server_name on;\n") + f.WriteString(" proxy_ssl_name $host;\n\n") + + f.WriteString(" set_real_ip_from 127.0.0.1;\n") + f.WriteString(" real_ip_header X-Forwarded-For;\n") + f.WriteString(" real_ip_recursive on;\n\n") + + writeSWStandardSettings(f, res.AppsSettings) + + f.WriteString(" proxy_set_header CDN-Loop \"WAF\";\n\n") + f.WriteString(fmt.Sprintf(" error_log %s/%d_error.log error;\n\n", SWLogDir, res.L7ResourceID)) + + f.WriteString(" location / {\n") + f.WriteString(fmt.Sprintf(" proxy_pass https://%s;\n", upstreamName)) + f.WriteString(" }\n") + f.WriteString("}\n\n") +} + +// generateSWCustomServerBlock генерирует кастомный SERVER_BLOCK для SW +func generateSWCustomServerBlock(f *os.File, config Config, res ResourceData, block CustomServerBlock, locationDirectives string) { + + aliasInfo := "" + if len(block.Aliases) > 0 { + aliasInfo = fmt.Sprintf(" + %d alias(es)", len(block.Aliases)) + } + log.Printf(" └─ SW SERVER_BLOCK: порт %d, server_name %s%s", block.Port, block.ServerName, aliasInfo) + + f.WriteString("# Custom SERVER_BLOCK for SW\n") + f.WriteString("server {\n") + f.WriteString(" include static/server.conf;\n") + + if block.IsHTTPS { + f.WriteString(fmt.Sprintf(" listen %d ssl;\n", block.Port)) + f.WriteString(fmt.Sprintf(" ssl_certificate %s;\n", SWSSLCertPath)) + f.WriteString(fmt.Sprintf(" ssl_certificate_key %s;\n", SWSSLKeyPath)) + f.WriteString(" ssl_protocols TLSv1.3 TLSv1.2;\n") + f.WriteString(" ssl_prefer_server_ciphers on;\n\n") + } else { + f.WriteString(fmt.Sprintf(" listen %d;\n", block.Port)) + } + + if len(block.Aliases) > 0 { + f.WriteString(fmt.Sprintf(" server_name %s %s;\n\n", block.ServerName, strings.Join(block.Aliases, " "))) + } else { + f.WriteString(fmt.Sprintf(" server_name %s;\n\n", block.ServerName)) + } + + writeSWRealIPConfig(f, config) + writeSWStandardSettings(f, res.AppsSettings) + + internalPort := SWInternalHTTPPort + if block.IsHTTPS { + internalPort = SWInternalHTTPSPort + } + f.WriteString(fmt.Sprintf(" set $BACKEND http://127.0.0.1:%d;\n", internalPort)) + + // Кастомные location блоки (если есть и это полный блок) + isFullLocationBlock := strings.Contains(locationDirectives, "location ") + if isFullLocationBlock && locationDirectives != "" { + log.Printf(" └─ Добавление location_nginx_custom в SERVER_BLOCK") + writeIndentedLines(f, locationDirectives) + f.WriteString("\n") + } + + f.WriteString(" location / {\n") + + // Контент из SERVER_BLOCK тега + lines := strings.Split(block.Content, "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed != "" { + f.WriteString(" " + trimmed + "\n") + } + } + + // location_nginx_custom директивы (если это не полный блок) + if !isFullLocationBlock && locationDirectives != "" { + writeIndentedLines(f, locationDirectives) + } + + f.WriteString(" include static/waf.conf;\n") + f.WriteString(" }\n") + f.WriteString("}\n\n") +} + +// writeSWRealIPConfig пишет real_ip конфигурацию (antibot networks) +func writeSWRealIPConfig(f *os.File, config Config) { + f.WriteString(" # antibot networks\n") + for _, net := range config.AntibotNetworks { + f.WriteString(fmt.Sprintf(" set_real_ip_from %s;\n", net)) + } + f.WriteString(" real_ip_header X-Forwarded-For;\n") + f.WriteString(" real_ip_recursive on;\n\n") +} + +// writeSWStandardSettings пишет стандартные настройки nginx +func writeSWStandardSettings(f *os.File, settings AppsSettings) { + // Проверяем есть ли server_directives_nginx_custom + if settings.ServerDirectivesNginxCustom.Valid && strings.TrimSpace(settings.ServerDirectivesNginxCustom.String) != "" { + log.Printf(" └─ Использование server_directives_nginx_custom (замена дефолтных настроек)") + + // Пишем только основные директивы (без таймаутов/буферов) + f.WriteString(" server_tokens off;\n") + f.WriteString(" sendfile on;\n") + f.WriteString(" gzip on;\n\n") + + f.WriteString(" proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;\n") + f.WriteString(" proxy_http_version 1.1;\n\n") + + f.WriteString(" #proxy_set_header X-Real-IP $remote_addr;\n") + f.WriteString(" #proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n") + f.WriteString(" proxy_set_header Connection \"upgrade\";\n") + f.WriteString(" proxy_set_header Upgrade $http_upgrade;\n") + f.WriteString(" proxy_set_header Host $host;\n") + f.WriteString(" proxy_pass_header Date;\n") + f.WriteString(" proxy_pass_header Server;\n\n") + + // Кастомные директивы (заменяют таймауты/буферы) + f.WriteString(" # Custom server directives (override defaults)\n") + lines := strings.Split(strings.TrimSpace(settings.ServerDirectivesNginxCustom.String), "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed != "" { + f.WriteString(" " + trimmed + "\n") + } + } + f.WriteString("\n") + } else { + // Дефолтные настройки + f.WriteString(" server_tokens off;\n") + f.WriteString(" sendfile on;\n") + f.WriteString(" gzip on;\n") + f.WriteString(" client_max_body_size 1g;\n") + f.WriteString(" large_client_header_buffers 4 128k;\n\n") + + f.WriteString(" proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;\n") + f.WriteString(" proxy_http_version 1.1;\n") + f.WriteString(" proxy_connect_timeout 3s;\n") + f.WriteString(" proxy_busy_buffers_size 32k;\n") + f.WriteString(" proxy_buffers 4 32k;\n") + f.WriteString(" proxy_buffer_size 16k;\n") + f.WriteString(" proxy_read_timeout 60s;\n") + f.WriteString(" proxy_send_timeout 60s;\n") + f.WriteString(" send_timeout 60s;\n") + f.WriteString(" keepalive_timeout 75s;\n") + f.WriteString(" keepalive_requests 1000;\n") + f.WriteString(" resolver_timeout 30s;\n\n") + + f.WriteString(" #proxy_set_header X-Real-IP $remote_addr;\n") + f.WriteString(" #proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n") + f.WriteString(" proxy_set_header Connection \"upgrade\";\n") + f.WriteString(" proxy_set_header Upgrade $http_upgrade;\n") + f.WriteString(" proxy_set_header Host $host;\n") + f.WriteString(" proxy_pass_header Date;\n") + f.WriteString(" proxy_pass_header Server;\n\n") + } +} + +// ensureSWStaticFiles создаёт static файлы если отсутствуют +func ensureSWStaticFiles() error { + if err := os.MkdirAll(SWStaticDir, 0755); err != nil { + return fmt.Errorf("создание директории %s: %w", SWStaticDir, err) + } + + // static/waf.conf + wafConfPath := filepath.Join(SWStaticDir, "waf.conf") + if _, err := os.Stat(wafConfPath); os.IsNotExist(err) { + wafContent := `uninitialized_variable_warn off; +rewrite_by_lua_block { require "solidwall.waf.nginx"() } +content_by_lua_block { require "solidwall.waf.nginx"() } +log_by_lua_block { require "solidwall.waf.nginx"() } +` + if err := os.WriteFile(wafConfPath, []byte(wafContent), 0644); err != nil { + return fmt.Errorf("создание waf.conf: %w", err) + } + log.Printf("✓ Создан файл: %s", wafConfPath) + } + + // static/server.conf + serverConfPath := filepath.Join(SWStaticDir, "server.conf") + if _, err := os.Stat(serverConfPath); os.IsNotExist(err) { + serverContent := `root conf/templates; +set $solidwall_request_id ""; +set $solidwall_request_decision ""; +set $solidwall_request_analysis_time ""; +set $solidwall_response_decision ""; +set $solidwall_response_analysis_time ""; +set $solidwall_backend_response_status_code ""; +set $solidwall_backend_header_time ""; +set $reason ""; +location = /__sw { + client_max_body_size 8k; + client_body_buffer_size 8k; + content_by_lua_block { require "solidwall.waf.nginx.external_api"() } +} +include user/server.conf; +` + if err := os.WriteFile(serverConfPath, []byte(serverContent), 0644); err != nil { + return fmt.Errorf("создание server.conf: %w", err) + } + log.Printf("✓ Создан файл: %s", serverConfPath) + } + + return nil +} + +// reloadSWNginx выполняет проверку и reload nginx +func reloadSWNginx() error { + // Проверка конфигурации + log.Printf(" → Проверка конфигурации SW nginx...") + testCmd := exec.Command(SWNginxBinary, "-t") + if output, err := testCmd.CombinedOutput(); err != nil { + log.Printf(" ✗ Ошибка проверки конфигурации:\n%s", string(output)) + return fmt.Errorf("nginx -t failed: %w", err) + } + log.Printf(" ✓ Конфигурация валидна") + + // Reload + log.Printf(" → Перезагрузка SW nginx...") + reloadCmd := exec.Command(SWNginxBinary, "-s", "reload") + if output, err := reloadCmd.CombinedOutput(); err != nil { + log.Printf(" ✗ Ошибка reload:\n%s", string(output)) + return fmt.Errorf("nginx -s reload failed: %w", err) + } + + return nil +} + +// sanitizeFilename очищает строку для использования в имени файла +func sanitizeFilename(s string) string { + s = strings.ReplaceAll(s, "/", "_") + s = strings.ReplaceAll(s, "\\", "_") + s = strings.ReplaceAll(s, ":", "_") + s = strings.ReplaceAll(s, "*", "_") + s = strings.ReplaceAll(s, "?", "_") + s = strings.ReplaceAll(s, "\"", "_") + s = strings.ReplaceAll(s, "<", "_") + s = strings.ReplaceAll(s, ">", "_") + s = strings.ReplaceAll(s, "|", "_") + return s +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..326afbc --- /dev/null +++ b/types.go @@ -0,0 +1,146 @@ +package main + +import ( + "database/sql" + + "github.com/lib/pq" +) + +// Структура для таблицы instances +type Instance struct { + Hostname string + Instance string +} + +// Структура для таблицы apps_settings (бывшая appsSettings) +type AppsSettings struct { + L7ResourceID int + WAFEnabled sql.NullBool + WAFVendor sql.NullString // Vendor WAF: ptaf, sw и т.д. + CustomInputHTTPPorts pq.Int64Array // Массив HTTP портов для прослушивания (Angie и контейнер) + CustomInputHTTPSPorts pq.Int64Array // Массив HTTPS портов для прослушивания (Angie и контейнер) + CustomOutputHTTPPorts pq.Int64Array // Массив HTTP портов для проксирования к origin + CustomOutputHTTPSPorts pq.Int64Array // Массив HTTPS портов для проксирования к origin + UpstreamAngieCustom sql.NullString + ServerNginxCustom sql.NullString + ServerAngieCustom sql.NullString + ClientTitle string + UpstreamNginxCustom sql.NullString + LocationAngieCustom sql.NullString + LocationNginxCustom sql.NullString + ServerDirectivesAngieCustom sql.NullString // Кастомные server директивы для Angie + ServerDirectivesNginxCustom sql.NullString // Кастомные server директивы для Nginx + SNI sql.NullInt64 // 0 = keepalive включен, 1 = keepalive отключен + Mode sql.NullString // Режим работы ресурса: auto = автоматическое управление, manual = ручное, disabled = отключен + SSLEnabled sql.NullBool // true = SSL включен (по умолчанию), false = plain HTTP без SSL + CustomAngieSSL sql.NullString // Кастомные SSL директивы для Angie - полностью заменяют дефолтные если заполнено + MaxFails sql.NullInt64 // Количество неудачных попыток к origin (дефолт 5) + FailTimeout sql.NullInt64 // Время в секундах после которого origin снова доступен (дефолт 15) +} + +// Структура для таблицы client_info (бывшая nodes) +type ClientInfo struct { + ContainersCount int + PTAFConfig sql.NullString + ClientTitle string + FluentBitPort sql.NullInt64 + WAFInstance string // Instance для связи с apps_settings +} + +// Структуры для API ответов +type OriginResponse struct { + Data struct { + Result struct { + Items []struct { + ID int `json:"id"` + IP string `json:"ip"` + Weight int `json:"weight"` + Mode string `json:"mode"` + Comment string `json:"comment"` + CreatedAt int64 `json:"createdAt"` + } `json:"items"` + } `json:"result"` + } `json:"data"` +} + +type AliasResponse struct { + Data struct { + Result struct { + Items []struct { + ID int `json:"id"` + Domain string `json:"domain"` + UseCustomSSL int `json:"useCustomSsl"` + UseLetsEncrypt int `json:"useLetsencryptSsl"` + SSLExpireDate int64 `json:"sslExpireDate"` + CreatedAt int64 `json:"createdAt"` + L7ResourceID int `json:"l7ResourceId"` + } `json:"items"` + } `json:"result"` + } `json:"data"` +} + +type ResourceResponse struct { + Data struct { + Result struct { + L7ResourceID int `json:"l7ResourceId"` + L7ResourceName string `json:"l7ResourceName"` + CreatedAt int64 `json:"createdAt"` + L7ResourceActive int `json:"l7ResourceIsActive"` + ModifiedAt int64 `json:"modifiedAt"` + ProtectedIP string `json:"protectedIp"` + HTTP2Support int `json:"http2Support"` + GRPCSupport int `json:"grpcSupport"` + WAFMode int `json:"wafMode"` + WAFEnabled int `json:"wafEnabled"` + } `json:"result"` + } `json:"data"` +} + +// Структура для хранения данных о ресурсе +type ResourceData struct { + L7ResourceID int + ServerName string + Aliases []string + Origins []OriginItem + AppsSettings AppsSettings +} + +type OriginItem struct { + IP string + Weight int + Mode string +} + +// Структура для портов +type PortMapping struct { + HTTPPorts map[int]int // map[customPort]dockerPort - для HTTP портов + HTTPSPorts map[int]int // map[customPort]dockerPort - для HTTPS портов +} + +// Структура для отсортированного вывода портов в docker-compose +type SortedPortMapping struct { + CustomPort int + DockerPort int +} + +// Структура для сохранения портов ресурса в файл +type SavedResourcePorts struct { + L7ResourceID int `json:"l7resourceid"` + HTTPPorts map[int]int `json:"http_ports"` // map[customPort]dockerPort + HTTPSPorts map[int]int `json:"https_ports"` // map[customPort]dockerPort +} + +// Структура для сохранения всех портов контейнера +type SavedContainerPorts struct { + ContainerNum int `json:"container_num"` + Resources []SavedResourcePorts `json:"resources"` +} + +// Структура для кастомного server блока +type CustomServerBlock struct { + Port int + ServerName string + Aliases []string + Content string + IsHTTPS bool +} diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..5e34d79 --- /dev/null +++ b/utils.go @@ -0,0 +1,337 @@ +package main + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "io" + "log" + "os" + "regexp" + "strconv" + "strings" +) + +// writeFileIfChanged записывает файл только если содержимое изменилось +func writeFileIfChanged(filepath string, content string) (bool, error) { + // Проверяем существует ли файл + oldHash := "" + if _, err := os.Stat(filepath); err == nil { + var hashErr error + oldHash, hashErr = getFileHash(filepath) + if hashErr != nil { + log.Printf(" ⚠ Ошибка чтения старого файла, перезаписываем") + oldHash = "" + } + } + + newHash := getStringHash(content) + + if oldHash != "" && oldHash == newHash { + return false, nil + } + + if oldHash != "" { + log.Printf(" → Хеш изменился: old=%s... new=%s...", oldHash[:8], newHash[:8]) + } + + err := os.WriteFile(filepath, []byte(content), 0644) + if err != nil { + return false, err + } + + return true, nil +} + +// getFileHash вычисляет SHA256 хеш файла +func getFileHash(filepath string) (string, error) { + file, err := os.Open(filepath) + if err != nil { + return "", err + } + defer file.Close() + + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + + return hex.EncodeToString(hash.Sum(nil)), nil +} + +// getStringHash вычисляет SHA256 хеш строки +func getStringHash(content string) string { + hash := sha256.Sum256([]byte(content)) + return hex.EncodeToString(hash[:]) +} + +// extractPodIPFromConfig извлекает POD_IP из существующего nginx.conf +func extractPodIPFromConfig(filepath string) string { + data, err := os.ReadFile(filepath) + if err != nil { + return "" + } + + lines := strings.Split(string(data), "\n") + for _, line := range lines { + if strings.Contains(line, "env POD_IP=") { + parts := strings.Split(line, "=") + if len(parts) >= 2 { + ip := strings.TrimSpace(strings.TrimSuffix(parts[1], ";")) + return ip + } + } + } + + return "" +} + +// processDomainsWithWWW обрабатывает список доменов и добавляет www. варианты если нужно +func processDomainsWithWWW(domains []string) []string { + domainMap := make(map[string]bool) + for _, domain := range domains { + domainMap[domain] = true + } + + result := make([]string, 0, len(domains)*2) + + for _, domain := range domains { + result = append(result, domain) + + if strings.HasPrefix(domain, "*.") || strings.HasPrefix(domain, "www.") { + continue + } + + wwwDomain := "www." + domain + if !domainMap[wwwDomain] { + result = append(result, wwwDomain) + domainMap[wwwDomain] = true + } + } + + return result +} + +// sanitizeDomainForNginx санитизирует домен для nginx конфигов +// Заменяет _ на - чтобы соответствовать санитизации в Angie +func sanitizeDomainForNginx(domain string) string { + return strings.ReplaceAll(domain, "_", "-") +} + +// sanitizeDomainsForNginx санитизирует массив доменов для nginx конфигов +func sanitizeDomainsForNginx(domains []string) []string { + result := make([]string, len(domains)) + for i, domain := range domains { + result[i] = sanitizeDomainForNginx(domain) + } + return result +} + +// sanitizeServerName убирает недопустимые символы из server_name для использования в имени файла +func sanitizeServerName(serverName string) string { + sanitized := strings.ReplaceAll(serverName, ".", "_") + sanitized = strings.ReplaceAll(sanitized, "*", "wildcard") + return sanitized +} + +// replaceMTLSPaths заменяет пути к mTLS сертификатам на путь внутри контейнера +func replaceMTLSPaths(content string) string { + content = strings.ReplaceAll(content, "/etc/ssl/certs/", "/opt/ptaf/conf/main/") + content = strings.ReplaceAll(content, "/etc/ssl/private/", "/opt/ptaf/conf/main/") + return content +} + +// getMapKeys возвращает ключи из map в виде слайса (для логирования) +func getMapKeys(m map[string]bool) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} + +// hasMTLSCertificates проверяет содержит ли строка директивы mTLS сертификатов +func hasMTLSCertificates(content string) bool { + return strings.Contains(content, "proxy_ssl_certificate") +} + +// getCustomServerDirectives извлекает кастомные server директивы из sql.NullString +func getCustomServerDirectives(nullStr sql.NullString) string { + if nullStr.Valid && strings.TrimSpace(nullStr.String) != "" { + return strings.TrimSpace(nullStr.String) + } + return "" +} + +// parseCustomServerBlocks парсит директивы для поиска [SERVER_BLOCK] директив. +// sanitize = true для Nginx (санитизирует домены _ → -), false для Angie (оставляет оригинальные домены) +// Синтаксис: +// +// [SERVER_BLOCK:PORT:SERVERNAME]...[/SERVER_BLOCK] +// [SERVER_BLOCK:PORT:SERVERNAME:ALIAS1:ALIAS2:...]...[/SERVER_BLOCK] +func parseCustomServerBlocks(customDirectives string, sanitize bool) []CustomServerBlock { + blocks := []CustomServerBlock{} + + if customDirectives == "" { + return blocks + } + + re := regexp.MustCompile(`(?s)\[SERVER_BLOCK:(\d+):([^\]]+)\]\s*\n(.*?)\[/SERVER_BLOCK\]`) + matches := re.FindAllStringSubmatch(customDirectives, -1) + + for _, match := range matches { + port, _ := strconv.Atoi(match[1]) + namesRaw := strings.TrimSpace(match[2]) + content := strings.TrimSpace(match[3]) + + names := strings.Split(namesRaw, ":") + + var serverName string + if sanitize { + serverName = sanitizeDomainForNginx(strings.TrimSpace(names[0])) + } else { + serverName = strings.TrimSpace(names[0]) + } + + var aliases []string + if len(names) > 1 { + for _, alias := range names[1:] { + trimmed := strings.TrimSpace(alias) + if trimmed != "" { + if sanitize { + aliases = append(aliases, sanitizeDomainForNginx(trimmed)) + } else { + aliases = append(aliases, trimmed) + } + } + } + } + + isHTTPS := port == 443 || port >= 8443 + + content = replaceMTLSPaths(content) + + blocks = append(blocks, CustomServerBlock{ + Port: port, + ServerName: serverName, + Aliases: aliases, + Content: content, + IsHTTPS: isHTTPS, + }) + } + + return blocks +} + +// stripServerBlocks удаляет SERVER_BLOCK директивы из строки +func stripServerBlocks(s string) string { + re := regexp.MustCompile(`(?s)\[SERVER_BLOCK:\d+:[^\]]+\].*?\[/SERVER_BLOCK\]\s*`) + return strings.TrimSpace(re.ReplaceAllString(s, "")) +} + +// collectServerBlockDomains собирает домены из SERVER_BLOCK по портам и возвращает +// (byPort map[port]map[domain]bool, allDomains map[domain]bool) +func collectServerBlockDomains(blocks []CustomServerBlock) (map[int]map[string]bool, map[string]bool) { + byPort := make(map[int]map[string]bool) + all := make(map[string]bool) + + for _, block := range blocks { + if byPort[block.Port] == nil { + byPort[block.Port] = make(map[string]bool) + } + byPort[block.Port][block.ServerName] = true + all[block.ServerName] = true + for _, alias := range block.Aliases { + byPort[block.Port][alias] = true + all[alias] = true + } + } + + return byPort, all +} + +// filterDomainsForPort фильтрует домены для конкретного порта, исключая те, что в SERVER_BLOCK +func filterDomainsForPort(port int, allDomains []string, serverBlockDomainsByPort map[int]map[string]bool, allServerBlockDomains map[string]bool) []string { + var result []string + + for _, domain := range allDomains { + if serverBlockDomainsByPort[port][domain] { + continue + } + + if port == 80 || port == 443 { + result = append(result, domain) + } else { + // Кастомный порт: исключаем ВСЕ домены из SERVER_BLOCK + if !allServerBlockDomains[domain] { + result = append(result, domain) + } + } + } + + return result +} + +// writeIndentedLines записывает строки с отступами для nginx location блоков +func writeIndentedLines(w stringWriter, content string) { + lines := strings.Split(content, "\n") + for _, line := range lines { + if strings.TrimSpace(line) != "" { + if strings.HasPrefix(line, " ") { + w.WriteString(line + "\n") + } else if strings.HasPrefix(line, " ") { + w.WriteString(" " + line + "\n") + } else { + w.WriteString(" " + line + "\n") + } + } + } +} + +// writeCustomLocationBlock записывает кастомный location блок с правильным proxy_pass +func writeCustomLocationBlock(w stringWriter, customLocationStr string, correctUpstream string, resID int, protocol string, port int) { + hasProxyPass := strings.Contains(customLocationStr, "proxy_pass") + lines := strings.Split(customLocationStr, "\n") + + if hasProxyPass { + for i, line := range lines { + if strings.Contains(line, "proxy_pass") { + trimmed := strings.TrimLeft(line, " \t") + indent := line[:len(line)-len(trimmed)] + lines[i] = indent + "proxy_pass " + correctUpstream + ";" + log.Printf(" └─ Заменен proxy_pass в кастомном location: %s", correctUpstream) + } + } + } + + var locationLines []string + inLocationBlock := false + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + + if strings.HasPrefix(trimmed, "location ") { + inLocationBlock = true + } + if trimmed == "}" { + if !hasProxyPass && inLocationBlock { + locationLines = append(locationLines, " proxy_pass "+correctUpstream+";") + log.Printf(" └─ Добавлен proxy_pass в кастомный location: %s", correctUpstream) + } + inLocationBlock = false + } + + if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") { + locationLines = append(locationLines, line) + } else { + locationLines = append(locationLines, " "+line) + } + } + + for _, line := range locationLines { + w.WriteString(line + "\n") + } + w.WriteString("\n") +}