Files

225 lines
7.0 KiB
Go

package main
import (
"bufio"
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"os"
"path/filepath"
"strings"
"syscall"
"github.com/joho/godotenv"
"golang.org/x/term"
"vpn-panel/internal/auth"
"vpn-panel/internal/database"
"vpn-panel/internal/store"
)
func main() {
_ = godotenv.Load()
fmt.Println()
fmt.Println(" ╔══════════════════════════════════════╗")
fmt.Println(" ║ VPN Panel — Установщик ║")
fmt.Println(" ║ Ядро: Xray-core ║")
fmt.Println(" ╚══════════════════════════════════════╝")
fmt.Println()
reader := bufio.NewReader(os.Stdin)
cwd, _ := os.Getwd()
envPath := filepath.Join(cwd, ".env")
if fileExists(envPath) {
fmt.Print("Файл .env уже существует. Перезаписать? [y/N]: ")
ans, _ := reader.ReadString('\n')
if strings.TrimSpace(strings.ToLower(ans)) != "y" {
fmt.Println("Установка отменена.")
return
}
}
domain := prompt(reader, "Домен панели (например panel.example.com)", "localhost")
appPort := prompt(reader, "Порт приложения", "8080")
fmt.Println("\n--- PostgreSQL 17 (только локально, Docker-сеть) ---")
fmt.Println("Хост БД в Docker: postgres (не localhost)")
if os.Getenv("POSTGRES_PASSWORD") != "" {
fmt.Printf("Пароль из окружения/.env: задан (пользователь %s)\n", envOr("POSTGRES_USER", "vpnpanel"))
}
dbHost := prompt(reader, "Хост БД", envOr("POSTGRES_HOST", "postgres"))
dbPort := prompt(reader, "Порт БД", envOr("POSTGRES_PORT", "5432"))
dbUser := prompt(reader, "Пользователь БД", envOr("POSTGRES_USER", "vpnpanel"))
dbPassDefault := envOr("POSTGRES_PASSWORD", "changeme")
dbPass := promptSecretDefault("Пароль БД", dbPassDefault)
dbName := prompt(reader, "Имя базы данных", envOr("POSTGRES_DB", "vpnpanel"))
fmt.Println("\n--- Администратор (единственный) ---")
adminEmail := prompt(reader, "Email администратора", "admin@localhost")
adminPass := promptSecret("Пароль администратора (мин. 8 символов)")
for len(adminPass) < 8 {
fmt.Println("Пароль слишком короткий.")
adminPass = promptSecret("Пароль администратора")
}
adminPass2 := promptSecret("Подтвердите пароль")
for adminPass != adminPass2 {
fmt.Println("Пароли не совпадают.")
adminPass = promptSecret("Пароль администратора")
adminPass2 = promptSecret("Подтвердите пароль")
}
secretKey, err := generateSecret()
if err != nil {
fmt.Fprintf(os.Stderr, "ошибка генерации SECRET_KEY: %v\n", err)
os.Exit(1)
}
databaseURL := fmt.Sprintf(
"postgres://%s:%s@%s:%s/%s?sslmode=disable",
dbUser, urlEncode(dbPass), dbHost, dbPort, dbName,
)
fmt.Println("\n--- Проверка подключения к БД ---")
ctx := context.Background()
pool, err := database.Connect(ctx, databaseURL)
if err != nil {
fmt.Fprintf(os.Stderr, "не удалось подключиться: %v\n", err)
printDBHelp(dbPassDefault)
os.Exit(1)
}
defer pool.Close()
if err := database.Migrate(ctx, pool); err != nil {
fmt.Fprintf(os.Stderr, "миграции: %v\n", err)
os.Exit(1)
}
hash, err := auth.HashPassword(adminPass)
if err != nil {
fmt.Fprintf(os.Stderr, "хеш пароля: %v\n", err)
os.Exit(1)
}
users := store.NewUserStore(pool)
has, _ := users.HasAdmin(ctx)
if has {
fmt.Println("Администратор уже есть в БД — пропускаем создание.")
} else {
u, err := users.CreateAdmin(ctx, strings.ToLower(strings.TrimSpace(adminEmail)), hash)
if err != nil {
fmt.Fprintf(os.Stderr, "создание админа: %v\n", err)
os.Exit(1)
}
fmt.Printf("Администратор создан: %s (%s)\n", u.Email, u.ID)
}
envContent := fmt.Sprintf(`# Сгенерировано установщиком VPN Panel
APP_PORT=%s
APP_DOMAIN=%s
DATABASE_URL=%s
SECRET_KEY=%s
INSTALLED=true
# PostgreSQL (docker-compose, только внутренняя сеть)
POSTGRES_USER=%s
POSTGRES_PASSWORD=%s
POSTGRES_DB=%s
POSTGRES_HOST=%s
POSTGRES_PORT=%s
`,
appPort, domain, databaseURL, secretKey,
dbUser, dbPass, dbName, dbHost, dbPort,
)
if err := os.WriteFile(envPath, []byte(envContent), 0600); err != nil {
fmt.Fprintf(os.Stderr, "запись .env: %v\n", err)
os.Exit(1)
}
fmt.Println()
fmt.Println(" ✓ Установка завершена")
fmt.Println(" ✓ Файл .env создан")
fmt.Println()
fmt.Println(" Дальше:")
fmt.Println(" docker compose up -d --build panel")
fmt.Println()
fmt.Printf(" Панель: http://%s:%s\n", domain, appPort)
fmt.Println()
}
func printDBHelp(defaultPass string) {
fmt.Println()
fmt.Println(" Подсказки:")
fmt.Println(" 1. PostgreSQL уже запущен? Пароль задан при ПЕРВОМ старте тома pgdata.")
fmt.Printf(" По умолчанию был: %s — нажмите Enter в поле «Пароль БД».\n", defaultPass)
fmt.Println(" 2. Сменили пароль в установщике, а том старый — сбросьте БД:")
fmt.Println(" docker compose down")
fmt.Println(" docker volume rm vpn-panel_pgdata")
fmt.Println(" docker compose up -d postgres")
fmt.Println(" docker compose --profile tools run --rm install")
fmt.Println(" 3. Убедитесь: docker compose up -d postgres && хост БД = postgres")
fmt.Println()
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func prompt(reader *bufio.Reader, label, defaultVal string) string {
if defaultVal != "" {
fmt.Printf("%s [%s]: ", label, defaultVal)
} else {
fmt.Printf("%s: ", label)
}
line, _ := reader.ReadString('\n')
line = strings.TrimSpace(line)
if line == "" {
return defaultVal
}
return line
}
func promptSecret(label string) string {
fmt.Printf("%s: ", label)
b, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println()
if err != nil {
return ""
}
return string(b)
}
func promptSecretDefault(label, defaultVal string) string {
fmt.Printf("%s [%s] (Enter = по умолчанию): ", label, defaultVal)
b, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println()
if err != nil || len(b) == 0 {
return defaultVal
}
return string(b)
}
func generateSecret() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func urlEncode(s string) string {
r := strings.NewReplacer(":", "%3A", "@", "%40", "/", "%2F")
return r.Replace(s)
}