package main import ( "fmt" "os" "os/exec" "strconv" "strings" "net" "time" "pulsegate/internal/config" "pulsegate/internal/models" "pulsegate/internal/secret" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/bubbles/textinput" "golang.org/x/term" ) type viewMode string const ( ViewServers viewMode = "servers" ViewSettings viewMode = "settings" ViewHelp viewMode = "help" ViewAddServer viewMode = "add_server" ViewEditServer viewMode = "edit_server" ViewDeleteConfirm viewMode = "delete_confirm" ViewCommands viewMode = "commands" ViewCommandOutput viewMode = "command_output" ) type model struct { cfg models.AppConfig servers []models.Server selected int view viewMode width int height int err error addInputs []textinput.Model addFocus int editIndex int commandSelected int commandOutput string commandTitle string commandError string serverStatus map[int]string } var ( green = lipgloss.Color("#00ff99") cyan = lipgloss.Color("#33ccff") gray = lipgloss.Color("#777777") text = lipgloss.Color("#d7ffe9") dimText = lipgloss.Color("#8aa99b") panelBg = lipgloss.Color("#07110d") border = lipgloss.Color("#00aa66") warn = lipgloss.Color("#ffaa00") baseStyle = lipgloss.NewStyle(). Foreground(text) headerStyle = lipgloss.NewStyle(). Bold(true). Foreground(green). Border(lipgloss.RoundedBorder()). BorderForeground(border). Padding(1, 2) panelStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(border). Padding(1, 2) selectedStyle = lipgloss.NewStyle(). Foreground(green). Bold(true) normalStyle = lipgloss.NewStyle(). Foreground(text) mutedStyle = lipgloss.NewStyle(). Foreground(gray) helpStyle = lipgloss.NewStyle(). Foreground(dimText) badgeStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#001a10")). Background(green). Bold(true). Padding(0, 1) ) func initialModel() model { cfg, err := config.LoadConfig("config.yaml") if err != nil { return model{err: err} } m := model{ cfg: cfg, servers: cfg.Servers, selected: 0, view: ViewServers, serverStatus: make(map[int]string), } m.initAddInputs() return m } func (m model) Init() tea.Cmd { return checkAllServerStatus(m.servers) } func (m *model) initAddInputs() { labels := []string{ "Name", "Host", "User", "Port", "Group", "Auth (key/password)", "Key Path", "Password ID", } m.addInputs = make([]textinput.Model, len(labels)) for i, label := range labels { input := textinput.New() input.Placeholder = label input.CharLimit = 120 input.Width = 40 switch label { case "Port": input.SetValue("22") case "Auth (key/password)": input.SetValue("key") case "Group": input.SetValue("Homelab") } if i == 0 { input.Focus() } m.addInputs[i] = input } } func (m *model) saveNewServer() error { port, err := strconv.Atoi(m.addInputs[3].Value()) if err != nil { port = 22 } server := models.Server{ Name: m.addInputs[0].Value(), Host: m.addInputs[1].Value(), User: m.addInputs[2].Value(), Port: port, Group: m.addInputs[4].Value(), Auth: m.addInputs[5].Value(), Key: m.addInputs[6].Value(), PasswordID: m.addInputs[7].Value(), KittyFix: true, } if server.Name == "" || server.Host == "" || server.User == "" { return fmt.Errorf("name, host und user sind pflichtfelder") } if server.Auth == "" { server.Auth = "key" } if server.Auth == "password" && server.PasswordID == "" { server.PasswordID = strings.ToLower(server.Name) + "-" + server.User } m.cfg.Servers = append(m.cfg.Servers, server) m.servers = m.cfg.Servers err = config.SaveConfig("config.yaml", m.cfg) if err != nil { return err } m.selected = len(m.servers) - 1 m.initAddInputs() m.view = ViewServers return nil } type quickCommandResultMsg struct { Title string Output string Error string } type serverStatusMsg struct { Index int Status string } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.err != nil { return m, nil } switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height case quickCommandResultMsg: m.commandTitle = msg.Title m.commandOutput = msg.Output m.commandError = msg.Error m.view = ViewCommandOutput return m, nil case serverStatusMsg: if m.serverStatus == nil { m.serverStatus = make(map[int]string) } m.serverStatus[msg.Index] = msg.Status return m, nil case tea.KeyMsg: // Command Output offen lassen, bis du ihn wegdrückst if m.view == ViewCommandOutput { switch msg.String() { case "q", "esc", "enter": m.view = ViewCommands return m, nil case "ctrl+c": return m, tea.Quit } } // Add/Edit Formular if m.view == ViewAddServer || m.view == ViewEditServer { switch msg.String() { case "ctrl+c": return m, tea.Quit case "esc", "q": m.view = ViewServers return m, nil case "tab", "down": m.addInputs[m.addFocus].Blur() m.addFocus = (m.addFocus + 1) % len(m.addInputs) m.addInputs[m.addFocus].Focus() return m, nil case "r": return m, checkAllServerStatus(m.servers) case "shift+tab", "up": m.addInputs[m.addFocus].Blur() m.addFocus-- if m.addFocus < 0 { m.addFocus = len(m.addInputs) - 1 } m.addInputs[m.addFocus].Focus() return m, nil case "enter": if m.addFocus < len(m.addInputs)-1 { m.addInputs[m.addFocus].Blur() m.addFocus++ m.addInputs[m.addFocus].Focus() return m, nil } var err error if m.view == ViewAddServer { err = m.saveNewServer() } else { err = m.saveEditedServer() } if err != nil { m.err = err } return m, nil } var cmd tea.Cmd m.addInputs[m.addFocus], cmd = m.addInputs[m.addFocus].Update(msg) return m, cmd } // Delete Confirm if m.view == ViewDeleteConfirm { switch msg.String() { case "y", "Y", "j": err := m.deleteSelectedServer() if err != nil { m.err = err } return m, nil case "n", "N", "esc", "q": m.view = ViewServers return m, nil } } // Normale Navigation switch msg.String() { case "ctrl+c": return m, tea.Quit case "q": if m.view != ViewServers { m.view = ViewServers return m, nil } return m, tea.Quit case "tab": m.view = nextView(m.view) case "a": m.view = ViewAddServer m.addFocus = 0 m.initAddInputs() return m, nil case "e": if m.view == ViewServers && len(m.servers) > 0 { m.editIndex = m.selected m.view = ViewEditServer m.loadServerIntoForm(m.servers[m.selected]) } return m, nil case "d": if m.view == ViewServers && len(m.servers) > 0 { m.view = ViewDeleteConfirm } return m, nil case "c": if len(m.servers) > 0 { m.view = ViewCommands m.commandSelected = 0 } return m, nil case "s": m.view = ViewSettings return m, nil case "h": m.view = ViewHelp return m, nil case "up", "k": if m.view == ViewServers && m.selected > 0 { m.selected-- } if m.view == ViewCommands && m.commandSelected > 0 { m.commandSelected-- } case "down", "j": if m.view == ViewServers && m.selected < len(m.servers)-1 { m.selected++ } if m.view == ViewCommands && m.commandSelected < len(m.cfg.QuickCommands)-1 { m.commandSelected++ } case "enter": if m.view == ViewServers && len(m.servers) > 0 { server := m.servers[m.selected] return m, connectSSH(server, m.cfg.Settings) } if m.view == ViewCommands && len(m.servers) > 0 && len(m.cfg.QuickCommands) > 0 { server := m.servers[m.selected] quick := m.cfg.QuickCommands[m.commandSelected] return m, runQuickCommand(server, m.cfg.Settings, quick) } } } return m, nil } func nextView(v viewMode) viewMode { switch v { case ViewServers: return ViewSettings case ViewSettings: return ViewHelp default: return ViewServers } } func (m model) View() string { if m.err != nil { return "Fehler: " + m.err.Error() } if m.width == 0 { m.width = 100 } if m.height == 0 { m.height = 30 } header := m.renderHeader() var right string switch m.view { case ViewServers: right = m.renderServerList() case ViewSettings: right = m.renderSettingsContent() case ViewHelp: right = m.renderHelpContent() case ViewAddServer: right = m.renderAddServerContent() case ViewEditServer: right = m.renderEditServerContent() case ViewDeleteConfirm: right = m.renderDeleteConfirmContent() case ViewCommands: right = m.renderCommandsContent() case ViewCommandOutput: right = m.renderCommandOutputContent() default: right = m.renderServerList() } body := lipgloss.JoinHorizontal( lipgloss.Top, m.renderNavigation(), " ", right, ) footer := helpStyle.Render("↑/↓ Auswahl Enter Verbinden a Hinzufügen e Editieren d Löschen c Commands r Status refresh Tab Ansicht q Zurück/Beenden") content := lipgloss.JoinVertical( lipgloss.Left, header, body, footer, ) return baseStyle.Render(content) } func (m model) renderHeader() string { title := "󰣀 PulseGate" subtitle := fmt.Sprintf( "Homelab SSH Control Center • Server: %d • Theme: %s • TERM: %s", len(m.servers), m.cfg.Settings.Theme, m.cfg.Settings.Terminal.Term, ) return headerStyle. Width(max(m.width-6, 60)). Render(title + "\n" + mutedStyle.Render(subtitle)) } /*func (m model) renderServerView() string { left := m.renderNavigation() right := m.renderServerList() return lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right) }*/ func (m model) renderNavigation() string { items := []struct { icon string name string view viewMode }{ {"󰒋", "Server", ViewServers}, {"󰐕", "Add Server", ViewAddServer}, {"󰢹", "Settings", ViewSettings}, {"󰋖", "Help", ViewHelp}, {"󰘳", "Commands", ViewCommands}, } var lines []string lines = append(lines, badgeStyle.Render(" NAVIGATION ")) lines = append(lines, "") for _, item := range items { line := fmt.Sprintf("%s %s", item.icon, item.name) if m.view == item.view { lines = append(lines, selectedStyle.Render("> "+line)) } else { lines = append(lines, normalStyle.Render(" "+line)) } } return panelStyle. Width(24). Height(max(m.height-9, 12)). Render(strings.Join(lines, "\n")) } func (m model) renderServerList() string { var lines []string lines = append(lines, badgeStyle.Render(" SERVERS ")) lines = append(lines, "") for i, server := range m.servers { authIcon := "󰌾" status := m.serverStatus[i] statusRendered := statusStyle(status).Render(statusIcon(status)) if server.Auth == "key" { authIcon = "󰌆" } kitty := "" if server.KittyFix { kitty = " 󰄛 kitty" } statusText := "" if status == "online" { statusText = " ssh" } else if status == "offline" { statusText = " off" } line := fmt.Sprintf( "%s%s %s %s %s@%s:%d [%s]%s", statusRendered, statusText, authIcon, server.Name, server.User, server.Host, server.Port, server.Group, kitty, ) if i == m.selected { lines = append(lines, selectedStyle.Render("> "+line)) } else { lines = append(lines, normalStyle.Render(" "+line)) } } if len(m.servers) == 0 { lines = append(lines, mutedStyle.Render("Keine Server in config.yaml gefunden.")) } width := max(m.width-36, 50) return panelStyle. Width(width). Height(max(m.height-9, 12)). Render(strings.Join(lines, "\n")) } func (m model) renderSettingsContent() string { lines := []string{ badgeStyle.Render(" SETTINGS "), "", selectedStyle.Render("Terminal"), fmt.Sprintf(" Kitty Fix global: %v", m.cfg.Settings.Terminal.EnableKittyFix), fmt.Sprintf(" TERM Override: %s", m.cfg.Settings.Terminal.Term), "", selectedStyle.Render("Hinweis"), " Wenn Kitty Fix aktiv ist, startet SSH mit TERM=xterm-256color.", " Das verhindert auf Ubuntu/Debian oft:", " 'Error opening terminal: xterm-kitty'", "", mutedStyle.Render("Später bauen wir hier Toggle-Optionen mit Enter ein."), } return panelStyle. Width(max(m.width-6, 70)). Height(max(m.height-9, 14)). Render(strings.Join(lines, "\n")) } func (m model) renderHelpContent() string { lines := []string{ badgeStyle.Render(" HELP "), "", "Enter Ausgewählten Server verbinden", "↑/↓ / j/k Server auswählen", "Tab Ansicht wechseln", "s Settings öffnen", "h Hilfe öffnen", "q Zurück oder Beenden", "", selectedStyle.Render("Kitty Fix"), "Wenn du per Kitty auf Ubuntu SSH nutzt und nano/clear Fehler machen:", "TERM=xterm-256color hilft meistens.", } return panelStyle. Width(max(m.width-6, 70)). Height(max(m.height-9, 14)). Render(strings.Join(lines, "\n")) } func connectSSH(server models.Server, settings models.Settings) tea.Cmd { switch server.Auth { case "key": args := []string{ "-p", strconv.Itoa(server.Port), } if server.Key != "" { args = append(args, "-i", expandHome(server.Key)) } args = append(args, server.User+"@"+server.Host) cmd := exec.Command("ssh", args...) cmd.Env = buildSSHEnv(server, settings) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return tea.ExecProcess(cmd, func(err error) tea.Msg { return err }) case "password": password, err := secret.GetPassword(server.PasswordID) if err != nil { fmt.Printf("\nPasswort für %s@%s: ", server.User, server.Host) pw, inputErr := term.ReadPassword(int(os.Stdin.Fd())) fmt.Println() if inputErr != nil { return func() tea.Msg { return inputErr } } password = string(pw) saveErr := secret.SavePassword(server.PasswordID, password) if saveErr != nil { fmt.Println("Fehler beim Speichern:", saveErr) } else { fmt.Println("Passwort gespeichert.") } } args := []string{ "-e", "ssh", "-p", strconv.Itoa(server.Port), server.User + "@" + server.Host, } cmd := exec.Command("sshpass", args...) cmd.Env = append(buildSSHEnv(server, settings), "SSHPASS="+password) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return tea.ExecProcess(cmd, func(err error) tea.Msg { return err }) default: return func() tea.Msg { return fmt.Errorf("unbekannte auth methode: %s", server.Auth) } } } func buildSSHEnv(server models.Server, settings models.Settings) []string { env := os.Environ() if settings.Terminal.EnableKittyFix && server.KittyFix { termValue := settings.Terminal.Term if termValue == "" { termValue = "xterm-256color" } env = append(env, "TERM="+termValue) } return env } func expandHome(path string) string { if len(path) >= 2 && path[:2] == "~/" { home, _ := os.UserHomeDir() return home + path[1:] } return path } func max(a, b int) int { if a > b { return a } return b } func (m *model) loadServerIntoForm(server models.Server) { m.initAddInputs() m.addInputs[0].SetValue(server.Name) m.addInputs[1].SetValue(server.Host) m.addInputs[2].SetValue(server.User) m.addInputs[3].SetValue(strconv.Itoa(server.Port)) m.addInputs[4].SetValue(server.Group) m.addInputs[5].SetValue(server.Auth) m.addInputs[6].SetValue(server.Key) m.addInputs[7].SetValue(server.PasswordID) m.addFocus = 0 m.addInputs[0].Focus() } func (m *model) saveEditedServer() error { if m.editIndex < 0 || m.editIndex >= len(m.servers) { return fmt.Errorf("ungültiger server index") } port, err := strconv.Atoi(m.addInputs[3].Value()) if err != nil { port = 22 } server := models.Server{ Name: m.addInputs[0].Value(), Host: m.addInputs[1].Value(), User: m.addInputs[2].Value(), Port: port, Group: m.addInputs[4].Value(), Auth: m.addInputs[5].Value(), Key: m.addInputs[6].Value(), PasswordID: m.addInputs[7].Value(), KittyFix: true, } if server.Name == "" || server.Host == "" || server.User == "" { return fmt.Errorf("name, host und user sind pflichtfelder") } if server.Auth == "" { server.Auth = "key" } if server.Auth == "password" && server.PasswordID == "" { server.PasswordID = strings.ToLower(server.Name) + "-" + server.User } m.cfg.Servers[m.editIndex] = server m.servers = m.cfg.Servers err = config.SaveConfig("config.yaml", m.cfg) if err != nil { return err } m.selected = m.editIndex m.view = ViewServers m.initAddInputs() return nil } func (m *model) deleteSelectedServer() error { if m.selected < 0 || m.selected >= len(m.servers) { return fmt.Errorf("kein gültiger server ausgewählt") } m.cfg.Servers = append( m.cfg.Servers[:m.selected], m.cfg.Servers[m.selected+1:]..., ) m.servers = m.cfg.Servers if m.selected >= len(m.servers) && m.selected > 0 { m.selected-- } err := config.SaveConfig("config.yaml", m.cfg) if err != nil { return err } m.view = ViewServers return nil } func (m model) renderEditServerContent() string { return m.renderServerFormContent(" EDIT SERVER ", "Server bearbeiten") } func (m model) renderAddServerContent() string { return m.renderServerFormContent(" ADD SERVER ", "Neuen SSH-Server hinzufügen") } func (m model) renderServerFormContent(badge string, title string) string { lines := []string{ badgeStyle.Render(badge), "", title, "", } labels := []string{ "Name", "Host", "User", "Port", "Group", "Auth", "Key Path", "Password ID", } for i, input := range m.addInputs { label := labels[i] if i == m.addFocus { lines = append(lines, selectedStyle.Render("> "+label)) } else { lines = append(lines, normalStyle.Render(" "+label)) } lines = append(lines, " "+input.View()) lines = append(lines, "") } lines = append(lines, mutedStyle.Render("Enter: nächstes Feld / speichern Tab: weiter Esc/q: abbrechen")) lines = append(lines, mutedStyle.Render("Auth: key oder password")) return panelStyle. Width(max(m.width-36, 60)). Height(max(m.height-9, 18)). Render(strings.Join(lines, "\n")) } func (m model) renderCommandOutputContent() string { lines := []string{ badgeStyle.Render(" COMMAND OUTPUT "), "", selectedStyle.Render(m.commandTitle), "", } if m.commandError != "" { lines = append(lines, warnStyle().Render("Fehler: "+m.commandError)) lines = append(lines, "") } output := strings.TrimSpace(m.commandOutput) if output == "" { output = "Keine Ausgabe." } lines = append(lines, output) lines = append(lines, "") lines = append(lines, mutedStyle.Render("q / Esc / Enter: zurück zu Quick Commands")) return panelStyle. Width(max(m.width-36, 60)). Height(max(m.height-9, 14)). Render(strings.Join(lines, "\n")) } func (m model) renderCommandsContent() string { lines := []string{ badgeStyle.Render(" QUICK COMMANDS "), "", } if len(m.servers) == 0 { lines = append(lines, mutedStyle.Render("Kein Server ausgewählt.")) } else { server := m.servers[m.selected] lines = append(lines, fmt.Sprintf("Server: %s %s@%s:%d", server.Name, server.User, server.Host, server.Port)) lines = append(lines, "") } if len(m.cfg.QuickCommands) == 0 { lines = append(lines, mutedStyle.Render("Keine quick_commands in config.yaml gefunden.")) } else { for i, cmd := range m.cfg.QuickCommands { line := fmt.Sprintf("%s → %s", cmd.Name, cmd.Command) if i == m.commandSelected { lines = append(lines, selectedStyle.Render("> "+line)) } else { lines = append(lines, normalStyle.Render(" "+line)) } } } lines = append(lines, "") lines = append(lines, mutedStyle.Render("Enter: Command ausführen ↑/↓ Auswahl q: zurück")) return panelStyle. Width(max(m.width-36, 60)). Height(max(m.height-9, 14)). Render(strings.Join(lines, "\n")) } func runQuickCommand(server models.Server, settings models.Settings, quick models.QuickCommand) tea.Cmd { return func() tea.Msg { title := fmt.Sprintf("%s auf %s", quick.Name, server.Name) args := []string{ "-p", strconv.Itoa(server.Port), } if settings.Terminal.EnableKittyFix && server.KittyFix { args = append(args, "-t") } var cmd *exec.Cmd if server.Auth == "key" { if server.Key != "" { args = append(args, "-i", expandHome(server.Key)) } args = append(args, server.User+"@"+server.Host, quick.Command) cmd = exec.Command("ssh", args...) cmd.Env = buildSSHEnv(server, settings) } else if server.Auth == "password" { password, err := secret.GetPassword(server.PasswordID) if err != nil { return quickCommandResultMsg{ Title: title, Output: "", Error: fmt.Sprintf("kein passwort gespeichert für %s", server.PasswordID), } } sshArgs := []string{ "-e", "ssh", "-p", strconv.Itoa(server.Port), server.User + "@" + server.Host, quick.Command, } cmd = exec.Command("sshpass", sshArgs...) cmd.Env = append(buildSSHEnv(server, settings), "SSHPASS="+password) } else { return quickCommandResultMsg{ Title: title, Output: "", Error: fmt.Sprintf("unbekannte auth methode: %s", server.Auth), } } output, err := cmd.CombinedOutput() errorText := "" if err != nil { errorText = err.Error() } return quickCommandResultMsg{ Title: title, Output: string(output), Error: errorText, } } } func checkAllServerStatus(servers []models.Server) tea.Cmd { cmds := make([]tea.Cmd, 0, len(servers)) for i, server := range servers { cmds = append(cmds, checkServerStatus(i, server)) } return tea.Batch(cmds...) } func checkServerStatus(index int, server models.Server) tea.Cmd { return func() tea.Msg { port := server.Port if port == 0 { port = 22 } addr := fmt.Sprintf("%s:%d", server.Host, port) conn, err := net.DialTimeout("tcp", addr, 1500*time.Millisecond) if err != nil { return serverStatusMsg{ Index: index, Status: "offline", } } _ = conn.Close() return serverStatusMsg{ Index: index, Status: "online", } } } func statusIcon(status string) string { switch status { case "online": return "●" case "offline": return "○" default: return "◌" } } func statusStyle(status string) lipgloss.Style { switch status { case "online": return lipgloss.NewStyle().Foreground(green).Bold(true) case "offline": return lipgloss.NewStyle().Foreground(gray) default: return lipgloss.NewStyle().Foreground(dimText) } } func (m model) renderDeleteConfirmContent() string { if len(m.servers) == 0 { return panelStyle.Render("Kein Server zum Löschen vorhanden.") } server := m.servers[m.selected] lines := []string{ badgeStyle.Render(" DELETE SERVER "), "", warnStyle().Render("Diesen Server wirklich löschen?"), "", fmt.Sprintf("Name: %s", server.Name), fmt.Sprintf("Host: %s@%s:%d", server.User, server.Host, server.Port), fmt.Sprintf("Group: %s", server.Group), "", selectedStyle.Render("y / j = löschen"), mutedStyle.Render("n / q / Esc = abbrechen"), } return panelStyle. Width(max(m.width-36, 60)). Height(max(m.height-9, 14)). Render(strings.Join(lines, "\n")) } func warnStyle() lipgloss.Style { return lipgloss.NewStyle(). Foreground(warn). Bold(true) } func main() { p := tea.NewProgram( initialModel(), tea.WithAltScreen(), ) if _, err := p.Run(); err != nil { fmt.Println("Fehler:", err) os.Exit(1) } }