From 3c2f5226d1d7bad09ba9cf6ebd7b148812afe432 Mon Sep 17 00:00:00 2001 From: vpn-panel Date: Thu, 21 May 2026 18:55:14 +0300 Subject: [PATCH] Initial commit: VPN panel on Go, PostgreSQL 17, Docker, Xray-core --- .env.example | 13 + .gitignore | 6 + DEPLOY.md | 255 +++++++++++++++++++ Dockerfile | 22 ++ README.md | 75 ++++++ cmd/install/main.go | 185 ++++++++++++++ cmd/panel/main.go | 83 ++++++ docker-compose.yml | 36 +++ go.mod | 20 ++ go.sum | 36 +++ internal/auth/password.go | 12 + internal/config/config.go | 40 +++ internal/database/database.go | 45 ++++ internal/database/migrations/001_init.sql | 16 ++ internal/handlers/auth.go | 138 ++++++++++ internal/handlers/flash.go | 40 +++ internal/handlers/handlers.go | 101 ++++++++ internal/handlers/home.go | 24 ++ internal/models/user.go | 15 ++ internal/session/session.go | 64 +++++ internal/store/user.go | 87 +++++++ web/embed.go | 9 + web/static/css/style.css | 293 ++++++++++++++++++++++ web/templates/index.html | 71 ++++++ web/templates/layout.html | 43 ++++ web/templates/login.html | 19 ++ web/templates/register.html | 30 +++ 27 files changed, 1778 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 DEPLOY.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 cmd/install/main.go create mode 100644 cmd/panel/main.go create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/auth/password.go create mode 100644 internal/config/config.go create mode 100644 internal/database/database.go create mode 100644 internal/database/migrations/001_init.sql create mode 100644 internal/handlers/auth.go create mode 100644 internal/handlers/flash.go create mode 100644 internal/handlers/handlers.go create mode 100644 internal/handlers/home.go create mode 100644 internal/models/user.go create mode 100644 internal/session/session.go create mode 100644 internal/store/user.go create mode 100644 web/embed.go create mode 100644 web/static/css/style.css create mode 100644 web/templates/index.html create mode 100644 web/templates/layout.html create mode 100644 web/templates/login.html create mode 100644 web/templates/register.html diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f1213fe --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Скопируйте в .env или запустите: go run ./cmd/install + +APP_PORT=8080 +APP_DOMAIN=localhost +DATABASE_URL=postgres://vpnpanel:changeme@postgres:5432/vpnpanel?sslmode=disable +SECRET_KEY=change-me-to-random-32-bytes-base64 +INSTALLED=false + +POSTGRES_USER=vpnpanel +POSTGRES_PASSWORD=changeme +POSTGRES_DB=vpnpanel +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..df7aebd --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +*.exe +/panel +/install +/vendor/ +go.sum.bak diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..33505e2 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,255 @@ +# Инструкция по развёртыванию VPN Panel + +Репозиторий: **https://git.evilfox.cc/test/vpn-panel.git** + +--- + +## 1. Подготовка сервера + +Рекомендуется VPS с Ubuntu 22.04/24.04 или Debian 12. + +```bash +# Обновление системы +sudo apt update && sudo apt upgrade -y + +# Docker +curl -fsSL https://get.docker.com | sudo sh +sudo usermod -aG docker $USER +# Перелогиньтесь или: newgrp docker + +# Git и Go (для установщика) +sudo apt install -y git golang-go +``` + +Проверка: + +```bash +docker --version +docker compose version +``` + +--- + +## 2. Клонирование проекта + +```bash +cd /opt +sudo git clone https://git.evilfox.cc/test/vpn-panel.git +sudo chown -R $USER:$USER vpn-panel +cd vpn-panel +``` + +Если репозиторий приватный — используйте токен или SSH: + +```bash +# HTTPS с токеном Gitea +git clone https://USER:TOKEN@git.evilfox.cc/test/vpn-panel.git + +# или SSH +git clone git@git.evilfox.cc:test/vpn-panel.git +``` + +--- + +## 3. Запуск PostgreSQL + +```bash +docker compose up -d postgres +docker compose ps +``` + +Дождитесь статуса `healthy` у контейнера `vpn-panel-db`. + +По умолчанию (до `.env`): + +| Параметр | Значение | +|----------|------------| +| Хост | `postgres` (внутри Docker) / `127.0.0.1` (с хоста) | +| Порт | `5432` | +| БД | `vpnpanel` | +| Пользователь | `vpnpanel` | +| Пароль | `changeme` (смените в установщике) | + +--- + +## 4. Установщик (первичная настройка) + +Интерактивно задаёт домен, БД и учётку администратора. + +### Вариант A — Go на сервере + +```bash +go run ./cmd/install +``` + +### Вариант B — сборка установщика + +```bash +go build -o install ./cmd/install +./install +``` + +### Вариант C — установщик в Docker + +```bash +docker compose build +docker compose run --rm panel /app/install +``` + +При запросах укажите: + +| Поле | Пример | Примечание | +|------|--------|------------| +| Домен панели | `panel.example.com` | Для ссылок и nginx | +| Порт приложения | `8080` | Внешний порт в compose | +| Хост БД | `postgres` | Имя сервиса в docker-compose | +| Порт БД | `5432` | | +| Пользователь БД | `vpnpanel` | | +| Пароль БД | *свой надёжный* | | +| Имя БД | `vpnpanel` | | +| Email админа | `admin@example.com` | **единственный** админ | +| Пароль админа | *мин. 8 символов* | | + +Будет создан файл `.env` (права `600`). **Не коммитьте `.env` в git.** + +--- + +## 5. Запуск панели + +```bash +docker compose up -d --build +docker compose logs -f panel +``` + +Проверка: + +```bash +curl http://127.0.0.1:8080/health +# {"status":"ok","core":"xray"} +``` + +В браузере: `http://IP_СЕРВЕРА:8080` + +--- + +## 6. HTTPS (Nginx + Let's Encrypt) + +Пример для домена `panel.example.com`: + +```bash +sudo apt install -y nginx certbot python3-certbot-nginx +``` + +`/etc/nginx/sites-available/vpn-panel`: + +```nginx +server { + listen 80; + server_name panel.example.com; + + location / { + proxy_pass http://127.0.0.1:8080; + 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; + } +} +``` + +```bash +sudo ln -s /etc/nginx/sites-available/vpn-panel /etc/nginx/sites-enabled/ +sudo nginx -t && sudo systemctl reload nginx +sudo certbot --nginx -d panel.example.com +``` + +В `.env` обновите `APP_DOMAIN=panel.example.com`. + +--- + +## 7. Файрвол + +```bash +sudo ufw allow OpenSSH +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp +# Если без nginx — открыть порт панели: +# sudo ufw allow 8080/tcp +sudo ufw enable +``` + +--- + +## 8. Регистрация администратора + +- Если установщик уже создал админа — войдите на `/login`. +- Если нет — один раз откройте `/register` (пока админа нет в БД). + +После первого админа `/register` закрывается. + +--- + +## 9. Обновление + +```bash +cd /opt/vpn-panel +git pull +docker compose up -d --build +``` + +--- + +## 10. Резервное копирование БД + +```bash +docker exec vpn-panel-db pg_dump -U vpnpanel vpnpanel > backup_$(date +%F).sql +``` + +Восстановление: + +```bash +cat backup.sql | docker exec -i vpn-panel-db psql -U vpnpanel vpnpanel +``` + +--- + +## Устранение неполадок + +| Проблема | Решение | +|----------|---------| +| `DATABASE_URL не задан` | Запустите `./install` или создайте `.env` из `.env.example` | +| Нет подключения к БД | `docker compose ps`, проверьте `healthy` у postgres | +| Порт занят | Смените `APP_PORT` в `.env` и в `docker-compose.yml` | +| Регистрация закрыта | Админ уже есть — используйте `/login` | + +Логи: + +```bash +docker compose logs panel +docker compose logs postgres +``` + +--- + +## Переменные `.env` (справочник) + +```env +APP_PORT=8080 +APP_DOMAIN=panel.example.com +DATABASE_URL=postgres://vpnpanel:PASSWORD@postgres:5432/vpnpanel?sslmode=disable +SECRET_KEY=<случайная строка, генерируется установщиком> +INSTALLED=true + +POSTGRES_USER=vpnpanel +POSTGRES_PASSWORD=PASSWORD +POSTGRES_DB=vpnpanel +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +``` + +--- + +## Связь с Xray-core + +Панель подготовлена под ядро [Xray-core](https://github.com/XTLS/Xray-core). Установка и конфигурация Xray на нодах — отдельный этап (будет добавлен в следующих версиях). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0387498 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.22-alpine AS builder + +WORKDIR /app +RUN apk add --no-cache git + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /panel ./cmd/panel +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /install ./cmd/install + +FROM alpine:3.20 + +RUN apk add --no-cache ca-certificates tzdata +WORKDIR /app + +COPY --from=builder /panel /app/panel +COPY --from=builder /install /app/install + +EXPOSE 8080 +CMD ["/app/panel"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f36500 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# VPN Panel + +Панель управления VPN на базе [Xray-core](https://github.com/XTLS/Xray-core). + +**Репозиторий:** https://git.evilfox.cc/test/vpn-panel.git + +**Стек:** Go · Docker · PostgreSQL 17 + +## Возможности + +- Главная страница с обзором панели +- Регистрация **одного** администратора (при первом запуске) +- Интерактивный установщик (БД, домен, админ) +- Готовый `docker-compose` с PostgreSQL 17 + +## Требования + +- Linux-сервер (Ubuntu 22.04+ / Debian 12+ рекомендуется) +- Docker 24+ и Docker Compose v2 +- Домен (опционально, для HTTPS через reverse proxy) +- Git + +## Развёртывание на сервере + +Полная инструкция: **[DEPLOY.md](DEPLOY.md)** + +### Кратко + +```bash +git clone https://git.evilfox.cc/test/vpn-panel.git +cd vpn-panel + +docker compose up -d postgres +go run ./cmd/install # или ./install после сборки + +docker compose up -d --build +``` + +Панель: `http://ВАШ_ДОМЕН:8080` + +## Локальная разработка + +```bash +docker compose up -d postgres +go run ./cmd/install +go run ./cmd/panel +``` + +## Структура + +``` +cmd/panel/ — веб-сервер панели +cmd/install/ — CLI установщик +internal/ — конфиг, БД, handlers, auth +web/ — HTML шаблоны и CSS +docker-compose.yml +``` + +## Переменные окружения + +| Переменная | Описание | +|----------------|-----------------------------------| +| `APP_PORT` | Порт HTTP (в Docker: 8080) | +| `APP_DOMAIN` | Домен панели | +| `DATABASE_URL` | Строка подключения PostgreSQL | +| `SECRET_KEY` | Ключ подписи сессий | +| `INSTALLED` | `true` после установки | + +## Xray-core + +Ядро прокси — [XTLS/Xray-core](https://github.com/XTLS/Xray-core). Интеграция управления нодами и конфигами — следующий этап. + +## Лицензия + +MIT diff --git a/cmd/install/main.go b/cmd/install/main.go new file mode 100644 index 0000000..9c89930 --- /dev/null +++ b/cmd/install/main.go @@ -0,0 +1,185 @@ +package main + +import ( + "bufio" + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "strings" + "syscall" + + "golang.org/x/term" + "vpn-panel/internal/auth" + "vpn-panel/internal/database" + "vpn-panel/internal/store" +) + +func main() { + fmt.Println() + fmt.Println(" ╔══════════════════════════════════════╗") + fmt.Println(" ║ VPN Panel — Установщик ║") + fmt.Println(" ║ Ядро: Xray-core ║") + fmt.Println(" ╚══════════════════════════════════════╝") + fmt.Println() + + reader := bufio.NewReader(os.Stdin) + cwd, _ := os.Getwd() + envPath := filepath.Join(cwd, ".env") + + if fileExists(envPath) { + fmt.Print("Файл .env уже существует. Перезаписать? [y/N]: ") + ans, _ := reader.ReadString('\n') + if strings.TrimSpace(strings.ToLower(ans)) != "y" { + fmt.Println("Установка отменена.") + return + } + } + + domain := prompt(reader, "Домен панели (например panel.example.com)", "localhost") + appPort := prompt(reader, "Порт приложения", "8080") + + fmt.Println("\n--- PostgreSQL 17 ---") + dbHost := prompt(reader, "Хост БД", "postgres") + dbPort := prompt(reader, "Порт БД", "5432") + dbUser := prompt(reader, "Пользователь БД", "vpnpanel") + dbPass := promptSecret("Пароль БД") + dbName := prompt(reader, "Имя базы данных", "vpnpanel") + + fmt.Println("\n--- Администратор (единственный) ---") + adminEmail := prompt(reader, "Email администратора", "admin@localhost") + adminPass := promptSecret("Пароль администратора (мин. 8 символов)") + for len(adminPass) < 8 { + fmt.Println("Пароль слишком короткий.") + adminPass = promptSecret("Пароль администратора") + } + adminPass2 := promptSecret("Подтвердите пароль") + for adminPass != adminPass2 { + fmt.Println("Пароли не совпадают.") + adminPass = promptSecret("Пароль администратора") + adminPass2 = promptSecret("Подтвердите пароль") + } + + secretKey, err := generateSecret() + if err != nil { + fmt.Fprintf(os.Stderr, "ошибка генерации SECRET_KEY: %v\n", err) + os.Exit(1) + } + + databaseURL := fmt.Sprintf( + "postgres://%s:%s@%s:%s/%s?sslmode=disable", + dbUser, urlEncode(dbPass), dbHost, dbPort, dbName, + ) + + fmt.Println("\n--- Проверка подключения к БД ---") + ctx := context.Background() + pool, err := database.Connect(ctx, databaseURL) + if err != nil { + fmt.Fprintf(os.Stderr, "не удалось подключиться: %v\n", err) + fmt.Println("Запустите PostgreSQL (docker compose up -d postgres) и повторите установку.") + os.Exit(1) + } + defer pool.Close() + + if err := database.Migrate(ctx, pool); err != nil { + fmt.Fprintf(os.Stderr, "миграции: %v\n", err) + os.Exit(1) + } + + hash, err := auth.HashPassword(adminPass) + if err != nil { + fmt.Fprintf(os.Stderr, "хеш пароля: %v\n", err) + os.Exit(1) + } + + users := store.NewUserStore(pool) + has, _ := users.HasAdmin(ctx) + if has { + fmt.Println("Администратор уже есть в БД — пропускаем создание.") + } else { + u, err := users.CreateAdmin(ctx, strings.ToLower(strings.TrimSpace(adminEmail)), hash) + if err != nil { + fmt.Fprintf(os.Stderr, "создание админа: %v\n", err) + os.Exit(1) + } + fmt.Printf("Администратор создан: %s (%s)\n", u.Email, u.ID) + } + + envContent := fmt.Sprintf(`# Сгенерировано установщиком VPN Panel +APP_PORT=%s +APP_DOMAIN=%s +DATABASE_URL=%s +SECRET_KEY=%s +INSTALLED=true + +# PostgreSQL (для docker-compose) +POSTGRES_USER=%s +POSTGRES_PASSWORD=%s +POSTGRES_DB=%s +POSTGRES_HOST=%s +POSTGRES_PORT=%s +`, + appPort, domain, databaseURL, secretKey, + dbUser, dbPass, dbName, dbHost, dbPort, + ) + + if err := os.WriteFile(envPath, []byte(envContent), 0600); err != nil { + fmt.Fprintf(os.Stderr, "запись .env: %v\n", err) + os.Exit(1) + } + + fmt.Println() + fmt.Println(" ✓ Установка завершена") + fmt.Println(" ✓ Файл .env создан") + fmt.Println() + fmt.Println(" Дальше:") + fmt.Println(" docker compose up -d") + fmt.Println(" или: go run ./cmd/panel") + fmt.Println() + fmt.Printf(" Панель: http://%s:%s\n", domain, appPort) + fmt.Println() +} + +func prompt(reader *bufio.Reader, label, defaultVal string) string { + if defaultVal != "" { + fmt.Printf("%s [%s]: ", label, defaultVal) + } else { + fmt.Printf("%s: ", label) + } + line, _ := reader.ReadString('\n') + line = strings.TrimSpace(line) + if line == "" { + return defaultVal + } + return line +} + +func promptSecret(label string) string { + fmt.Printf("%s: ", label) + b, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Println() + if err != nil { + return "" + } + return string(b) +} + +func generateSecret() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(b), nil +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func urlEncode(s string) string { + r := strings.NewReplacer(":", "%3A", "@", "%40", "/", "%2F") + return r.Replace(s) +} diff --git a/cmd/panel/main.go b/cmd/panel/main.go new file mode 100644 index 0000000..7f7ab68 --- /dev/null +++ b/cmd/panel/main.go @@ -0,0 +1,83 @@ +package main + +import ( + "context" + "fmt" + "io/fs" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/joho/godotenv" + "vpn-panel/internal/config" + "vpn-panel/internal/database" + "vpn-panel/internal/handlers" + "vpn-panel/web" +) + +func main() { + _ = godotenv.Load() + + cfg, err := config.Load() + if err != nil { + log.Fatalf("конфигурация: %v", err) + } + + ctx := context.Background() + pool, err := database.Connect(ctx, cfg.DatabaseURL) + if err != nil { + log.Fatalf("база данных: %v", err) + } + defer pool.Close() + + if err := database.Migrate(ctx, pool); err != nil { + log.Fatalf("миграции: %v", err) + } + + h, err := handlers.New(cfg, pool) + if err != nil { + log.Fatalf("handlers: %v", err) + } + + mux := http.NewServeMux() + mux.HandleFunc("GET /", h.Home) + mux.HandleFunc("GET /health", h.Health) + mux.HandleFunc("GET /register", h.RegisterAdmin) + mux.HandleFunc("POST /register", h.RegisterAdmin) + mux.HandleFunc("GET /login", h.Login) + mux.HandleFunc("POST /login", h.Login) + mux.HandleFunc("GET /logout", h.Logout) + + staticFS, err := fs.Sub(web.Static, "static") + if err != nil { + log.Fatal(err) + } + mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) + + addr := fmt.Sprintf(":%d", cfg.AppPort) + srv := &http.Server{ + Addr: addr, + Handler: mux, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + } + + go func() { + log.Printf("VPN Panel запущена на http://%s%s", cfg.AppDomain, addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatal(err) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + log.Println("остановлена") +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..846584c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +services: + postgres: + image: postgres:17-alpine + container_name: vpn-panel-db + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-vpnpanel} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme} + POSTGRES_DB: ${POSTGRES_DB:-vpnpanel} + volumes: + - pgdata:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-vpnpanel} -d ${POSTGRES_DB:-vpnpanel}"] + interval: 5s + timeout: 5s + retries: 10 + + panel: + build: . + container_name: vpn-panel-app + restart: unless-stopped + env_file: + - .env + ports: + - "${APP_PORT:-8080}:8080" + environment: + APP_PORT: "8080" + depends_on: + postgres: + condition: service_healthy + command: ["/app/panel"] + +volumes: + pgdata: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8c02ecb --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module vpn-panel + +go 1.22 + +require ( + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.7.2 + github.com/joho/godotenv v1.5.1 + golang.org/x/crypto v0.31.0 + golang.org/x/term v0.27.0 +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/text v0.21.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d76a023 --- /dev/null +++ b/go.sum @@ -0,0 +1,36 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..562b071 --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,12 @@ +package auth + +import "golang.org/x/crypto/bcrypt" + +func HashPassword(password string) (string, error) { + b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(b), err +} + +func CheckPassword(hash, password string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..908a8b2 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,40 @@ +package config + +import ( + "fmt" + "os" + "strconv" +) + +type Config struct { + AppPort int + AppDomain string + DatabaseURL string + SecretKey string + Installed bool +} + +func Load() (*Config, error) { + port, _ := strconv.Atoi(getEnv("APP_PORT", "8080")) + cfg := &Config{ + AppPort: port, + AppDomain: getEnv("APP_DOMAIN", "localhost"), + DatabaseURL: os.Getenv("DATABASE_URL"), + SecretKey: getEnv("SECRET_KEY", ""), + Installed: getEnv("INSTALLED", "false") == "true", + } + if cfg.DatabaseURL == "" { + return nil, fmt.Errorf("DATABASE_URL не задан") + } + if cfg.SecretKey == "" { + return nil, fmt.Errorf("SECRET_KEY не задан") + } + return cfg, nil +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..27f387f --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,45 @@ +package database + +import ( + "context" + "embed" + "fmt" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" +) + +//go:embed migrations/*.sql +var migrationFS embed.FS + +func Connect(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) { + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + return nil, fmt.Errorf("подключение к PostgreSQL: %w", err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("ping PostgreSQL: %w", err) + } + return pool, nil +} + +func Migrate(ctx context.Context, pool *pgxpool.Pool) error { + entries, err := migrationFS.ReadDir("migrations") + if err != nil { + return err + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { + continue + } + data, err := migrationFS.ReadFile("migrations/" + e.Name()) + if err != nil { + return err + } + if _, err := pool.Exec(ctx, string(data)); err != nil { + return fmt.Errorf("миграция %s: %w", e.Name(), err) + } + } + return nil +} diff --git a/internal/database/migrations/001_init.sql b/internal/database/migrations/001_init.sql new file mode 100644 index 0000000..1630074 --- /dev/null +++ b/internal/database/migrations/001_init.sql @@ -0,0 +1,16 @@ +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go new file mode 100644 index 0000000..6fc5e41 --- /dev/null +++ b/internal/handlers/auth.go @@ -0,0 +1,138 @@ +package handlers + +import ( + "net/http" + "strings" + + "vpn-panel/internal/auth" + "vpn-panel/internal/session" + "vpn-panel/internal/store" +) + +func (h *Handler) currentUser(r *http.Request) *session.Data { + c, err := r.Cookie(session.CookieName()) + if err != nil { + return nil + } + d, err := session.Verify(h.secret, c.Value) + if err != nil { + return nil + } + return d +} + +func (h *Handler) RegisterAdmin(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + has, err := h.users.HasAdmin(ctx) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if has { + flashSet(w, "Администратор уже создан. Регистрация закрыта.", "error") + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + + if r.Method == http.MethodGet { + h.render(w, "register", h.pageData(w, r, "Регистрация администратора", nil)) + return + } + + email := strings.TrimSpace(strings.ToLower(r.FormValue("email"))) + password := r.FormValue("password") + confirm := r.FormValue("password_confirm") + + if email == "" || len(password) < 8 { + flashSet(w, "Email обязателен, пароль — минимум 8 символов.", "error") + http.Redirect(w, r, "/register", http.StatusSeeOther) + return + } + if password != confirm { + flashSet(w, "Пароли не совпадают.", "error") + http.Redirect(w, r, "/register", http.StatusSeeOther) + return + } + + hash, err := auth.HashPassword(password) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + u, err := h.users.CreateAdmin(ctx, email, hash) + if err == store.ErrAdminExists { + flashSet(w, "Администратор уже существует.", "error") + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + token, err := session.Sign(h.secret, session.Data{ + UserID: u.ID, + Email: u.Email, + Role: u.Role, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + http.SetCookie(w, &http.Cookie{ + Name: session.CookieName(), + Value: token, + Path: "/", + MaxAge: int((7 * 24 * 60 * 60)), + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: r.TLS != nil, + }) + flashSet(w, "Администратор успешно создан. Добро пожаловать!", "success") + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + h.render(w, "login", h.pageData(w, r, "Вход", nil)) + return + } + + email := strings.TrimSpace(strings.ToLower(r.FormValue("email"))) + password := r.FormValue("password") + + u, err := h.users.GetByEmail(r.Context(), email) + if err != nil || u == nil || !auth.CheckPassword(u.PasswordHash, password) { + flashSet(w, "Неверный email или пароль.", "error") + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + token, err := session.Sign(h.secret, session.Data{ + UserID: u.ID, + Email: u.Email, + Role: u.Role, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + http.SetCookie(w, &http.Cookie{ + Name: session.CookieName(), + Value: token, + Path: "/", + MaxAge: 7 * 24 * 60 * 60, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: r.TLS != nil, + }) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: session.CookieName(), Path: "/", MaxAge: -1, + }) + http.Redirect(w, r, "/", http.StatusSeeOther) +} diff --git a/internal/handlers/flash.go b/internal/handlers/flash.go new file mode 100644 index 0000000..abad87c --- /dev/null +++ b/internal/handlers/flash.go @@ -0,0 +1,40 @@ +package handlers + +import ( + "net/http" +) + +const flashCookie = "vpn_panel_flash" + +func flashSet(w http.ResponseWriter, msg, level string) { + http.SetCookie(w, &http.Cookie{ + Name: flashCookie, + Value: level + ":" + msg, + Path: "/", + MaxAge: 30, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) +} + +func flashGet(w http.ResponseWriter, r *http.Request) map[string]string { + c, err := r.Cookie(flashCookie) + if err != nil { + return nil + } + http.SetCookie(w, &http.Cookie{Name: flashCookie, Path: "/", MaxAge: -1}) + parts := splitFlash(c.Value) + if len(parts) != 2 { + return nil + } + return map[string]string{"Level": parts[0], "Message": parts[1]} +} + +func splitFlash(v string) []string { + for i := 0; i < len(v); i++ { + if v[i] == ':' { + return []string{v[:i], v[i+1:]} + } + } + return nil +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go new file mode 100644 index 0000000..1492991 --- /dev/null +++ b/internal/handlers/handlers.go @@ -0,0 +1,101 @@ +package handlers + +import ( + "html/template" + "io/fs" + "net/http" + "path/filepath" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" + "vpn-panel/internal/config" + "vpn-panel/internal/store" + "vpn-panel/web" +) + +type Handler struct { + cfg *config.Config + pool *pgxpool.Pool + users *store.UserStore + tmpl map[string]*template.Template + secret string + domain string +} + +func New(cfg *config.Config, pool *pgxpool.Pool) (*Handler, error) { + tmpl, err := loadTemplates() + if err != nil { + return nil, err + } + return &Handler{ + cfg: cfg, + pool: pool, + users: store.NewUserStore(pool), + tmpl: tmpl, + secret: cfg.SecretKey, + domain: cfg.AppDomain, + }, nil +} + +func loadTemplates() (map[string]*template.Template, error) { + funcs := template.FuncMap{ + "eq": func(a, b any) bool { return a == b }, + } + out := make(map[string]*template.Template) + + err := fs.WalkDir(web.Templates, "templates", func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || !strings.HasSuffix(path, ".html") { + return err + } + name := strings.TrimSuffix(filepath.Base(path), ".html") + content, err := web.Templates.ReadFile(path) + if err != nil { + return err + } + base, err := web.Templates.ReadFile("templates/layout.html") + if err != nil { + return err + } + t, err := template.New("layout").Funcs(funcs).Parse(string(base)) + if err != nil { + return err + } + if _, err := t.Parse(string(content)); err != nil { + return err + } + out[name] = t + return nil + }) + return out, err +} + +func (h *Handler) render(w http.ResponseWriter, name string, data any) { + t := h.tmpl[name] + if t == nil { + http.Error(w, "template not found", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := t.ExecuteTemplate(w, "layout", data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func (h *Handler) pageData(w http.ResponseWriter, r *http.Request, title string, extra map[string]any) map[string]any { + hasAdmin, _ := h.users.HasAdmin(r.Context()) + data := map[string]any{ + "Title": title, + "Domain": h.domain, + "Year": "2026", + "Flash": flashGet(w, r), + "CanRegister": !hasAdmin, + "HasAdmin": hasAdmin, + } + if sess := h.currentUser(r); sess != nil { + data["User"] = sess + } + for k, v := range extra { + data[k] = v + } + return data +} diff --git a/internal/handlers/home.go b/internal/handlers/home.go new file mode 100644 index 0000000..e71a4be --- /dev/null +++ b/internal/handlers/home.go @@ -0,0 +1,24 @@ +package handlers + +import ( + "net/http" +) + +func (h *Handler) Home(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + hasAdmin, _ := h.users.HasAdmin(ctx) + userCount, _ := h.users.CountUsers(ctx) + + h.render(w, "index", h.pageData(w, r, "VPN Панель — Xray", map[string]any{ + "HasAdmin": hasAdmin, + "CanRegister": !hasAdmin, + "UserCount": userCount, + "XrayVersion": "Xray-core", + "Installed": h.cfg.Installed, + })) +} + +func (h *Handler) Health(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"ok","core":"xray"}`)) +} diff --git a/internal/models/user.go b/internal/models/user.go new file mode 100644 index 0000000..31d919f --- /dev/null +++ b/internal/models/user.go @@ -0,0 +1,15 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type User struct { + ID uuid.UUID + Email string + PasswordHash string + Role string + CreatedAt time.Time +} diff --git a/internal/session/session.go b/internal/session/session.go new file mode 100644 index 0000000..ecda39c --- /dev/null +++ b/internal/session/session.go @@ -0,0 +1,64 @@ +package session + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/google/uuid" +) + +const cookieName = "vpn_panel_session" +const maxAge = 7 * 24 * time.Hour + +type Data struct { + UserID uuid.UUID `json:"uid"` + Email string `json:"email"` + Role string `json:"role"` + Exp int64 `json:"exp"` +} + +func CookieName() string { return cookieName } + +func Sign(secret string, d Data) (string, error) { + d.Exp = time.Now().Add(maxAge).Unix() + payload, err := json.Marshal(d) + if err != nil { + return "", err + } + b64 := base64.RawURLEncoding.EncodeToString(payload) + sig := sign(secret, b64) + return b64 + "." + sig, nil +} + +func Verify(secret, token string) (*Data, error) { + parts := strings.Split(token, ".") + if len(parts) != 2 { + return nil, errors.New("invalid token") + } + if sign(secret, parts[0]) != parts[1] { + return nil, errors.New("bad signature") + } + raw, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return nil, err + } + var d Data + if err := json.Unmarshal(raw, &d); err != nil { + return nil, err + } + if time.Now().Unix() > d.Exp { + return nil, errors.New("expired") + } + return &d, nil +} + +func sign(secret, payload string) string { + m := hmac.New(sha256.New, []byte(secret)) + m.Write([]byte(payload)) + return base64.RawURLEncoding.EncodeToString(m.Sum(nil)) +} diff --git a/internal/store/user.go b/internal/store/user.go new file mode 100644 index 0000000..d922275 --- /dev/null +++ b/internal/store/user.go @@ -0,0 +1,87 @@ +package store + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "vpn-panel/internal/models" +) + +var ErrAdminExists = errors.New("администратор уже зарегистрирован") + +type UserStore struct { + pool *pgxpool.Pool +} + +func NewUserStore(pool *pgxpool.Pool) *UserStore { + return &UserStore{pool: pool} +} + +func (s *UserStore) HasAdmin(ctx context.Context) (bool, error) { + var n int + err := s.pool.QueryRow(ctx, + `SELECT COUNT(*) FROM users WHERE role = 'admin'`, + ).Scan(&n) + return n > 0, err +} + +func (s *UserStore) CreateAdmin(ctx context.Context, email, passwordHash string) (*models.User, error) { + has, err := s.HasAdmin(ctx) + if err != nil { + return nil, err + } + if has { + return nil, ErrAdminExists + } + + var u models.User + err = s.pool.QueryRow(ctx, ` + INSERT INTO users (email, password_hash, role) + VALUES ($1, $2, 'admin') + RETURNING id, email, password_hash, role, created_at + `, email, passwordHash).Scan( + &u.ID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt, + ) + if err != nil { + return nil, err + } + return &u, nil +} + +func (s *UserStore) GetByEmail(ctx context.Context, email string) (*models.User, error) { + var u models.User + err := s.pool.QueryRow(ctx, ` + SELECT id, email, password_hash, role, created_at + FROM users WHERE email = $1 + `, email).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &u, nil +} + +func (s *UserStore) CountUsers(ctx context.Context) (int, error) { + var n int + err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&n) + return n, err +} + +func (s *UserStore) GetAdminID(ctx context.Context) (uuid.UUID, bool, error) { + var id uuid.UUID + err := s.pool.QueryRow(ctx, + `SELECT id FROM users WHERE role = 'admin' LIMIT 1`, + ).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, false, nil + } + if err != nil { + return uuid.Nil, false, err + } + return id, true, nil +} diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..015a2c0 --- /dev/null +++ b/web/embed.go @@ -0,0 +1,9 @@ +package web + +import "embed" + +//go:embed static/* +var Static embed.FS + +//go:embed templates/* +var Templates embed.FS diff --git a/web/static/css/style.css b/web/static/css/style.css new file mode 100644 index 0000000..019225c --- /dev/null +++ b/web/static/css/style.css @@ -0,0 +1,293 @@ +:root { + --bg: #0a0e14; + --surface: #121820; + --border: #1e2a3a; + --text: #e6edf5; + --muted: #8b9cb3; + --accent: #3d9eff; + --accent2: #7c5cff; + --success: #3dd68c; + --error: #f07178; + --radius: 12px; + --font: "Outfit", system-ui, sans-serif; + --mono: "JetBrains Mono", monospace; +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: var(--font); + background: var(--bg); + color: var(--text); + min-height: 100vh; + line-height: 1.6; +} + +.bg-grid { + position: fixed; + inset: 0; + background-image: + linear-gradient(rgba(61, 158, 255, 0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(61, 158, 255, 0.03) 1px, transparent 1px); + background-size: 48px 48px; + pointer-events: none; + z-index: 0; +} + +.header { + position: relative; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 2rem; + border-bottom: 1px solid var(--border); + background: rgba(10, 14, 20, 0.85); + backdrop-filter: blur(12px); +} + +.logo { + display: flex; + align-items: center; + gap: 0.5rem; + text-decoration: none; + color: var(--text); + font-weight: 700; + font-size: 1.15rem; +} + +.logo-icon { color: var(--accent); font-size: 1.4rem; } + +.logo-badge { + font-size: 0.65rem; + font-family: var(--mono); + background: linear-gradient(135deg, var(--accent), var(--accent2)); + color: #fff; + padding: 0.15rem 0.45rem; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.nav { display: flex; align-items: center; gap: 1.25rem; } +.nav a { color: var(--muted); text-decoration: none; font-weight: 500; transition: color 0.2s; } +.nav a:hover { color: var(--text); } +.nav-user { color: var(--accent); font-size: 0.9rem; font-family: var(--mono); } + +.btn-nav { + background: var(--accent) !important; + color: #fff !important; + padding: 0.4rem 1rem; + border-radius: 8px; +} + +.main { + position: relative; + z-index: 1; + max-width: 1100px; + margin: 0 auto; + padding: 2rem; +} + +.flash { + position: relative; + z-index: 20; + max-width: 1100px; + margin: 1rem auto 0; + padding: 0.85rem 1.25rem; + border-radius: var(--radius); + font-weight: 500; +} + +.flash-success { background: rgba(61, 214, 140, 0.15); border: 1px solid var(--success); color: var(--success); } +.flash-error { background: rgba(240, 113, 120, 0.15); border: 1px solid var(--error); color: var(--error); } + +.hero { text-align: center; padding: 4rem 1rem 3rem; position: relative; } + +.hero-glow { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 400px; + height: 400px; + background: radial-gradient(circle, rgba(61, 158, 255, 0.12) 0%, transparent 70%); + pointer-events: none; +} + +.hero-label { + font-family: var(--mono); + font-size: 0.8rem; + color: var(--accent); + text-transform: uppercase; + letter-spacing: 0.15em; + margin-bottom: 1rem; +} + +.hero h1 { + font-size: clamp(2rem, 5vw, 3rem); + font-weight: 700; + line-height: 1.2; + margin-bottom: 1rem; +} + +.gradient-text { + background: linear-gradient(135deg, var(--accent), var(--accent2)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.hero-desc { color: var(--muted); max-width: 560px; margin: 0 auto 2rem; font-size: 1.05rem; } +.hero-desc a { color: var(--accent); } + +.hero-actions { display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap; } + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.75rem 1.5rem; + border-radius: 10px; + font-weight: 600; + text-decoration: none; + border: none; + cursor: pointer; + font-family: inherit; + font-size: 1rem; + transition: transform 0.15s, box-shadow 0.15s; +} + +.btn:hover { transform: translateY(-1px); } + +.btn-primary { + background: linear-gradient(135deg, var(--accent), var(--accent2)); + color: #fff; + box-shadow: 0 4px 24px rgba(61, 158, 255, 0.35); +} + +.btn-ghost { + background: transparent; + color: var(--text); + border: 1px solid var(--border); +} + +.btn-block { width: 100%; } + +.pill { + display: inline-block; + padding: 0.5rem 1rem; + border-radius: 999px; + font-size: 0.9rem; + font-family: var(--mono); +} + +.pill-ok { background: rgba(61, 214, 140, 0.15); color: var(--success); border: 1px solid var(--success); } + +.stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 1rem; + margin-bottom: 3rem; +} + +.stat-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.5rem; + text-align: center; +} + +.stat-value { + display: block; + font-size: 1.75rem; + font-weight: 700; + font-family: var(--mono); + color: var(--accent); +} + +.stat-label { font-size: 0.85rem; color: var(--muted); } + +.features h2 { text-align: center; margin-bottom: 1.5rem; font-size: 1.5rem; } + +.feature-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; +} + +.feature-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem; +} + +.feature-card h3 { font-size: 1rem; margin-bottom: 0.5rem; color: var(--accent); } +.feature-card p { font-size: 0.9rem; color: var(--muted); } + +.cta-banner { + margin-top: 2rem; + padding: 2rem; + text-align: center; + background: linear-gradient(135deg, rgba(61, 158, 255, 0.08), rgba(124, 92, 255, 0.08)); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.cta-banner h2 { margin-bottom: 0.5rem; } +.cta-banner p { color: var(--muted); margin-bottom: 1.25rem; } + +.auth-page { + display: flex; + justify-content: center; + padding: 3rem 1rem; +} + +.auth-card { + width: 100%; + max-width: 420px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 2rem; +} + +.auth-card h1 { font-size: 1.5rem; margin-bottom: 0.5rem; } +.auth-sub { color: var(--muted); font-size: 0.9rem; margin-bottom: 1.5rem; } +.auth-warn { color: var(--error); margin-bottom: 1rem; } +.auth-footer { margin-top: 1.5rem; text-align: center; font-size: 0.9rem; } +.auth-footer a { color: var(--muted); } + +.form label { display: block; margin-bottom: 1rem; } +.form label span { display: block; font-size: 0.85rem; color: var(--muted); margin-bottom: 0.35rem; } + +.form input { + width: 100%; + padding: 0.7rem 1rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + font-family: inherit; + font-size: 1rem; +} + +.form input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(61, 158, 255, 0.2); +} + +.footer { + position: relative; + z-index: 1; + text-align: center; + padding: 2rem; + color: var(--muted); + font-size: 0.85rem; + border-top: 1px solid var(--border); +} + +.footer a { color: var(--accent); text-decoration: none; } diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..843a520 --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,71 @@ +{{define "content"}} +
+
+

Панель управления VPN

+

Управляйте Xray из одного места

+

+ Централизованная панель на базе + Xray-core: + пользователи, ноды, конфигурации и мониторинг. +

+
+ {{if .User}} + Вы вошли как {{.User.Email}} + {{else if .CanRegister}} + Создать администратора + Войти + {{else}} + Войти в панель + {{end}} +
+
+ +
+
+ {{if .HasAdmin}}1{{else}}0{{end}} + Администратор +
+
+ {{.UserCount}} + Пользователей +
+
+ {{.XrayVersion}} + Ядро +
+
+ {{if .Installed}}✓{{else}}—{{end}} + Установка +
+
+ +
+

Возможности

+
+
+

VLESS / REALITY

+

Поддержка современных протоколов Xray: VLESS, XTLS Vision, REALITY.

+
+
+

PostgreSQL 17

+

Надёжное хранение пользователей и настроек в PostgreSQL.

+
+
+

Docker

+

Развёртывание панели и БД через Docker Compose за минуты.

+
+
+

Один админ

+

При первом запуске регистрируется единственный администратор панели.

+
+
+
+ +{{if not .HasAdmin}} +
+

Первый запуск

+

Администратор ещё не создан. Зарегистрируйте единственную учётную запись администратора.

+ Регистрация администратора +
+{{end}} +{{end}} diff --git a/web/templates/layout.html b/web/templates/layout.html new file mode 100644 index 0000000..f079b56 --- /dev/null +++ b/web/templates/layout.html @@ -0,0 +1,43 @@ +{{define "layout"}} + + + + + {{.Title}} — VPN Panel + + + + + +
+
+ + +
+ + {{if .Flash}} +
{{.Flash.Message}}
+ {{end}} + +
+ {{template "content" .}} +
+ + + +{{end}} diff --git a/web/templates/login.html b/web/templates/login.html new file mode 100644 index 0000000..c9553dc --- /dev/null +++ b/web/templates/login.html @@ -0,0 +1,19 @@ +{{define "content"}} +
+
+

Вход в панель

+
+ + + +
+ +
+
+{{end}} diff --git a/web/templates/register.html b/web/templates/register.html new file mode 100644 index 0000000..9e127b3 --- /dev/null +++ b/web/templates/register.html @@ -0,0 +1,30 @@ +{{define "content"}} +
+
+

Регистрация администратора

+

Допускается только один администратор. После создания регистрация будет закрыта.

+ + {{if .CanRegister}} +
+ + + + +
+ {{else}} +

Администратор уже существует.

+ Войти + {{end}} + +
+
+{{end}}