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
+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)
}