Add Go VPN admin panel with Docker Compose and Postgres 17.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user