8e766b540e
- 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
475 lines
15 KiB
Bash
Executable File
475 lines
15 KiB
Bash
Executable File
#!/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
|