mirror of
https://git.evilfox.cc/test2/wg.git
synced 2026-07-31 11:53:22 +00:00
Add Go WireGuard panel with PostgreSQL 17 and Dokploy compose.
This commit is contained in:
@@ -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()))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user