859 lines
37 KiB
Go
859 lines
37 KiB
Go
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, debug bool) (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, debug)
|
||
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, debug bool) error {
|
||
changed, err := generateAngieConfigsWithoutReload(config, clientTitle, resources, resourcePortMap, debug)
|
||
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, debug bool) 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))
|
||
if res.AppsSettings.BalancingMethod.Valid && res.AppsSettings.BalancingMethod.String != "" {
|
||
f.WriteString(fmt.Sprintf(" %s;\n", strings.TrimSpace(res.AppsSettings.BalancingMethod.String)))
|
||
}
|
||
maxFails := getMaxFails(res.AppsSettings)
|
||
failTimeout := getFailTimeout(res.AppsSettings)
|
||
for _, ports := range containerPorts {
|
||
if dockerPort, ok := ports.HTTPSPorts[customPort]; ok {
|
||
f.WriteString(fmt.Sprintf(" server 127.0.0.1:%d weight=50 max_fails=%d fail_timeout=%ds;\n", dockerPort, maxFails, failTimeout))
|
||
}
|
||
}
|
||
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))
|
||
if res.AppsSettings.BalancingMethod.Valid && res.AppsSettings.BalancingMethod.String != "" {
|
||
f.WriteString(fmt.Sprintf(" %s;\n", strings.TrimSpace(res.AppsSettings.BalancingMethod.String)))
|
||
}
|
||
maxFails := getMaxFails(res.AppsSettings)
|
||
failTimeout := getFailTimeout(res.AppsSettings)
|
||
for _, ports := range containerPorts {
|
||
if dockerPort, ok := ports.HTTPPorts[customPort]; ok {
|
||
f.WriteString(fmt.Sprintf(" server 127.0.0.1:%d weight=50 max_fails=%d fail_timeout=%ds;\n", dockerPort, maxFails, failTimeout))
|
||
}
|
||
}
|
||
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
|
||
maxFails := getMaxFails(res.AppsSettings)
|
||
failTimeout := getFailTimeout(res.AppsSettings)
|
||
balancingMethod := ""
|
||
if res.AppsSettings.BalancingMethod.Valid && res.AppsSettings.BalancingMethod.String != "" {
|
||
balancingMethod = strings.TrimSpace(res.AppsSettings.BalancingMethod.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))
|
||
if balancingMethod != "" {
|
||
f.WriteString(fmt.Sprintf(" %s;\n", balancingMethod))
|
||
}
|
||
// Определяем 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 max_fails=%d fail_timeout=%ds;\n", dockerPort, maxFails, failTimeout))
|
||
}
|
||
}
|
||
} else {
|
||
upstreamName = fmt.Sprintf("unsecure%d_%d_%s", res.L7ResourceID, block.Port, block.ServerName)
|
||
f.WriteString(fmt.Sprintf("upstream %s {\n", upstreamName))
|
||
if balancingMethod != "" {
|
||
f.WriteString(fmt.Sprintf(" %s;\n", balancingMethod))
|
||
}
|
||
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 max_fails=%d fail_timeout=%ds;\n", dockerPort, maxFails, failTimeout))
|
||
}
|
||
}
|
||
}
|
||
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), debug, getProxyNextUpstream(res.AppsSettings))
|
||
|
||
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), debug, getProxyNextUpstream(res.AppsSettings))
|
||
|
||
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, debug)
|
||
}
|
||
}
|
||
|
||
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, debug bool, proxyNextUpstream string) {
|
||
f.WriteString(" server_tokens off;\n")
|
||
f.WriteString(" sendfile on;\n")
|
||
f.WriteString(" gzip on;\n")
|
||
f.WriteString(" client_max_body_size 0;\n")
|
||
f.WriteString("\n")
|
||
f.WriteString(fmt.Sprintf(" proxy_next_upstream %s;\n", proxyNextUpstream))
|
||
f.WriteString(" proxy_http_version 1.1;\n")
|
||
f.WriteString("\n")
|
||
f.WriteString(" js_set $safe_host main.sanitizeHost;\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 $proxy_connection;\n")
|
||
f.WriteString(" proxy_set_header Upgrade $http_upgrade;\n")
|
||
f.WriteString(" proxy_set_header Host $safe_host;\n")
|
||
f.WriteString(" proxy_set_header X-Original-Host $host;\n")
|
||
f.WriteString(" proxy_pass_header Date;\n")
|
||
f.WriteString(" proxy_pass_header Server;\n")
|
||
if debug {
|
||
f.WriteString(" client_body_buffer_size 1m;\n")
|
||
}
|
||
writeCustomOrDefaultDirectives(f, customDirectives)
|
||
}
|
||
|
||
// writeNginxStandardSettings записывает стандартные настройки сервера (Nginx)
|
||
func writeNginxStandardSettings(f *os.File, customDirectives string, proxyNextUpstream string) {
|
||
f.WriteString(" server_tokens off;\n")
|
||
f.WriteString(" sendfile on;\n")
|
||
f.WriteString(" gzip on;\n")
|
||
f.WriteString(" client_max_body_size 0;\n")
|
||
f.WriteString("\n")
|
||
f.WriteString(fmt.Sprintf(" proxy_next_upstream %s;\n", proxyNextUpstream))
|
||
f.WriteString(" proxy_http_version 1.1;\n")
|
||
f.WriteString("\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 $proxy_connection;\n")
|
||
f.WriteString(" proxy_set_header Upgrade $http_upgrade;\n")
|
||
f.WriteString(" proxy_set_header Host $backend_host;\n")
|
||
f.WriteString(" proxy_pass_header Date;\n")
|
||
f.WriteString(" proxy_pass_header Server;\n")
|
||
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 8 64k;\n")
|
||
f.WriteString(" proxy_buffer_size 64k;\n")
|
||
f.WriteString(" proxy_busy_buffers_size 128k;\n")
|
||
}
|
||
}
|
||
|
||
// generateAngieCustomServerBlock генерирует кастомный server блок для Angie
|
||
func generateAngieCustomServerBlock(f *os.File, config Config, res ResourceData, block CustomServerBlock, containerPorts []PortMapping, customLocationStr string, debug bool) {
|
||
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), debug, getProxyNextUpstream(res.AppsSettings))
|
||
|
||
// Кастомные 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) {
|
||
// Метод балансировки (если задан)
|
||
if settings.BalancingMethod.Valid && settings.BalancingMethod.String != "" {
|
||
f.WriteString(fmt.Sprintf(" %s;\n", strings.TrimSpace(settings.BalancingMethod.String)))
|
||
}
|
||
|
||
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), getProxyNextUpstream(res.AppsSettings))
|
||
|
||
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), getProxyNextUpstream(res.AppsSettings))
|
||
|
||
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), getProxyNextUpstream(res.AppsSettings))
|
||
|
||
// Кастомные 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")
|
||
}
|