diff --git a/README.md b/README.md new file mode 100644 index 0000000..4195f08 --- /dev/null +++ b/README.md @@ -0,0 +1,233 @@ +# PulseGate + +**PulseGate** ist ein neon-inspiriertes SSH Control Center als TUI-Anwendung für dein Terminal. +Die App verwaltet gespeicherte Server, unterstützt Key- und Passwort-Login, Quick Commands, Statuschecks und Kitty/TERM-Fixes für saubere SSH-Sessions unter Hyprland/Kitty. + +![PulseGate Screenshot](./pulsegate_screenshot.png) + +## Features + +- Server aus `config.yaml` laden +- Server hinzufügen, bearbeiten und löschen +- SSH-Verbindung per Key oder Passwort +- Passwortspeicherung über den Linux-Keyring +- Quick Commands pro ausgewähltem Server ausführen +- Command-Output in der TUI anzeigen +- SSH-Port-Statuscheck mit Refresh +- Kitty-Fix über `TERM=xterm-256color` +- Vollbild-TUI mit Navigation, Settings und Help-View + +## Voraussetzungen + +### Arch / CachyOS + +```bash +sudo pacman -S go git sshpass gnome-keyring libsecret seahorse +``` + +Optional, aber empfohlen: + +```bash +sudo pacman -S openssh +``` + +## Installation + +Repository klonen oder Projektordner öffnen: + +```bash +cd ~/Projects/pulsegate +``` + +Go-Abhängigkeiten installieren: + +```bash +go mod tidy +``` + +Falls du die Dependencies manuell installieren möchtest: + +```bash +go get github.com/charmbracelet/bubbletea +go get github.com/charmbracelet/lipgloss +go get github.com/charmbracelet/bubbles/textinput +go get gopkg.in/yaml.v3 +go get github.com/99designs/keyring +go get golang.org/x/term +``` + +## Konfiguration + +Lege im Projektordner eine `config.yaml` an: + +```yaml +settings: + theme: neon-green + terminal: + term: xterm-256color + enable_kitty_fix: true + +servers: + - name: Unraid + host: 10.0.0.15 + user: root + port: 22 + group: Homelab + auth: password + password_id: unraid-root + kitty_fix: true + + - name: Webhost + host: 10.0.0.23 + user: administrator + port: 22 + group: Homelab + auth: key + key: ~/.ssh/id_ed25519 + kitty_fix: true + +quick_commands: + - name: Docker PS + command: docker ps + + - name: Disk Usage + command: df -h + + - name: RAM Usage + command: free -h + + - name: Uptime + command: uptime +``` + +## Starten + +Während der Entwicklung: + +```bash +go run . +``` + +Als Binary bauen: + +```bash +go build -o pulsegate +./pulsegate +``` + +Optional global installieren: + +```bash +sudo cp pulsegate /usr/local/bin/ +pulsegate +``` + +## Bedienung + +| Taste | Funktion | +|---|---| +| `↑` / `↓` oder `k` / `j` | Auswahl bewegen | +| `Enter` | Server verbinden oder Command ausführen | +| `a` | Server hinzufügen | +| `e` | Server bearbeiten | +| `d` | Server löschen | +| `c` | Quick Commands öffnen | +| `r` | SSH-Port-Status aktualisieren | +| `Tab` | Ansicht wechseln | +| `s` | Settings öffnen | +| `h` | Hilfe öffnen | +| `q` | Zurück oder beenden | + +## Passwort-Login + +PulseGate speichert Passwörter nicht in der `config.yaml`, sondern über den Linux-Keyring. +Beim ersten Verbindungsaufbau mit `auth: password` fragt PulseGate nach dem Passwort und speichert es unter der angegebenen `password_id`. + +Beispiel: + +```yaml +auth: password +password_id: unraid-root +``` + +Zum Anzeigen oder Löschen gespeicherter Passwörter kannst du Seahorse verwenden: + +```bash +seahorse +``` + +Dort nach `pulsegate` oder der jeweiligen `password_id` suchen. + +## SSH-Key-Login + +Beispielkonfiguration: + +```yaml +auth: key +key: ~/.ssh/id_ed25519 +``` + +Public Key auf den Server kopieren: + +```bash +ssh-copy-id -i ~/.ssh/id_ed25519.pub user@server-ip +``` + +## Kitty / TERM Fix + +Wenn du über Kitty per SSH auf Server gehst, kann es zu Fehlern kommen wie: + +```text +Error opening terminal: xterm-kitty +``` + +PulseGate kann für solche Server automatisch setzen: + +```bash +TERM=xterm-256color +``` + +Aktivierung global: + +```yaml +settings: + terminal: + term: xterm-256color + enable_kitty_fix: true +``` + +Aktivierung pro Server: + +```yaml +kitty_fix: true +``` + +## Screenshot austauschen + +Der enthaltene Screenshot ist ein Beispielbild. +Einen echten Screenshot kannst du unter Hyprland z. B. mit `grim` erstellen: + +```bash +grim -g "$(slurp)" pulsegate_screenshot.png +``` + +Oder mit einem anderen Screenshot-Tool deiner Wahl. +Lege die Datei anschließend neben die README: + +```text +README.md +pulsegate_screenshot.png +``` + +## Roadmap + +Mögliche nächste Features: + +- Suche und Filter mit `/` +- Scrollbarer Command-Output +- Servergruppen / Tags +- Import aus `~/.ssh/config` +- Passwort ändern/löschen direkt in der TUI +- Themes wie `neon-green`, `neon-cyan`, `orange-authentik` +- Health Checks mit SSH-Login-Test +- Quick Commands pro Servergruppe diff --git a/go.mod b/go.mod index 68a500f..94d99a3 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module pdev-ssh +module pulsegate go 1.26.2 diff --git a/internal/config/config.go b/internal/config/config.go index 01877f3..b799a55 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,7 +3,7 @@ package config import ( "os" - "pdev-ssh/internal/models" + "pulsegate/internal/models" "gopkg.in/yaml.v3" ) diff --git a/internal/secret/keyring.go b/internal/secret/keyring.go index eeb7f48..ec3b5bd 100644 --- a/internal/secret/keyring.go +++ b/internal/secret/keyring.go @@ -6,7 +6,7 @@ import ( "github.com/99designs/keyring" ) -const serviceName = "pdev-ssh" +const serviceName = "pulsegate" func OpenKeyring() (keyring.Keyring, error) { return keyring.Open(keyring.Config{ diff --git a/internal/sshclient/ssh.go b/internal/sshclient/ssh.go index 94cb95d..51e35aa 100644 --- a/internal/sshclient/ssh.go +++ b/internal/sshclient/ssh.go @@ -4,7 +4,7 @@ import ( "fmt" "os" - "pdev-ssh/internal/models" + "pulsegate/internal/models" "golang.org/x/crypto/ssh" ) diff --git a/main.go b/main.go index bac59ab..89dfe71 100644 --- a/main.go +++ b/main.go @@ -6,10 +6,12 @@ import ( "os/exec" "strconv" "strings" + "net" + "time" - "pdev-ssh/internal/config" - "pdev-ssh/internal/models" - "pdev-ssh/internal/secret" + "pulsegate/internal/config" + "pulsegate/internal/models" + "pulsegate/internal/secret" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -46,6 +48,7 @@ type model struct { commandOutput string commandTitle string commandError string + serverStatus map[int]string } var ( @@ -104,6 +107,7 @@ func initialModel() model { servers: cfg.Servers, selected: 0, view: ViewServers, + serverStatus: make(map[int]string), } m.initAddInputs() @@ -111,7 +115,7 @@ func initialModel() model { } func (m model) Init() tea.Cmd { - return nil + return checkAllServerStatus(m.servers) } func (m *model) initAddInputs() { @@ -202,6 +206,11 @@ type quickCommandResultMsg struct { 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 @@ -219,6 +228,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 @@ -248,6 +265,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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() @@ -437,7 +457,7 @@ func (m model) View() string { right, ) - footer := helpStyle.Render("↑/↓ Auswahl Enter Verbinden/Ausführen a Hinzufügen e Editieren d Löschen c Commands Tab Ansicht q Zurück/Beenden") + 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, @@ -450,7 +470,7 @@ func (m model) View() string { } func (m model) renderHeader() string { - title := "󰣀 PDEV SSH Manager" + title := "󰣀 PulseGate" subtitle := fmt.Sprintf( "Homelab SSH Control Center • Server: %d • Theme: %s • TERM: %s", len(m.servers), @@ -509,6 +529,8 @@ func (m model) renderServerList() string { for i, server := range m.servers { authIcon := "󰌾" + status := m.serverStatus[i] + statusRendered := statusStyle(status).Render(statusIcon(status)) if server.Auth == "key" { authIcon = "󰌆" } @@ -518,8 +540,17 @@ func (m model) renderServerList() string { kitty = " 󰄛 kitty" } + statusText := "" + if status == "online" { + statusText = " ssh" + } else if status == "offline" { + statusText = " off" + } + line := fmt.Sprintf( - "%s %s %s@%s:%d [%s]%s", + "%s%s %s %s %s@%s:%d [%s]%s", + statusRendered, + statusText, authIcon, server.Name, server.User, @@ -966,6 +997,64 @@ func runQuickCommand(server models.Server, settings models.Settings, quick model } } +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.") diff --git a/pulsegate_screenshot.png b/pulsegate_screenshot.png new file mode 100644 index 0000000..e76fb79 Binary files /dev/null and b/pulsegate_screenshot.png differ