Initial commit: VPN panel on Go, PostgreSQL 17, Docker, Xray-core

This commit is contained in:
vpn-panel
2026-05-21 18:55:14 +03:00
commit 3c2f5226d1
27 changed files with 1778 additions and 0 deletions
+13
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
.env
*.exe
/panel
/install
/vendor/
go.sum.bak
+255
View File
@@ -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 на нодах — отдельный этап (будет добавлен в следующих версиях).
+22
View File
@@ -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"]
+75
View File
@@ -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
+185
View File
@@ -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)
}
+83
View File
@@ -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("остановлена")
}
+36
View File
@@ -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:
+20
View File
@@ -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
)
+36
View File
@@ -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=
+12
View File
@@ -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
}
+40
View File
@@ -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
}
+45
View File
@@ -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
}
+16
View File
@@ -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);
+138
View File
@@ -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)
}
+40
View File
@@ -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
}
+101
View File
@@ -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
}
+24
View File
@@ -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"}`))
}
+15
View File
@@ -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
}
+64
View File
@@ -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))
}
+87
View File
@@ -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
}
+9
View File
@@ -0,0 +1,9 @@
package web
import "embed"
//go:embed static/*
var Static embed.FS
//go:embed templates/*
var Templates embed.FS
+293
View File
@@ -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; }
+71
View File
@@ -0,0 +1,71 @@
{{define "content"}}
<section class="hero">
<div class="hero-glow"></div>
<p class="hero-label">Панель управления VPN</p>
<h1>Управляйте <span class="gradient-text">Xray</span> из одного места</h1>
<p class="hero-desc">
Централизованная панель на базе
<a href="https://github.com/XTLS/Xray-core" target="_blank" rel="noopener">Xray-core</a>:
пользователи, ноды, конфигурации и мониторинг.
</p>
<div class="hero-actions">
{{if .User}}
<span class="pill pill-ok">Вы вошли как {{.User.Email}}</span>
{{else if .CanRegister}}
<a href="/register" class="btn btn-primary">Создать администратора</a>
<a href="/login" class="btn btn-ghost">Войти</a>
{{else}}
<a href="/login" class="btn btn-primary">Войти в панель</a>
{{end}}
</div>
</section>
<section class="stats">
<article class="stat-card">
<span class="stat-value">{{if .HasAdmin}}1{{else}}0{{end}}</span>
<span class="stat-label">Администратор</span>
</article>
<article class="stat-card">
<span class="stat-value">{{.UserCount}}</span>
<span class="stat-label">Пользователей</span>
</article>
<article class="stat-card">
<span class="stat-value">{{.XrayVersion}}</span>
<span class="stat-label">Ядро</span>
</article>
<article class="stat-card">
<span class="stat-value">{{if .Installed}}✓{{else}}—{{end}}</span>
<span class="stat-label">Установка</span>
</article>
</section>
<section class="features">
<h2>Возможности</h2>
<div class="feature-grid">
<div class="feature-card">
<h3>VLESS / REALITY</h3>
<p>Поддержка современных протоколов Xray: VLESS, XTLS Vision, REALITY.</p>
</div>
<div class="feature-card">
<h3>PostgreSQL 17</h3>
<p>Надёжное хранение пользователей и настроек в PostgreSQL.</p>
</div>
<div class="feature-card">
<h3>Docker</h3>
<p>Развёртывание панели и БД через Docker Compose за минуты.</p>
</div>
<div class="feature-card">
<h3>Один админ</h3>
<p>При первом запуске регистрируется единственный администратор панели.</p>
</div>
</div>
</section>
{{if not .HasAdmin}}
<section class="cta-banner">
<h2>Первый запуск</h2>
<p>Администратор ещё не создан. Зарегистрируйте единственную учётную запись администратора.</p>
<a href="/register" class="btn btn-primary">Регистрация администратора</a>
</section>
{{end}}
{{end}}
+43
View File
@@ -0,0 +1,43 @@
{{define "layout"}}<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} — VPN Panel</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Outfit:wght@300;400;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div class="bg-grid"></div>
<header class="header">
<a href="/" class="logo">
<span class="logo-icon"></span>
<span>VPN Panel</span>
<span class="logo-badge">Xray</span>
</a>
<nav class="nav">
<a href="/">Главная</a>
{{if .User}}
<span class="nav-user">{{.User.Email}}</span>
<a href="/logout">Выход</a>
{{else}}
<a href="/login">Вход</a>
{{if .CanRegister}}<a href="/register" class="btn-nav">Регистрация</a>{{end}}
{{end}}
</nav>
</header>
{{if .Flash}}
<div class="flash flash-{{.Flash.Level}}">{{.Flash.Message}}</div>
{{end}}
<main class="main">
{{template "content" .}}
</main>
<footer class="footer">
<p>Ядро: <a href="https://github.com/XTLS/Xray-core" target="_blank" rel="noopener">Xray-core</a> · {{.Domain}} · © {{.Year}}</p>
</footer>
</body>
</html>{{end}}
+19
View File
@@ -0,0 +1,19 @@
{{define "content"}}
<section class="auth-page">
<div class="auth-card">
<h1>Вход в панель</h1>
<form method="post" action="/login" class="form">
<label>
<span>Email</span>
<input type="email" name="email" required autocomplete="email">
</label>
<label>
<span>Пароль</span>
<input type="password" name="password" required autocomplete="current-password">
</label>
<button type="submit" class="btn btn-primary btn-block">Войти</button>
</form>
<p class="auth-footer"><a href="/">← На главную</a></p>
</div>
</section>
{{end}}
+30
View File
@@ -0,0 +1,30 @@
{{define "content"}}
<section class="auth-page">
<div class="auth-card">
<h1>Регистрация администратора</h1>
<p class="auth-sub">Допускается только <strong>один</strong> администратор. После создания регистрация будет закрыта.</p>
{{if .CanRegister}}
<form method="post" action="/register" class="form">
<label>
<span>Email</span>
<input type="email" name="email" required autocomplete="email" placeholder="admin@example.com">
</label>
<label>
<span>Пароль (мин. 8 символов)</span>
<input type="password" name="password" required minlength="8" autocomplete="new-password">
</label>
<label>
<span>Подтверждение пароля</span>
<input type="password" name="password_confirm" required minlength="8" autocomplete="new-password">
</label>
<button type="submit" class="btn btn-primary btn-block">Создать администратора</button>
</form>
{{else}}
<p class="auth-warn">Администратор уже существует.</p>
<a href="/login" class="btn btn-ghost btn-block">Войти</a>
{{end}}
<p class="auth-footer"><a href="/">← На главную</a></p>
</div>
</section>
{{end}}