Change real_ip

This commit is contained in:
Magnus Root 2026-04-24 17:44:32 +03:00
parent f2998502db
commit 707407a433
11 changed files with 259 additions and 82 deletions

View file

@ -114,6 +114,8 @@ waf-host-02 | instance-c
- debug # Режим отладки (bool, влияет на лог-уровень) - debug # Режим отладки (bool, влияет на лог-уровень)
- shm_size # Размер /dev/shm в GB (дефолт: 1) - shm_size # Размер /dev/shm в GB (дефолт: 1)
- ptaf_fallback_code # Код ответа ptaf_fallback: число или 'pass' (дефолт: 418) - ptaf_fallback_code # Код ответа ptaf_fallback: число или 'pass' (дефолт: 418)
- sp_antiddos # Использовать антиддос сети в set_real_ip (дефолт: true)
- real_ip_networks # Кастомные сети для set_real_ip если sp_antiddos = false (inet[])
``` ```
**Важно:** `waf_instance` определяет на каком хосте должен работать клиент **Важно:** `waf_instance` определяет на каком хосте должен работать клиент
@ -516,13 +518,43 @@ proxy_next_upstream error timeout invalid_header http_502 http_503 http_504;
2. Ожидаем полного освобождения портов (до 180 сек) 2. Ожидаем полного освобождения портов (до 180 сек)
3. Запуск нового контейнера (до 3 попыток с паузой 3 сек) 3. Запуск нового контейнера (до 3 попыток с паузой 3 сек)
### Сохранение портов
`.ports.json` сохраняется **только после успешного запуска контейнера**:
- При пересоздании контейнера (`recreateContainer`) — сохраняется если `recreateContainer` вернул `true`
- При первом запуске нового контейнера — сохраняется если контейнер успешно запустился
- При неизменённом compose — сохраняется сразу (порты не менялись)
Если контейнер не запустился — `.ports.json` остаётся старым, и при следующем запуске magos порты будут перевыделены заново.
### Проверка после запуска ### Проверка после запуска
Если контейнер запущен менее 5 минут и порты не слушают — автоматическое пересоздание. Если контейнер запущен менее 5 минут и порты не слушают — автоматическое пересоздание.
--- ---
## Константы и лимиты ## Именование контейнеров
Имя Docker-контейнера формируется динамически на основе hostname хоста:
```
PTAF-TB-docker-AZ3-HW-node01 → {clientTitle}_AZ3_hn01_a001
PTAF-TB-docker-AZ1-node04 → {clientTitle}_AZ1_n04_a001
```
**Правила парсинга hostname:**
- `AZ{N}` — номер зоны доступности
- Наличие `HW` в hostname → префикс `hn` (hardware node), отсутствие → `n`
- `node{NN}` — номер ноды
- `a{001}` — порядковый номер агента
**Fallback:** если hostname не соответствует формату — используется старый формат `ptaf_{clientTitle}_{001}`.
**Важно:** директории на диске (`/home/install/ptaf/{clientTitle}/{clientTitle}-ptaf-agent{001}/`) не меняются — только `container_name` и `hostname` в Docker.
---
### Производительность ### Производительность
@ -561,9 +593,13 @@ large_client_header_buffers 4 128k;
### Фильтрация IP адресов ### Фильтрация IP адресов
**WAF Networks** - исключаются из origins (предотвращение routing loops) **WAF Networks** - исключаются из origins (предотвращение routing loops). Всегда добавляются в `set_real_ip_from` в Angie.
**Antibot Networks** - используются для идентификации antibot трафика **Antibot Networks** - используются для `set_real_ip_from` в Angie по умолчанию (`sp_antiddos = true`).
**Custom Real IP Networks** - если у клиента `sp_antiddos = false` и заполнено поле `real_ip_networks` в `client_info` — используются вместо Antibot Networks в `set_real_ip_from`. Применяется для клиентов без антиддоса перед Angie.
Внутри контейнера nginx всегда использует `set_real_ip_from 172.16.0.0/12` (Docker bridge сеть Angie).
### SSL/TLS ### SSL/TLS
@ -753,8 +789,17 @@ UPDATE apps_settings SET balancing_method = 'least_conn' WHERE l7resourceid = 12
```sql ```sql
-- Пропускать трафик если WAF недоступен -- Пропускать трафик если WAF недоступен
UPDATE client_info SET ptaf_fallback_code = 'pass' WHERE client_uuid = 12345; UPDATE client_info SET ptaf_fallback_code = 'pass' WHERE client_title = 'CLIENT001';
-- Вернуть 503 вместо 418 -- Вернуть 503 вместо 418
UPDATE client_info SET ptaf_fallback_code = '503' WHERE client_uuid = 12345; UPDATE client_info SET ptaf_fallback_code = '503' WHERE client_title = 'CLIENT001';
```
### Отключить антиддос для клиента (кастомные real_ip сети)
```sql
UPDATE client_info
SET sp_antiddos = false,
real_ip_networks = '{10.0.0.0/8, 192.168.1.0/24}'
WHERE client_title = 'CLIENT001';
``` ```

View file

@ -98,7 +98,9 @@ WHERE hostname = $1 AND instance = $2;
| `docker_image_download` | TEXT | URL для скачивания tar-архива образа | `https://...` | | `docker_image_download` | TEXT | URL для скачивания tar-архива образа | `https://...` |
| `debug` | BOOLEAN | Режим отладки | `false` | | `debug` | BOOLEAN | Режим отладки | `false` |
| `shm_size` | INTEGER | Размер /dev/shm в GB | `1` | | `shm_size` | INTEGER | Размер /dev/shm в GB | `1` |
| `ptaf_fallback_code` | TEXT | Код ответа ptaf_fallback | `'418'` | | `ptaf_fallback_code` | TEXT | Код ответа ptaf_fallback: число или 'pass' | `418` |
| `sp_antiddos` | BOOLEAN | Использовать антиддос сети в set_real_ip | `true` |
| `real_ip_networks` | INET[] | Кастомные сети для set_real_ip (если sp_antiddos=false) | `{10.0.0.0/8}` |
### Используемые запросы ### Используемые запросы
@ -114,7 +116,10 @@ SELECT
docker_image, docker_image,
docker_image_download, docker_image_download,
debug, debug,
shm_size shm_size,
ptaf_fallback_code,
sp_antiddos,
real_ip_networks
FROM client_info FROM client_info
WHERE waf_instance = $1; WHERE waf_instance = $1;
``` ```
@ -133,7 +138,10 @@ SELECT
docker_image, docker_image,
docker_image_download, docker_image_download,
debug, debug,
shm_size shm_size,
ptaf_fallback_code,
sp_antiddos,
real_ip_networks
FROM client_info FROM client_info
WHERE client_title = $1; WHERE client_title = $1;
``` ```
@ -154,6 +162,15 @@ ALTER TABLE client_info
ALTER TABLE client_info ALTER TABLE client_info
ADD COLUMN IF NOT EXISTS shm_size INTEGER DEFAULT 1; ADD COLUMN IF NOT EXISTS shm_size INTEGER DEFAULT 1;
ALTER TABLE client_info
ADD COLUMN IF NOT EXISTS ptaf_fallback_code TEXT DEFAULT '418';
ALTER TABLE client_info
ADD COLUMN IF NOT EXISTS sp_antiddos BOOLEAN DEFAULT TRUE;
ALTER TABLE client_info
ADD COLUMN IF NOT EXISTS real_ip_networks INET[] DEFAULT NULL;
``` ```
### Ключевые поля ### Ключевые поля
@ -180,6 +197,28 @@ ALTER TABLE client_info
Размер разделяемой памяти `/dev/shm` для Docker-контейнера в гигабайтах. Результат в docker-compose: `shm_size: '2gb'`. Дефолт: 1. Размер разделяемой памяти `/dev/shm` для Docker-контейнера в гигабайтах. Результат в docker-compose: `shm_size: '2gb'`. Дефолт: 1.
#### ptaf_fallback_code
Код HTTP ответа который PTAF возвращает когда WAF-модуль недоступен. Записывается в блок `http {}` файла `nginx.conf` — применяется глобально для всех ресурсов контейнера.
```sql
UPDATE client_info SET ptaf_fallback_code = 'pass' WHERE client_title = 'CLIENT001';
UPDATE client_info SET ptaf_fallback_code = '503' WHERE client_title = 'CLIENT001';
```
#### sp_antiddos / real_ip_networks
`sp_antiddos = true` (дефолт) — в Angie используются антиддос сети из таблицы `ips` для `set_real_ip_from`.
`sp_antiddos = false` + непустой `real_ip_networks` — вместо антиддос сетей используются кастомные сети из `real_ip_networks`. WAF сети из `ips` при этом всегда присутствуют.
```sql
UPDATE client_info
SET sp_antiddos = false,
real_ip_networks = '{10.0.0.0/8, 192.168.1.0/24}'
WHERE client_title = 'CLIENT001';
```
--- ---
## Таблица: apps_settings ## Таблица: apps_settings
@ -248,8 +287,7 @@ SELECT
max_fails, max_fails,
fail_timeout, fail_timeout,
balancing_method, balancing_method,
proxy_next_upstream_codes, proxy_next_upstream_codes
ptaf_fallback_code
FROM apps_settings FROM apps_settings
WHERE client_title = $1 WHERE client_title = $1
ORDER BY l7resourceid ASC; ORDER BY l7resourceid ASC;
@ -268,9 +306,6 @@ ALTER TABLE apps_settings
ALTER TABLE apps_settings ALTER TABLE apps_settings
ADD COLUMN IF NOT EXISTS proxy_next_upstream_codes TEXT DEFAULT '500,502,503,504'; ADD COLUMN IF NOT EXISTS proxy_next_upstream_codes TEXT DEFAULT '500,502,503,504';
ALTER TABLE client_info
ADD COLUMN IF NOT EXISTS ptaf_fallback_code TEXT DEFAULT '418';
``` ```
### Кастомные поля (подробно) ### Кастомные поля (подробно)
@ -310,21 +345,6 @@ WHERE l7resourceid = 12345;
proxy_next_upstream error timeout invalid_header http_502 http_503 http_504; proxy_next_upstream error timeout invalid_header http_502 http_503 http_504;
``` ```
#### ptaf_fallback_code
Код HTTP ответа который PTAF возвращает когда WAF-модуль недоступен.
```sql
-- Пропускать трафик если WAF недоступен
UPDATE client_info SET ptaf_fallback_code = 'pass' WHERE client_uuid = 12345;
-- Вернуть 503 вместо 418
UPDATE client_info SET ptaf_fallback_code = '503' WHERE client_uuid = 12345;
-- Вернуть дефолт (418)
UPDATE client_info SET ptaf_fallback_code = NULL WHERE client_uuid = 12345;
```
#### upstream_angie_custom / upstream_nginx_custom #### upstream_angie_custom / upstream_nginx_custom
**Режим:** REPLACE (полная замена keepalive директив) **Режим:** REPLACE (полная замена keepalive директив)
@ -563,6 +583,12 @@ SELECT client_title FROM client_info WHERE debug = true;
-- Клиенты с индивидуальным образом -- Клиенты с индивидуальным образом
SELECT client_title, docker_image FROM client_info WHERE docker_image IS NOT NULL; SELECT client_title, docker_image FROM client_info WHERE docker_image IS NOT NULL;
-- Клиенты без антиддоса
SELECT client_title, real_ip_networks FROM client_info WHERE sp_antiddos = false;
-- Клиенты с кастомным ptaf_fallback
SELECT client_title, ptaf_fallback_code FROM client_info WHERE ptaf_fallback_code != '418';
-- Ресурсы с кастомной балансировкой -- Ресурсы с кастомной балансировкой
SELECT client_title, l7resourceid, balancing_method SELECT client_title, l7resourceid, balancing_method
FROM apps_settings FROM apps_settings

View file

@ -10,7 +10,7 @@ import (
"time" "time"
) )
func setupContainers(config Config, clientTitle string, clientInfo *ClientInfo, resources []ResourceData, resourcePortMap map[int][]PortMapping) error { func setupContainers(config Config, clientTitle string, clientInfo *ClientInfo, resources []ResourceData, resourcePortMap map[int][]PortMapping, hostname string) error {
log.Printf("Настройка контейнеров для %s", clientTitle) log.Printf("Настройка контейнеров для %s", clientTitle)
baseDir := filepath.Join("/home/install/ptaf", clientTitle) baseDir := filepath.Join("/home/install/ptaf", clientTitle)
containerChanges := make(map[int]bool) containerChanges := make(map[int]bool)
@ -19,11 +19,13 @@ func setupContainers(config Config, clientTitle string, clientInfo *ClientInfo,
for containerNum := 1; containerNum <= clientInfo.ContainersCount; containerNum++ { for containerNum := 1; containerNum <= clientInfo.ContainersCount; containerNum++ {
containerName := fmt.Sprintf("%s-ptaf-agent%03d", clientTitle, containerNum) containerName := fmt.Sprintf("%s-ptaf-agent%03d", clientTitle, containerNum)
containerDir := filepath.Join(baseDir, containerName) containerDir := filepath.Join(baseDir, containerName)
containerFullName := buildContainerFullName(clientTitle, containerNum, hostname)
log.Printf(" Обработка контейнера: %s", containerName) log.Printf(" Обработка контейнера: %s", containerName)
confDDir := filepath.Join("/home/install/conf/ptaf-nginx", clientTitle, fmt.Sprintf("ptaf-agent%03d", containerNum), "conf.d")
dirs := []string{ dirs := []string{
containerDir, containerDir,
filepath.Join("/home/install/conf/ptaf-nginx", clientTitle, fmt.Sprintf("ptaf-agent%03d", containerNum), "conf.d"), confDDir,
} }
for _, dir := range dirs { for _, dir := range dirs {
existed := false existed := false
@ -37,6 +39,10 @@ func setupContainers(config Config, clientTitle string, clientInfo *ClientInfo,
log.Printf(" ✓ Создана директория: %s", dir) log.Printf(" ✓ Создана директория: %s", dir)
} }
} }
// conf.d должна быть доступна пользователю ptaf внутри контейнера
if err := os.Chmod(confDDir, 0777); err != nil {
return fmt.Errorf("ошибка установки прав для conf.d %s: %w", confDDir, err)
}
logDir := filepath.Join("/var/log/ptaf_nginx", clientTitle, fmt.Sprintf("ptaf-agent%03d", containerNum)) logDir := filepath.Join("/var/log/ptaf_nginx", clientTitle, fmt.Sprintf("ptaf-agent%03d", containerNum))
logDirExisted := false logDirExisted := false
@ -151,7 +157,7 @@ func setupContainers(config Config, clientTitle string, clientInfo *ClientInfo,
for idx, pm := range containerPorts { for idx, pm := range containerPorts {
log.Printf(" PortMapping[%d]: HTTP=%v, HTTPS=%v", idx, pm.HTTPPorts, pm.HTTPSPorts) log.Printf(" PortMapping[%d]: HTTP=%v, HTTPS=%v", idx, pm.HTTPPorts, pm.HTTPSPorts)
} }
changed, err = generateDockerCompose(composeFile, config, clientTitle, clientInfo, containerNum, resources, containerPorts) changed, err = generateDockerCompose(composeFile, config, clientTitle, clientInfo, containerNum, containerFullName, resources, containerPorts)
if err != nil { if err != nil {
return fmt.Errorf("ошибка генерации docker-compose.yml: %w", err) return fmt.Errorf("ошибка генерации docker-compose.yml: %w", err)
} }
@ -159,8 +165,12 @@ func setupContainers(config Config, clientTitle string, clientInfo *ClientInfo,
log.Printf(" ✓ docker-compose.yml изменен: %s", composeFile) log.Printf(" ✓ docker-compose.yml изменен: %s", composeFile)
composeChanges[containerNum] = true composeChanges[containerNum] = true
} }
if err := saveContainerPorts(containerDir, containerNum, resources, containerPorts); err != nil { // Сохраняем порты только если compose не изменился (нет пересоздания)
log.Printf(" ⚠ Ошибка сохранения портов: %v", err) // Если compose изменился — сохраним после успешного запуска контейнера
if !composeChanges[containerNum] {
if err := saveContainerPorts(containerDir, containerNum, resources, containerPorts); err != nil {
log.Printf(" ⚠ Ошибка сохранения портов: %v", err)
}
} }
} }
@ -169,12 +179,27 @@ func setupContainers(config Config, clientTitle string, clientInfo *ClientInfo,
containerName := fmt.Sprintf("%s-ptaf-agent%03d", clientTitle, containerNum) containerName := fmt.Sprintf("%s-ptaf-agent%03d", clientTitle, containerNum)
containerDir := filepath.Join(baseDir, containerName) containerDir := filepath.Join(baseDir, containerName)
composeFile := filepath.Join(containerDir, "docker-compose.yml") composeFile := filepath.Join(containerDir, "docker-compose.yml")
containerFullName := fmt.Sprintf("ptaf_%s_%03d", clientTitle, containerNum) containerFullName := buildContainerFullName(clientTitle, containerNum, hostname)
containerExists, containerRunning := checkContainerStatus(containerFullName) containerExists, containerRunning := checkContainerStatus(containerFullName)
// Собираем порты контейнера для сохранения после успешного запуска
containerPorts := []PortMapping{}
for _, res := range resources {
ports, ok := resourcePortMap[res.L7ResourceID]
if !ok || containerNum-1 >= len(ports) {
continue
}
containerPorts = append(containerPorts, ports[containerNum-1])
}
if containerExists { if containerExists {
if composeChanges[containerNum] { if composeChanges[containerNum] {
recreateContainer(containerFullName, composeFile, containerDir, clientTitle, resources, resourcePortMap, containerNum) success := recreateContainer(containerFullName, composeFile, containerDir, clientTitle, resources, resourcePortMap, containerNum)
if success {
if err := saveContainerPorts(containerDir, containerNum, resources, containerPorts); err != nil {
log.Printf(" ⚠ Ошибка сохранения портов: %v", err)
}
}
continue continue
} }
if !containerRunning { if !containerRunning {
@ -312,6 +337,9 @@ func setupContainers(config Config, clientTitle string, clientInfo *ClientInfo,
log.Printf(" ⚠ Контейнер %s не запустился после %d попыток. Попробуйте вручную: cd %s && docker-compose up -d", containerFullName, maxRetries, containerDir) log.Printf(" ⚠ Контейнер %s не запустился после %d попыток. Попробуйте вручную: cd %s && docker-compose up -d", containerFullName, maxRetries, containerDir)
continue continue
} }
if err := saveContainerPorts(containerDir, containerNum, resources, containerPorts); err != nil {
log.Printf(" ⚠ Ошибка сохранения портов: %v", err)
}
} }
} }

View file

@ -71,7 +71,7 @@ func isPortBusyByAnyProcess(port int) bool {
return false return false
} }
func recreateContainer(containerFullName, composeFile, containerDir, clientTitle string, resources []ResourceData, resourcePortMap map[int][]PortMapping, containerNum int) { func recreateContainer(containerFullName, composeFile, containerDir, clientTitle string, resources []ResourceData, resourcePortMap map[int][]PortMapping, containerNum int) bool {
log.Printf(" docker-compose.yml изменился, пересоздаём контейнер %s...", containerFullName) log.Printf(" docker-compose.yml изменился, пересоздаём контейнер %s...", containerFullName)
portsToFree := make([]int, 0) portsToFree := make([]int, 0)
for _, res := range resources { for _, res := range resources {
@ -110,7 +110,7 @@ func recreateContainer(containerFullName, composeFile, containerDir, clientTitle
log.Printf(" → Контейнер не остановился, принудительное удаление...") log.Printf(" → Контейнер не остановился, принудительное удаление...")
if err := forceRemoveContainer(containerFullName); err != nil { if err := forceRemoveContainer(containerFullName); err != nil {
log.Printf(" ⚠ Не удалось принудительно удалить контейнер: %v", err) log.Printf(" ⚠ Не удалось принудительно удалить контейнер: %v", err)
return return false
} }
} }
} }
@ -165,7 +165,7 @@ func recreateContainer(containerFullName, composeFile, containerDir, clientTitle
if !allPortsFree { if !allPortsFree {
log.Printf(" ⚠ Порты не освободились за 180 секунд!") log.Printf(" ⚠ Порты не освободились за 180 секунд!")
log.Printf(" → Проверьте вручную: sudo lsof -i :%d", portsToFree[0]) log.Printf(" → Проверьте вручную: sudo lsof -i :%d", portsToFree[0])
return return false
} }
} }
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
@ -179,18 +179,19 @@ func recreateContainer(containerFullName, composeFile, containerDir, clientTitle
log.Printf(" ⚠ Порт всё ещё занят после ожидания освобождения!") log.Printf(" ⚠ Порт всё ещё занят после ожидания освобождения!")
log.Printf(" → Проверьте: docker ps -a | grep %s", clientTitle) log.Printf(" → Проверьте: docker ps -a | grep %s", clientTitle)
log.Printf(" → Найдите процесс: sudo lsof -i | grep LISTEN | grep 127.0.0.1") log.Printf(" → Найдите процесс: sudo lsof -i | grep LISTEN | grep 127.0.0.1")
return return false
} }
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
_, isRunning := checkContainerStatus(containerFullName) _, isRunning := checkContainerStatus(containerFullName)
if !isRunning { if !isRunning {
log.Printf(" ⚠ Контейнер %s не запустился. Попробуйте вручную: cd %s && docker-compose up -d", containerFullName, containerDir) log.Printf(" ⚠ Контейнер %s не запустился. Попробуйте вручную: cd %s && docker-compose up -d", containerFullName, containerDir)
return return false
} }
log.Printf(" ✓ Контейнер %s пересоздан с новыми портами", containerFullName) log.Printf(" ✓ Контейнер %s пересоздан с новыми портами", containerFullName)
return true
} }
func startContainer(containerFullName, composeFile, containerDir string) { func startContainer(containerFullName, composeFile, containerDir string) bool {
log.Printf(" Контейнер %s остановлен, запускаем...", containerFullName) log.Printf(" Контейнер %s остановлен, запускаем...", containerFullName)
cmd := exec.Command("docker-compose", "-f", composeFile, "up", "-d") cmd := exec.Command("docker-compose", "-f", composeFile, "up", "-d")
cmd.Dir = containerDir cmd.Dir = containerDir
@ -200,9 +201,10 @@ func startContainer(containerFullName, composeFile, containerDir string) {
_, isRunning := checkContainerStatus(containerFullName) _, isRunning := checkContainerStatus(containerFullName)
if !isRunning { if !isRunning {
log.Printf(" ⚠ Контейнер %s не запустился. Попробуйте вручную: cd %s && docker-compose up -d", containerFullName, containerDir) log.Printf(" ⚠ Контейнер %s не запустился. Попробуйте вручную: cd %s && docker-compose up -d", containerFullName, containerDir)
return return false
} }
log.Printf(" ✓ Контейнер %s запущен", containerFullName) log.Printf(" ✓ Контейнер %s запущен", containerFullName)
return true
} }
// getContainerStartedAt возвращает время запуска контейнера // getContainerStartedAt возвращает время запуска контейнера
@ -646,10 +648,11 @@ func generateMimeTypes(filePath string) (bool, error) {
} }
// generateDockerCompose генерирует docker-compose.yml файл // generateDockerCompose генерирует docker-compose.yml файл
func generateDockerCompose(filePath string, config Config, clientTitle string, clientInfo *ClientInfo, containerNum int, resources []ResourceData, portMappings []PortMapping) (bool, error) { func generateDockerCompose(filePath string, config Config, clientTitle string, clientInfo *ClientInfo, containerNum int, containerFullName string, resources []ResourceData, portMappings []PortMapping) (bool, error) {
type ComposeData struct { type ComposeData struct {
ClientTitle string ClientTitle string
ContainerNum int ContainerNum int
ContainerFullName string
DockerImage string DockerImage string
PTAFConfig string PTAFConfig string
WorkerProcesses int WorkerProcesses int
@ -714,6 +717,7 @@ func generateDockerCompose(filePath string, config Config, clientTitle string, c
data := ComposeData{ data := ComposeData{
ClientTitle: clientTitle, ClientTitle: clientTitle,
ContainerNum: containerNum, ContainerNum: containerNum,
ContainerFullName: containerFullName,
DockerImage: dockerImage, DockerImage: dockerImage,
PTAFConfig: "", PTAFConfig: "",
WorkerProcesses: config.WorkerProcesses, WorkerProcesses: config.WorkerProcesses,
@ -739,8 +743,8 @@ func generateDockerCompose(filePath string, config Config, clientTitle string, c
image: '{{.DockerImage}}' image: '{{.DockerImage}}'
restart: unless-stopped restart: unless-stopped
shm_size: '{{.ShmSize}}gb' shm_size: '{{.ShmSize}}gb'
container_name: ptaf_{{.ClientTitle}}_{{printf "%03d" .ContainerNum}} container_name: {{.ContainerFullName}}
hostname: ptaf_{{.ClientTitle}}_{{printf "%03d" .ContainerNum}} hostname: {{.ContainerFullName}}
environment: environment:
- CONNECTION_STRING={{.PTAFConfig}} - CONNECTION_STRING={{.PTAFConfig}}
- WORKER_PROCESSES={{.WorkerProcesses}} - WORKER_PROCESSES={{.WorkerProcesses}}

12
db.go
View file

@ -6,7 +6,7 @@ import (
"log" "log"
"strings" "strings"
_ "github.com/lib/pq" "github.com/lib/pq"
) )
// connectToDB подключается к базе данных PostgreSQL // connectToDB подключается к базе данных PostgreSQL
@ -98,7 +98,8 @@ func checkHostBelongsToInstance(db *sql.DB, hostname, instance string) (bool, er
func getClientInfoByInstance(db *sql.DB, instance string) ([]ClientInfo, error) { func getClientInfoByInstance(db *sql.DB, instance string) ([]ClientInfo, error) {
query := ` query := `
SELECT containers_count, ptaf_config, client_title, fluent_bit_port, waf_instance, SELECT containers_count, ptaf_config, client_title, fluent_bit_port, waf_instance,
docker_image, docker_image_download, debug, shm_size, ptaf_fallback_code docker_image, docker_image_download, debug, shm_size, ptaf_fallback_code,
sp_antiddos, real_ip_networks
FROM client_info FROM client_info
WHERE waf_instance = $1 WHERE waf_instance = $1
` `
@ -127,6 +128,8 @@ func getClientInfoByInstance(db *sql.DB, instance string) ([]ClientInfo, error)
&ci.Debug, &ci.Debug,
&ci.ShmSize, &ci.ShmSize,
&ci.PtafFallbackCode, &ci.PtafFallbackCode,
&ci.SpAntiddos,
pq.Array(&ci.RealIPNetworks),
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("ошибка сканирования строки: %w", err) return nil, fmt.Errorf("ошибка сканирования строки: %w", err)
@ -212,7 +215,8 @@ func getAppsSettingsByClientTitle(db *sql.DB, clientTitle string) ([]AppsSetting
func getClientInfoByClientTitle(db *sql.DB, clientTitle string) (*ClientInfo, error) { func getClientInfoByClientTitle(db *sql.DB, clientTitle string) (*ClientInfo, error) {
query := ` query := `
SELECT containers_count, ptaf_config, client_title, fluent_bit_port, waf_instance, SELECT containers_count, ptaf_config, client_title, fluent_bit_port, waf_instance,
docker_image, docker_image_download, debug, shm_size, ptaf_fallback_code docker_image, docker_image_download, debug, shm_size, ptaf_fallback_code,
sp_antiddos, real_ip_networks
FROM client_info FROM client_info
WHERE client_title = $1 WHERE client_title = $1
` `
@ -230,6 +234,8 @@ func getClientInfoByClientTitle(db *sql.DB, clientTitle string) (*ClientInfo, er
&clientInfo.Debug, &clientInfo.Debug,
&clientInfo.ShmSize, &clientInfo.ShmSize,
&clientInfo.PtafFallbackCode, &clientInfo.PtafFallbackCode,
&clientInfo.SpAntiddos,
pq.Array(&clientInfo.RealIPNetworks),
) )
if err != nil { if err != nil {
if strings.Contains(err.Error(), "does not exist") { if strings.Contains(err.Error(), "does not exist") {

View file

@ -30,12 +30,22 @@ func getActualContainerCount(clientTitle string) int {
} }
// Считаем по drain-маркерам в /tmp (контейнеры могли быть уже удалены с диска) // Считаем по drain-маркерам в /tmp (контейнеры могли быть уже удалены с диска)
for num := max + 1; ; num++ { // Ищем маркеры по паттерну /tmp/ptaf-drain-{clientTitle}_* независимо от формата имени
markerFile := fmt.Sprintf("/tmp/ptaf-drain-ptaf_%s_%03d", clientTitle, num) matches, _ := filepath.Glob(fmt.Sprintf("/tmp/ptaf-drain-%s_*", clientTitle))
if _, err := os.Stat(markerFile); err != nil { for _, match := range matches {
break // Извлекаем номер агента из конца имени файла (формат: ..._a001 или ..._001)
base := filepath.Base(match)
base = strings.TrimPrefix(base, "ptaf-drain-")
// Убираем префикс clientTitle_
rest := strings.TrimPrefix(base, clientTitle+"_")
// Ищем номер в конце: _a001 или _001
parts := strings.Split(rest, "_")
lastPart := parts[len(parts)-1]
lastPart = strings.TrimPrefix(lastPart, "a")
var num int
if _, err := fmt.Sscanf(lastPart, "%d", &num); err == nil && num > max {
max = num
} }
max = num
} }
return max return max
@ -161,7 +171,8 @@ func processAllDrainingContainersGlobally(db *sql.DB, hostname string) {
hasExistingMarkers := false hasExistingMarkers := false
actualCount := getActualContainerCount(clientTitle) actualCount := getActualContainerCount(clientTitle)
for containerNum := 1; containerNum <= actualCount; containerNum++ { for containerNum := 1; containerNum <= actualCount; containerNum++ {
if _, err := os.Stat(fmt.Sprintf("/tmp/ptaf-drain-ptaf_%s_%03d", clientTitle, containerNum)); err == nil { containerFullName := buildContainerFullName(clientTitle, containerNum, hostname)
if _, err := os.Stat(fmt.Sprintf("/tmp/ptaf-drain-%s", containerFullName)); err == nil {
hasExistingMarkers = true hasExistingMarkers = true
break break
} }
@ -169,7 +180,7 @@ func processAllDrainingContainersGlobally(db *sql.DB, hostname string) {
if !hasExistingMarkers { if !hasExistingMarkers {
log.Printf(" → Помечаю контейнеры для миграции") log.Printf(" → Помечаю контейнеры для миграции")
for containerNum := 1; containerNum <= actualCount; containerNum++ { for containerNum := 1; containerNum <= actualCount; containerNum++ {
containerFullName := fmt.Sprintf("ptaf_%s_%03d", clientTitle, containerNum) containerFullName := buildContainerFullName(clientTitle, containerNum, hostname)
exists, _ := checkContainerStatus(containerFullName) exists, _ := checkContainerStatus(containerFullName)
if exists { if exists {
if err := markContainerForDrain(containerFullName, true); err != nil { if err := markContainerForDrain(containerFullName, true); err != nil {
@ -210,12 +221,10 @@ func processAllDrainingContainersGlobally(db *sql.DB, hostname string) {
func checkAndProcessExistingDrainMarkers(clientTitle string, containersCount int, processedCount *int) { func checkAndProcessExistingDrainMarkers(clientTitle string, containersCount int, processedCount *int) {
hasAnyDrainMarkers := false hasAnyDrainMarkers := false
actualCount := getActualContainerCount(clientTitle) // Ищем маркеры по паттерну независимо от формата имени контейнера
for containerNum := 1; containerNum <= actualCount; containerNum++ { matches, _ := filepath.Glob(fmt.Sprintf("/tmp/ptaf-drain-%s_*", clientTitle))
if _, err := os.Stat(fmt.Sprintf("/tmp/ptaf-drain-ptaf_%s_%03d", clientTitle, containerNum)); err == nil { if len(matches) > 0 {
hasAnyDrainMarkers = true hasAnyDrainMarkers = true
break
}
} }
if !hasAnyDrainMarkers { if !hasAnyDrainMarkers {
if _, err := os.Stat(fmt.Sprintf("/tmp/ptaf-angie-drain-%s", clientTitle)); err == nil { if _, err := os.Stat(fmt.Sprintf("/tmp/ptaf-angie-drain-%s", clientTitle)); err == nil {
@ -244,7 +253,7 @@ func processDrainingContainers(clientTitle string, maxContainerNum int) {
actualCount := getActualContainerCount(clientTitle) actualCount := getActualContainerCount(clientTitle)
for containerNum := maxContainerNum + 1; containerNum <= actualCount; containerNum++ { for containerNum := maxContainerNum + 1; containerNum <= actualCount; containerNum++ {
containerName := fmt.Sprintf("%s-ptaf-agent%03d", clientTitle, containerNum) containerName := fmt.Sprintf("%s-ptaf-agent%03d", clientTitle, containerNum)
containerFullName := fmt.Sprintf("ptaf_%s_%03d", clientTitle, containerNum) containerFullName := buildContainerFullName(clientTitle, containerNum, hostname)
containerDir := filepath.Join(baseDir, containerName) containerDir := filepath.Join(baseDir, containerName)
composeFile := filepath.Join(containerDir, "docker-compose.yml") composeFile := filepath.Join(containerDir, "docker-compose.yml")
exists, _ := checkContainerStatus(containerFullName) exists, _ := checkContainerStatus(containerFullName)

View file

@ -345,7 +345,7 @@ func handleClientMigration(clientInfo ClientInfo, hostname string) {
actualCount := getActualContainerCount(clientInfo.ClientTitle) actualCount := getActualContainerCount(clientInfo.ClientTitle)
for containerNum := 1; containerNum <= actualCount; containerNum++ { for containerNum := 1; containerNum <= actualCount; containerNum++ {
containerFullName := fmt.Sprintf("ptaf_%s_%03d", clientInfo.ClientTitle, containerNum) containerFullName := buildContainerFullName(clientInfo.ClientTitle, containerNum, hostname)
exists, _ := checkContainerStatus(containerFullName) exists, _ := checkContainerStatus(containerFullName)
if exists { if exists {
log.Printf(" → Помечаю контейнер %s для drain (миграция, таймаут %d сек)", log.Printf(" → Помечаю контейнер %s для drain (миграция, таймаут %d сек)",
@ -373,14 +373,14 @@ func handleClientMigration(clientInfo ClientInfo, hostname string) {
} }
// cleanStaleDrainMarkers очищает устаревшие drain-маркеры если клиент вернулся на хост // cleanStaleDrainMarkers очищает устаревшие drain-маркеры если клиент вернулся на хост
func cleanStaleDrainMarkers(clientInfo ClientInfo) { func cleanStaleDrainMarkers(clientInfo ClientInfo, hostname string) {
log.Printf(" → Проверка и очистка устаревших маркеров drain для %s", clientInfo.ClientTitle) log.Printf(" → Проверка и очистка устаревших маркеров drain для %s", clientInfo.ClientTitle)
cleanedContainers := 0 cleanedContainers := 0
cleanedAngieMarker := false cleanedAngieMarker := false
actualCount := getActualContainerCount(clientInfo.ClientTitle) actualCount := getActualContainerCount(clientInfo.ClientTitle)
for containerNum := 1; containerNum <= actualCount; containerNum++ { for containerNum := 1; containerNum <= actualCount; containerNum++ {
containerFullName := fmt.Sprintf("ptaf_%s_%03d", clientInfo.ClientTitle, containerNum) containerFullName := buildContainerFullName(clientInfo.ClientTitle, containerNum, hostname)
markerFile := fmt.Sprintf("/tmp/ptaf-drain-%s", containerFullName) markerFile := fmt.Sprintf("/tmp/ptaf-drain-%s", containerFullName)
if _, err := os.Stat(markerFile); err == nil { if _, err := os.Stat(markerFile); err == nil {

View file

@ -10,7 +10,7 @@ import (
) )
// generateAngieConfigsWithoutReload генерирует конфиги Angie БЕЗ автоматического reload // generateAngieConfigsWithoutReload генерирует конфиги Angie БЕЗ автоматического reload
func generateAngieConfigsWithoutReload(config Config, clientTitle string, resources []ResourceData, resourcePortMap map[int][]PortMapping, debug bool) (bool, error) { func generateAngieConfigsWithoutReload(config Config, clientTitle string, clientInfo ClientInfo, resources []ResourceData, resourcePortMap map[int][]PortMapping, debug bool) (bool, error) {
log.Printf("Генерация конфигов Angie для %s", clientTitle) log.Printf("Генерация конфигов Angie для %s", clientTitle)
configDir := "/etc/angie/http.d" configDir := "/etc/angie/http.d"
@ -47,7 +47,7 @@ func generateAngieConfigsWithoutReload(config Config, clientTitle string, resour
return false, fmt.Errorf("не найдены порты для l7ResourceID %d", res.L7ResourceID) return false, fmt.Errorf("не найдены порты для l7ResourceID %d", res.L7ResourceID)
} }
err := writeAngieResourceConfig(f, config, res, containerPorts, debug) err := writeAngieResourceConfig(f, config, clientInfo, res, containerPorts, debug)
if err != nil { if err != nil {
os.Remove(tmpFile) os.Remove(tmpFile)
return false, fmt.Errorf("ошибка генерации конфига для l7ResourceID %d: %w", res.L7ResourceID, err) return false, fmt.Errorf("ошибка генерации конфига для l7ResourceID %d: %w", res.L7ResourceID, err)
@ -93,8 +93,8 @@ func generateAngieConfigsWithoutReload(config Config, clientTitle string, resour
} }
// generateAngieConfigs генерирует конфигурационные файлы Angie с автоматическим reload // generateAngieConfigs генерирует конфигурационные файлы Angie с автоматическим reload
func generateAngieConfigs(config Config, clientTitle string, resources []ResourceData, resourcePortMap map[int][]PortMapping, debug bool) error { func generateAngieConfigs(config Config, clientTitle string, clientInfo ClientInfo, resources []ResourceData, resourcePortMap map[int][]PortMapping, debug bool) error {
changed, err := generateAngieConfigsWithoutReload(config, clientTitle, resources, resourcePortMap, debug) changed, err := generateAngieConfigsWithoutReload(config, clientTitle, clientInfo, resources, resourcePortMap, debug)
if err != nil { if err != nil {
return err return err
} }
@ -107,7 +107,7 @@ func generateAngieConfigs(config Config, clientTitle string, resources []Resourc
} }
// writeAngieResourceConfig записывает конфигурацию для одного ресурса в Angie // writeAngieResourceConfig записывает конфигурацию для одного ресурса в Angie
func writeAngieResourceConfig(f *os.File, config Config, res ResourceData, containerPorts []PortMapping, debug bool) error { func writeAngieResourceConfig(f *os.File, config Config, clientInfo ClientInfo, res ResourceData, containerPorts []PortMapping, debug bool) error {
allDomains := []string{res.ServerName} allDomains := []string{res.ServerName}
allDomains = append(allDomains, res.Aliases...) allDomains = append(allDomains, res.Aliases...)
processedDomains := processDomainsWithWWW(allDomains) processedDomains := processDomainsWithWWW(allDomains)
@ -271,7 +271,7 @@ func writeAngieResourceConfig(f *os.File, config Config, res ResourceData, conta
f.WriteString(fmt.Sprintf(" access_log /var/log/angie/%d_%s_access.log waf;\n", res.L7ResourceID, res.ServerName)) 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)) f.WriteString(fmt.Sprintf(" error_log /var/log/angie/%d_%s_error.log error;\n\n", res.L7ResourceID, res.ServerName))
writeNetworkSettings(f, config) writeNetworkSettings(f, config, clientInfo)
writeStandardServerSettings(f, getCustomServerDirectives(res.AppsSettings.ServerDirectivesAngieCustom), debug, getProxyNextUpstream(res.AppsSettings)) writeStandardServerSettings(f, getCustomServerDirectives(res.AppsSettings.ServerDirectivesAngieCustom), debug, getProxyNextUpstream(res.AppsSettings))
if hasCustomLocation { if hasCustomLocation {
@ -324,7 +324,7 @@ func writeAngieResourceConfig(f *os.File, config Config, res ResourceData, conta
writeAngieSSLSettings(f, res.AppsSettings.CustomAngieSSL) writeAngieSSLSettings(f, res.AppsSettings.CustomAngieSSL)
} }
writeNetworkSettings(f, config) writeNetworkSettings(f, config, clientInfo)
writeStandardServerSettings(f, getCustomServerDirectives(res.AppsSettings.ServerDirectivesAngieCustom), debug, getProxyNextUpstream(res.AppsSettings)) writeStandardServerSettings(f, getCustomServerDirectives(res.AppsSettings.ServerDirectivesAngieCustom), debug, getProxyNextUpstream(res.AppsSettings))
if hasCustomLocation { if hasCustomLocation {
@ -357,7 +357,7 @@ func writeAngieResourceConfig(f *os.File, config Config, res ResourceData, conta
} }
log.Printf(" └─ Angie Server блок: порт %d, server_name %s%s", log.Printf(" └─ Angie Server блок: порт %d, server_name %s%s",
block.Port, block.ServerName, aliasInfo) block.Port, block.ServerName, aliasInfo)
generateAngieCustomServerBlock(f, config, res, block, containerPorts, customLocationStr, debug) generateAngieCustomServerBlock(f, config, clientInfo, res, block, containerPorts, customLocationStr, debug)
} }
} }
@ -365,14 +365,22 @@ func writeAngieResourceConfig(f *os.File, config Config, res ResourceData, conta
} }
// writeNetworkSettings записывает WAF и Antibot networks для Angie // writeNetworkSettings записывает WAF и Antibot networks для Angie
func writeNetworkSettings(f *os.File, config Config) { func writeNetworkSettings(f *os.File, config Config, clientInfo ClientInfo) {
f.WriteString(" # waf networks\n") f.WriteString(" # waf networks\n")
for _, net := range config.WAFNetworks { for _, net := range config.WAFNetworks {
f.WriteString(fmt.Sprintf(" set_real_ip_from %s;\n", net)) f.WriteString(fmt.Sprintf(" set_real_ip_from %s;\n", net))
} }
f.WriteString(" # antibot networks\n") if !clientInfo.SpAntiddos && len(clientInfo.RealIPNetworks) > 0 {
for _, net := range config.AntibotNetworks { // Кастомные сети вместо антиддос сетей
f.WriteString(fmt.Sprintf(" set_real_ip_from %s;\n", net)) f.WriteString(" # custom real_ip networks\n")
for _, net := range clientInfo.RealIPNetworks {
f.WriteString(fmt.Sprintf(" set_real_ip_from %s;\n", net))
}
} else {
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("\n real_ip_header X-Forwarded-For;\n")
f.WriteString(" real_ip_recursive on;\n\n") f.WriteString(" real_ip_recursive on;\n\n")
@ -500,7 +508,7 @@ func writeNginxCustomOrDefaultDirectives(f *os.File, customDirectives string) {
} }
// generateAngieCustomServerBlock генерирует кастомный server блок для Angie // generateAngieCustomServerBlock генерирует кастомный server блок для Angie
func generateAngieCustomServerBlock(f *os.File, config Config, res ResourceData, block CustomServerBlock, containerPorts []PortMapping, customLocationStr string, debug bool) { func generateAngieCustomServerBlock(f *os.File, config Config, clientInfo ClientInfo, res ResourceData, block CustomServerBlock, containerPorts []PortMapping, customLocationStr string, debug bool) {
var upstreamName string var upstreamName string
if block.IsHTTPS { if block.IsHTTPS {
upstreamName = fmt.Sprintf("secure%d_%d_%s", res.L7ResourceID, block.Port, res.ServerName) upstreamName = fmt.Sprintf("secure%d_%d_%s", res.L7ResourceID, block.Port, res.ServerName)
@ -538,7 +546,7 @@ func generateAngieCustomServerBlock(f *os.File, config Config, res ResourceData,
f.WriteString(fmt.Sprintf(" error_log /var/log/angie/%d_%s_port%d_error.log error;\n\n", 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)) res.L7ResourceID, sanitizeServerName(block.ServerName), block.Port))
writeNetworkSettings(f, config) writeNetworkSettings(f, config, clientInfo)
writeStandardServerSettings(f, getCustomServerDirectives(res.AppsSettings.ServerDirectivesAngieCustom), debug, getProxyNextUpstream(res.AppsSettings)) writeStandardServerSettings(f, getCustomServerDirectives(res.AppsSettings.ServerDirectivesAngieCustom), debug, getProxyNextUpstream(res.AppsSettings))
// Кастомные location блоки из location_angie_custom (перед location /) // Кастомные location блоки из location_angie_custom (перед location /)

View file

@ -72,7 +72,7 @@ func processPTAFClient(
} }
// Автоочистка устаревших маркеров drain // Автоочистка устаревших маркеров drain
cleanStaleDrainMarkers(clientInfo) cleanStaleDrainMarkers(clientInfo, hostname)
// Получение данных ресурсов // Получение данных ресурсов
resourcesData := fetchPTAFResourcesData(db, config, appsSettingsList) resourcesData := fetchPTAFResourcesData(db, config, appsSettingsList)
@ -95,14 +95,14 @@ func processPTAFClient(
// ════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════
// 1. Генерация конфигурации Angie БЕЗ автоматического reload // 1. Генерация конфигурации Angie БЕЗ автоматического reload
angieChanged, err := generateAngieConfigsWithoutReload(config, clientInfo.ClientTitle, resourcesData, resourcePortMap, clientInfo.Debug) angieChanged, err := generateAngieConfigsWithoutReload(config, clientInfo.ClientTitle, clientInfo, resourcesData, resourcePortMap, clientInfo.Debug)
if err != nil { if err != nil {
log.Printf("Ошибка генерации конфигов Angie для %s: %v", clientInfo.ClientTitle, err) log.Printf("Ошибка генерации конфигов Angie для %s: %v", clientInfo.ClientTitle, err)
return false return false
} }
// 2. Создание директорий и конфигов для контейнеров (пересоздание если нужно) // 2. Создание директорий и конфигов для контейнеров (пересоздание если нужно)
err = setupContainers(config, clientInfo.ClientTitle, &clientInfo, resourcesData, resourcePortMap) err = setupContainers(config, clientInfo.ClientTitle, &clientInfo, resourcesData, resourcePortMap, hostname)
if err != nil { if err != nil {
log.Printf("Ошибка настройки контейнеров для %s: %v", clientInfo.ClientTitle, err) log.Printf("Ошибка настройки контейнеров для %s: %v", clientInfo.ClientTitle, err)
return false return false

View file

@ -53,6 +53,8 @@ type ClientInfo struct {
Debug bool // Режим отладки (влияет на лог-уровень и переменные окружения) Debug bool // Режим отладки (влияет на лог-уровень и переменные окружения)
ShmSize int // Размер /dev/shm в GB (дефолт 1) ShmSize int // Размер /dev/shm в GB (дефолт 1)
PtafFallbackCode sql.NullString // Код ответа ptaf_fallback: число или 'pass' (дефолт: 418) PtafFallbackCode sql.NullString // Код ответа ptaf_fallback: число или 'pass' (дефолт: 418)
SpAntiddos bool // Использовать антиддос сети для set_real_ip (дефолт true)
RealIPNetworks []string // Кастомные сети для set_real_ip если SpAntiddos = false
} }
// Структуры для API ответов // Структуры для API ответов

View file

@ -4,6 +4,7 @@ import (
"crypto/sha256" "crypto/sha256"
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"fmt"
"io" "io"
"log" "log"
"os" "os"
@ -335,3 +336,51 @@ func writeCustomLocationBlock(w stringWriter, customLocationStr string, correctU
} }
w.WriteString("\n") w.WriteString("\n")
} }
// parseHostnameForContainer парсит hostname хоста и возвращает префикс для имени контейнера.
// Примеры:
//
// PTAF-TB-docker-AZ3-HW-node01 → AZ3_hn01
// PTAF-TB-docker-AZ1-node04 → AZ1_n04
func parseHostnameForContainer(hostname string) string {
// Извлекаем AZ номер
azPart := ""
nodePart := ""
isHW := false
parts := strings.Split(hostname, "-")
for i, part := range parts {
if strings.HasPrefix(strings.ToUpper(part), "AZ") {
azPart = strings.ToUpper(part) // например AZ3
}
if strings.ToUpper(part) == "HW" {
isHW = true
}
if strings.HasPrefix(strings.ToLower(part), "node") && i == len(parts)-1 {
// Берём только цифры после "node"
nodeNum := strings.TrimPrefix(strings.ToLower(part), "node")
if isHW {
nodePart = "hn" + nodeNum
} else {
nodePart = "n" + nodeNum
}
}
}
if azPart == "" || nodePart == "" {
return ""
}
return azPart + "_" + nodePart
}
// buildContainerFullName формирует полное имя Docker-контейнера.
// Если hostname парсится корректно: {clientTitle}_{AZx}_{hn/n}{NN}_a{001}
// Иначе fallback на старый формат: ptaf_{clientTitle}_{001}
func buildContainerFullName(clientTitle string, containerNum int, hostname string) string {
prefix := parseHostnameForContainer(hostname)
if prefix == "" {
return fmt.Sprintf("ptaf_%s_%03d", clientTitle, containerNum)
}
// prefix = "AZ3_hn01", итог: "PSB_CFA_AZ3_hn01_a001"
return fmt.Sprintf("%s_%s_a%03d", clientTitle, prefix, containerNum)
}