Add Go WireGuard panel with PostgreSQL 17 and Dokploy compose.

This commit is contained in:
2026-07-29 09:15:00 +03:00
parent 370e2455d4
commit 31ce2af622
25 changed files with 2423 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
.git
.env
.env.*
!.env.example
bin/
tmp/
archivem3tAG/
archivem3tAG.tar.gz
*.exe
.idea/
.vscode/
+18
View File
@@ -0,0 +1,18 @@
# Dokploy / Docker Compose env (copy to .env and edit)
APP_URL=https://wg.example.com
SITE_NAME=Evilfox.cc
SECURE_COOKIES=true
SESSION_SECRET=replace-with-long-random-secret
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@evilfox.cc
ADMIN_PASSWORD=replace-with-strong-admin-password
POSTGRES_DB=wg
POSTGRES_USER=wg
POSTGRES_PASSWORD=replace-with-strong-db-password
AUTO_IMPORT_MYSQL=true
APP_PUBLISH_PORT=8080
TZ=Europe/Moscow
+7
View File
@@ -0,0 +1,7 @@
.env
.env.local
*.exe
bin/
tmp/
.idea/
.vscode/
+28
View File
@@ -0,0 +1,28 @@
# syntax=docker/dockerfile:1
FROM golang:1.26-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 GOARCH=amd64 go build -ldflags="-s -w" -o /out/wg-panel ./cmd/server
FROM alpine:3.21
WORKDIR /app
RUN apk add --no-cache ca-certificates tzdata curl
COPY --from=builder /out/wg-panel /app/wg-panel
COPY migrations /app/migrations
COPY web /app/web
COPY wg.sql /data/wg.sql
ENV APP_ROOT=/app \
MIGRATIONS_DIR=/app/migrations \
TEMPLATES_DIR=/app/web/templates \
STATIC_DIR=/app/web/static \
MYSQL_DUMP_PATH=/data/wg.sql \
APP_PORT=8080
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD curl -fsS http://127.0.0.1:8080/login >/dev/null || exit 1
USER nobody
CMD ["/app/wg-panel"]
+135
View File
@@ -0,0 +1,135 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/evilfox/wg-panel/internal/auth"
"github.com/evilfox/wg-panel/internal/config"
"github.com/evilfox/wg-panel/internal/db"
"github.com/evilfox/wg-panel/internal/handlers"
"github.com/evilfox/wg-panel/internal/mysqlimport"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
cfg := config.Load()
ctx := context.Background()
root := envOr("APP_ROOT", ".")
migrationsDir := envOr("MIGRATIONS_DIR", filepath.Join(root, "migrations"))
templatesDir := envOr("TEMPLATES_DIR", filepath.Join(root, "web", "templates"))
staticDir := envOr("STATIC_DIR", filepath.Join(root, "web", "static"))
pool, err := waitForDB(ctx, cfg.DatabaseURL, 60*time.Second)
if err != nil {
log.Fatalf("database: %v", err)
}
defer pool.Close()
if err := db.Migrate(ctx, pool, migrationsDir); err != nil {
log.Fatalf("migrate: %v", err)
}
if cfg.AutoImportDump {
if _, err := os.Stat(cfg.MySQLDumpPath); err == nil {
if err := mysqlimport.ImportFile(ctx, pool, cfg.MySQLDumpPath); err != nil {
log.Fatalf("mysql import: %v", err)
}
} else {
log.Printf("mysql dump not found at %s, skip import", cfg.MySQLDumpPath)
}
}
authStore := auth.NewStore(cfg, pool)
if err := authStore.EnsureAdmin(ctx); err != nil {
log.Fatalf("ensure admin: %v", err)
}
log.Printf("admin ready: username=%s email=%s", cfg.AdminUsername, cfg.AdminEmail)
app, err := handlers.New(pool, authStore, cfg, templatesDir)
if err != nil {
log.Fatalf("handlers: %v", err)
}
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(app.OfflineMiddleware)
fileServer(r, "/static/", http.Dir(staticDir))
r.Get("/", app.Home)
r.Get("/login", app.LoginPage)
r.Post("/login", app.LoginPost)
r.Get("/logout", app.Logout)
r.Get("/wg", app.PublicWG)
r.Get("/wg_config.php", app.PublicWG)
r.Route("/admin", func(r chi.Router) {
r.Use(authStore.RequireAdmin)
r.Get("/", app.AdminDashboard)
r.Post("/status", app.AdminStatusPost)
r.Get("/wg", app.AdminWG)
r.Post("/wg/upload", app.AdminWGUpload)
r.Post("/wg/delete", app.AdminWGDelete)
r.Post("/wg/settings", app.AdminWGSettings)
})
srv := &http.Server{
Addr: ":" + cfg.AppPort,
Handler: r,
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
log.Printf("listening on :%s (%s)", cfg.AppPort, cfg.AppURL)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server: %v", err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
}
func waitForDB(ctx context.Context, databaseURL string, timeout time.Duration) (*pgxpool.Pool, error) {
deadline := time.Now().Add(timeout)
var last error
for time.Now().Before(deadline) {
pool, err := db.Connect(ctx, databaseURL)
if err == nil {
return pool, nil
}
last = err
log.Printf("waiting for postgres: %v", err)
time.Sleep(2 * time.Second)
}
return nil, last
}
func fileServer(r chi.Router, pathPrefix string, root http.FileSystem) {
r.Handle(pathPrefix+"*", http.StripPrefix(pathPrefix, http.FileServer(root)))
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
+48
View File
@@ -0,0 +1,48 @@
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-wg}
POSTGRES_USER: ${POSTGRES_USER:-wg}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 20
networks:
- wg
app:
build: .
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
- "${APP_PUBLISH_PORT:-8080}:8080"
environment:
APP_PORT: "8080"
APP_URL: ${APP_URL:-http://localhost:8080}
SITE_NAME: ${SITE_NAME:-Evilfox.cc}
DATABASE_URL: postgres://${POSTGRES_USER:-wg}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-wg}?sslmode=disable
SESSION_SECRET: ${SESSION_SECRET:?set SESSION_SECRET}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@evilfox.cc}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD}
AUTO_IMPORT_MYSQL: ${AUTO_IMPORT_MYSQL:-true}
MYSQL_DUMP_PATH: /data/wg.sql
SECURE_COOKIES: ${SECURE_COOKIES:-false}
TZ: ${TZ:-UTC}
networks:
- wg
volumes:
pgdata:
networks:
wg:
driver: bridge
+20
View File
@@ -0,0 +1,20 @@
module github.com/evilfox/wg-panel
go 1.26.5
require (
github.com/go-chi/chi/v5 v5.3.1
github.com/gorilla/sessions v1.4.0
github.com/jackc/pgx/v5 v5.10.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/crypto v0.54.0
)
require (
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.40.0 // indirect
)
+38
View File
@@ -0,0 +1,38 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
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/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+180
View File
@@ -0,0 +1,180 @@
package auth
import (
"context"
"encoding/gob"
"errors"
"fmt"
"net/http"
"time"
"github.com/evilfox/wg-panel/internal/config"
"github.com/evilfox/wg-panel/internal/models"
"github.com/gorilla/sessions"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/crypto/bcrypt"
)
func init() {
gob.Register(int64(0))
}
const SessionName = "wg_session"
type Store struct {
Sessions *sessions.CookieStore
DB *pgxpool.Pool
Cfg config.Config
}
func NewStore(cfg config.Config, db *pgxpool.Pool) *Store {
store := sessions.NewCookieStore([]byte(cfg.SessionSecret))
store.Options = &sessions.Options{
Path: "/",
HttpOnly: true,
Secure: cfg.SecureCookies,
SameSite: http.SameSiteLaxMode,
MaxAge: 0,
}
return &Store{Sessions: store, DB: db, Cfg: cfg}
}
func (s *Store) EnsureAdmin(ctx context.Context) error {
if s.Cfg.AdminPassword == "" {
return errors.New("ADMIN_PASSWORD is required in environment")
}
hash, err := bcrypt.GenerateFromPassword([]byte(s.Cfg.AdminPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
var id int64
err = s.DB.QueryRow(ctx, `SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1`).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
_, err = s.DB.Exec(ctx, `
INSERT INTO users (username, email, password_hash, role, is_approved)
VALUES ($1, $2, $3, 'admin', TRUE)
ON CONFLICT (username) DO UPDATE
SET email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash,
role = 'admin',
is_approved = TRUE,
updated_at = NOW()`,
s.Cfg.AdminUsername, s.Cfg.AdminEmail, string(hash),
)
return err
}
if err != nil {
return err
}
_, err = s.DB.Exec(ctx, `
UPDATE users
SET username = $1,
email = $2,
password_hash = $3,
role = 'admin',
is_approved = TRUE,
updated_at = NOW()
WHERE id = $4`,
s.Cfg.AdminUsername, s.Cfg.AdminEmail, string(hash), id,
)
return err
}
func (s *Store) Authenticate(ctx context.Context, login, password string) (*models.User, error) {
var u models.User
err := s.DB.QueryRow(ctx, `
SELECT id, username, email, password_hash, role, is_approved, created_at
FROM users
WHERE username = $1 OR email = $1
LIMIT 1`, login,
).Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &u.Role, &u.IsApproved, &u.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, errors.New("invalid credentials")
}
if err != nil {
return nil, err
}
if !u.IsApproved {
return nil, errors.New("account pending approval")
}
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
return nil, errors.New("invalid credentials")
}
return &u, nil
}
func (s *Store) Login(w http.ResponseWriter, r *http.Request, u *models.User, remember bool) error {
sess, _ := s.Sessions.Get(r, SessionName)
sess.Values["user_id"] = u.ID
sess.Values["username"] = u.Username
sess.Values["role"] = u.Role
sess.Values["remember"] = remember
if remember {
sess.Options.MaxAge = int(s.Cfg.RememberDuration().Seconds())
} else {
sess.Options.MaxAge = 0
}
return sess.Save(r, w)
}
func (s *Store) Logout(w http.ResponseWriter, r *http.Request) error {
sess, _ := s.Sessions.Get(r, SessionName)
sess.Options.MaxAge = -1
sess.Values = map[interface{}]interface{}{}
return sess.Save(r, w)
}
func (s *Store) CurrentUser(r *http.Request) *models.SessionUser {
sess, err := s.Sessions.Get(r, SessionName)
if err != nil {
return nil
}
id, ok := sess.Values["user_id"].(int64)
if !ok {
// gorilla may decode numbers as int
switch v := sess.Values["user_id"].(type) {
case int:
id = int64(v)
ok = true
case float64:
id = int64(v)
ok = true
}
}
if !ok || id == 0 {
return nil
}
username, _ := sess.Values["username"].(string)
role, _ := sess.Values["role"].(string)
return &models.SessionUser{ID: id, Username: username, Role: role}
}
func (s *Store) RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u := s.CurrentUser(r)
if u == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if u.Role != "admin" {
http.Error(w, "Доступ запрещён", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func HashPassword(password string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(b), nil
}
func FormatDuration(d time.Duration) string {
return fmt.Sprintf("%d", int(d.Seconds()))
}
+75
View File
@@ -0,0 +1,75 @@
package config
import (
"os"
"strconv"
"strings"
"time"
)
type Config struct {
AppPort string
AppURL string
SiteName string
DatabaseURL string
SessionSecret string
AdminUsername string
AdminEmail string
AdminPassword string
MySQLDumpPath string
AutoImportDump bool
SecureCookies bool
RememberDays int
}
func Load() Config {
return Config{
AppPort: getenv("APP_PORT", "8080"),
AppURL: strings.TrimRight(getenv("APP_URL", "http://localhost:8080"), "/"),
SiteName: getenv("SITE_NAME", "Evilfox.cc"),
DatabaseURL: getenv("DATABASE_URL", "postgres://wg:wg@localhost:5432/wg?sslmode=disable"),
SessionSecret: getenv("SESSION_SECRET", "change-me-session-secret-32chars!!"),
AdminUsername: getenv("ADMIN_USERNAME", "admin"),
AdminEmail: getenv("ADMIN_EMAIL", "admin@evilfox.cc"),
AdminPassword: getenv("ADMIN_PASSWORD", ""),
MySQLDumpPath: getenv("MYSQL_DUMP_PATH", "/data/wg.sql"),
AutoImportDump: getenvBool("AUTO_IMPORT_MYSQL", true),
SecureCookies: getenvBool("SECURE_COOKIES", false),
RememberDays: getenvInt("REMEMBER_DAYS", 30),
}
}
func (c Config) RememberDuration() time.Duration {
return time.Duration(c.RememberDays) * 24 * time.Hour
}
func getenv(key, def string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return def
}
func getenvBool(key string, def bool) bool {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return def
}
b, err := strconv.ParseBool(v)
if err != nil {
return def
}
return b
}
func getenvInt(key string, def int) int {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
return def
}
return n
}
+45
View File
@@ -0,0 +1,45 @@
package db
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func Connect(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, fmt.Errorf("parse database url: %w", err)
}
cfg.MaxConns = 20
cfg.MinConns = 2
cfg.MaxConnLifetime = time.Hour
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("connect: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping: %w", err)
}
return pool, nil
}
func Migrate(ctx context.Context, pool *pgxpool.Pool, migrationsDir string) error {
path := filepath.Join(migrationsDir, "001_schema.sql")
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read migration %s: %w", path, err)
}
if _, err := pool.Exec(ctx, string(data)); err != nil {
return fmt.Errorf("apply schema: %w", err)
}
log.Println("database schema applied")
return nil
}
+418
View File
@@ -0,0 +1,418 @@
package handlers
import (
"archive/zip"
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"html/template"
"io"
"log"
"math/big"
"net/http"
"net/url"
"path"
"path/filepath"
"strconv"
"strings"
"github.com/evilfox/wg-panel/internal/auth"
"github.com/evilfox/wg-panel/internal/config"
"github.com/evilfox/wg-panel/internal/models"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/skip2/go-qrcode"
)
type App struct {
DB *pgxpool.Pool
Auth *auth.Store
Cfg config.Config
Templates *template.Template
}
func New(db *pgxpool.Pool, a *auth.Store, cfg config.Config, templatesDir string) (*App, error) {
funcs := template.FuncMap{
"appURL": func() string { return cfg.AppURL },
"siteName": func() string { return cfg.SiteName },
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
}
tmpl, err := template.New("").Funcs(funcs).ParseGlob(filepath.Join(templatesDir, "*.html"))
if err != nil {
return nil, err
}
return &App{DB: db, Auth: a, Cfg: cfg, Templates: tmpl}, nil
}
func (a *App) render(w http.ResponseWriter, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := a.Templates.ExecuteTemplate(w, name, data); err != nil {
log.Printf("template %s: %v", name, err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}
func (a *App) SiteOnline(ctx context.Context) (models.SiteStatus, error) {
var s models.SiteStatus
err := a.DB.QueryRow(ctx, `SELECT id, is_online, message FROM site_status WHERE id = 1`).Scan(&s.ID, &s.IsOnline, &s.Message)
if err != nil {
return models.SiteStatus{IsOnline: true, Message: ""}, err
}
return s, nil
}
func (a *App) OfflineMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/login") || strings.HasPrefix(r.URL.Path, "/admin") || strings.HasPrefix(r.URL.Path, "/static") {
next.ServeHTTP(w, r)
return
}
u := a.Auth.CurrentUser(r)
if u != nil && u.Role == "admin" {
next.ServeHTTP(w, r)
return
}
st, err := a.SiteOnline(r.Context())
if err == nil && !st.IsOnline {
a.render(w, "offline.html", map[string]any{"Message": st.Message, "SiteName": a.Cfg.SiteName})
return
}
next.ServeHTTP(w, r)
})
}
func (a *App) Home(w http.ResponseWriter, r *http.Request) {
u := a.Auth.CurrentUser(r)
a.render(w, "home.html", map[string]any{"User": u})
}
func (a *App) LoginPage(w http.ResponseWriter, r *http.Request) {
if u := a.Auth.CurrentUser(r); u != nil {
if u.Role == "admin" {
http.Redirect(w, r, "/admin", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
a.render(w, "login.html", map[string]any{"Error": ""})
}
func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
a.render(w, "login.html", map[string]any{"Error": "Некорректная форма"})
return
}
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
remember := r.FormValue("remember_me") == "1"
u, err := a.Auth.Authenticate(r.Context(), username, password)
if err != nil {
msg := "Неверное имя пользователя или пароль."
if err.Error() == "account pending approval" {
msg = "Ваш аккаунт ожидает одобрения администратором."
}
a.render(w, "login.html", map[string]any{"Error": msg, "Username": username})
return
}
if err := a.Auth.Login(w, r, u, remember); err != nil {
a.render(w, "login.html", map[string]any{"Error": "Ошибка сессии"})
return
}
if u.Role == "admin" {
http.Redirect(w, r, "/admin", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (a *App) Logout(w http.ResponseWriter, r *http.Request) {
_ = a.Auth.Logout(w, r)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (a *App) AdminDashboard(w http.ResponseWriter, r *http.Request) {
u := a.Auth.CurrentUser(r)
var totalConfigs, totalUsers int
_ = a.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM wg_configs`).Scan(&totalConfigs)
_ = a.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM users`).Scan(&totalUsers)
st, _ := a.SiteOnline(r.Context())
a.render(w, "admin_dashboard.html", map[string]any{
"User": u,
"TotalConfigs": totalConfigs,
"TotalUsers": totalUsers,
"SiteOnline": st.IsOnline,
"SiteMessage": st.Message,
})
}
func (a *App) AdminStatusPost(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
online := r.FormValue("is_online") == "1"
message := strings.TrimSpace(r.FormValue("message"))
if message == "" {
message = "Сайт временно недоступен."
}
_, _ = a.DB.Exec(r.Context(), `
UPDATE site_status SET is_online = $1, message = $2, updated_at = NOW() WHERE id = 1`, online, message)
http.Redirect(w, r, "/admin", http.StatusSeeOther)
}
func generateToken(n int) (string, error) {
const chars = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
b := make([]byte, n)
for i := 0; i < n; i++ {
v, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
if err != nil {
return "", err
}
b[i] = chars[v.Int64()]
}
return string(b), nil
}
func (a *App) AdminWG(w http.ResponseWriter, r *http.Request) {
u := a.Auth.CurrentUser(r)
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
const perPage = 10
var total int
_ = a.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM wg_configs`).Scan(&total)
totalPages := (total + perPage - 1) / perPage
if totalPages < 1 {
totalPages = 1
}
offset := (page - 1) * perPage
rows, err := a.DB.Query(r.Context(), `
SELECT id, COALESCE(token,''), original_filename, created_at
FROM wg_configs ORDER BY created_at DESC LIMIT $1 OFFSET $2`, perPage, offset)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
defer rows.Close()
var configs []models.WGConfig
for rows.Next() {
var c models.WGConfig
if err := rows.Scan(&c.ID, &c.Token, &c.OriginalFilename, &c.CreatedAt); err != nil {
http.Error(w, err.Error(), 500)
return
}
configs = append(configs, c)
}
msg := r.URL.Query().Get("msg")
msgType := r.URL.Query().Get("type")
token := r.URL.Query().Get("token")
settings := a.wgSettings(r.Context())
a.render(w, "admin_wg.html", map[string]any{
"User": u,
"Configs": configs,
"Page": page,
"TotalPages": totalPages,
"Message": msg,
"MessageType": msgType,
"NewToken": token,
"PublicURL": a.Cfg.AppURL + "/wg?token=",
"ShowZipFull": settings["show_zip_full"] == "1",
"ShowZipConf": settings["show_zip_conf"] == "1",
})
}
func (a *App) AdminWGUpload(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(1 << 20); err != nil {
http.Redirect(w, r, "/admin/wg?msg="+urlQ("Ошибка формы")+"&type=error", http.StatusSeeOther)
return
}
file, hdr, err := r.FormFile("wg_conf")
if err != nil {
http.Redirect(w, r, "/admin/wg?msg="+urlQ("Файл не выбран")+"&type=error", http.StatusSeeOther)
return
}
defer file.Close()
if !strings.HasSuffix(strings.ToLower(hdr.Filename), ".conf") {
http.Redirect(w, r, "/admin/wg?msg="+urlQ("Разрешены только .conf")+"&type=error", http.StatusSeeOther)
return
}
content, err := io.ReadAll(io.LimitReader(file, 10000))
if err != nil || len(content) == 0 {
http.Redirect(w, r, "/admin/wg?msg="+urlQ("Не удалось прочитать файл")+"&type=error", http.StatusSeeOther)
return
}
var token string
for {
token, err = generateToken(5)
if err != nil {
http.Redirect(w, r, "/admin/wg?msg="+urlQ("Ошибка токена")+"&type=error", http.StatusSeeOther)
return
}
var exists bool
_ = a.DB.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM wg_configs WHERE token=$1)`, token).Scan(&exists)
if !exists {
break
}
}
orig := path.Base(hdr.Filename)
_, err = a.DB.Exec(r.Context(), `
INSERT INTO wg_configs (token, config_content, original_filename)
VALUES ($1, $2, $3)`, token, string(content), orig)
if err != nil {
http.Redirect(w, r, "/admin/wg?msg="+urlQ("Ошибка сохранения")+"&type=error", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/wg?msg="+urlQ("Конфиг сохранён")+"&type=success&token="+token, http.StatusSeeOther)
}
func (a *App) AdminWGDelete(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64)
if id > 0 {
_, _ = a.DB.Exec(r.Context(), `DELETE FROM wg_configs WHERE id=$1`, id)
}
http.Redirect(w, r, "/admin/wg", http.StatusSeeOther)
}
func (a *App) PublicWG(w http.ResponseWriter, r *http.Request) {
token := sanitizeToken(r.URL.Query().Get("token"))
if len(token) < 5 {
http.Error(w, "Неверный токен", http.StatusBadRequest)
return
}
download := r.URL.Query().Get("download")
var cfg models.WGConfig
err := a.DB.QueryRow(r.Context(), `
SELECT id, COALESCE(token,''), config_content, original_filename
FROM wg_configs WHERE token=$1`, token,
).Scan(&cfg.ID, &cfg.Token, &cfg.ConfigContent, &cfg.OriginalFilename)
if err != nil {
if err == pgx.ErrNoRows {
http.Error(w, "Конфиг не найден", http.StatusNotFound)
return
}
http.Error(w, err.Error(), 500)
return
}
fileName := sanitizeFilename(cfg.OriginalFilename)
if !strings.HasSuffix(strings.ToLower(fileName), ".conf") {
fileName += ".conf"
}
settings := a.wgSettings(r.Context())
if download == "full" || download == "conf" {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
fw, err := zw.Create(fileName)
if err != nil {
http.Error(w, "zip error", 500)
return
}
_, _ = fw.Write([]byte(cfg.ConfigContent))
if download == "full" {
png, err := qrcode.Encode(cfg.ConfigContent, qrcode.Medium, 300)
if err == nil {
qrw, _ := zw.Create("qr_code.png")
_, _ = qrw.Write(png)
}
}
_ = zw.Close()
name := fmt.Sprintf("wg_config_%s.zip", token)
if download == "conf" {
name = fmt.Sprintf("wg_config_%s_only.conf.zip", token)
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
_, _ = w.Write(buf.Bytes())
return
}
png, err := qrcode.Encode(cfg.ConfigContent, qrcode.Medium, 300)
qrData := ""
if err == nil {
qrData = "data:image/png;base64," + base64.StdEncoding.EncodeToString(png)
}
a.render(w, "wg_public.html", map[string]any{
"FileName": fileName,
"Token": token,
"QR": qrData,
"Config": cfg.ConfigContent,
"ShowZipFull": settings["show_zip_full"] == "1",
"ShowZipConf": settings["show_zip_conf"] == "1",
})
}
func (a *App) wgSettings(ctx context.Context) map[string]string {
out := map[string]string{"show_zip_full": "0", "show_zip_conf": "1"}
rows, err := a.DB.Query(ctx, `SELECT setting_key, setting_value FROM wg_config_settings`)
if err != nil {
return out
}
defer rows.Close()
for rows.Next() {
var k, v string
if rows.Scan(&k, &v) == nil {
out[k] = v
}
}
return out
}
func (a *App) AdminWGSettings(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
full := "0"
conf := "0"
if r.FormValue("show_zip_full") == "1" {
full = "1"
}
if r.FormValue("show_zip_conf") == "1" {
conf = "1"
}
_, _ = a.DB.Exec(r.Context(), `
INSERT INTO wg_config_settings (setting_key, setting_value, updated_at)
VALUES ('show_zip_full', $1, NOW())
ON CONFLICT (setting_key) DO UPDATE SET setting_value = EXCLUDED.setting_value, updated_at = NOW()`, full)
_, _ = a.DB.Exec(r.Context(), `
INSERT INTO wg_config_settings (setting_key, setting_value, updated_at)
VALUES ('show_zip_conf', $1, NOW())
ON CONFLICT (setting_key) DO UPDATE SET setting_value = EXCLUDED.setting_value, updated_at = NOW()`, conf)
http.Redirect(w, r, "/admin/wg", http.StatusSeeOther)
}
func sanitizeToken(s string) string {
var b strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
}
}
return b.String()
}
func sanitizeFilename(s string) string {
s = path.Base(s)
var b strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' {
b.WriteRune(r)
} else {
b.WriteByte('_')
}
}
if b.Len() == 0 {
return "wg_config.conf"
}
return b.String()
}
func urlQ(s string) string {
return url.QueryEscape(s)
}
+35
View File
@@ -0,0 +1,35 @@
package models
import "time"
type User struct {
ID int64
Username string
Email string
PasswordHash string
Role string
IsApproved bool
CreatedAt time.Time
}
type WGConfig struct {
ID int64
Token string
UserID *int64
ConfigContent string
CreatedAt time.Time
OriginalFilename string
IsDemo bool
}
type SiteStatus struct {
ID int64
IsOnline bool
Message string
}
type SessionUser struct {
ID int64
Username string
Role string
}
+342
View File
@@ -0,0 +1,342 @@
package mysqlimport
import (
"context"
"fmt"
"log"
"os"
"regexp"
"strconv"
"strings"
"unicode/utf8"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
reInsert = regexp.MustCompile(`(?is)INSERT INTO\s+\x60?(\w+)\x60?\s+VALUES\s*(.+?);`)
reCreate = regexp.MustCompile(`(?is)CREATE TABLE\s+\x60?(\w+)\x60?`)
)
// ImportFile converts and loads a MySQL dump into PostgreSQL once.
func ImportFile(ctx context.Context, pool *pgxpool.Pool, dumpPath string) error {
var done string
err := pool.QueryRow(ctx, `SELECT value FROM app_meta WHERE key = 'mysql_import_done'`).Scan(&done)
if err == nil && done == "1" {
log.Println("mysql import already done, skipping")
return nil
}
raw, err := os.ReadFile(dumpPath)
if err != nil {
return fmt.Errorf("read dump: %w", err)
}
if !utf8.Valid(raw) {
// try windows-1251/latin1 fallback: replace invalid
raw = []byte(strings.ToValidUTF8(string(raw), "?"))
}
text := string(raw)
tablesOrder := []string{
"users",
"site_status",
"servers",
"servers_simple",
"countries",
"wg_configs",
"wg_config_settings",
"demo_keys",
"awg_configs",
"amnezia_configs",
"vless_configs",
"admin_uploads",
"user_servers",
"license_keys",
"rate_limits",
}
tx, err := pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
// Temporarily disable FK checks via deferred constraints where possible
if _, err := tx.Exec(ctx, `SET session_replication_role = replica`); err != nil {
log.Printf("warn: cannot set session_replication_role: %v", err)
}
inserts := map[string][]string{}
for _, m := range reInsert.FindAllStringSubmatch(text, -1) {
table := strings.ToLower(m[1])
valuesPart := strings.TrimSpace(m[2])
rows, err := splitValueTuples(valuesPart)
if err != nil {
return fmt.Errorf("parse inserts for %s: %w", table, err)
}
inserts[table] = append(inserts[table], rows...)
}
for _, table := range tablesOrder {
rows := inserts[table]
if len(rows) == 0 {
continue
}
log.Printf("importing %s (%d rows)", table, len(rows))
if err := importTable(ctx, tx, table, rows); err != nil {
return fmt.Errorf("import %s: %w", table, err)
}
}
// Fix sequences
for _, table := range tablesOrder {
_, _ = tx.Exec(ctx, fmt.Sprintf(`
SELECT setval(pg_get_serial_sequence('%s','id'), COALESCE((SELECT MAX(id) FROM %s), 1), true)
`, table, table))
}
_, err = tx.Exec(ctx, `
INSERT INTO app_meta (key, value, updated_at)
VALUES ('mysql_import_done', '1', NOW())
ON CONFLICT (key) DO UPDATE SET value = '1', updated_at = NOW()`)
if err != nil {
return err
}
if _, err := tx.Exec(ctx, `SET session_replication_role = DEFAULT`); err != nil {
log.Printf("warn: restore session_replication_role: %v", err)
}
if err := tx.Commit(ctx); err != nil {
return err
}
log.Println("mysql dump imported successfully")
return nil
}
func importTable(ctx context.Context, tx pgx.Tx, table string, rows []string) error {
cols, err := tableColumns(table)
if err != nil {
return err
}
for _, row := range rows {
vals, err := parseRowValues(row)
if err != nil {
return err
}
if len(vals) != len(cols) {
// try truncate/pad carefully
if len(vals) < len(cols) {
return fmt.Errorf("column count mismatch for %s: got %d want %d value=%s", table, len(vals), len(cols), truncate(row, 120))
}
vals = vals[:len(cols)]
}
converted := make([]any, len(vals))
placeholders := make([]string, len(vals))
for i, v := range vals {
converted[i] = convertValue(table, cols[i], v)
placeholders[i] = fmt.Sprintf("$%d", i+1)
}
sql := fmt.Sprintf(
`INSERT INTO %s (%s) VALUES (%s) ON CONFLICT DO NOTHING`,
table,
strings.Join(quoteIdent(cols), ", "),
strings.Join(placeholders, ", "),
)
if _, err := tx.Exec(ctx, sql, converted...); err != nil {
return fmt.Errorf("insert failed (%s): %w; sample=%s", table, err, truncate(row, 160))
}
}
return nil
}
func tableColumns(table string) ([]string, error) {
m := map[string][]string{
"admin_uploads": {"id", "original_name", "stored_name", "file_path", "file_url", "file_size", "uploaded_at"},
"amnezia_configs": {"id", "config_name", "original_filename", "file_path", "file_url", "config_content", "host", "port", "username", "password", "description", "created_at"},
"awg_configs": {"id", "config_name", "config_content", "qr_code_url", "created_at"},
"countries": {"id", "code", "name_ru", "name_en", "flag", "sort_order"},
"demo_keys": {"id", "config_id", "token", "expires_at", "created_at"},
"license_keys": {"id", "key_value", "server_id", "protocol", "client_id", "email", "used_by", "created_at", "expires_at"},
"rate_limits": {"id", "user_id", "action", "created_at"},
"servers": {"id", "name", "ip", "port", "panel_username", "panel_password", "status", "created_at", "updated_at", "inbound_id"},
"servers_simple": {"id", "name", "host", "port", "country", "status", "created_at"},
"site_status": {"id", "is_online", "message", "updated_at"},
"user_servers": {"id", "user_id", "server_id", "inbound_id", "protocol", "client_id", "email", "created_at"},
"users": {"id", "username", "email", "password_hash", "role", "is_approved", "created_at", "updated_at"},
"vless_configs": {"id", "vless_uri", "subscription_url", "created_at"},
"wg_config_settings": {"setting_key", "setting_value", "updated_at"},
"wg_configs": {"id", "token", "user_id", "config_content", "created_at", "original_filename", "is_demo"},
}
cols, ok := m[table]
if !ok {
return nil, fmt.Errorf("unknown table %s", table)
}
return cols, nil
}
func convertValue(table, col, raw string) any {
v := strings.TrimSpace(raw)
if strings.EqualFold(v, "NULL") {
return nil
}
if len(v) >= 2 && v[0] == '\'' && v[len(v)-1] == '\'' {
v = unquoteMySQLString(v)
}
// MySQL dump has an empty country code for "Other"
if table == "countries" && col == "code" && strings.TrimSpace(v) == "" {
return "_"
}
boolCols := map[string]bool{
"is_approved": true,
"is_online": true,
"is_demo": true,
}
if boolCols[col] {
if v == "1" || strings.EqualFold(v, "true") {
return true
}
return false
}
intCols := map[string]bool{
"id": true, "user_id": true, "server_id": true, "config_id": true,
"port": true, "file_size": true, "inbound_id": true, "sort_order": true,
"used_by": true,
}
if intCols[col] {
n, err := strconv.ParseInt(v, 10, 64)
if err == nil {
return n
}
}
_ = table
return v
}
func unquoteMySQLString(s string) string {
s = s[1 : len(s)-1]
replacer := strings.NewReplacer(
`\\`, `\`,
`\'`, `'`,
`\"`, `"`,
`\n`, "\n",
`\r`, "\r",
`\t`, "\t",
`\0`, "\x00",
`\Z`, "\x1a",
)
return replacer.Replace(s)
}
func splitValueTuples(s string) ([]string, error) {
var rows []string
depth := 0
inStr := false
esc := false
start := -1
for i := 0; i < len(s); i++ {
c := s[i]
if inStr {
if esc {
esc = false
continue
}
if c == '\\' {
esc = true
continue
}
if c == '\'' {
// mysql '' escape
if i+1 < len(s) && s[i+1] == '\'' {
i++
continue
}
inStr = false
}
continue
}
if c == '\'' {
inStr = true
continue
}
if c == '(' {
if depth == 0 {
start = i + 1
}
depth++
continue
}
if c == ')' {
depth--
if depth == 0 && start >= 0 {
rows = append(rows, s[start:i])
start = -1
}
continue
}
}
if depth != 0 || inStr {
return nil, fmt.Errorf("unbalanced values clause")
}
return rows, nil
}
func parseRowValues(row string) ([]string, error) {
var vals []string
inStr := false
esc := false
start := 0
for i := 0; i < len(row); i++ {
c := row[i]
if inStr {
if esc {
esc = false
continue
}
if c == '\\' {
esc = true
continue
}
if c == '\'' {
if i+1 < len(row) && row[i+1] == '\'' {
i++
continue
}
inStr = false
}
continue
}
if c == '\'' {
inStr = true
continue
}
if c == ',' {
vals = append(vals, strings.TrimSpace(row[start:i]))
start = i + 1
}
}
vals = append(vals, strings.TrimSpace(row[start:]))
return vals, nil
}
func quoteIdent(cols []string) []string {
out := make([]string, len(cols))
for i, c := range cols {
out[i] = `"` + c + `"`
}
return out
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
// Touch for unused regex keep
var _ = reCreate
+44
View File
@@ -0,0 +1,44 @@
package mysqlimport
import (
"os"
"testing"
)
func TestParseDump(t *testing.T) {
path := `D:\Новая папка (10)\wg\wg.sql`
raw, err := os.ReadFile(path)
if err != nil {
t.Skip(err)
}
text := string(raw)
counts := map[string]int{}
for _, m := range reInsert.FindAllStringSubmatch(text, -1) {
table := m[1]
rows, err := splitValueTuples(m[2])
if err != nil {
t.Fatalf("%s: %v", table, err)
}
for _, row := range rows {
cols, err := tableColumns(table)
if err != nil {
t.Fatalf("cols %s: %v", table, err)
}
vals, err := parseRowValues(row)
if err != nil {
t.Fatalf("row %s: %v", table, err)
}
if len(vals) != len(cols) {
t.Fatalf("%s col mismatch got=%d want=%d sample=%s", table, len(vals), len(cols), truncate(row, 100))
}
}
counts[table] += len(rows)
}
if counts["wg_configs"] < 1 {
t.Fatalf("expected wg_configs rows, got %+v", counts)
}
if counts["users"] < 1 {
t.Fatalf("expected users rows, got %+v", counts)
}
t.Logf("counts: %+v", counts)
}
+167
View File
@@ -0,0 +1,167 @@
-- PostgreSQL 17 schema (converted from MySQL wg.sql)
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
email VARCHAR(128) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(16) NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')),
is_approved BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS site_status (
id BIGSERIAL PRIMARY KEY,
is_online BOOLEAN NOT NULL DEFAULT TRUE,
message TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS servers (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
ip VARCHAR(45) NOT NULL,
port INTEGER NOT NULL DEFAULT 54321,
panel_username VARCHAR(64) NOT NULL,
panel_password VARCHAR(255) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ,
inbound_id INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS servers_simple (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
host VARCHAR(255) NOT NULL,
port INTEGER NOT NULL,
country VARCHAR(255) DEFAULT '?',
status VARCHAR(16) DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS countries (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(8) NOT NULL UNIQUE,
name_ru VARCHAR(128) NOT NULL,
name_en VARCHAR(128) NOT NULL,
flag VARCHAR(16) NOT NULL DEFAULT '',
sort_order INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS wg_configs (
id BIGSERIAL PRIMARY KEY,
token VARCHAR(32) UNIQUE,
user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
config_content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
original_filename VARCHAR(255) NOT NULL DEFAULT 'wg_config.conf',
is_demo BOOLEAN DEFAULT FALSE
);
CREATE INDEX IF NOT EXISTS idx_wg_configs_user_id ON wg_configs(user_id);
CREATE TABLE IF NOT EXISTS wg_config_settings (
setting_key VARCHAR(50) PRIMARY KEY,
setting_value VARCHAR(10) NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS demo_keys (
id BIGSERIAL PRIMARY KEY,
config_id BIGINT NOT NULL REFERENCES wg_configs(id) ON DELETE CASCADE,
token VARCHAR(32) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS awg_configs (
id BIGSERIAL PRIMARY KEY,
config_name VARCHAR(100) NOT NULL,
config_content TEXT NOT NULL,
qr_code_url VARCHAR(512) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS amnezia_configs (
id BIGSERIAL PRIMARY KEY,
config_name VARCHAR(255) NOT NULL,
original_filename VARCHAR(255) NOT NULL,
file_path VARCHAR(512) NOT NULL,
file_url VARCHAR(512) NOT NULL,
config_content TEXT NOT NULL,
host VARCHAR(255) NOT NULL,
port INTEGER NOT NULL DEFAULT 51820,
username VARCHAR(255),
password VARCHAR(255),
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS vless_configs (
id BIGSERIAL PRIMARY KEY,
vless_uri TEXT NOT NULL,
subscription_url TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS admin_uploads (
id BIGSERIAL PRIMARY KEY,
original_name VARCHAR(255) NOT NULL,
stored_name VARCHAR(255) NOT NULL,
file_path VARCHAR(512) NOT NULL,
file_url VARCHAR(512) NOT NULL DEFAULT '',
file_size INTEGER NOT NULL,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS user_servers (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
inbound_id INTEGER NOT NULL,
protocol VARCHAR(20) NOT NULL,
client_id VARCHAR(64) NOT NULL,
email VARCHAR(128) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_user_servers_user ON user_servers(user_id);
CREATE INDEX IF NOT EXISTS idx_user_servers_server ON user_servers(server_id);
CREATE TABLE IF NOT EXISTS license_keys (
id BIGSERIAL PRIMARY KEY,
key_value CHAR(36) NOT NULL UNIQUE,
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
protocol VARCHAR(20) NOT NULL DEFAULT 'vless',
client_id VARCHAR(64) NOT NULL,
email VARCHAR(128) NOT NULL DEFAULT 'license@ultravpn.cc',
used_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS rate_limits (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
action VARCHAR(100) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_rate_limits_user_action_time ON rate_limits(user_id, action, created_at);
CREATE TABLE IF NOT EXISTS app_meta (
key VARCHAR(64) PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO site_status (id, is_online, message)
VALUES (1, TRUE, 'Сайт работает в штатном режиме.')
ON CONFLICT (id) DO NOTHING;
INSERT INTO wg_config_settings (setting_key, setting_value) VALUES
('show_zip_full', '0'),
('show_zip_conf', '1')
ON CONFLICT (setting_key) DO NOTHING;
+86
View File
@@ -0,0 +1,86 @@
:root {
--bg-deep: #050508;
--bg-card: rgba(12, 14, 22, 0.9);
--border: rgba(0, 212, 255, 0.15);
--border-bright: rgba(0, 212, 255, 0.4);
--accent: #00d4ff;
--accent-dim: #00a8cc;
--accent-glow: rgba(0, 212, 255, 0.25);
--text: #e8ecf4;
--text-muted: #8b95a8;
--error-bg: rgba(255, 82, 82, 0.12);
--error-border: rgba(255, 82, 82, 0.35);
--error-text: #ff9e9e;
--success-bg: rgba(0, 230, 118, 0.12);
--success-text: #00e676;
--font-mono: 'JetBrains Mono', monospace;
--font-sans: 'Outfit', system-ui, sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font-sans);
background: var(--bg-deep);
color: var(--text);
min-height: 100vh;
}
.bg-pattern {
position: fixed; inset: 0; z-index: 0; pointer-events: none;
background:
linear-gradient(180deg, transparent 0%, rgba(0, 212, 255, 0.02) 50%, transparent 100%),
repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0, 212, 255, 0.03) 2px, rgba(0, 212, 255, 0.03) 4px),
repeating-linear-gradient(90deg, transparent, transparent 2px, rgba(0, 212, 255, 0.03) 2px, rgba(0, 212, 255, 0.03) 4px);
}
.bg-glow {
position: fixed; width: 120%; height: 55vh; top: -10%; left: -10%; z-index: 0; pointer-events: none;
background: radial-gradient(ellipse at 50% 0%, var(--accent-glow) 0%, transparent 55%);
}
.wrap { position: relative; z-index: 1; max-width: 1000px; margin: 0 auto; padding: 1.5rem; }
.card {
background: var(--bg-card); border: 1px solid var(--border); border-radius: 14px; padding: 1.5rem; margin-bottom: 1.25rem;
}
h1, .title { font-family: var(--font-mono); color: var(--accent); }
.muted { color: var(--text-muted); }
.btn {
display: inline-block; padding: 0.75rem 1.2rem; font-family: var(--font-mono); font-size: 0.9rem;
color: var(--bg-deep); background: var(--accent); border: 1px solid var(--accent); border-radius: 10px;
text-decoration: none; cursor: pointer;
}
.btn:hover { background: var(--accent-dim); box-shadow: 0 0 20px var(--accent-glow); }
.btn-ghost {
background: transparent; color: var(--accent);
}
.btn-ghost:hover { background: rgba(0, 212, 255, 0.1); }
.btn-danger { background: transparent; color: var(--error-text); border-color: var(--error-border); }
input[type=text], input[type=password], input[type=email], textarea, input[type=file] {
width: 100%; padding: 0.85rem 1rem; color: var(--text); background: rgba(255,255,255,0.04);
border: 1px solid var(--border); border-radius: 10px; outline: none; font-family: var(--font-sans);
}
input:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
label { display: block; font-family: var(--font-mono); font-size: 0.8rem; color: var(--text-muted); margin-bottom: 0.45rem; text-transform: uppercase; letter-spacing: 0.04em; }
.form-group { margin-bottom: 1.1rem; }
.alert { padding: 0.9rem 1rem; border-radius: 10px; margin-bottom: 1rem; }
.alert.error { background: var(--error-bg); border: 1px solid var(--error-border); color: var(--error-text); }
.alert.success { background: var(--success-bg); border: 1px solid rgba(0,230,118,0.3); color: var(--success-text); }
.nav { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; margin-bottom: 1.5rem; }
.nav a { color: var(--accent); text-decoration: none; font-family: var(--font-mono); font-size: 0.9rem; }
.table { width: 100%; border-collapse: collapse; }
.table th, .table td { text-align: left; padding: 0.75rem 0.5rem; border-bottom: 1px solid var(--border); font-size: 0.92rem; vertical-align: top; }
.table th { color: var(--text-muted); font-family: var(--font-mono); font-weight: 500; }
code { font-family: var(--font-mono); color: var(--accent); }
.pager { display: flex; gap: 0.75rem; margin-top: 1rem; align-items: center; }
.login-box {
position: relative; z-index: 1; width: 100%; max-width: 420px; margin: 8vh auto;
background: var(--bg-card); border: 1px solid var(--border); border-radius: 16px; padding: 2.25rem;
}
.center { text-align: center; }
.qr img { border-radius: 12px; border: 1px solid var(--border); }
pre.config {
font-family: var(--font-mono); font-size: 0.8rem; line-height: 1.5; white-space: pre-wrap; word-break: break-all;
background: rgba(0,0,0,0.35); border: 1px solid var(--border); border-radius: 10px; padding: 1rem; max-height: 320px; overflow: auto;
}
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; }
.stat { padding: 1.2rem; border: 1px solid var(--border); border-radius: 12px; background: rgba(0,0,0,0.25); }
.stat .n { font-family: var(--font-mono); font-size: 1.8rem; color: var(--accent); }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition-duration: 0.01ms !important; }
}
+43
View File
@@ -0,0 +1,43 @@
{{define "admin_dashboard.html"}}
<!DOCTYPE html>
<html lang="ru">
<head>
{{template "layout_head" .}}
<title>Админка — {{siteName}}</title>
</head>
<body>
<div class="bg-pattern"></div><div class="bg-glow"></div>
<div class="wrap">
<div class="nav">
<strong class="title">Админ · {{siteName}}</strong>
<a href="/admin/wg">WireGuard</a>
<span style="flex:1"></span>
<span class="muted">{{.User.Username}}</span>
<a href="/logout">Выход</a>
</div>
<div class="stats">
<div class="stat"><div class="muted">WG конфиги</div><div class="n">{{.TotalConfigs}}</div></div>
<div class="stat"><div class="muted">Пользователи</div><div class="n">{{.TotalUsers}}</div></div>
<div class="stat"><div class="muted">Сайт</div><div class="n">{{if .SiteOnline}}ON{{else}}OFF{{end}}</div></div>
</div>
<div class="card" style="margin-top:1.25rem">
<h1 style="font-size:1.1rem;margin-bottom:1rem">Статус сайта</h1>
<form method="POST" action="/admin/status">
<div class="form-group">
<label>Режим</label>
<select name="is_online" style="width:100%;padding:0.85rem 1rem;border-radius:10px;background:rgba(255,255,255,0.04);color:var(--text);border:1px solid var(--border)">
<option value="1" {{if .SiteOnline}}selected{{end}}>Онлайн</option>
<option value="0" {{if not .SiteOnline}}selected{{end}}>Оффлайн</option>
</select>
</div>
<div class="form-group">
<label>Сообщение оффлайна</label>
<textarea name="message" rows="3">{{.SiteMessage}}</textarea>
</div>
<button class="btn" type="submit">Сохранить</button>
</form>
</div>
</div>
</body>
</html>
{{end}}
+83
View File
@@ -0,0 +1,83 @@
{{define "admin_wg.html"}}
<!DOCTYPE html>
<html lang="ru">
<head>
{{template "layout_head" .}}
<title>WireGuard — {{siteName}}</title>
</head>
<body>
<div class="bg-pattern"></div><div class="bg-glow"></div>
<div class="wrap">
<div class="nav">
<a href="/admin">← Админка</a>
<strong class="title">WireGuard .conf</strong>
<span style="flex:1"></span>
<a href="/logout">Выход</a>
</div>
{{if .Message}}
<div class="alert {{.MessageType}}">{{.Message}}{{if .NewToken}} · <code>{{.PublicURL}}{{.NewToken}}</code>{{end}}</div>
{{end}}
<div class="card">
<h1 style="font-size:1.1rem;margin-bottom:1rem">Загрузить .conf</h1>
<form method="POST" action="/admin/wg/upload" enctype="multipart/form-data">
<div class="form-group">
<label for="wg_conf">Файл WireGuard (.conf)</label>
<input id="wg_conf" type="file" name="wg_conf" accept=".conf" required>
</div>
<button class="btn" type="submit" name="submit_form" value="1">Сохранить</button>
</form>
</div>
<div class="card">
<h1 style="font-size:1.1rem;margin-bottom:1rem">Настройки скачивания</h1>
<form method="POST" action="/admin/wg/settings">
<div class="form-group" style="display:flex;gap:0.6rem;align-items:center">
<input type="checkbox" id="show_zip_full" name="show_zip_full" value="1" {{if .ShowZipFull}}checked{{end}}>
<label for="show_zip_full" style="margin:0;text-transform:none;font-family:var(--font-sans)">ZIP (conf + QR)</label>
</div>
<div class="form-group" style="display:flex;gap:0.6rem;align-items:center">
<input type="checkbox" id="show_zip_conf" name="show_zip_conf" value="1" {{if .ShowZipConf}}checked{{end}}>
<label for="show_zip_conf" style="margin:0;text-transform:none;font-family:var(--font-sans)">ZIP только conf</label>
</div>
<button class="btn btn-ghost" type="submit">Обновить настройки</button>
</form>
</div>
<div class="card">
<h1 style="font-size:1.1rem;margin-bottom:1rem">Конфиги</h1>
<table class="table">
<thead>
<tr><th>ID</th><th>Токен</th><th>Файл</th><th>Дата</th><th>Ссылка</th><th></th></tr>
</thead>
<tbody>
{{range .Configs}}
<tr>
<td>{{.ID}}</td>
<td><code>{{.Token}}</code></td>
<td>{{.OriginalFilename}}</td>
<td class="muted">{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
<td><a style="color:var(--accent)" href="/wg?token={{.Token}}" target="_blank">открыть</a></td>
<td>
<form method="POST" action="/admin/wg/delete" onsubmit="return confirm('Удалить?')">
<input type="hidden" name="id" value="{{.ID}}">
<button class="btn btn-danger" type="submit">Удалить</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="6" class="muted">Пока нет конфигов</td></tr>
{{end}}
</tbody>
</table>
<div class="pager">
{{if gt .Page 1}}<a class="btn btn-ghost" href="/admin/wg?page={{sub .Page 1}}"></a>{{end}}
<span class="muted">стр. {{.Page}} / {{.TotalPages}}</span>
{{if lt .Page .TotalPages}}<a class="btn btn-ghost" href="/admin/wg?page={{add .Page 1}}"></a>{{end}}
</div>
</div>
</div>
</body>
</html>
{{end}}
+29
View File
@@ -0,0 +1,29 @@
{{define "home.html"}}
<!DOCTYPE html>
<html lang="ru">
<head>
{{template "layout_head" .}}
<title>{{siteName}}</title>
</head>
<body>
<div class="bg-pattern"></div><div class="bg-glow"></div>
<div class="wrap">
<div class="nav">
<strong class="title">{{siteName}}</strong>
<span style="flex:1"></span>
{{if .User}}
<a href="/admin">Админка</a>
<a href="/logout">Выход</a>
{{else}}
<a href="/login">Вход</a>
{{end}}
</div>
<div class="card">
<h1>{{siteName}}</h1>
<p class="muted" style="margin:1rem 0 1.5rem">WireGuard панель на Go + PostgreSQL 17</p>
<a class="btn" href="/login">Войти в админку</a>
</div>
</div>
</body>
</html>
{{end}}
+33
View File
@@ -0,0 +1,33 @@
{{define "login.html"}}
<!DOCTYPE html>
<html lang="ru">
<head>
{{template "layout_head" .}}
<title>Вход — {{siteName}}</title>
</head>
<body>
<div class="bg-pattern"></div><div class="bg-glow"></div>
<div class="login-box">
<h1 class="center">Вход в аккаунт</h1>
<p class="muted center" style="margin:0.4rem 0 1.5rem">{{siteName}}</p>
{{if .Error}}<div class="alert error">{{.Error}}</div>{{end}}
<form method="POST" action="/login">
<div class="form-group">
<label for="username">Имя пользователя или email</label>
<input id="username" name="username" type="text" required autofocus value="{{.Username}}">
</div>
<div class="form-group">
<label for="password">Пароль</label>
<input id="password" name="password" type="password" required>
</div>
<div class="form-group" style="display:flex;gap:0.6rem;align-items:center">
<input type="checkbox" id="remember_me" name="remember_me" value="1">
<label for="remember_me" style="margin:0;text-transform:none;letter-spacing:normal;font-family:var(--font-sans)">Запомнить меня</label>
</div>
<button class="btn" style="width:100%" type="submit">Войти</button>
</form>
<p class="center muted" style="margin-top:1.2rem"><a href="/" style="color:var(--accent)">На главную</a></p>
</div>
</body>
</html>
{{end}}
+18
View File
@@ -0,0 +1,18 @@
{{define "offline.html"}}
<!DOCTYPE html>
<html lang="ru">
<head>
{{template "layout_head" .}}
<title>Недоступен — {{.SiteName}}</title>
</head>
<body>
<div class="bg-pattern"></div><div class="bg-glow"></div>
<div class="wrap" style="max-width:560px;margin-top:10vh">
<div class="card center">
<h1>Сайт временно недоступен</h1>
<p class="muted" style="margin-top:1rem">{{.Message}}</p>
</div>
</div>
</body>
</html>
{{end}}
+10
View File
@@ -0,0 +1,10 @@
{{define "layout_head"}}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#050508">
<meta name="color-scheme" content="dark">
<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=JetBrains+Mono:wght@400;500;600;700&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/app.css">
{{end}}
+41
View File
@@ -0,0 +1,41 @@
{{define "wg_public.html"}}
<!DOCTYPE html>
<html lang="ru">
<head>
{{template "layout_head" .}}
<title>{{.FileName}}</title>
</head>
<body>
<div class="bg-pattern"></div><div class="bg-glow"></div>
<div class="wrap" style="max-width:560px">
<div class="nav">
<h1 style="font-size:1.2rem">{{.FileName}}</h1>
</div>
<div class="alert success">Конфиг готов.</div>
{{if .QR}}
<div class="card center qr">
<h1 style="font-size:1rem;margin-bottom:1rem">Отсканируйте QR-код</h1>
<img src="{{.QR}}" width="300" height="300" alt="QR">
</div>
{{end}}
<div class="center" style="margin-bottom:1.25rem">
{{if .ShowZipFull}}<a class="btn" href="/wg?token={{.Token}}&download=full">ZIP (conf + QR)</a>{{end}}
{{if .ShowZipConf}}<a class="btn btn-ghost" href="/wg?token={{.Token}}&download=conf">ZIP только conf</a>{{end}}
</div>
<div class="card">
<h1 style="font-size:1rem;margin-bottom:0.8rem">Текст конфигурации</h1>
<pre class="config" id="cfg">{{.Config}}</pre>
<button class="btn btn-ghost" style="margin-top:0.8rem" type="button" id="copyBtn">Копировать</button>
</div>
</div>
<script>
document.getElementById('copyBtn')?.addEventListener('click', async function() {
const t = document.getElementById('cfg')?.textContent || '';
try { await navigator.clipboard.writeText(t); this.textContent = 'Скопировано'; }
catch(e) { this.textContent = 'Ошибка'; }
setTimeout(() => this.textContent = 'Копировать', 2000);
});
</script>
</body>
</html>
{{end}}
+469
View File
File diff suppressed because one or more lines are too long