From b1e31430b56a6d7ad56ee988784e05b6c1ecc273 Mon Sep 17 00:00:00 2001 From: orohi Date: Wed, 29 Jul 2026 03:47:42 +0300 Subject: [PATCH] Add Go VPN admin panel with Docker Compose and Postgres 17. Co-authored-by: Cursor --- .env.example | 17 ++ .gitignore | 12 ++ Dockerfile | 22 +++ cmd/server/main.go | 54 ++++++ docker-compose.yml | 44 +++++ go.mod | 14 ++ go.sum | 16 ++ internal/auth/auth.go | 80 ++++++++ internal/config/config.go | 64 +++++++ internal/db/db.go | 178 ++++++++++++++++++ internal/handlers/handlers.go | 163 +++++++++++++++++ internal/models/models.go | 34 ++++ web/static/css/app.css | 331 ++++++++++++++++++++++++++++++++++ web/templates/dashboard.html | 69 +++++++ web/templates/home.html | 29 +++ web/templates/login.html | 34 ++++ web/templates/protocols.html | 68 +++++++ 17 files changed, 1229 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 cmd/server/main.go create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/auth/auth.go create mode 100644 internal/config/config.go create mode 100644 internal/db/db.go create mode 100644 internal/handlers/handlers.go create mode 100644 internal/models/models.go create mode 100644 web/static/css/app.css create mode 100644 web/templates/dashboard.html create mode 100644 web/templates/home.html create mode 100644 web/templates/login.html create mode 100644 web/templates/protocols.html diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f9a8fd9 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# App +APP_PORT=8080 +APP_SECRET=change-me-to-a-long-random-secret-key +APP_ENV=production + +# Admin authorization +ADMIN_EMAIL=admin@panel.local +ADMIN_PASSWORD=Admin123!ChangeMe +ADMIN_NAME=Administrator + +# PostgreSQL 17 +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +POSTGRES_DB=vpn_panel +POSTGRES_USER=vpn_admin +POSTGRES_PASSWORD=VpnDb_Secure_Pass_17 +DATABASE_URL=postgres://vpn_admin:VpnDb_Secure_Pass_17@postgres:5432/vpn_panel?sslmode=disable diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a291d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.env +*.exe +*.dll +*.so +*.dylib +bin/ +dist/ +tmp/ +.idea/ +.vscode/ +*.log +panel diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ae22022 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.23-alpine AS builder + +WORKDIR /src +RUN apk add --no-cache git ca-certificates + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/panel ./cmd/server + +FROM alpine:3.21 +RUN apk add --no-cache ca-certificates tzdata +WORKDIR /app + +COPY --from=builder /out/panel /app/panel +COPY web /app/web + +ENV APP_PORT=8080 +EXPOSE 8080 + +CMD ["/app/panel"] diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..f7a72da --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "log" + "net/http" + "time" + + "github.com/orohi/vpn-panel/internal/auth" + "github.com/orohi/vpn-panel/internal/config" + "github.com/orohi/vpn-panel/internal/db" + "github.com/orohi/vpn-panel/internal/handlers" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("config: %v", err) + } + + database, err := db.Connect(cfg) + if err != nil { + log.Fatalf("db: %v", err) + } + defer database.Close() + + if err := db.Migrate(database); err != nil { + log.Fatalf("migrate: %v", err) + } + if err := db.SeedAdmin(database, cfg); err != nil { + log.Fatalf("seed admin: %v", err) + } + if err := db.SeedProtocols(database); err != nil { + log.Fatalf("seed protocols: %v", err) + } + + authMgr := auth.New(cfg.Secret) + srv, err := handlers.New(database, authMgr, "web/templates") + if err != nil { + log.Fatalf("handlers: %v", err) + } + + httpServer := &http.Server{ + Addr: ":" + cfg.Port, + Handler: srv.Routes(), + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + log.Printf("VPN Panel listening on :%s", cfg.Port) + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("server: %v", err) + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a82c4ec --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,44 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + container_name: vpn-panel + restart: unless-stopped + ports: + - "${APP_PORT:-8080}:8080" + env_file: + - .env + environment: + DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable + POSTGRES_HOST: postgres + depends_on: + postgres: + condition: service_healthy + networks: + - vpn-net + + postgres: + image: postgres:17-alpine + container_name: vpn-panel-db + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - vpn-net + +volumes: + postgres_data: + +networks: + vpn-net: + driver: bridge diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..40f8532 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/orohi/vpn-panel + +go 1.23 + +require ( + github.com/google/uuid v1.6.0 + github.com/gorilla/mux v1.8.1 + github.com/gorilla/sessions v1.3.0 + github.com/joho/godotenv v1.5.1 + github.com/lib/pq v1.10.9 + golang.org/x/crypto v0.31.0 +) + +require github.com/gorilla/securecookie v1.1.2 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7d76da3 --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +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/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.3.0 h1:XYlkq7KcpOB2ZhHBPv5WpjMIxrQosiZanfoy1HLZFzg= +github.com/gorilla/sessions v1.3.0/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..28c5cc0 --- /dev/null +++ b/internal/auth/auth.go @@ -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) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..d7d7a07 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..0771846 --- /dev/null +++ b/internal/db/db.go @@ -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 +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go new file mode 100644 index 0000000..253eec9 --- /dev/null +++ b/internal/handlers/handlers.go @@ -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) +} diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..611f550 --- /dev/null +++ b/internal/models/models.go @@ -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 +} diff --git a/web/static/css/app.css b/web/static/css/app.css new file mode 100644 index 0000000..487278e --- /dev/null +++ b/web/static/css/app.css @@ -0,0 +1,331 @@ +:root { + --bg: #0b1220; + --bg-2: #111a2e; + --surface: rgba(18, 28, 48, 0.86); + --line: rgba(148, 175, 210, 0.16); + --text: #e8eef8; + --muted: #93a4bf; + --accent: #3dd6c6; + --accent-2: #4f8cff; + --danger: #ff6b7a; + --ok: #3dd6c6; + --off: #7a879c; + --radius: 14px; + --font: "DM Sans", sans-serif; + --mono: "JetBrains Mono", ui-monospace, monospace; +} + +* { box-sizing: border-box; } +html, body { margin: 0; min-height: 100%; } +body { + font-family: var(--font); + color: var(--text); + background: var(--bg); + line-height: 1.5; +} + +a { color: inherit; text-decoration: none; } +code { + font-family: var(--mono); + font-size: 0.85em; + background: rgba(255,255,255,0.06); + padding: 0.15em 0.4em; + border-radius: 6px; +} + +.home-bg { + position: fixed; + inset: 0; + background: + radial-gradient(900px 500px at 12% 10%, rgba(61, 214, 198, 0.18), transparent 55%), + radial-gradient(800px 480px at 88% 18%, rgba(79, 140, 255, 0.2), transparent 50%), + linear-gradient(160deg, #070d18 0%, #0d1730 45%, #0a1424 100%); + z-index: -1; +} + +.brand { + font-weight: 700; + letter-spacing: -0.03em; + font-size: 1.15rem; +} +.brand-center { display: block; text-align: center; margin-bottom: 1.25rem; } + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + border: 1px solid transparent; + border-radius: 999px; + padding: 0.7rem 1.2rem; + font: inherit; + font-weight: 600; + cursor: pointer; + transition: transform .15s ease, background .15s ease, border-color .15s ease; +} +.btn:hover { transform: translateY(-1px); } +.btn-primary { + background: linear-gradient(135deg, var(--accent), #2bb8d4); + color: #04131a; +} +.btn-ghost { + background: transparent; + border-color: var(--line); + color: var(--text); +} +.btn-ghost:hover { border-color: rgba(61,214,198,0.45); } +.btn-sm { padding: 0.45rem 0.9rem; font-size: 0.9rem; } +.btn-block { width: 100%; } + +.page-home { min-height: 100vh; } +.home-top { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.25rem 2rem; +} +.home-hero { + max-width: 760px; + padding: 12vh 2rem 4rem; + animation: rise .7s ease both; +} +.brand-mark { + margin: 0 0 0.75rem; + font-size: clamp(2.4rem, 7vw, 4.4rem); + font-weight: 700; + letter-spacing: -0.05em; + background: linear-gradient(120deg, #fff 20%, var(--accent) 70%, var(--accent-2)); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} +.home-hero h1 { + margin: 0 0 1rem; + font-size: clamp(1.35rem, 3vw, 1.85rem); + font-weight: 600; + letter-spacing: -0.02em; +} +.lead { color: var(--muted); font-size: 1.05rem; max-width: 34rem; } +.cta-row { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-top: 1.75rem; } + +.page-login { min-height: 100vh; display: grid; place-items: center; padding: 2rem; } +.login-wrap { width: min(100%, 400px); animation: rise .55s ease both; } +.login-form { + background: var(--surface); + border: 1px solid var(--line); + border-radius: calc(var(--radius) + 4px); + padding: 1.5rem; + backdrop-filter: blur(12px); +} +.login-form h1 { margin: 0 0 0.35rem; font-size: 1.35rem; } +.muted { color: var(--muted); margin: 0 0 1rem; } +.alert { + background: rgba(255,107,122,0.12); + border: 1px solid rgba(255,107,122,0.35); + color: #ffb4bc; + padding: 0.7rem 0.85rem; + border-radius: 10px; + margin-bottom: 1rem; + font-size: 0.95rem; +} +label { + display: grid; + gap: 0.35rem; + margin-bottom: 0.9rem; + font-size: 0.9rem; + color: var(--muted); +} +input { + width: 100%; + border: 1px solid var(--line); + background: rgba(0,0,0,0.25); + color: var(--text); + border-radius: 10px; + padding: 0.75rem 0.9rem; + font: inherit; +} +input:focus { + outline: none; + border-color: rgba(61,214,198,0.55); + box-shadow: 0 0 0 3px rgba(61,214,198,0.12); +} + +.page-admin { + display: grid; + grid-template-columns: 240px 1fr; + min-height: 100vh; + background: + radial-gradient(700px 400px at 100% 0%, rgba(79,140,255,0.12), transparent 50%), + var(--bg); +} + +.sidebar { + display: flex; + flex-direction: column; + gap: 1.5rem; + padding: 1.5rem 1.1rem; + border-right: 1px solid var(--line); + background: rgba(8, 14, 26, 0.75); +} +.sidebar nav { display: grid; gap: 0.35rem; } +.sidebar nav a { + padding: 0.65rem 0.85rem; + border-radius: 10px; + color: var(--muted); + font-weight: 500; +} +.sidebar nav a:hover, +.sidebar nav a.active { + background: rgba(61,214,198,0.1); + color: var(--text); +} +.sidebar-foot { + margin-top: auto; + display: grid; + gap: 0.5rem; + padding-top: 1rem; + border-top: 1px solid var(--line); +} +.user-chip { + font-size: 0.9rem; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.link-btn { + background: none; + border: 0; + color: var(--accent); + font: inherit; + cursor: pointer; + padding: 0; + text-align: left; +} + +.admin-main { padding: 1.75rem 2rem 2.5rem; } +.admin-header h1 { margin: 0; font-size: 1.7rem; letter-spacing: -0.03em; } +.admin-header .muted { margin-top: 0.35rem; } + +.stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.9rem; + margin: 1.5rem 0 2rem; +} +.stat { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 1rem 1.1rem; +} +.stat-label { display: block; color: var(--muted); font-size: 0.85rem; margin-bottom: 0.35rem; } +.stat-value { font-size: 1.8rem; font-weight: 700; letter-spacing: -0.04em; } +.stat-value.accent { color: var(--accent); } + +.panel-section { margin-top: 0.5rem; } +.section-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} +.section-head h2 { margin: 0; font-size: 1.15rem; } + +.proto-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 0.85rem; +} +.proto-card { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 1rem; + animation: rise .45s ease both; +} +.proto-card.is-off { opacity: 0.72; } +.proto-top { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.45rem; +} +.proto-top h3 { margin: 0; font-size: 1.05rem; } +.proto-card p { + margin: 0 0 0.85rem; + color: var(--muted); + font-size: 0.9rem; + min-height: 2.7em; +} +.proto-meta { + display: flex; + justify-content: space-between; + align-items: center; + color: var(--muted); + font-size: 0.85rem; +} + +.badge { + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.04em; + padding: 0.25rem 0.55rem; + border-radius: 999px; +} +.badge.on { background: rgba(61,214,198,0.15); color: var(--ok); } +.badge.off { background: rgba(122,135,156,0.18); color: var(--off); } + +.table-wrap { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + overflow: auto; + margin-top: 1.25rem; +} +.data-table { + width: 100%; + border-collapse: collapse; + min-width: 680px; +} +.data-table th, +.data-table td { + text-align: left; + padding: 0.95rem 1rem; + border-bottom: 1px solid var(--line); + vertical-align: top; +} +.data-table th { + color: var(--muted); + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.data-table tr:last-child td { border-bottom: 0; } +.cell-desc { + color: var(--muted); + font-size: 0.85rem; + margin-top: 0.25rem; + max-width: 360px; +} +.actions { width: 1%; white-space: nowrap; } + +@keyframes rise { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +@media (max-width: 860px) { + .page-admin { grid-template-columns: 1fr; } + .sidebar { + border-right: 0; + border-bottom: 1px solid var(--line); + } + .sidebar nav { grid-auto-flow: column; grid-auto-columns: max-content; overflow-x: auto; } + .sidebar-foot { display: flex; align-items: center; justify-content: space-between; } + .stats { grid-template-columns: 1fr; } + .admin-main { padding: 1.25rem; } + .home-top, .home-hero { padding-left: 1.25rem; padding-right: 1.25rem; } +} diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html new file mode 100644 index 0000000..b74ba5a --- /dev/null +++ b/web/templates/dashboard.html @@ -0,0 +1,69 @@ + + + + + + {{.Title}} · VPN Panel + + + + + + + + +
+
+

Обзор

+

Состояние панели и активные протоколы

+
+ +
+
+ Протоколов + {{.Stats.ProtocolsTotal}} +
+
+ Включено + {{.Stats.ProtocolsEnabled}} +
+
+ Админов + {{.Stats.AdminsTotal}} +
+
+ +
+
+

Протоколы

+ Управление +
+
+ {{range .Protocols}} +
+
+

{{.Name}}

+ {{if .Enabled}}ON{{else}}OFF{{end}} +
+

{{.Description}}

+
+ {{.Code}} + порт {{.Port}} +
+
+ {{end}} +
+
+
+ + diff --git a/web/templates/home.html b/web/templates/home.html new file mode 100644 index 0000000..708062e --- /dev/null +++ b/web/templates/home.html @@ -0,0 +1,29 @@ + + + + + + {{.Title}} · VPN Panel + + + + + + + +
+ VPN Panel + Войти +
+ +
+

VPN Panel

+

Админ-панель управления VPN-протоколами

+

WireGuard, OpenVPN, VLESS, VMess, Trojan, Shadowsocks и Hysteria2 — в одном месте.

+ +
+ + diff --git a/web/templates/login.html b/web/templates/login.html new file mode 100644 index 0000000..41f17a3 --- /dev/null +++ b/web/templates/login.html @@ -0,0 +1,34 @@ + + + + + + {{.Title}} · VPN Panel + + + + + + + +
+ VPN Panel + +
+ + diff --git a/web/templates/protocols.html b/web/templates/protocols.html new file mode 100644 index 0000000..58cfdc3 --- /dev/null +++ b/web/templates/protocols.html @@ -0,0 +1,68 @@ + + + + + + {{.Title}} · VPN Panel + + + + + + + + +
+
+

Протоколы

+

Включение и отключение VPN-протоколов

+
+ +
+ + + + + + + + + + + + {{range .Protocols}} + + + + + + + + {{end}} + +
ПротоколКодПортСтатус
+ {{.Name}} +
{{.Description}}
+
{{.Code}}{{.Port}} + {{if .Enabled}}Включён{{else}}Выключен{{end}} + +
+ +
+
+
+
+ +