Files
phishingclub/backend/install/interactive.go
Ronni Skansing ac8841cd50 fix default file log path to stdout/errout
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
2025-10-30 09:31:23 +01:00

653 lines
19 KiB
Go

package install
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/phishingclub/phishingclub/config"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
var (
titleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Background(lipgloss.Color("#0B3D91")).
Bold(true).
Padding(2, 2)
inputStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#0B3D91"))
focusedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Background(lipgloss.Color("#1E88E5")).
Bold(true)
blurredStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#0B3D91"))
cursorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF9E43"))
helpStyle = lipgloss.NewStyle().
Italic(true).
Foreground(lipgloss.Color("#607D8B"))
errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#F44336")).
Bold(true)
buttonStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Background(lipgloss.Color("#43A047")).
Bold(true).
Padding(0, 3)
sectionStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Background(lipgloss.Color("#6A1B9A")).
Bold(true).
Padding(0, 1)
modeStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Background(lipgloss.Color("#FF5722")).
Bold(true).
Padding(0, 1)
)
type InstallMode int
const (
BasicMode InstallMode = iota
AdvancedMode
)
// inputWithHelp extends textinput.Model to include help text
type InputWithHelp struct {
textinput.Model
HelpText string
}
// configModel is the model for the tea app
type ConfigModel struct {
inputs []InputWithHelp
focusIndex int
err error
shouldInstall bool
config *config.Config
currentMode InstallMode
}
// init initializes the model
func (m ConfigModel) Init() tea.Cmd {
return textinput.Blink
}
// update handles updates
func (m ConfigModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc":
return m, tea.Quit
// mode switching
case "f1":
if m.currentMode != BasicMode {
m.currentMode = BasicMode
m.focusIndex = 0
m.inputs = m.createBasicInputs()
return m, nil
}
case "f2":
if m.currentMode != AdvancedMode {
m.currentMode = AdvancedMode
m.focusIndex = 0
m.inputs = m.createAdvancedInputs()
return m, nil
}
// navigate between inputs with tab/shift+tab
case "tab", "shift+tab", "enter", "up", "down":
s := msg.String()
// check if user pressed enter while submit button was focused
if s == "enter" && m.focusIndex == len(m.inputs) {
// validate config and set shouldInstall
var err error
m.shouldInstall = true
// apply input values to configuration
err = m.applyConfig()
if err != nil {
m.err = err
m.shouldInstall = false
return m, nil
}
return m, tea.Quit
}
// cycle indexes
if s == "up" || s == "shift+tab" {
m.focusIndex--
} else {
m.focusIndex++
}
if m.focusIndex > len(m.inputs) {
m.focusIndex = 0
} else if m.focusIndex < 0 {
m.focusIndex = len(m.inputs)
}
cmds := make([]tea.Cmd, len(m.inputs))
for i := 0; i < len(m.inputs); i++ {
if i == m.focusIndex {
// set focused state
cmds[i] = m.inputs[i].Focus()
m.inputs[i].PromptStyle = focusedStyle
m.inputs[i].TextStyle = focusedStyle
} else {
// remove focused state
m.inputs[i].Blur()
m.inputs[i].PromptStyle = blurredStyle
m.inputs[i].TextStyle = blurredStyle
}
}
return m, tea.Batch(cmds...)
}
}
// handle character input
cmd := m.updateInputs(msg)
return m, cmd
}
func (m *ConfigModel) updateInputs(msg tea.Msg) tea.Cmd {
var cmds = make([]tea.Cmd, len(m.inputs))
// only text inputs with Focus() set will respond
for i := range m.inputs {
m.inputs[i].Model, cmds[i] = m.inputs[i].Model.Update(msg)
}
return tea.Batch(cmds...)
}
func (m ConfigModel) View() string {
var b strings.Builder
b.WriteString("\n")
b.WriteString(titleStyle.Render(" 🐟 Phishing Club Interactive Installer "))
b.WriteString("\n\n")
if m.err != nil {
b.WriteString(errorStyle.Render(fmt.Sprintf("Error: %s\n\n", m.err.Error())))
}
// show current mode and switching instructions
modeText := "Basic Mode"
if m.currentMode == AdvancedMode {
modeText = "Advanced Mode"
}
b.WriteString(fmt.Sprintf("Mode: %s ", modeStyle.Render(modeText)))
b.WriteString(helpStyle.Render("(F1: Basic | F2: Advanced)"))
b.WriteString("\n\n")
// group inputs by section for advanced mode
if m.currentMode == AdvancedMode {
m.renderAdvancedSections(&b)
} else {
m.renderBasicInputs(&b)
}
button := blurredStyle.Copy()
if m.focusIndex == len(m.inputs) {
button = buttonStyle
}
fmt.Fprintf(&b, "\n%s\n", button.Render(" Install "))
return b.String()
}
func (m ConfigModel) renderBasicInputs(b *strings.Builder) {
for i, input := range m.inputs {
b.WriteString(input.View())
// display help text for the focused input
if i == m.focusIndex {
b.WriteString("\n " + helpStyle.Render(input.HelpText))
}
b.WriteString("\n")
}
}
func (m ConfigModel) renderAdvancedSections(b *strings.Builder) {
sections := []struct {
title string
start int
end int
}{
{"Server Configuration", 0, 6},
{"Database Configuration", 6, 8},
{"TLS Configuration", 8, 10},
{"Logging Configuration", 10, 12},
{"Security Configuration", 12, len(m.inputs)},
}
currentSection := -1
for i, input := range m.inputs {
// check if we're starting a new section
for j, section := range sections {
if i == section.start {
if currentSection >= 0 {
b.WriteString("\n")
}
b.WriteString(sectionStyle.Render(fmt.Sprintf(" %s ", section.title)))
b.WriteString("\n")
currentSection = j
break
}
}
b.WriteString(input.View())
// display help text for the focused input
if i == m.focusIndex {
b.WriteString("\n " + helpStyle.Render(input.HelpText))
}
b.WriteString("\n")
}
}
// createBasicInputs creates inputs for basic mode
func (m *ConfigModel) createBasicInputs() []InputWithHelp {
var inputs []InputWithHelp
var prompts = []struct {
prompt string
defaultValue string
placeholder string
description string
}{
{"HTTP port", strconv.Itoa(config.DefaultProductionHTTPPhishingPort), "80", "port for HTTP phishing server"},
{"HTTPS port", strconv.Itoa(config.DefaultProductionHTTPSPhishingPort), "443", "port for HTTPS phishing server"},
{"Admin port", strconv.Itoa(config.DefaultProductionAdministrationPort), "0 (random port)", "admin server port - can not be the same as the ports used by the phishing server"},
{"Admin host", config.DefaultAdminHost, "localhost", "admin server hostname - used for TLS certificate"},
{"Use Auto TLS", config.DefaultAdminAutoTLSString, "true/false", "use automated TLS for the admin service"},
{"ACME email", config.DefaultACMEEmail, config.DefaultACMEEmail, "email for Let's Encrypt notifications"},
}
for i, p := range prompts {
inputs = append(inputs, m.createInput(i, p.prompt, p.defaultValue, p.placeholder, p.description))
}
return inputs
}
// createAdvancedInputs creates inputs for advanced mode
func (m *ConfigModel) createAdvancedInputs() []InputWithHelp {
var inputs []InputWithHelp
var prompts = []struct {
prompt string
defaultValue string
placeholder string
description string
}{
// server configuration
{"HTTP port", strconv.Itoa(config.DefaultProductionHTTPPhishingPort), "80", "port for HTTP phishing server"},
{"HTTPS port", strconv.Itoa(config.DefaultProductionHTTPSPhishingPort), "443", "port for HTTPS phishing server"},
{"Admin port", strconv.Itoa(config.DefaultProductionAdministrationPort), "0 (random port)", "admin server port - can not be the same as the ports used by the phishing server"},
{"Admin host", config.DefaultAdminHost, "localhost", "admin server hostname - used for TLS certificate"},
{"Use Auto TLS", config.DefaultAdminAutoTLSString, "true/false", "use automated TLS for the admin service"},
{"ACME email", config.DefaultACMEEmail, config.DefaultACMEEmail, "email for Let's Encrypt notifications"},
// database configuration
{"Database engine", config.DefaultDatabase, "sqlite3/postgres", "database engine to use (sqlite3 or postgres)"},
{"Database DSN", config.DefaultAdministrationDSN, "file:./db.sqlite3", "database connection string"},
// tls configuration
{"TLS cert path", "", "/path/to/cert.pem", "path to TLS certificate file (leave empty for auto TLS)"},
{"TLS key path", "", "/path/to/key.pem", "path to TLS private key file (leave empty for auto TLS)"},
// logging configuration
{"Log file path", config.DefaultLogFilePath, "", "path to log file (empty for stdout)"},
{"Error log path", config.DefaultErrLogFilePath, "", "path to error log file (empty for stderr)"},
// security configuration
{"Admin allowed IPs", "", "192.168.1.0/24,10.0.0.1", "comma-separated list of IP/CIDR ranges allowed to access admin (empty for all)"},
{"Trusted proxies", "", "192.168.1.1,10.0.0.1", "comma-separated list of trusted proxy IPs/CIDR ranges"},
{"Trusted IP header", config.DefaultTrustedIPHeader, "X-Real-IP", "header name to check for real client IP from trusted proxies"},
}
for i, p := range prompts {
inputs = append(inputs, m.createInput(i, p.prompt, p.defaultValue, p.placeholder, p.description))
}
return inputs
}
// createInput creates a single input field
func (m *ConfigModel) createInput(index int, prompt, defaultValue, placeholder, description string) InputWithHelp {
t := textinput.New()
t.Cursor.Style = cursorStyle
t.CharLimit = 256
// configure each input
t.Placeholder = placeholder
t.PromptStyle = blurredStyle
t.TextStyle = blurredStyle
// the first input is focused
if index == 0 {
t.PromptStyle = focusedStyle
t.TextStyle = focusedStyle
t.Focus()
}
// set the prompt with the default value displayed
t.Prompt = fmt.Sprintf("%s [%s]: ", prompt, defaultValue)
// create custom input with help text
return InputWithHelp{
Model: t,
HelpText: description,
}
}
// initialModel creates the initial model for the tea app
func InitialModel(currentConfig *config.Config) ConfigModel {
model := ConfigModel{
config: currentConfig,
currentMode: BasicMode,
focusIndex: 0,
}
model.inputs = model.createBasicInputs()
return model
}
// applyConfig takes the input values and applies them to the config
func (m *ConfigModel) applyConfig() error {
if m.currentMode == BasicMode {
return m.applyBasicConfig()
}
return m.applyAdvancedConfig()
}
// applyBasicConfig applies basic configuration
func (m *ConfigModel) applyBasicConfig() error {
// get the input values or use defaults if empty
httpPort := getValueOrDefault(m.inputs[0].Value(), strconv.Itoa(config.DefaultProductionHTTPPhishingPort))
httpsPort := getValueOrDefault(m.inputs[1].Value(), strconv.Itoa(config.DefaultProductionHTTPSPhishingPort))
adminPort := getValueOrDefault(m.inputs[2].Value(), strconv.Itoa(config.DefaultProductionAdministrationPort))
adminHost := getValueOrDefault(m.inputs[3].Value(), config.DefaultAdminHost)
autoTLS := getValueOrDefault(m.inputs[4].Value(), config.DefaultAdminAutoTLSString)
acmeEmail := getValueOrDefault(m.inputs[5].Value(), config.DefaultACMEEmail)
return m.applyServerConfig(httpPort, httpsPort, adminPort, adminHost, autoTLS, acmeEmail)
}
// applyAdvancedConfig applies advanced configuration
func (m *ConfigModel) applyAdvancedConfig() error {
// server configuration
httpPort := getValueOrDefault(m.inputs[0].Value(), strconv.Itoa(config.DefaultProductionHTTPPhishingPort))
httpsPort := getValueOrDefault(m.inputs[1].Value(), strconv.Itoa(config.DefaultProductionHTTPSPhishingPort))
adminPort := getValueOrDefault(m.inputs[2].Value(), strconv.Itoa(config.DefaultProductionAdministrationPort))
adminHost := getValueOrDefault(m.inputs[3].Value(), config.DefaultAdminHost)
autoTLS := getValueOrDefault(m.inputs[4].Value(), config.DefaultAdminAutoTLSString)
acmeEmail := getValueOrDefault(m.inputs[5].Value(), config.DefaultACMEEmail)
// apply server config first
if err := m.applyServerConfig(httpPort, httpsPort, adminPort, adminHost, autoTLS, acmeEmail); err != nil {
return err
}
// database configuration
dbEngine := getValueOrDefault(m.inputs[6].Value(), config.DefaultDatabase)
dbDSN := getValueOrDefault(m.inputs[7].Value(), config.DefaultAdministrationDSN)
// validate database engine
if dbEngine != config.DatabaseUsePostgres && dbEngine != config.DefaultAdministrationUseSqlite {
return fmt.Errorf("invalid database engine: %s (must be 'postgres' or 'sqlite3')", dbEngine)
}
// set database config
m.config.SetDatabaseEngine(dbEngine)
m.config.SetDatabaseDSN(dbDSN)
// tls configuration (if not using auto TLS)
tlsCertPath := m.inputs[8].Value()
tlsKeyPath := m.inputs[9].Value()
if tlsCertPath != "" {
m.config.SetTLSCertPath(tlsCertPath)
}
if tlsKeyPath != "" {
m.config.SetTLSKeyPath(tlsKeyPath)
}
// logging configuration
logPath := m.inputs[10].Value()
errLogPath := m.inputs[11].Value()
m.config.SetLogPath(logPath)
m.config.SetErrLogPath(errLogPath)
// security configuration
adminAllowed := m.inputs[12].Value()
trustedProxies := m.inputs[13].Value()
trustedIPHeader := m.inputs[14].Value()
// parse comma-separated IP lists
adminAllowedList := []string{}
trustedProxiesList := []string{}
if adminAllowed != "" {
adminAllowedList = strings.Split(strings.ReplaceAll(adminAllowed, " ", ""), ",")
}
if trustedProxies != "" {
trustedProxiesList = strings.Split(strings.ReplaceAll(trustedProxies, " ", ""), ",")
}
// set security config
m.config.IPSecurity.AdminAllowed = adminAllowedList
m.config.IPSecurity.TrustedProxies = trustedProxiesList
m.config.IPSecurity.TrustedIPHeader = trustedIPHeader
return nil
}
// applyServerConfig applies server configuration (common to both modes)
func (m *ConfigModel) applyServerConfig(httpPort, httpsPort, adminPort, adminHost, autoTLS, acmeEmail string) error {
// convert ports to integers
httpPortInt, err := strconv.Atoi(httpPort)
if err != nil {
return fmt.Errorf("invalid HTTP port: %w", err)
}
httpsPortInt, err := strconv.Atoi(httpsPort)
if err != nil {
return fmt.Errorf("invalid HTTPS port: %w", err)
}
adminPortInt, err := strconv.Atoi(adminPort)
if err != nil {
return fmt.Errorf("invalid admin port: %w", err)
}
// validate port values
if httpPortInt <= 0 || httpPortInt > 65535 {
return fmt.Errorf("HTTP port must be between 1 and 65535")
}
if httpsPortInt <= 0 || httpsPortInt > 65535 {
return fmt.Errorf("HTTPS port must be between 1 and 65535")
}
if adminPortInt < 0 || adminPortInt > 65535 {
return fmt.Errorf("admin port must be between 0 and 65535")
}
// check for port conflicts
if httpPortInt == httpsPortInt {
return fmt.Errorf("HTTP and HTTPS ports cannot be the same")
}
if adminPortInt != 0 && (adminPortInt == httpPortInt || adminPortInt == httpsPortInt) {
return fmt.Errorf("admin port cannot be the same as HTTP or HTTPS ports")
}
// convert autoTLS to boolean
autoTLSBool := false
if strings.ToLower(autoTLS) == config.DefaultAdminAutoTLSString {
autoTLSBool = true
}
// set values in config
err = m.config.SetPhishingHTTPNetAddress(fmt.Sprintf("0.0.0.0:%d", httpPortInt))
if err != nil {
return fmt.Errorf("failed to set HTTP address: %w", err)
}
err = m.config.SetPhishingHTTPSNetAddress(fmt.Sprintf("0.0.0.0:%d", httpsPortInt))
if err != nil {
return fmt.Errorf("failed to set HTTPS address: %w", err)
}
err = m.config.SetAdminNetAddress(fmt.Sprintf("0.0.0.0:%d", adminPortInt))
if err != nil {
return fmt.Errorf("failed to set admin address: %w", err)
}
m.config.SetTLSHost(adminHost)
m.config.SetTLSAuto(autoTLSBool)
m.config.SetACMEEmail(acmeEmail)
return nil
}
// getValueOrDefault returns the value or the default if value is empty
func getValueOrDefault(value, defaultValue string) string {
if value == "" {
return defaultValue
}
return value
}
func RunInteractiveInstall() error {
// first check if we're running as root
if os.Geteuid() != 0 {
return fmt.Errorf("installation must be run as root")
}
// create installation directories first
if err := createDirectories(); err != nil {
return fmt.Errorf("failed to create install directories: %w", err)
}
// get default configuration
conf := config.NewProductionDefaultConfig()
// run the tea program
p := tea.NewProgram(InitialModel(conf))
model, err := p.Run()
if err != nil {
return fmt.Errorf("error running interactive installer: %w", err)
}
// get the final model
finalModel := model.(ConfigModel)
if !finalModel.shouldInstall {
return fmt.Errorf("installation cancelled")
}
// save the config to the installation directory
configPath := filepath.Join(installDir, "config.json")
err = finalModel.config.WriteToFile(configPath)
if err != nil {
return fmt.Errorf("failed to save configuration to %s: %w", configPath, err)
}
if err := os.Chmod(configPath, 0600); err != nil {
return fmt.Errorf("failed to set config file permissions: %w", err)
}
fmt.Printf("Configuration saved to %s\n", configPath)
// now run the actual installation
err = InstallWithConfig(finalModel.config)
if err != nil {
return err
}
return nil
}
// runInteractiveConfigOnly runs the interactive installer and saves config without installing
func RunInteractiveConfigOnly(configPath string) error {
fmt.Println("📝 Running in CONFIG-ONLY mode - no actual installation will be performed")
fmt.Println()
// get default configuration (no root check needed for config-only mode)
conf := config.NewProductionDefaultConfig()
// run the tea program
p := tea.NewProgram(InitialModel(conf))
model, err := p.Run()
if err != nil {
return fmt.Errorf("error running interactive installer: %w", err)
}
// get the final model
finalModel := model.(ConfigModel)
if !finalModel.shouldInstall {
fmt.Println("Installation cancelled by user")
return nil
}
// save the config to the specified path
err = finalModel.config.WriteToFile(configPath)
if err != nil {
return fmt.Errorf("failed to save configuration to %s: %w", configPath, err)
}
fmt.Printf("✅ Configuration saved to %s\n", configPath)
fmt.Println("💡 Review the config file and run without --config-only flag as root to install")
return nil
}
// installWithConfig handles the installation using the provided configuration
func InstallWithConfig(conf *config.Config) error {
steps := []struct {
name string
fn func() error
}{
{"check sqlite dependency", checkSQLiteDependency},
{"create user and group", createUserAndGroup},
{"create directories", createDirectories},
{"install binary", installBinary},
{"install systemd service", installSystemdService},
{"set permissions", setPermissions},
{"enable service", enableService},
{"start service", startService},
{"print info", outputCredentialsAndInfo},
}
for _, step := range steps {
fmt.Printf("Step: %s\n", step.name)
if err := step.fn(); err != nil {
return fmt.Errorf("%s: %w", step.name, err)
}
}
fmt.Println()
fmt.Println("Installer completed successfully! 🐟")
fmt.Println()
fmt.Println("# Tips")
fmt.Println("'journalctl -u phishingclub.service -f' to see logs")
fmt.Println("'systemctl status phishingclub' to check status of the service")
fmt.Println("")
return nil
}