Initial commit: VPN panel on Go, PostgreSQL 17, Docker, Xray-core
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.env
|
||||||
|
*.exe
|
||||||
|
/panel
|
||||||
|
/install
|
||||||
|
/vendor/
|
||||||
|
go.sum.bak
|
||||||
@@ -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
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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("остановлена")
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"}`))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed static/*
|
||||||
|
var Static embed.FS
|
||||||
|
|
||||||
|
//go:embed templates/*
|
||||||
|
var Templates embed.FS
|
||||||
@@ -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; }
|
||||||
@@ -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}}
|
||||||
@@ -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}}
|
||||||
@@ -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}}
|
||||||
@@ -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}}
|
||||||
Reference in New Issue
Block a user