diff --git a/.env.example b/.env.example index f9a8fd9..40ce5e6 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,12 @@ APP_PORT=8080 APP_SECRET=change-me-to-a-long-random-secret-key APP_ENV=production +# Public domain (reverse proxy / SSL) +DOMAIN=panel.example.com +ACME_EMAIL=admin@example.com +# Dokploy Traefik cert resolver name (часто letsencrypt) +TRAEFIK_CERT_RESOLVER=letsencrypt + # Admin authorization ADMIN_EMAIL=admin@panel.local ADMIN_PASSWORD=Admin123!ChangeMe diff --git a/README.md b/README.md index feaf11b..2a8fd7d 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,11 @@ - Go 1.23 (веб-админка) - PostgreSQL 17 - Docker Compose +- Reverse proxy + SSL: **Caddy** / **Nginx** / **Traefik** ## Быстрый старт (Dokploy / Docker) -1. Скопируйте `.env.example` → `.env` и задайте пароли. +1. Скопируйте `.env.example` → `.env` и задайте пароли + `DOMAIN`. 2. В Dokploy создайте приложение типа **Docker Compose**, укажите этот репозиторий. 3. Deploy — поднимутся сервисы `app` и `postgres`. @@ -30,6 +31,151 @@ docker compose up -d --build При первом запуске админ создаётся автоматически из этих переменных. +--- + +## Reverse proxy + SSL (Let's Encrypt) + +Примеры лежат в `deploy/`. Сеть Docker: `vpn-panel-net`. + +### Что нужно до выпуска сертификата + +1. **DNS**: A (и при необходимости AAAA) запись `DOMAIN` → публичный IP сервера. +2. **Порты**: `80` и `443` открыты на firewall / security group (TCP; для Caddy HTTP/3 ещё `443/udp`). +3. **Домен в `.env`**: + ```env + DOMAIN=panel.example.com + ACME_EMAIL=admin@example.com + ``` +4. **Не светить панель наружу на `:8080`** — только через proxy: + ```bash + docker compose -f docker-compose.yml -f deploy/compose.behind-proxy.yml up -d + ``` + Либо в Dokploy / compose закомментируйте `ports` у `app`, оставьте доступ по внутренней сети `app:8080`. +5. Прокси обязан передавать заголовки `Host`, `X-Forwarded-Proto`, `X-Forwarded-For` (уже есть в примерах). + +> Let's Encrypt не выпустит сертификат на `localhost` / приватный IP. Нужен реальный домен с публичным DNS. + +--- + +### Вариант A — Caddy (самый простой, SSL автоматом) + +Caddy сам получает и продлевает сертификат. + +1. В `deploy/caddy/Caddyfile` домен берётся из `{$DOMAIN:...}` (или задайте в `.env`). +2. Запуск вместе с панелью: + +```bash +# из корня репозитория +docker compose \ + -f docker-compose.yml \ + -f deploy/compose.behind-proxy.yml \ + -f deploy/caddy/docker-compose.caddy.yml \ + up -d --build +``` + +3. Откройте `https://panel.example.com`. + +Файлы: `deploy/caddy/Caddyfile`, `deploy/caddy/docker-compose.caddy.yml`. + +--- + +### Вариант B — Nginx + Certbot + +1. Подставьте свой домен в `deploy/nginx/nginx.conf` (`server_name` и пути к сертификатам). +2. Поднимите стек (сначала HTTP + ACME webroot): + +```bash +docker compose \ + -f docker-compose.yml \ + -f deploy/compose.behind-proxy.yml \ + -f deploy/nginx/docker-compose.nginx.yml \ + up -d --build +``` + +3. Выпустите сертификат: + +```bash +docker compose -f deploy/nginx/docker-compose.nginx.yml run --rm certbot certonly \ + --webroot -w /var/www/certbot \ + -d panel.example.com \ + --email admin@example.com \ + --agree-tos --no-eff-email +``` + +4. В `deploy/nginx/nginx.conf`: + - раскомментируйте блок `server { listen 443 ... }`; + - включите `return 301 https://$host$request_uri;` в HTTP-сервере; + - закомментируйте временный `location /` на порту 80 (оставьте только ACME). +5. Перезагрузите nginx: + +```bash +docker compose -f docker-compose.yml -f deploy/nginx/docker-compose.nginx.yml exec nginx nginx -s reload +``` + +Продление: контейнер `certbot` крутит `renew` раз в 12 часов; после renew — `nginx -s reload`. + +Файлы: `deploy/nginx/`. + +--- + +### Вариант C — Traefik (свой или Dokploy) + +#### C1. Свой Traefik из репозитория + +```bash +docker compose \ + -f docker-compose.yml \ + -f deploy/compose.behind-proxy.yml \ + -f deploy/traefik/docker-compose.traefik.yml \ + up -d --build +``` + +В `.env`: `DOMAIN`, `ACME_EMAIL`. Сертификат выпускается HTTP-challenge на entrypoint `web`. + +#### C2. Dokploy (встроенный Traefik) + +Dokploy уже поднимает Traefik — **не** добавляйте `deploy/traefik/docker-compose.traefik.yml`. + +1. В Dokploy укажите домен для сервиса `app`. +2. Либо добавьте labels к `app` (см. `deploy/traefik/labels.example.yml`): + +```yaml +labels: + - traefik.enable=true + - traefik.http.routers.vpn-panel.rule=Host(`panel.example.com`) + - traefik.http.routers.vpn-panel.entrypoints=websecure + - traefik.http.routers.vpn-panel.tls=true + - traefik.http.routers.vpn-panel.tls.certresolver=letsencrypt + - traefik.http.services.vpn-panel.loadbalancer.server.port=8080 +``` + +Имя `certresolver` в Dokploy часто `letsencrypt` — проверьте в настройках (или `TRAEFIK_CERT_RESOLVER` в `.env`). + +3. Не публикуйте порт `8080` наружу в UI Dokploy, если трафик идёт только через Traefik. + +--- + +### Чеклист после включения HTTPS + +| Проверка | Ожидание | +|---|---| +| `https://DOMAIN/` | Главная панель | +| `https://DOMAIN/health` | `ok` | +| HTTP → HTTPS | Редирект 301 (Caddy/Traefik по умолчанию; Nginx — после шага 4) | +| Сертификат | Валидный LE, не self-signed | +| Порт `8080` снаружи | Закрыт | + +### Типичные ошибки + +| Симптом | Причина | +|---|---| +| ACME timeout / pending | DNS ещё не указывает на сервер, или закрыт порт 80 | +| 502 Bad Gateway | Proxy не в сети `vpn-panel-net` / сервис `app` не запущен | +| Редирект-петля | За приложением HTTPS, а `X-Forwarded-Proto` не передаётся | +| Certbot: no such file | Домен в путях `nginx.conf` не совпадает с `-d` | + +--- + ## Протоколы (seed) WireGuard, OpenVPN, VLESS, VMess, Trojan, Shadowsocks, Hysteria2 — можно включать/выключать в админке. diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile new file mode 100644 index 0000000..cbac4b7 --- /dev/null +++ b/deploy/caddy/Caddyfile @@ -0,0 +1,28 @@ +{$DOMAIN:panel.example.com} { + encode gzip zstd + + # Healthcheck без редиректов наружу + handle /health { + reverse_proxy app:8080 + } + + reverse_proxy app:8080 { + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + header_up Host {host} + } + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options nosniff + X-Frame-Options DENY + Referrer-Policy strict-origin-when-cross-origin + -Server + } + + log { + output stdout + format console + } +} diff --git a/deploy/caddy/docker-compose.caddy.yml b/deploy/caddy/docker-compose.caddy.yml new file mode 100644 index 0000000..9033aee --- /dev/null +++ b/deploy/caddy/docker-compose.caddy.yml @@ -0,0 +1,38 @@ +# Пример: Caddy как reverse proxy с авто-SSL (Let's Encrypt) +# +# Запуск вместе с панелью: +# docker compose -f docker-compose.yml -f deploy/caddy/docker-compose.caddy.yml up -d --build +# +# Перед стартом: +# 1. DNS A/AAAA домена → IP сервера +# 2. Открыты порты 80 и 443 +# 3. В .env задайте DOMAIN=panel.example.com +# 4. В docker-compose панели лучше НЕ публиковать 8080 наружу (или оставить только localhost) + +services: + caddy: + image: caddy:2-alpine + container_name: vpn-panel-caddy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + - "443:443/udp" + environment: + DOMAIN: ${DOMAIN:-panel.example.com} + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + networks: + - vpn-net + depends_on: + - app + +volumes: + caddy_data: + caddy_config: + +networks: + vpn-net: + name: vpn-panel-net diff --git a/deploy/compose.behind-proxy.yml b/deploy/compose.behind-proxy.yml new file mode 100644 index 0000000..14ff590 --- /dev/null +++ b/deploy/compose.behind-proxy.yml @@ -0,0 +1,13 @@ +# Override: не публиковать :8080 наружу (только через reverse proxy). +# Нужен Docker Compose v2.24+ (поддержка !override). +# +# docker compose -f docker-compose.yml -f deploy/compose.behind-proxy.yml up -d +# +# Если !override не поддерживается — в docker-compose.yml закомментируйте +# блок ports у сервиса app и оставьте только expose: ["8080"]. + +services: + app: + ports: !override [] + expose: + - "8080" diff --git a/deploy/nginx/docker-compose.nginx.yml b/deploy/nginx/docker-compose.nginx.yml new file mode 100644 index 0000000..22846f7 --- /dev/null +++ b/deploy/nginx/docker-compose.nginx.yml @@ -0,0 +1,51 @@ +# Пример: Nginx + Certbot (Let's Encrypt) +# +# Запуск: +# docker compose -f docker-compose.yml -f deploy/nginx/docker-compose.nginx.yml up -d --build +# +# Первый выпуск сертификата (после того как DNS уже указывает на сервер): +# docker compose -f deploy/nginx/docker-compose.nginx.yml run --rm certbot certonly \ +# --webroot -w /var/www/certbot \ +# -d panel.example.com \ +# --email admin@example.com --agree-tos --no-eff-email +# +# Затем раскомментируйте HTTPS-server в nginx.conf и: +# docker compose -f docker-compose.yml -f deploy/nginx/docker-compose.nginx.yml up -d +# +# Обновление сертификатов (cron / systemd timer на хосте): +# docker compose -f deploy/nginx/docker-compose.nginx.yml run --rm certbot renew +# docker compose -f deploy/nginx/docker-compose.nginx.yml exec nginx nginx -s reload + +services: + nginx: + image: nginx:1.27-alpine + container_name: vpn-panel-nginx + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ./snippets:/etc/nginx/snippets:ro + - certbot_www:/var/www/certbot:ro + - certbot_certs:/etc/letsencrypt:ro + networks: + - vpn-net + depends_on: + - app + + certbot: + image: certbot/certbot:latest + container_name: vpn-panel-certbot + volumes: + - certbot_www:/var/www/certbot + - certbot_certs:/etc/letsencrypt + entrypoint: /bin/sh -c "trap exit TERM; while :; do certbot renew --webroot -w /var/www/certbot --quiet; sleep 12h & wait $${!}; done" + +volumes: + certbot_www: + certbot_certs: + +networks: + vpn-net: + name: vpn-panel-net diff --git a/deploy/nginx/nginx.conf b/deploy/nginx/nginx.conf new file mode 100644 index 0000000..fe86bde --- /dev/null +++ b/deploy/nginx/nginx.conf @@ -0,0 +1,42 @@ +# Замените panel.example.com на свой DOMAIN. +# После выпуска сертификата Certbot раскомментируйте блок HTTPS ниже. + +upstream vpn_panel { + server app:8080; + keepalive 32; +} + +server { + listen 80; + listen [::]:80; + server_name panel.example.com; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # Пока сертификата нет — проксируем HTTP. + # После выпуска SSL лучше редиректить на HTTPS (раскомментируйте return и закомментируйте location /). + # return 301 https://$host$request_uri; + + location / { + proxy_pass http://vpn_panel; + include /etc/nginx/snippets/proxy_params.conf; + } +} + +# --- HTTPS (раскомментировать после certbot certonly) --- +# server { +# listen 443 ssl http2; +# listen [::]:443 ssl http2; +# server_name panel.example.com; +# +# ssl_certificate /etc/letsencrypt/live/panel.example.com/fullchain.pem; +# ssl_certificate_key /etc/letsencrypt/live/panel.example.com/privkey.pem; +# include /etc/nginx/snippets/ssl_params.conf; +# +# location / { +# proxy_pass http://vpn_panel; +# include /etc/nginx/snippets/proxy_params.conf; +# } +# } diff --git a/deploy/nginx/snippets/proxy_params.conf b/deploy/nginx/snippets/proxy_params.conf new file mode 100644 index 0000000..c30e713 --- /dev/null +++ b/deploy/nginx/snippets/proxy_params.conf @@ -0,0 +1,10 @@ +proxy_http_version 1.1; +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-Host $host; +proxy_set_header Connection ""; +proxy_read_timeout 60s; +proxy_send_timeout 60s; +proxy_buffering on; diff --git a/deploy/nginx/snippets/ssl_params.conf b/deploy/nginx/snippets/ssl_params.conf new file mode 100644 index 0000000..4a85cf8 --- /dev/null +++ b/deploy/nginx/snippets/ssl_params.conf @@ -0,0 +1,14 @@ +ssl_session_cache shared:SSL:10m; +ssl_session_timeout 1d; +ssl_session_tickets off; + +ssl_protocols TLSv1.2 TLSv1.3; +ssl_prefer_server_ciphers off; + +# Modern Mozilla intermediate +ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305; + +add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; +add_header X-Content-Type-Options nosniff always; +add_header X-Frame-Options DENY always; +add_header Referrer-Policy strict-origin-when-cross-origin always; diff --git a/deploy/traefik/docker-compose.traefik.yml b/deploy/traefik/docker-compose.traefik.yml new file mode 100644 index 0000000..cdb0c6e --- /dev/null +++ b/deploy/traefik/docker-compose.traefik.yml @@ -0,0 +1,62 @@ +# Пример: Traefik v3 + Let's Encrypt (HTTP challenge) +# +# Запуск: +# docker compose -f docker-compose.yml -f deploy/traefik/docker-compose.traefik.yml up -d --build +# +# В .env обязательны: +# DOMAIN=panel.example.com +# ACME_EMAIL=admin@example.com +# +# Если используете Dokploy — у него уже есть Traefik. +# Тогда НЕ поднимайте этот compose, а только повесьте labels на сервис app +# (см. labels.example.yml / README). + +services: + traefik: + image: traefik:v3.3 + container_name: vpn-panel-traefik + restart: unless-stopped + command: + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --providers.docker.network=vpn-panel-net + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + - --entrypoints.web.http.redirections.entrypoint.to=websecure + - --entrypoints.web.http.redirections.entrypoint.scheme=https + - --certificatesresolvers.le.acme.httpchallenge=true + - --certificatesresolvers.le.acme.httpchallenge.entrypoint=web + - --certificatesresolvers.le.acme.email=${ACME_EMAIL:-admin@example.com} + - --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json + - --api.dashboard=false + - --log.level=INFO + ports: + - "80:80" + - "443:443" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - traefik_letsencrypt:/letsencrypt + networks: + - vpn-net + + app: + labels: + - traefik.enable=true + - traefik.docker.network=vpn-panel-net + - traefik.http.routers.vpn-panel.rule=Host(`${DOMAIN:-panel.example.com}`) + - traefik.http.routers.vpn-panel.entrypoints=websecure + - traefik.http.routers.vpn-panel.tls=true + - traefik.http.routers.vpn-panel.tls.certresolver=le + - traefik.http.services.vpn-panel.loadbalancer.server.port=8080 + - traefik.http.middlewares.vpn-panel-headers.headers.stsSeconds=31536000 + - traefik.http.middlewares.vpn-panel-headers.headers.stsIncludeSubdomains=true + - traefik.http.middlewares.vpn-panel-headers.headers.contentTypeNosniff=true + - traefik.http.middlewares.vpn-panel-headers.headers.frameDeny=true + - traefik.http.routers.vpn-panel.middlewares=vpn-panel-headers + +volumes: + traefik_letsencrypt: + +networks: + vpn-net: + name: vpn-panel-net diff --git a/deploy/traefik/labels.example.yml b/deploy/traefik/labels.example.yml new file mode 100644 index 0000000..0333907 --- /dev/null +++ b/deploy/traefik/labels.example.yml @@ -0,0 +1,22 @@ +# Labels для сервиса app, если Traefik уже запущен (Dokploy / общий Traefik). +# Скопируйте в docker-compose.yml под services.app: +# +# labels: +# - traefik.enable=true +# - traefik.http.routers.vpn-panel.rule=Host(`panel.example.com`) +# - traefik.http.routers.vpn-panel.entrypoints=websecure +# - traefik.http.routers.vpn-panel.tls=true +# - traefik.http.routers.vpn-panel.tls.certresolver=letsencrypt +# - traefik.http.services.vpn-panel.loadbalancer.server.port=8080 +# +# Имя certresolver в Dokploy часто: letsencrypt + +services: + app: + labels: + - traefik.enable=true + - traefik.http.routers.vpn-panel.rule=Host(`${DOMAIN:-panel.example.com}`) + - traefik.http.routers.vpn-panel.entrypoints=websecure + - traefik.http.routers.vpn-panel.tls=true + - traefik.http.routers.vpn-panel.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-letsencrypt} + - traefik.http.services.vpn-panel.loadbalancer.server.port=8080 diff --git a/docker-compose.yml b/docker-compose.yml index a82c4ec..5bef1f9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,4 +41,5 @@ volumes: networks: vpn-net: + name: vpn-panel-net driver: bridge