Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 39 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ With lazyssh, you can quickly navigate, connect, manage, and transfer files betw
## ✨ Features

### Server Management

- 📜 Read & display servers from your `~/.ssh/config` in a scrollable list.
- ➕ Add a new server from the UI with comprehensive SSH configuration options.
- ✏ Edit existing server entries directly from the UI with a tabbed interface.
Expand All @@ -21,12 +22,14 @@ With lazyssh, you can quickly navigate, connect, manage, and transfer files betw
- 🏓 Ping server to check status.

### Quick Server Navigation

- 🔍 Fuzzy search by alias, IP, or tags.
- 🖥 One‑keypress SSH into the selected server (Enter).
- 🏷 Tag servers (e.g., prod, dev, test) for quick filtering.
- ↕️ Sort by alias or last SSH (toggle + reverse).

### Advanced SSH Configuration

- 🔗 Port forwarding (LocalForward, RemoteForward, DynamicForward).
- 🚀 Connection multiplexing for faster subsequent connections.
- 🔐 Advanced authentication options (public key, password, agent forwarding).
Expand All @@ -35,17 +38,19 @@ With lazyssh, you can quickly navigate, connect, manage, and transfer files betw
- ⚙️ Extensive SSH config options organized in tabbed interface.

### Key Management

- 🔑 SSH key autocomplete with automatic detection of available keys.
- 📝 Smart key selection with support for multiple keys.


### Upcoming

- 📁 Copy files between local and servers with an easy picker UI.
- 🔑 SSH Key Deployment Features:
- Use default local public key (`~/.ssh/id_ed25519.pub` or `~/.ssh/id_rsa.pub`)
- Paste custom public keys manually
- Generate new keypairs and deploy them
- Automatically append keys to `~/.ssh/authorized_keys` with correct permissions
- Use default local public key (`~/.ssh/id_ed25519.pub` or `~/.ssh/id_rsa.pub`)
- Paste custom public keys manually
- Generate new keypairs and deploy them
- Automatically append keys to `~/.ssh/authorized_keys` with correct permissions

---

## 🔐 Security Notice
Expand All @@ -63,7 +68,6 @@ It is simply a UI/TUI wrapper around your existing `~/.ssh/config` file.

- File permissions on your SSH config are preserved to ensure security.


## 🛡️ Config Safety: Non‑destructive writes and backups

- Non‑destructive edits: lazyssh only writes the minimal required changes to your ~/.ssh/config. It uses a parser that preserves existing comments, spacing, order, and any settings it didn’t touch. Your handcrafted comments and formatting remain intact.
Expand All @@ -72,35 +76,55 @@ It is simply a UI/TUI wrapper around your existing `~/.ssh/config` file.
- One‑time original backup: before lazyssh makes its first change, it creates a single snapshot named config.original.backup beside your SSH config. If this file is present, it will never be recreated or overwritten.
- Rolling backups: on every subsequent save, lazyssh also creates a timestamped backup named like: ~/.ssh/config-<timestamp>-lazyssh.backup. The app keeps at most 10 of these backups, automatically removing the oldest ones.

## 📂 SSH Config `Include` Support

lazyssh honours top-level `Include` directives in your `~/.ssh/config`. Hosts defined in included files (e.g. `~/.ssh/config.d/work`) appear in the server list alongside hosts defined in the main config.

- **Reads:** all `Include`d files are parsed in OpenSSH precedence order. When the same alias is defined in more than one file, the first definition wins (matching OpenSSH semantics) but every source file is recorded so the UI can prompt on edit.
- **Writes route back to the source file:** editing or deleting a host modifies whichever file actually defines it. Other files are never touched, and only the file that changed is re-serialized — preserving handcrafted formatting elsewhere.
- **Ambiguity prompt:** if the same alias is defined in multiple included files, the first edit/delete shows a modal asking which file to write to. Your choice is remembered in `~/.lazyssh/metadata.json` (per-alias `file` field), so subsequent edits go straight through without re-prompting.
- **New hosts always go to the main config.** This keeps `Include`d files clean and predictable; you can move a host between files manually if you want it to live elsewhere.
- **Per-file backups:** rolling backups (`<basename>-<timestamp>-lazyssh.backup`) and the one-time `<basename>.original.backup` are created alongside each included file the first time lazyssh writes to it.

### Include support Limitations

- `Include` directives **inside** `Host`/`Match` blocks are ignored. Only top-level Includes are honored.
- `Match` directives are not modelled as host entries.

## 📷 Screenshots

<div align="center">

### 🚀 Startup

<img src="./docs/loader.png" alt="App starting splash/loader" width="800" />

Clean loading screen when launching the app

---

### 📋 Server Management Dashboard

<img src="./docs/list server.png" alt="Server list view" width="900" />

Main dashboard displaying all configured servers with status indicators, pinned favorites at the top, and easy navigation

---

### 🔎 Search

<img src="./docs/search.png" alt="Fuzzy search servers" width="900" />

Fuzzy search functionality to quickly find servers by name, IP address, or tags

---

### ➕ Add/Edit Server

<img src="./docs/add server.png" alt="Add a new server" width="900" />

Tabbed interface for managing SSH connections with extensive configuration options organized into:

- **Basic** - Host, user, port, keys, tags
- **Connection** - Proxy, timeouts, multiplexing, canonicalization
- **Forwarding** - Port forwarding, X11, agent
Expand All @@ -110,6 +134,7 @@ Tabbed interface for managing SSH connections with extensive configuration optio
---

### 🔐 Connect to server

<img src="./docs/ssh.png" alt="SSH connection details" width="900" />

SSH into the selected server
Expand Down Expand Up @@ -180,12 +205,12 @@ make run
| q | Quit |

**In Server Form:**
| Key | Action |
| Key | Action |
| ------ | -------------------- |
| Ctrl+H | Previous tab |
| Ctrl+L | Next tab |
| Ctrl+S | Save |
| Esc | Cancel |
| Ctrl+H | Previous tab |
| Ctrl+L | Next tab |
| Ctrl+S | Save |
| Esc | Cancel |

Tip: The hint bar at the top of the list shows the most useful shortcuts.

Expand All @@ -205,10 +230,11 @@ We love seeing the community make Lazyssh better 🚀
This repository enforces semantic PR titles via an automated GitHub Action. Please format your PR title as:

- type(scope): short descriptive subject
Notes:
Notes:
- Scope is optional and should be one of: ui, cli, config, parser.

Allowed types in this repo:

- feat: a new feature
- fix: a bug fix
- improve: quality or UX improvements that are not a refactor or perf
Expand All @@ -220,6 +246,7 @@ Allowed types in this repo:
- revert: reverts a previous commit

Examples:

- feat(ui): add server pinning and sorting options
- fix(parser): handle comments at end of Host blocks
- improve(cli): show friendly error when ssh binary missing
Expand All @@ -239,11 +266,9 @@ If you find Lazyssh useful, please consider giving the repo a **star** ⭐️ an
<br/>
<a href="https://buymeacoffee.com/adembc" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" width="200"></a>


---

## 🙏 Acknowledgments

- Built with [tview](https://github.com/rivo/tview) and [tcell](https://github.com/gdamore/tcell).
- Inspired by [k9s](https://github.com/derailed/k9s) and [lazydocker](https://github.com/jesseduffield/lazydocker).

54 changes: 30 additions & 24 deletions internal/adapters/data/ssh_config_file/backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,26 +24,28 @@ import (
"time"
)

// createBackup creates a timestamped backup of the current config file
func (r *Repository) createBackup() error {
if _, err := r.fileSystem.Stat(r.configPath); os.IsNotExist(err) {
// createBackupFor creates a timestamped backup of the given config file and
// prunes older backups for that file beyond MaxBackups.
func (r *Repository) createBackupFor(path string) error {
if _, err := r.fileSystem.Stat(path); os.IsNotExist(err) {
return nil
} else if err != nil {
return fmt.Errorf("failed to check if config file exists: %w", err)
}

timestamp := time.Now().UnixMilli()
backupPath := fmt.Sprintf("%s-%d-%s", r.configPath, timestamp, BackupSuffix)
backupPath := fmt.Sprintf("%s-%d-%s", path, timestamp, BackupSuffix)

if err := r.copyFile(r.configPath, backupPath); err != nil {
if err := r.copyFile(path, backupPath); err != nil {
return fmt.Errorf("failed to copy config to backup: %w", err)
}

r.logger.Infof("Created backup: %s", backupPath)

configDir := filepath.Dir(r.configPath)
configDir := filepath.Dir(path)
baseName := filepath.Base(path)

backupFiles, err := r.findBackupFiles(configDir)
backupFiles, err := r.findBackupFilesFor(configDir, baseName)
if err != nil {
return err
}
Expand Down Expand Up @@ -102,49 +104,53 @@ func (r *Repository) copyFile(src, dst string) error {
return destFile.Sync()
}

// findBackupFiles finds all backup files for the given config file
func (r *Repository) findBackupFiles(dir string) ([]os.FileInfo, error) {
// findBackupFilesFor finds rolling backup files for the named config file in
// dir. Backups are recognized by `<baseName>-<timestamp>-<BackupSuffix>`.
func (r *Repository) findBackupFilesFor(dir, baseName string) ([]os.FileInfo, error) {
entries, err := r.fileSystem.ReadDir(dir)
if err != nil {
return nil, err
}

var backupFiles []os.FileInfo
prefix := baseName + "-"
backupFiles := make([]os.FileInfo, 0, len(entries))

for _, entry := range entries {
name := entry.Name()
if strings.HasSuffix(name, BackupSuffix) {
info, err := entry.Info()
if err != nil {
r.logger.Warnf("failed to get info for backup file %s: %v", name, err)
continue
}
backupFiles = append(backupFiles, info)
if !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, BackupSuffix) {
continue
}
info, err := entry.Info()
if err != nil {
r.logger.Warnf("failed to get info for backup file %s: %v", name, err)
continue
}
backupFiles = append(backupFiles, info)
}

return backupFiles, nil
}

// createOriginalBackupIfNeeded creates a one-time original backup of the current SSH config.
func (r *Repository) createOriginalBackupIfNeeded() error {
// If no SSH config file, nothing to do.
if _, err := r.fileSystem.Stat(r.configPath); os.IsNotExist(err) {
// createOriginalBackupForIfNeeded creates a one-time original backup of the
// given config file (next to it, named `<baseName>.original.backup`).
func (r *Repository) createOriginalBackupForIfNeeded(path string) error {
if _, err := r.fileSystem.Stat(path); os.IsNotExist(err) {
return nil
} else if err != nil {
return fmt.Errorf("failed to check if config file exists: %w", err)
}

configDir := filepath.Dir(r.configPath)
originalBackupPath := filepath.Join(configDir, OriginalBackupName)
configDir := filepath.Dir(path)
baseName := filepath.Base(path)
originalBackupPath := filepath.Join(configDir, baseName+".original.backup")

if _, err := r.fileSystem.Stat(originalBackupPath); err == nil {
return nil
} else if !r.fileSystem.IsNotExist(err) {
return fmt.Errorf("failed to check if original backup exists: %w", err)
}

if err := r.copyFile(r.configPath, originalBackupPath); err != nil {
if err := r.copyFile(path, originalBackupPath); err != nil {
return fmt.Errorf("failed to create original backup: %w", err)
}

Expand Down
75 changes: 42 additions & 33 deletions internal/adapters/data/ssh_config_file/config_io.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,37 +23,42 @@ import (
"github.com/kevinburke/ssh_config"
)

// loadConfig reads and parses the SSH config file.
// If the file does not exist, it returns an empty config without error to support first-run behavior.
func (r *Repository) loadConfig() (*ssh_config.Config, error) {
file, err := r.fileSystem.Open(r.configPath)
// loadConfig reads and parses the SSH config file plus every file pulled in
// via top-level `Include` directives. Returns a loadedConfig containing all
// per-file parses in OpenSSH precedence order (main first).
func (r *Repository) loadConfig() (*loadedConfig, error) {
lc, err := r.resolveIncludes(r.configPath)
if err != nil {
if r.fileSystem.IsNotExist(err) {
return &ssh_config.Config{Hosts: []*ssh_config.Host{}}, nil
}
return nil, fmt.Errorf("failed to open config file: %w", err)
return nil, fmt.Errorf("failed to load config: %w", err)
}
defer func() {
if cerr := file.Close(); cerr != nil {
r.logger.Warnf("failed to close config file: %v", cerr)
}
}()
return lc, nil
}

cfg, err := ssh_config.Decode(file)
if err != nil {
return nil, fmt.Errorf("failed to decode config: %w", err)
// saveFiles writes only the entries of lc whose paths appear in dirty back to
// disk. Each file gets its own atomic temp+rename and its own rolling backup.
func (r *Repository) saveFiles(lc *loadedConfig, dirty []string) error {
dirtySet := make(map[string]bool, len(dirty))
for _, p := range dirty {
dirtySet[p] = true
}

return cfg, nil
for _, f := range lc.files {
if !dirtySet[f.path] {
continue
}
if err := r.writeOneFile(f.path, f.cfg); err != nil {
return err
}
}
return nil
}

// saveConfig writes the SSH config back to the file with atomic operations and backup management.
func (r *Repository) saveConfig(cfg *ssh_config.Config) error {
configDir := filepath.Dir(r.configPath)
func (r *Repository) writeOneFile(path string, cfg *ssh_config.Config) error {
configDir := filepath.Dir(path)

tempFile, err := r.createTempFile(configDir)
if err != nil {
return fmt.Errorf("failed to create temporary file: %w", err)
return fmt.Errorf("failed to create temporary file for %s: %w", path, err)
}

defer func() {
Expand All @@ -66,24 +71,29 @@ func (r *Repository) saveConfig(cfg *ssh_config.Config) error {
return fmt.Errorf("failed to write config to temporary file: %w", err)
}

// Ensure a one-time original backup exists before any modifications managed by lazyssh.
if err := r.createOriginalBackupIfNeeded(); err != nil {
return fmt.Errorf("failed to create original backup: %w", err)
if err := r.createOriginalBackupForIfNeeded(path); err != nil {
return fmt.Errorf("failed to create original backup for %s: %w", path, err)
}

if err := r.createBackupFor(path); err != nil {
return fmt.Errorf("failed to create backup for %s: %w", path, err)
}

if err := r.createBackup(); err != nil {
return fmt.Errorf("failed to create backup: %w", err)
// Resolve symlinks before atomic rename so we don't replace a symlink with a regular file.
target := path
if resolved, err := filepath.EvalSymlinks(path); err == nil {
target = resolved
}

if err := r.fileSystem.Rename(tempFile, r.configPath); err != nil {
return fmt.Errorf("failed to atomically replace config file: %w", err)
if err := r.fileSystem.Rename(tempFile, target); err != nil {
return fmt.Errorf("failed to atomically replace %s: %w", target, err)
}

r.logger.Infof("SSH config successfully updated: %s", r.configPath)
r.logger.Infof("SSH config successfully updated: %s", target)
return nil
}

// writeConfigToFile writes the SSH config content to the specified file
// writeConfigToFile writes the SSH config content to the specified file.
func (r *Repository) writeConfigToFile(filePath string, cfg *ssh_config.Config) error {
file, err := r.fileSystem.OpenFile(filePath, os.O_WRONLY|os.O_TRUNC, SSHConfigPerms)
if err != nil {
Expand All @@ -107,13 +117,12 @@ func (r *Repository) writeConfigToFile(filePath string, cfg *ssh_config.Config)
return nil
}

// createTempFile creates a temporary file in the specified directory
// createTempFile creates a temporary file in the specified directory.
func (r *Repository) createTempFile(dir string) (string, error) {
timestamp := time.Now().Format("20060102150405")
timestamp := time.Now().Format("20060102150405.000000")
tempFileName := fmt.Sprintf("config%s%s", timestamp, TempSuffix)
tempFilePath := filepath.Join(dir, tempFileName)

// Create the temp file with explicit 0600 permissions
f, err := r.fileSystem.OpenFile(tempFilePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, SSHConfigPerms)
if err != nil {
return "", err
Expand Down
Loading
Loading