Files
tp/main_dc/generate-configs.sh
T
valitovgaziz 8e766b540e feat: CI/CD, per-domain HTTPS, backup, config generator
- sites.yml — единый источник истины для всех сайтов
- generate-configs.sh — генератор nginx конфигов, certbot domains.txt, .env
- nginx: per-domain HTTPS (вместо all-or-nothing switch-config)
- certbot: единый renew-all.sh, динамический init (без 5 дублирующих скриптов)
- backup: контейнер с pg_dump + rclone (Яндекс.Диск), ежедневно в 3AM
- Gitea + Gitea Runner в docker-compose (self-hosted Git + CI/CD)
- .gitea/workflows/deploy.yml — CI/CD pipeline: push → авто-деплой
- Makefile: generate-configs, reconfig, deploy, backup, restore, gitea, help
2026-06-12 12:22:19 +05:00

475 lines
15 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/bash
# generate-configs.sh — генератор конфигов из sites.yml
# Генерирует: nginx-http.conf, nginx-ssl.conf, certbot/domains.txt, обновляет .env
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$DIR"
SITES_YML="$DIR/sites.yml"
NGINX_DIR="$DIR/nginx"
ENV_FILE="$DIR/.env"
if [ ! -f "$SITES_YML" ]; then
echo "Ошибка: $SITES_YML не найден"
exit 1
fi
echo "=== Генерация конфигов из sites.yml ==="
# Используем python3 с quoted heredoc — предотвращает интерпретацию $ переменных bash
python3 - "$DIR" "$NGINX_DIR" "$ENV_FILE" << 'PYEOF'
import yaml, os, sys
BASE_DIR = sys.argv[1]
NGINX_DIR = sys.argv[2]
ENV_FILE = sys.argv[3]
SITES_YML = os.path.join(BASE_DIR, "sites.yml")
with open(SITES_YML) as f:
data = yaml.safe_load(f)
sites = data.get("sites", {})
if not sites:
print("Ошибка: в sites.yml нет сайтов")
sys.exit(1)
# собираем данные
all_domains = []
env_domains = {}
site_list = []
for name, cfg in sites.items():
domain = cfg["domain"]
aliases = cfg.get("aliases", [])
all_domains.append(domain)
all_domains.extend(aliases)
env_key = f"DOMAINS_{name}"
env_val = ",".join([domain] + aliases)
env_domains[env_key] = env_val
site_list.append({
"name": name,
"domain": domain,
"aliases": aliases,
"type": cfg.get("type", "upstream"),
"upstream": cfg.get("upstream", ""),
"root": cfg.get("root", ""),
"api": cfg.get("api", {}),
})
env_domains["ALL_DOMAINS"] = ",".join(all_domains)
def all_server_names():
"""Возвращает строку со всеми доменами и алиасами через пробел"""
parts = []
for s in site_list:
parts.append(s["domain"])
parts.extend(s["aliases"])
return " ".join(parts)
def all_server_names_multiline():
"""Возвращает строку с переносами для nginx server_name"""
lines = []
for s in site_list:
lines.append(s["domain"])
for a in s["aliases"]:
lines.append(a)
return " \\\n ".join(lines)
# ──────────────────────────────────────────────
# 2. Генерация nginx-http.conf
# ──────────────────────────────────────────────
http_conf = f"""# Автоматически сгенерировано generate-configs.sh — не редактировать вручную
# HTTP-only конфигурация (работает когда нет сертификатов)
server {{
listen 80;
server_name {all_server_names_multiline()};
location / {{
root /usr/share/nginx/stub/html;
index index.html;
}}
location /.well-known/acme-challenge/ {{
root /var/www/certbot;
}}
}}
# Блок для HTTPS → HTTP редиректа (порт 443)
server {{
listen 443 ssl;
server_name {all_server_names_multiline()};
ssl_certificate /etc/nginx/ssl/dummy.crt;
ssl_certificate_key /etc/nginx/ssl/dummy.key;
return 301 http://$host$request_uri;
}}
"""
http_conf_path = os.path.join(NGINX_DIR, "nginx-http.conf")
with open(http_conf_path, "w") as f:
f.write(http_conf.lstrip())
print(f" ✓ {http_conf_path}")
# ──────────────────────────────────────────────
# 3. Генерация nginx-ssl.conf
# ──────────────────────────────────────────────
ssl_server_blocks = []
for s in site_list:
server_names = " ".join([s["domain"]] + s["aliases"])
block = f"""
server {{
listen 443 ssl;
server_name {server_names};
ssl_certificate /etc/letsencrypt/live/{s["domain"]}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{s["domain"]}/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers "EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH";
"""
if s["type"] == "upstream":
block += f"""
location / {{
proxy_pass {s["upstream"]};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_connect_timeout 600;
proxy_send_timeout 600;
proxy_read_timeout 600;
}}
"""
elif s["type"] == "static":
block += f"""
location / {{
root {s["root"]};
index index.html;
try_files $uri $uri/ /index.html;
}}
"""
# API routes
for path, target in s["api"].items():
cors_block = ""
if "/api/" in path:
cors_block = """
if ($request_method = OPTIONS) {
add_header 'Access-Control-Allow-Origin' "$http_origin";
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, PATCH, DELETE';
add_header 'Access-Control-Allow-Headers' 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Length' 0;
add_header 'Content-Type' 'text/plain charset=UTF-8';
return 204;
}
"""
block += f"""
location {path} {{
proxy_pass {target};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_connect_timeout 600;
proxy_send_timeout 600;
proxy_read_timeout 600;
{cors_block}
}}
"""
if s["type"] == "static":
block += f"""
location /uploads/ {{
alias /uploads/;
expires 1y;
add_header Cache-Control "public, immutable";
}}
"""
block += "}"
ssl_server_blocks.append(block)
ssl_conf = f"""# Автоматически сгенерировано generate-configs.sh — не редактировать вручную
# Полная HTTPS конфигурация
# --- HTTP → HTTPS редирект ---
server {{
listen 80;
server_name {all_server_names()};
location /.well-known/acme-challenge/ {{
root /var/www/certbot;
}}
location /uploads/ {{
alias /uploads/;
expires 1y;
add_header Cache-Control "public, immutable";
}}
location / {{
return 301 https://$host$request_uri;
}}
}}
# --- HTTPS серверные блоки ---
{''.join(ssl_server_blocks)}
"""
ssl_conf_path = os.path.join(NGINX_DIR, "nginx-ssl.conf")
with open(ssl_conf_path, "w") as f:
f.write(ssl_conf.lstrip())
print(f" ✓ {ssl_conf_path}")
# ──────────────────────────────────────────────
# 4. Генерация per-domain конфигов (conf.available/)
# ──────────────────────────────────────────────
CONF_AVAILABLE = os.path.join(NGINX_DIR, "conf.available")
os.makedirs(CONF_AVAILABLE, exist_ok=True)
# 00-http.conf — базовый HTTP catch-all (всегда активен)
base_http = f"""# Автоматически сгенерировано generate-configs.sh
server {{
listen 80 default_server;
server_name _;
location / {{
root /usr/share/nginx/stub/html;
index index.html;
}}
location /.well-known/acme-challenge/ {{
root /var/www/certbot;
}}
}}
"""
path = os.path.join(CONF_AVAILABLE, "00-http.conf")
with open(path, "w") as f:
f.write(base_http.lstrip())
print(f" ✓ conf.available/00-http.conf")
# per-domain: SSL + HTTP fallback
ORDER = ["10", "20", "30", "40", "50", "60", "70", "80", "90"]
for idx, s in enumerate(site_list):
prefix = ORDER[idx] if idx < len(ORDER) else f"{90 + idx}"
safe_name = s["name"]
server_names = " ".join([s["domain"]] + s["aliases"])
# --- SSL variant ---
ssl_block = f"""# CERT_DOMAIN={s["domain"]}
# Автоматически сгенерировано generate-configs.sh
server {{
listen 443 ssl;
server_name {server_names};
ssl_certificate /etc/letsencrypt/live/{s["domain"]}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{s["domain"]}/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers "EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH";
"""
if s["type"] == "upstream":
ssl_block += f"""
location / {{
proxy_pass {s["upstream"]};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_connect_timeout 600;
proxy_send_timeout 600;
proxy_read_timeout 600;
}}
"""
elif s["type"] == "static":
ssl_block += f"""
location / {{
root {s["root"]};
index index.html;
try_files $uri $uri/ /index.html;
}}
"""
for path, target in s["api"].items():
cors = ""
if "/api/" in path:
cors = """
if ($request_method = OPTIONS) {
add_header 'Access-Control-Allow-Origin' "$http_origin";
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, PATCH, DELETE';
add_header 'Access-Control-Allow-Headers' 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Length' 0;
add_header 'Content-Type' 'text/plain charset=UTF-8';
return 204;
}
"""
ssl_block += f"""
location {path} {{
proxy_pass {target};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_connect_timeout 600;
proxy_send_timeout 600;
proxy_read_timeout 600;
{cors}
}}
"""
if s["type"] == "static":
ssl_block += f"""
location /uploads/ {{
alias /uploads/;
expires 1y;
add_header Cache-Control "public, immutable";
}}
"""
ssl_block += "}"
ssl_path = os.path.join(CONF_AVAILABLE, f"{prefix}-{safe_name}.ssl.conf")
with open(ssl_path, "w") as f:
f.write(ssl_block.lstrip())
# --- HTTP fallback variant ---
http_block = f"""# HTTP fallback for {s["domain"]} (no SSL cert)
server {{
listen 80;
server_name {server_names};
"""
if s["type"] == "upstream":
http_block += f"""
location / {{
proxy_pass {s["upstream"]};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 600;
proxy_send_timeout 600;
proxy_read_timeout 600;
}}
"""
elif s["type"] == "static":
http_block += f"""
location / {{
root {s["root"]};
index index.html;
try_files $uri $uri/ /index.html;
}}
"""
for path, target in s["api"].items():
cors = ""
if "/api/" in path:
cors = """
if ($request_method = OPTIONS) {
add_header 'Access-Control-Allow-Origin' "$http_origin";
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, PATCH, DELETE';
add_header 'Access-Control-Allow-Headers' 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Length' 0;
add_header 'Content-Type' 'text/plain charset=UTF-8';
return 204;
}
"""
http_block += f"""
location {path} {{
proxy_pass {target};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 600;
proxy_send_timeout 600;
proxy_read_timeout 600;
{cors}
}}
"""
if s["type"] == "static":
http_block += f"""
location /uploads/ {{
alias /uploads/;
expires 1y;
add_header Cache-Control "public, immutable";
}}
"""
http_block += "}"
http_path = os.path.join(CONF_AVAILABLE, f"{prefix}-{safe_name}.http.conf")
with open(http_path, "w") as f:
f.write(http_block.lstrip())
print(f" ✓ conf.available/{prefix}-{safe_name}.ssl.conf + .http.conf")
# ──────────────────────────────────────────────
# 5. Генерация certbot/domains.txt
# ──────────────────────────────────────────────
domains_txt_path = os.path.join(BASE_DIR, "certbot", "domains.txt")
with open(domains_txt_path, "w") as f:
for d in all_domains:
f.write(d + "\n")
print(f" ✓ {domains_txt_path}")
# ──────────────────────────────────────────────
# 6. Обновление .env
# ──────────────────────────────────────────────
if os.path.exists(ENV_FILE):
with open(ENV_FILE) as f:
env_lines = f.readlines()
else:
env_lines = []
new_env = []
for line in env_lines:
stripped = line.strip()
if stripped.startswith("DOMAINS_") or stripped.startswith("ALL_DOMAINS") or "CERTBOT NGINX VARIABLES" in stripped:
continue
new_env.append(line)
# удаляем пустые строки в начале
while new_env and not new_env[0].strip():
new_env.pop(0)
domain_keys = {k: v for k, v in env_domains.items()}
insert_idx = None
for i, line in enumerate(new_env):
if line.strip().startswith("EMAIL="):
insert_idx = i + 1
break
env_header = "#CERTBOT NGINX VARIABLES — авто-сгенерировано, не редактировать вручную\n"
domain_lines = [f"{k}={v}\n" for k, v in sorted(domain_keys.items())]
if insert_idx is not None:
new_env.insert(insert_idx, env_header)
for dl in reversed(domain_lines):
new_env.insert(insert_idx + 1, dl)
else:
new_env = [env_header] + domain_lines + new_env
with open(ENV_FILE, "w") as f:
f.writelines(new_env)
print(f" ✓ {ENV_FILE} (обновлён)")
print()
print("=== Генерация завершена ===")
print(f"Сгенерировано {len(site_list)} сайтов:")
for s in site_list:
print(f" • {s['domain']} ({s['type']})")
print()
print("Не забудь перезапустить nginx: docker compose restart nginx")
PYEOF