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