diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..086d612 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +# Git +.git +.gitignore + +# Env and secrets +.env + +# Build artifacts +*.exe +shop +/vendor/ + +# Docs and dev +README.md +scripts/ diff --git a/.env.example b/.env.example index f729dc6..3a39781 100644 --- a/.env.example +++ b/.env.example @@ -10,5 +10,13 @@ ADMIN_PASSWORD=change_me # Секрет для сессий (обязательно смените в продакшене) SESSION_SECRET=your_random_secret_key_here -# Domain for Caddy SSL (production) -DOMAIN=shop.example.com +# Часовой пояс сервера (Ubuntu) +TZ=Europe/Moscow + +# --- Продакшен на Ubuntu с доменом --- +# DOMAIN=shop.example.com +# CADDY_EMAIL=admin@example.com +# CADDYFILE=./Caddyfile.prod + +# Для доступа по IP без домена оставьте DOMAIN закомментированным +# и используйте Caddyfile по умолчанию (HTTP на порту 80) diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f7e2def --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +*.sh text eol=lf diff --git a/Caddyfile b/Caddyfile index d2ff5cc..de3b3af 100644 --- a/Caddyfile +++ b/Caddyfile @@ -1,14 +1,5 @@ -{ - admin localhost:2019 -} - -# Локальная разработка — HTTP на порту 80 +# HTTP — доступ по IP или localhost (без домена) :80 { + encode gzip reverse_proxy app:8080 } - -# Продакшен — автоматический SSL от Let's Encrypt -# Раскомментируйте и закомментируйте блок :80 выше -# {$DOMAIN} { -# reverse_proxy app:8080 -# } diff --git a/Caddyfile.prod b/Caddyfile.prod new file mode 100644 index 0000000..03c7025 --- /dev/null +++ b/Caddyfile.prod @@ -0,0 +1,8 @@ +{ + email {$CADDY_EMAIL} +} + +{$DOMAIN} { + encode gzip + reverse_proxy app:8080 +} diff --git a/Dockerfile b/Dockerfile index 8365482..284439e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,8 @@ FROM alpine:3.21 RUN apk add --no-cache ca-certificates tzdata +ENV TZ=UTC + WORKDIR /app COPY --from=builder /shop /app/shop diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a60c02e --- /dev/null +++ b/Makefile @@ -0,0 +1,39 @@ +.PHONY: help install deploy up down logs restart ssl update + +help: + @echo "Shop — команды для Ubuntu" + @echo "" + @echo " make install — установить Docker (sudo)" + @echo " make deploy — первый запуск проекта" + @echo " make up — поднять контейнеры" + @echo " make down — остановить контейнеры" + @echo " make logs — логи всех сервисов" + @echo " make restart — перезапуск" + @echo " make ssl — включить SSL (DOMAIN=... EMAIL=...)" + @echo " make update — git pull + пересборка" + +install: + sudo ./scripts/install-ubuntu.sh + +deploy: + chmod +x scripts/*.sh + ./scripts/deploy.sh + +up: + docker compose up -d + +down: + docker compose down + +logs: + docker compose logs -f + +restart: + docker compose restart + +ssl: + @test -n "$(DOMAIN)" || (echo "Укажите DOMAIN=shop.example.com EMAIL=admin@example.com"; exit 1) + ./scripts/enable-ssl.sh "$(DOMAIN)" "$(EMAIL)" + +update: + ./scripts/update.sh diff --git a/README.md b/README.md index 6f84941..e08a84c 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Главная страница интернет-магазина на **Go**, **PostgreSQL 17**, **Docker Compose** и **Caddy** (обратный прокси + SSL). +Проект настроен для развёртывания на **Ubuntu 22.04 / 24.04**. + ## Стек | Компонент | Технология | @@ -9,20 +11,74 @@ | Backend | Go 1.23, `net/http`, `pgx/v5` | | База данных | PostgreSQL 17 | | Прокси / SSL | Caddy 2 | +| ОС | Ubuntu 22.04 / 24.04 | | Контейнеризация | Docker Compose | -## Быстрый старт +## Быстрый старт на Ubuntu ```bash -# Скопировать переменные окружения -cp .env.example .env +# Клонировать репозиторий +git clone https://git.evilfox.cc/test2/shop-go.git /opt/shop +cd /opt/shop -# Запустить все сервисы -docker compose up --build -d +# 1. Установить Docker (один раз, с sudo) +sudo ./scripts/install-ubuntu.sh -# Открыть в браузере -# http://localhost -# http://localhost/admin/login — админка +# Перелогиниться или: +newgrp docker + +# 2. Запустить магазин +chmod +x scripts/*.sh +./scripts/deploy.sh +``` + +Откройте в браузере: +- `http://IP_СЕРВЕРА` — магазин +- `http://IP_СЕРВЕРА/admin/login` — админка + +Или через Make: + +```bash +make install # sudo, установка Docker +make deploy # первый запуск +make logs # просмотр логов +``` + +## Продакшен с доменом и SSL + +1. Настройте DNS: A-запись домена → IP сервера Ubuntu +2. Откройте порты 80 и 443 (скрипт `install-ubuntu.sh` настраивает ufw) +3. Включите SSL: + +```bash +./scripts/enable-ssl.sh shop.example.com admin@example.com +``` + +Или: + +```bash +make ssl DOMAIN=shop.example.com EMAIL=admin@example.com +``` + +Caddy автоматически получит сертификат Let's Encrypt. + +Вручную через `.env`: + +```env +DOMAIN=shop.example.com +CADDY_EMAIL=admin@example.com +CADDYFILE=./Caddyfile.prod +``` + +Затем: `docker compose up -d --force-recreate caddy` + +## Автозапуск при загрузке Ubuntu + +```bash +sudo cp systemd/shop.service /etc/systemd/system/ +# Отредактируйте WorkingDirectory, если проект не в /opt/shop +sudo systemctl daemon-reload +sudo systemctl enable --now shop ``` ## Админ @@ -35,7 +91,9 @@ ADMIN_PASSWORD=change_me SESSION_SECRET=your_random_secret_key_here ``` -Вход: **http://localhost/admin/login** +Скрипт `deploy.sh` генерирует пароли автоматически при первом запуске. + +Вход: `/admin/login` При изменении `ADMIN_PASSWORD` пароль обновится в БД при следующем запуске. @@ -46,29 +104,26 @@ SESSION_SECRET=your_random_secret_key_here | Caddy | 80, 443 | Обратный прокси, SSL | | App | 8080 (внутри) | Go-приложение | | PostgreSQL | 5432 (внутри) | База данных | -| Caddy Admin | 2019 | Панель управления Caddy | -## SSL в продакшене - -1. Укажите домен в `.env`: - ``` - DOMAIN=shop.example.com - ``` - -2. Обновите `Caddyfile` — раскомментируйте блок с `{$DOMAIN}` и закомментируйте `:80`. - -3. Убедитесь, что порты 80 и 443 открыты. Caddy автоматически получит сертификат Let's Encrypt. - -## Локальная разработка без Docker +## Обновление на сервере ```bash -# Запустить только PostgreSQL +./scripts/update.sh +# или +make update +``` + +## Локальная разработка (Ubuntu) + +```bash +# Только PostgreSQL в Docker docker compose up db -d -# Установить зависимости и запустить приложение +export DB_HOST=localhost DB_USER=shop DB_PASSWORD=shop_secret DB_NAME=shop +export ADMIN_EMAIL=admin@shop.local ADMIN_PASSWORD=change_me + go mod tidy go run ./cmd/shop - # http://localhost:8080 ``` @@ -76,15 +131,19 @@ go run ./cmd/shop ``` shop/ -├── cmd/shop/main.go # Точка входа +├── cmd/shop/main.go ├── internal/ -│ ├── database/ # Подключение к PostgreSQL -│ ├── handlers/ # HTTP-обработчики, шаблоны, статика -│ ├── models/ # Модели данных -│ └── repository/ # Запросы к БД -├── migrations/ # SQL-миграции -├── Caddyfile # Конфигурация Caddy +├── migrations/ +├── scripts/ +│ ├── install-ubuntu.sh # установка Docker + ufw +│ ├── deploy.sh # первый запуск +│ ├── enable-ssl.sh # SSL для домена +│ └── update.sh # обновление +├── systemd/shop.service # автозапуск +├── Caddyfile # HTTP (IP / localhost) +├── Caddyfile.prod # HTTPS (домен) ├── docker-compose.yml +├── Makefile └── Dockerfile ``` @@ -108,3 +167,9 @@ docker compose down # С удалением данных БД docker compose down -v ``` + +## Требования к серверу Ubuntu + +- 1 CPU, 1 GB RAM (минимум) +- Ubuntu 22.04 или 24.04 LTS +- Открытые порты 22 (SSH), 80, 443 diff --git a/cmd/shop/main.go b/cmd/shop/main.go index 3e4902a..0bdb94a 100644 --- a/cmd/shop/main.go +++ b/cmd/shop/main.go @@ -13,6 +13,7 @@ import ( "shop/internal/config" "shop/internal/database" "shop/internal/handlers" + "shop/internal/middleware" "shop/internal/repository" ) @@ -63,7 +64,7 @@ func main() { addr := ":" + getEnv("APP_PORT", "8080") srv := &http.Server{ Addr: addr, - Handler: mux, + Handler: middleware.TrustProxy(mux), ReadTimeout: 10 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, diff --git a/docker-compose.yml b/docker-compose.yml index 9fad432..eecfd16 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ services: POSTGRES_USER: ${DB_USER:-shop} POSTGRES_PASSWORD: ${DB_PASSWORD:-shop_secret} POSTGRES_DB: ${DB_NAME:-shop} + TZ: ${TZ:-UTC} volumes: - pgdata:/var/lib/postgresql/data - ./migrations/001_init.sql:/docker-entrypoint-initdb.d/001_init.sql:ro @@ -25,21 +26,17 @@ services: dockerfile: Dockerfile container_name: shop-app restart: unless-stopped + env_file: .env environment: DB_HOST: db DB_PORT: "5432" - DB_USER: ${DB_USER:-shop} - DB_PASSWORD: ${DB_PASSWORD:-shop_secret} - DB_NAME: ${DB_NAME:-shop} APP_PORT: "8080" - ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@shop.local} - ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change_me} - SESSION_SECRET: ${SESSION_SECRET:-dev_session_secret} depends_on: db: condition: service_healthy networks: - shop-net + init: true caddy: image: caddy:2-alpine @@ -48,17 +45,19 @@ services: ports: - "80:80" - "443:443" - - "2019:2019" + env_file: .env environment: DOMAIN: ${DOMAIN:-localhost} + CADDY_EMAIL: ${CADDY_EMAIL:-} volumes: - - ./Caddyfile:/etc/caddy/Caddyfile:ro + - ${CADDYFILE:-./Caddyfile}:/etc/caddy/Caddyfile:ro - caddy_data:/data - caddy_config:/config depends_on: - app networks: - shop-net + init: true volumes: pgdata: diff --git a/internal/auth/session.go b/internal/auth/session.go index ad85287..c527a07 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -58,27 +58,36 @@ func (s *Session) Validate(token string) (string, bool) { return email, true } -func (s *Session) SetCookie(w http.ResponseWriter, token string) { +func (s *Session) SetCookie(w http.ResponseWriter, r *http.Request, token string) { http.SetCookie(w, &http.Cookie{ Name: cookieName, Value: token, Path: "/admin", HttpOnly: true, + Secure: s.isSecure(r), SameSite: http.SameSiteLaxMode, MaxAge: int(sessionTTL.Seconds()), }) } -func (s *Session) ClearCookie(w http.ResponseWriter) { +func (s *Session) ClearCookie(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ Name: cookieName, Value: "", Path: "/admin", HttpOnly: true, + Secure: s.isSecure(r), MaxAge: -1, }) } +func (s *Session) isSecure(r *http.Request) bool { + if r.TLS != nil { + return true + } + return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") +} + func (s *Session) FromRequest(r *http.Request) (string, bool) { c, err := r.Cookie(cookieName) if err != nil { diff --git a/internal/handlers/admin.go b/internal/handlers/admin.go index 259c7d9..ebc8bb6 100644 --- a/internal/handlers/admin.go +++ b/internal/handlers/admin.go @@ -81,12 +81,12 @@ func (h *AdminHandler) Login(w http.ResponseWriter, r *http.Request) { return } - h.session.SetCookie(w, token) + h.session.SetCookie(w, r, token) http.Redirect(w, r, "/admin/", http.StatusSeeOther) } func (h *AdminHandler) Logout(w http.ResponseWriter, r *http.Request) { - h.session.ClearCookie(w) + h.session.ClearCookie(w, r) http.Redirect(w, r, "/admin/login", http.StatusSeeOther) } diff --git a/internal/middleware/proxy.go b/internal/middleware/proxy.go new file mode 100644 index 0000000..8e7dc31 --- /dev/null +++ b/internal/middleware/proxy.go @@ -0,0 +1,19 @@ +package middleware + +import ( + "net/http" + "strings" +) + +// TrustProxy восстанавливает схему HTTPS за Caddy/nginx. +func TrustProxy(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if proto := r.Header.Get("X-Forwarded-Proto"); strings.EqualFold(proto, "https") { + r.URL.Scheme = "https" + } + if host := r.Header.Get("X-Forwarded-Host"); host != "" { + r.Host = host + } + next.ServeHTTP(w, r) + }) +} diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..72be33f --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${ROOT_DIR}" + +if ! command -v docker &>/dev/null; then + echo "Docker не найден. Сначала: sudo ./scripts/install-ubuntu.sh" + exit 1 +fi + +if [[ ! -f .env ]]; then + cp .env.example .env + SECRET=$(openssl rand -hex 32) + DB_PASS=$(openssl rand -hex 16) + sed -i "s/your_random_secret_key_here/${SECRET}/" .env + sed -i "s/DB_PASSWORD=shop_secret/DB_PASSWORD=${DB_PASS}/" .env + sed -i "s/ADMIN_PASSWORD=change_me/ADMIN_PASSWORD=$(openssl rand -base64 18 | tr -d '/+=')/" .env + echo "Создан .env с автоматически сгенерированными паролями." + echo "Проверьте ADMIN_EMAIL и DOMAIN перед продакшеном." +fi + +docker compose pull --ignore-buildable 2>/dev/null || true +docker compose up --build -d + +echo "" +echo "Сервисы запущены." +echo " Магазин: http://$(hostname -I | awk '{print $1}')" +echo " Админка: http://$(hostname -I | awk '{print $1}')/admin/login" +echo "" +echo "Логи: docker compose logs -f" diff --git a/scripts/enable-ssl.sh b/scripts/enable-ssl.sh new file mode 100644 index 0000000..9ba3fc7 --- /dev/null +++ b/scripts/enable-ssl.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Переключение Caddy на продакшен-режим с SSL (Ubuntu + домен) +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${ROOT_DIR}" + +if [[ ! -f .env ]]; then + echo "Сначала запустите: ./scripts/deploy.sh" + exit 1 +fi + +DOMAIN="${1:-}" +EMAIL="${2:-}" + +if [[ -z "${DOMAIN}" || -z "${EMAIL}" ]]; then + echo "Использование: ./scripts/enable-ssl.sh <домен> " + echo "Пример: ./scripts/enable-ssl.sh shop.example.com admin@example.com" + exit 1 +fi + +set_env() { + local key="$1" val="$2" + if grep -q "^${key}=" .env; then + sed -i "s|^${key}=.*|${key}=${val}|" .env + else + echo "${key}=${val}" >> .env + fi +} + +set_env DOMAIN "${DOMAIN}" +set_env CADDY_EMAIL "${EMAIL}" +set_env CADDYFILE "./Caddyfile.prod" + +docker compose up -d --force-recreate caddy + +echo "SSL включён для https://${DOMAIN}" +echo "Убедитесь, что DNS A-запись указывает на этот сервер." diff --git a/scripts/install-ubuntu.sh b/scripts/install-ubuntu.sh new file mode 100644 index 0000000..b22b14b --- /dev/null +++ b/scripts/install-ubuntu.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Установка Docker и Docker Compose на Ubuntu 22.04 / 24.04 + +if [[ "${EUID}" -ne 0 ]]; then + echo "Запустите с sudo: sudo ./scripts/install-ubuntu.sh" + exit 1 +fi + +if ! grep -qi ubuntu /etc/os-release 2>/dev/null; then + echo "Скрипт предназначен для Ubuntu." + exit 1 +fi + +export DEBIAN_FRONTEND=noninteractive + +apt-get update +apt-get install -y ca-certificates curl gnupg ufw + +# Docker official repo +install -m 0755 -d /etc/apt/keyrings +if [[ ! -f /etc/apt/keyrings/docker.gpg ]]; then + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg + chmod a+r /etc/apt/keyrings/docker.gpg +fi + +echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "${VERSION_CODENAME}") stable" \ + > /etc/apt/sources.list.d/docker.list + +apt-get update +apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + +systemctl enable --now docker + +# Firewall +ufw allow OpenSSH +ufw allow 80/tcp +ufw allow 443/tcp +ufw --force enable + +SUDO_USER_NAME="${SUDO_USER:-$USER}" +if id "${SUDO_USER_NAME}" &>/dev/null; then + usermod -aG docker "${SUDO_USER_NAME}" + echo "Пользователь ${SUDO_USER_NAME} добавлен в группу docker." + echo "Перелогиньтесь или выполните: newgrp docker" +fi + +echo "" +echo "Docker установлен: $(docker --version)" +echo "Compose: $(docker compose version)" diff --git a/scripts/update.sh b/scripts/update.sh new file mode 100644 index 0000000..7786916 --- /dev/null +++ b/scripts/update.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${ROOT_DIR}" + +git pull origin main +docker compose up --build -d +docker image prune -f + +echo "Обновление завершено." diff --git a/setup.sh b/setup.sh new file mode 100644 index 0000000..8de3fa7 --- /dev/null +++ b/setup.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Быстрая установка на Ubuntu — запуск: sudo bash setup.sh +set -euo pipefail + +cd "$(dirname "$0")" + +if [[ "${EUID}" -ne 0 ]]; then + echo "Запустите: sudo bash setup.sh" + exit 1 +fi + +bash scripts/install-ubuntu.sh + +REAL_USER="${SUDO_USER:-ubuntu}" +if id "${REAL_USER}" &>/dev/null; then + sudo -u "${REAL_USER}" bash scripts/deploy.sh +else + bash scripts/deploy.sh +fi + +echo "" +echo "Готово! Откройте http://$(hostname -I | awk '{print $1}')" diff --git a/systemd/shop.service b/systemd/shop.service new file mode 100644 index 0000000..8d69fe0 --- /dev/null +++ b/systemd/shop.service @@ -0,0 +1,16 @@ +[Unit] +Description=Shop internet store (Docker Compose) +Requires=docker.service +After=docker.service network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=/opt/shop +ExecStart=/usr/bin/docker compose up -d --remove-orphans +ExecStop=/usr/bin/docker compose down +TimeoutStartSec=300 + +[Install] +WantedBy=multi-user.target