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
This commit is contained in:
valitovgaziz
2026-06-12 12:22:19 +05:00
parent abcb327278
commit 8e766b540e
31 changed files with 1535 additions and 343 deletions
+474
View File
@@ -0,0 +1,474 @@
#!/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