225 lines
7.0 KiB
Go
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)
|
|
}
|