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
+17
View File
@@ -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
+12
View File
@@ -0,0 +1,12 @@
.env
*.exe
*.dll
*.so
*.dylib
bin/
dist/
tmp/
.idea/
.vscode/
*.log
panel
+22
View File
@@ -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"]
+54
View File
@@ -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)
}
}
+44
View File
@@ -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
+14
View File
@@ -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
+16
View File
@@ -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=
+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
}
+331
View File
@@ -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; }
}
+69
View File
@@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · VPN Panel</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body class="page-admin">
<aside class="sidebar">
<a class="brand" href="/admin">VPN Panel</a>
<nav>
<a class="{{if eq .Active "dashboard"}}active{{end}}" href="/admin">Обзор</a>
<a class="{{if eq .Active "protocols"}}active{{end}}" href="/admin/protocols">Протоколы</a>
</nav>
<div class="sidebar-foot">
<span class="user-chip">{{.UserName}}</span>
<form method="POST" action="/logout"><button type="submit" class="link-btn">Выйти</button></form>
</div>
</aside>
<main class="admin-main">
<header class="admin-header">
<h1>Обзор</h1>
<p class="muted">Состояние панели и активные протоколы</p>
</header>
<section class="stats">
<div class="stat">
<span class="stat-label">Протоколов</span>
<span class="stat-value">{{.Stats.ProtocolsTotal}}</span>
</div>
<div class="stat">
<span class="stat-label">Включено</span>
<span class="stat-value accent">{{.Stats.ProtocolsEnabled}}</span>
</div>
<div class="stat">
<span class="stat-label">Админов</span>
<span class="stat-value">{{.Stats.AdminsTotal}}</span>
</div>
</section>
<section class="panel-section">
<div class="section-head">
<h2>Протоколы</h2>
<a href="/admin/protocols" class="btn btn-ghost btn-sm">Управление</a>
</div>
<div class="proto-grid">
{{range .Protocols}}
<article class="proto-card {{if .Enabled}}is-on{{else}}is-off{{end}}">
<div class="proto-top">
<h3>{{.Name}}</h3>
<span class="badge {{if .Enabled}}on{{else}}off{{end}}">{{if .Enabled}}ON{{else}}OFF{{end}}</span>
</div>
<p>{{.Description}}</p>
<div class="proto-meta">
<code>{{.Code}}</code>
<span>порт {{.Port}}</span>
</div>
</article>
{{end}}
</div>
</section>
</main>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · VPN Panel</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body class="page-home">
<div class="home-bg" aria-hidden="true"></div>
<header class="home-top">
<a class="brand" href="/">VPN Panel</a>
<a class="btn btn-ghost" href="/login">Войти</a>
</header>
<main class="home-hero">
<p class="brand-mark">VPN Panel</p>
<h1>Админ-панель управления VPN-протоколами</h1>
<p class="lead">WireGuard, OpenVPN, VLESS, VMess, Trojan, Shadowsocks и Hysteria2 — в одном месте.</p>
<div class="cta-row">
<a class="btn btn-primary" href="/login">Открыть админку</a>
<a class="btn btn-ghost" href="/admin/protocols">Протоколы</a>
</div>
</main>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · VPN Panel</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body class="page-login">
<div class="home-bg" aria-hidden="true"></div>
<main class="login-wrap">
<a class="brand brand-center" href="/">VPN Panel</a>
<form class="login-form" method="POST" action="/login" autocomplete="on">
<h1>Вход в админку</h1>
<p class="muted">Используйте данные из <code>.env</code></p>
{{if .Error}}
<div class="alert">{{.Error}}</div>
{{end}}
<label>
Email
<input type="email" name="email" required value="{{.Email}}" placeholder="admin@panel.local" autofocus>
</label>
<label>
Пароль
<input type="password" name="password" required placeholder="••••••••">
</label>
<button class="btn btn-primary btn-block" type="submit">Войти</button>
</form>
</main>
</body>
</html>
+68
View File
@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · VPN Panel</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body class="page-admin">
<aside class="sidebar">
<a class="brand" href="/admin">VPN Panel</a>
<nav>
<a class="{{if eq .Active "dashboard"}}active{{end}}" href="/admin">Обзор</a>
<a class="{{if eq .Active "protocols"}}active{{end}}" href="/admin/protocols">Протоколы</a>
</nav>
<div class="sidebar-foot">
<span class="user-chip">{{.UserName}}</span>
<form method="POST" action="/logout"><button type="submit" class="link-btn">Выйти</button></form>
</div>
</aside>
<main class="admin-main">
<header class="admin-header">
<h1>Протоколы</h1>
<p class="muted">Включение и отключение VPN-протоколов</p>
</header>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Протокол</th>
<th>Код</th>
<th>Порт</th>
<th>Статус</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Protocols}}
<tr>
<td>
<strong>{{.Name}}</strong>
<div class="cell-desc">{{.Description}}</div>
</td>
<td><code>{{.Code}}</code></td>
<td>{{.Port}}</td>
<td>
<span class="badge {{if .Enabled}}on{{else}}off{{end}}">{{if .Enabled}}Включён{{else}}Выключен{{end}}</span>
</td>
<td class="actions">
<form method="POST" action="/admin/protocols/{{.ID}}/toggle">
<button type="submit" class="btn btn-sm {{if .Enabled}}btn-ghost{{else}}btn-primary{{end}}">
{{if .Enabled}}Выключить{{else}}Включить{{end}}
</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</main>
</body>
</html>