Initial commit: VPN panel on Go, PostgreSQL 17, Docker, Xray-core
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user