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