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 }