Add Go VPN admin panel with Docker Compose and Postgres 17.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-29 03:47:42 +03:00
co-authored by Cursor
parent 23101e355c
commit b1e31430b5
17 changed files with 1229 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
package auth
import (
"net/http"
"github.com/gorilla/sessions"
"golang.org/x/crypto/bcrypt"
)
const SessionName = "vpn_panel_session"
type Manager struct {
store *sessions.CookieStore
}
func New(secret string) *Manager {
store := sessions.NewCookieStore([]byte(secret))
store.Options = &sessions.Options{
Path: "/",
MaxAge: 86400 * 7,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}
return &Manager{store: store}
}
func CheckPassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
func (m *Manager) Login(w http.ResponseWriter, r *http.Request, userID, email, name string) error {
sess, err := m.store.Get(r, SessionName)
if err != nil {
sess, _ = m.store.New(r, SessionName)
}
sess.Values["authenticated"] = true
sess.Values["user_id"] = userID
sess.Values["email"] = email
sess.Values["name"] = name
return sess.Save(r, w)
}
func (m *Manager) Logout(w http.ResponseWriter, r *http.Request) error {
sess, _ := m.store.Get(r, SessionName)
sess.Values["authenticated"] = false
sess.Options.MaxAge = -1
return sess.Save(r, w)
}
func (m *Manager) IsAuthenticated(r *http.Request) bool {
sess, err := m.store.Get(r, SessionName)
if err != nil {
return false
}
ok, _ := sess.Values["authenticated"].(bool)
return ok
}
func (m *Manager) CurrentName(r *http.Request) string {
sess, err := m.store.Get(r, SessionName)
if err != nil {
return ""
}
name, _ := sess.Values["name"].(string)
if name != "" {
return name
}
email, _ := sess.Values["email"].(string)
return email
}
func (m *Manager) RequireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !m.IsAuthenticated(r) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next(w, r)
}
}
+64
View File
@@ -0,0 +1,64 @@
package config
import (
"os"
"strconv"
"github.com/joho/godotenv"
)
type Config struct {
Port string
Secret string
Env string
DatabaseURL string
AdminEmail string
AdminPassword string
AdminName string
PostgresHost string
PostgresPort string
PostgresDB string
PostgresUser string
PostgresPass string
}
func Load() (*Config, error) {
_ = godotenv.Load()
cfg := &Config{
Port: getEnv("APP_PORT", "8080"),
Secret: getEnv("APP_SECRET", "dev-secret-change-me"),
Env: getEnv("APP_ENV", "development"),
DatabaseURL: os.Getenv("DATABASE_URL"),
AdminEmail: getEnv("ADMIN_EMAIL", "admin@panel.local"),
AdminPassword: getEnv("ADMIN_PASSWORD", "Admin123!ChangeMe"),
AdminName: getEnv("ADMIN_NAME", "Administrator"),
PostgresHost: getEnv("POSTGRES_HOST", "localhost"),
PostgresPort: getEnv("POSTGRES_PORT", "5432"),
PostgresDB: getEnv("POSTGRES_DB", "vpn_panel"),
PostgresUser: getEnv("POSTGRES_USER", "vpn_admin"),
PostgresPass: getEnv("POSTGRES_PASSWORD", "VpnDb_Secure_Pass_17"),
}
if cfg.DatabaseURL == "" {
cfg.DatabaseURL = "postgres://" + cfg.PostgresUser + ":" + cfg.PostgresPass +
"@" + cfg.PostgresHost + ":" + cfg.PostgresPort + "/" + cfg.PostgresDB + "?sslmode=disable"
}
return cfg, nil
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func (c *Config) PortInt() int {
n, err := strconv.Atoi(c.Port)
if err != nil {
return 8080
}
return n
}
+178
View File
@@ -0,0 +1,178 @@
package db
import (
"database/sql"
"fmt"
"log"
"time"
"github.com/google/uuid"
_ "github.com/lib/pq"
"golang.org/x/crypto/bcrypt"
"github.com/orohi/vpn-panel/internal/config"
"github.com/orohi/vpn-panel/internal/models"
)
func Connect(cfg *config.Config) (*sql.DB, error) {
var db *sql.DB
var err error
for i := 1; i <= 30; i++ {
db, err = sql.Open("postgres", cfg.DatabaseURL)
if err == nil {
err = db.Ping()
}
if err == nil {
db.SetMaxOpenConns(20)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(time.Hour)
log.Println("connected to postgres")
return db, nil
}
log.Printf("waiting for postgres (%d/30): %v", i, err)
time.Sleep(2 * time.Second)
}
return nil, fmt.Errorf("postgres unavailable: %w", err)
}
func Migrate(db *sql.DB) error {
schema := `
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'admin',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS protocols (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
port INT NOT NULL DEFAULT 0,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`
_, err := db.Exec(schema)
return err
}
func SeedAdmin(db *sql.DB, cfg *config.Config) error {
var exists bool
err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)`, cfg.AdminEmail).Scan(&exists)
if err != nil {
return err
}
if exists {
return nil
}
hash, err := bcrypt.GenerateFromPassword([]byte(cfg.AdminPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
_, err = db.Exec(
`INSERT INTO users (email, password_hash, name, role) VALUES ($1, $2, $3, 'admin')`,
cfg.AdminEmail, string(hash), cfg.AdminName,
)
if err == nil {
log.Printf("admin seeded: %s", cfg.AdminEmail)
}
return err
}
func SeedProtocols(db *sql.DB) error {
defaults := []struct {
Code, Name, Description string
Port, Sort int
}{
{"wireguard", "WireGuard", "Современный быстрый VPN-протокол на основе Noise", 51820, 1},
{"openvpn", "OpenVPN", "Классический VPN через UDP/TCP с TLS", 1194, 2},
{"vless", "VLESS", "Лёгкий протокол Xray без шифрования на уровне протокола", 443, 3},
{"vmess", "VMess", "Протокол V2Ray/Xray с обфускацией трафика", 443, 4},
{"trojan", "Trojan", "Трафик маскируется под HTTPS", 443, 5},
{"shadowsocks", "Shadowsocks", "SOCKS5-прокси с шифрованием AEAD", 8388, 6},
{"hysteria2", "Hysteria2", "UDP-протокол на базе QUIC для нестабильных сетей", 443, 7},
}
for _, p := range defaults {
_, err := db.Exec(`
INSERT INTO protocols (code, name, description, port, enabled, sort_order)
VALUES ($1, $2, $3, $4, TRUE, $5)
ON CONFLICT (code) DO NOTHING`,
p.Code, p.Name, p.Description, p.Port, p.Sort,
)
if err != nil {
return err
}
}
return nil
}
func GetUserByEmail(db *sql.DB, email string) (*models.User, error) {
u := &models.User{}
err := db.QueryRow(`
SELECT id, email, password_hash, name, role, created_at
FROM users WHERE email = $1`, email).Scan(
&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.Role, &u.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return u, nil
}
func ListProtocols(db *sql.DB) ([]models.Protocol, error) {
rows, err := db.Query(`
SELECT id, code, name, description, port, enabled, sort_order, created_at, updated_at
FROM protocols ORDER BY sort_order ASC, name ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var list []models.Protocol
for rows.Next() {
var p models.Protocol
if err := rows.Scan(
&p.ID, &p.Code, &p.Name, &p.Description, &p.Port,
&p.Enabled, &p.SortOrder, &p.CreatedAt, &p.UpdatedAt,
); err != nil {
return nil, err
}
list = append(list, p)
}
return list, rows.Err()
}
func ToggleProtocol(db *sql.DB, id uuid.UUID) error {
_, err := db.Exec(`
UPDATE protocols SET enabled = NOT enabled, updated_at = NOW() WHERE id = $1`, id)
return err
}
func GetStats(db *sql.DB) (models.DashboardStats, error) {
var s models.DashboardStats
err := db.QueryRow(`SELECT COUNT(*) FROM protocols`).Scan(&s.ProtocolsTotal)
if err != nil {
return s, err
}
err = db.QueryRow(`SELECT COUNT(*) FROM protocols WHERE enabled = TRUE`).Scan(&s.ProtocolsEnabled)
if err != nil {
return s, err
}
err = db.QueryRow(`SELECT COUNT(*) FROM users WHERE role = 'admin'`).Scan(&s.AdminsTotal)
return s, err
}
+163
View File
@@ -0,0 +1,163 @@
package handlers
import (
"database/sql"
"html/template"
"log"
"net/http"
"path/filepath"
"github.com/google/uuid"
"github.com/gorilla/mux"
"github.com/orohi/vpn-panel/internal/auth"
"github.com/orohi/vpn-panel/internal/db"
)
type Server struct {
DB *sql.DB
Auth *auth.Manager
Templates *template.Template
}
type pageData map[string]any
func New(database *sql.DB, authMgr *auth.Manager, templatesDir string) (*Server, error) {
pattern := filepath.Join(templatesDir, "*.html")
tmpl, err := template.ParseGlob(pattern)
if err != nil {
return nil, err
}
return &Server{DB: database, Auth: authMgr, Templates: tmpl}, nil
}
func (s *Server) Routes() http.Handler {
r := mux.NewRouter()
r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
r.HandleFunc("/", s.home).Methods(http.MethodGet)
r.HandleFunc("/login", s.loginGet).Methods(http.MethodGet)
r.HandleFunc("/login", s.loginPost).Methods(http.MethodPost)
r.HandleFunc("/logout", s.logout).Methods(http.MethodPost, http.MethodGet)
r.HandleFunc("/admin", s.Auth.RequireAuth(s.dashboard)).Methods(http.MethodGet)
r.HandleFunc("/admin/protocols", s.Auth.RequireAuth(s.protocols)).Methods(http.MethodGet)
r.HandleFunc("/admin/protocols/{id}/toggle", s.Auth.RequireAuth(s.toggleProtocol)).Methods(http.MethodPost)
r.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}).Methods(http.MethodGet)
return r
}
func (s *Server) render(w http.ResponseWriter, name string, data pageData) {
if data == nil {
data = pageData{}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.Templates.ExecuteTemplate(w, name, data); err != nil {
log.Printf("template %s: %v", name, err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}
func (s *Server) home(w http.ResponseWriter, r *http.Request) {
if s.Auth.IsAuthenticated(r) {
http.Redirect(w, r, "/admin", http.StatusSeeOther)
return
}
s.render(w, "home.html", pageData{
"Title": "VPN Panel",
})
}
func (s *Server) loginGet(w http.ResponseWriter, r *http.Request) {
if s.Auth.IsAuthenticated(r) {
http.Redirect(w, r, "/admin", http.StatusSeeOther)
return
}
s.render(w, "login.html", pageData{
"Title": "Вход",
"Error": "",
})
}
func (s *Server) loginPost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
email := r.FormValue("email")
password := r.FormValue("password")
user, err := db.GetUserByEmail(s.DB, email)
if err != nil || user == nil || !auth.CheckPassword(user.PasswordHash, password) {
s.render(w, "login.html", pageData{
"Title": "Вход",
"Error": "Неверный email или пароль",
"Email": email,
})
return
}
if err := s.Auth.Login(w, r, user.ID.String(), user.Email, user.Name); err != nil {
http.Error(w, "session error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin", http.StatusSeeOther)
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
_ = s.Auth.Logout(w, r)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
stats, err := db.GetStats(s.DB)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
protocols, err := db.ListProtocols(s.DB)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
s.render(w, "dashboard.html", pageData{
"Title": "Админка",
"UserName": s.Auth.CurrentName(r),
"Stats": stats,
"Protocols": protocols,
"Active": "dashboard",
})
}
func (s *Server) protocols(w http.ResponseWriter, r *http.Request) {
protocols, err := db.ListProtocols(s.DB)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
s.render(w, "protocols.html", pageData{
"Title": "Протоколы",
"UserName": s.Auth.CurrentName(r),
"Protocols": protocols,
"Active": "protocols",
})
}
func (s *Server) toggleProtocol(w http.ResponseWriter, r *http.Request) {
idStr := mux.Vars(r)["id"]
id, err := uuid.Parse(idStr)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
if err := db.ToggleProtocol(s.DB, id); err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/protocols", http.StatusSeeOther)
}
+34
View File
@@ -0,0 +1,34 @@
package models
import (
"time"
"github.com/google/uuid"
)
type User struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"-"`
Name string `json:"name"`
Role string `json:"role"`
CreatedAt time.Time `json:"created_at"`
}
type Protocol struct {
ID uuid.UUID `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Description string `json:"description"`
Port int `json:"port"`
Enabled bool `json:"enabled"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type DashboardStats struct {
ProtocolsTotal int
ProtocolsEnabled int
AdminsTotal int
}