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:
Executable
+474
@@ -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
|
||||
Reference in New Issue
Block a user