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,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
@@ -0,0 +1,12 @@
|
||||
.env
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
bin/
|
||||
dist/
|
||||
tmp/
|
||||
.idea/
|
||||
.vscode/
|
||||
*.log
|
||||
panel
|
||||
+22
@@ -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"]
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user