Changed Project Name to Pulsegate

+added README.MD
This commit is contained in:
2026-05-03 02:54:48 +02:00
parent b886c3e9e8
commit 9c6e2b2ff5
7 changed files with 333 additions and 11 deletions

233
README.md Normal file
View File

@@ -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

2
go.mod
View File

@@ -1,4 +1,4 @@
module pdev-ssh module pulsegate
go 1.26.2 go 1.26.2

View File

@@ -3,7 +3,7 @@ package config
import ( import (
"os" "os"
"pdev-ssh/internal/models" "pulsegate/internal/models"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )

View File

@@ -6,7 +6,7 @@ import (
"github.com/99designs/keyring" "github.com/99designs/keyring"
) )
const serviceName = "pdev-ssh" const serviceName = "pulsegate"
func OpenKeyring() (keyring.Keyring, error) { func OpenKeyring() (keyring.Keyring, error) {
return keyring.Open(keyring.Config{ return keyring.Open(keyring.Config{

View File

@@ -4,7 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"pdev-ssh/internal/models" "pulsegate/internal/models"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )

103
main.go
View File

@@ -6,10 +6,12 @@ import (
"os/exec" "os/exec"
"strconv" "strconv"
"strings" "strings"
"net"
"time"
"pdev-ssh/internal/config" "pulsegate/internal/config"
"pdev-ssh/internal/models" "pulsegate/internal/models"
"pdev-ssh/internal/secret" "pulsegate/internal/secret"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@@ -46,6 +48,7 @@ type model struct {
commandOutput string commandOutput string
commandTitle string commandTitle string
commandError string commandError string
serverStatus map[int]string
} }
var ( var (
@@ -104,6 +107,7 @@ func initialModel() model {
servers: cfg.Servers, servers: cfg.Servers,
selected: 0, selected: 0,
view: ViewServers, view: ViewServers,
serverStatus: make(map[int]string),
} }
m.initAddInputs() m.initAddInputs()
@@ -111,7 +115,7 @@ func initialModel() model {
} }
func (m model) Init() tea.Cmd { func (m model) Init() tea.Cmd {
return nil return checkAllServerStatus(m.servers)
} }
func (m *model) initAddInputs() { func (m *model) initAddInputs() {
@@ -202,6 +206,11 @@ type quickCommandResultMsg struct {
Error string Error string
} }
type serverStatusMsg struct {
Index int
Status string
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.err != nil { if m.err != nil {
return m, nil return m, nil
@@ -219,6 +228,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.view = ViewCommandOutput m.view = ViewCommandOutput
return m, nil 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: case tea.KeyMsg:
// Command Output offen lassen, bis du ihn wegdrückst // Command Output offen lassen, bis du ihn wegdrückst
@@ -249,6 +266,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.addInputs[m.addFocus].Focus() m.addInputs[m.addFocus].Focus()
return m, nil return m, nil
case "r":
return m, checkAllServerStatus(m.servers)
case "shift+tab", "up": case "shift+tab", "up":
m.addInputs[m.addFocus].Blur() m.addInputs[m.addFocus].Blur()
m.addFocus-- m.addFocus--
@@ -437,7 +457,7 @@ func (m model) View() string {
right, 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( content := lipgloss.JoinVertical(
lipgloss.Left, lipgloss.Left,
@@ -450,7 +470,7 @@ func (m model) View() string {
} }
func (m model) renderHeader() string { func (m model) renderHeader() string {
title := "󰣀 PDEV SSH Manager" title := "󰣀 PulseGate"
subtitle := fmt.Sprintf( subtitle := fmt.Sprintf(
"Homelab SSH Control Center • Server: %d • Theme: %s • TERM: %s", "Homelab SSH Control Center • Server: %d • Theme: %s • TERM: %s",
len(m.servers), len(m.servers),
@@ -509,6 +529,8 @@ func (m model) renderServerList() string {
for i, server := range m.servers { for i, server := range m.servers {
authIcon := "󰌾" authIcon := "󰌾"
status := m.serverStatus[i]
statusRendered := statusStyle(status).Render(statusIcon(status))
if server.Auth == "key" { if server.Auth == "key" {
authIcon = "󰌆" authIcon = "󰌆"
} }
@@ -518,8 +540,17 @@ func (m model) renderServerList() string {
kitty = " 󰄛 kitty" kitty = " 󰄛 kitty"
} }
statusText := ""
if status == "online" {
statusText = " ssh"
} else if status == "offline" {
statusText = " off"
}
line := fmt.Sprintf( line := fmt.Sprintf(
"%s %s %s@%s:%d [%s]%s", "%s%s %s %s %s@%s:%d [%s]%s",
statusRendered,
statusText,
authIcon, authIcon,
server.Name, server.Name,
server.User, 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 { func (m model) renderDeleteConfirmContent() string {
if len(m.servers) == 0 { if len(m.servers) == 0 {
return panelStyle.Render("Kein Server zum Löschen vorhanden.") return panelStyle.Render("Kein Server zum Löschen vorhanden.")

BIN
pulsegate_screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB