443 lines
13 KiB
Go
Executable file
443 lines
13 KiB
Go
Executable file
package main
|
||
|
||
import (
|
||
"crypto/sha256"
|
||
"database/sql"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"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")
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// parseSidBlocks парсит строку вида {10111,10231}{10112} в срезы блоков SID
|
||
// Возвращает [][]int где каждый элемент — блок SID для одного набора контейнеров
|
||
func parseSidBlocks(sidBlock string) ([][]int, error) {
|
||
sidBlock = strings.TrimSpace(sidBlock)
|
||
if sidBlock == "" {
|
||
return nil, fmt.Errorf("пустая строка sid_block")
|
||
}
|
||
|
||
var blocks [][]int
|
||
i := 0
|
||
for i < len(sidBlock) {
|
||
if sidBlock[i] != '{' {
|
||
return nil, fmt.Errorf("ожидался '{' на позиции %d", i)
|
||
}
|
||
end := strings.Index(sidBlock[i:], "}")
|
||
if end < 0 {
|
||
return nil, fmt.Errorf("не найдена закрывающая '}' начиная с позиции %d", i)
|
||
}
|
||
inner := sidBlock[i+1 : i+end]
|
||
parts := strings.Split(inner, ",")
|
||
var block []int
|
||
for _, p := range parts {
|
||
p = strings.TrimSpace(p)
|
||
if p == "" {
|
||
continue
|
||
}
|
||
var sid int
|
||
if _, err := fmt.Sscanf(p, "%d", &sid); err != nil {
|
||
return nil, fmt.Errorf("невалидный SID '%s': %w", p, err)
|
||
}
|
||
block = append(block, sid)
|
||
}
|
||
if len(block) > 0 {
|
||
blocks = append(blocks, block)
|
||
}
|
||
i += end + 1
|
||
}
|
||
return blocks, nil
|
||
}
|
||
|
||
// isBlockClientTitle проверяет является ли clientTitle блочным виртуальным именем
|
||
// вида {clientTitle}_block{N} которые создаются при one_container=false
|
||
func isBlockClientTitle(clientTitle string) bool {
|
||
// Ищем паттерн _block{число} в конце строки
|
||
parts := strings.Split(clientTitle, "_block")
|
||
if len(parts) < 2 {
|
||
return false
|
||
}
|
||
lastPart := parts[len(parts)-1]
|
||
for _, c := range lastPart {
|
||
if c < '0' || c > '9' {
|
||
return false
|
||
}
|
||
}
|
||
return len(lastPart) > 0
|
||
}
|