diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index 48513c0..7f056b4 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -17,7 +17,7 @@ builds:
- 386
main: ./cmd/main.go
ldflags:
- -X main.version={{.Version}} -X main.gitCommit={{.Commit}} -X main.buildTime={{.Date}}
+ -X main.version={{.Version}} -X main.gitCommit={{.Commit}}
diff --git a/README.md b/README.md
index 681cff0..69d15e6 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
Lazyssh is a terminal-based, interactive SSH manager inspired by tools like lazydocker and k9s — but built for managing your fleet of servers directly from your terminal.
-With lazyssh, you can quickly navigate, connect, manage, and transfer files between your local machine and any server defined in your ~/.ssh/config. No more remembering IP addresses or running long scp commands — just a clean, keyboard-driven UI.
+With lazyssh, you can quickly navigate, connect, manage, and transfer files between your local machine and any server defined in your `~/.ssh/config`. No more remembering IP addresses or running long scp commands — just a clean, keyboard-driven UI.
---
@@ -31,12 +31,28 @@ With lazyssh, you can quickly navigate, connect, manage, and transfer files betw
- 📁 Copy files between local and servers with an easy picker UI.
- 📡 Port forwarding (local↔remote) from the UI.
- 🔑 Enhanced Key Management:
- - Use default local public key (~/.ssh/id_ed25519.pub or ~/.ssh/id_rsa.pub)
+ - 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
+ - Automatically append keys to `~/.ssh/authorized_keys` with correct permissions
---
+## 🔐 Security Notice
+
+lazyssh does not introduce any new security risks.
+It is simply a UI/TUI wrapper around your existing `~/.ssh/config` file.
+
+- All SSH connections are executed through your system’s native ssh binary (OpenSSH).
+
+- Private keys, passwords, and credentials are never stored, transmitted, or modified by lazyssh.
+
+- Your existing IdentityFile paths and ssh-agent integrations work exactly as before.
+
+- lazyssh only reads and updates your `~/.ssh/config`. A backup of the file is created automatically before any changes.
+
+- File permissions on your SSH config are preserved to ensure security.
+
+
## 📷 Screenshots
diff --git a/cmd/main.go b/cmd/main.go
index 6791b7a..386ae75 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -18,7 +18,6 @@ import (
"fmt"
"os"
"path/filepath"
- "time"
"github.com/Adembc/lazyssh/internal/adapters/data/file"
"github.com/Adembc/lazyssh/internal/logger"
@@ -31,7 +30,6 @@ import (
var (
version = "develop"
gitCommit = "unknown"
- buildTime = time.Now().Format("2006-01-02 15:04:05")
)
func main() {
@@ -55,7 +53,7 @@ func main() {
serverRepo := file.NewServerRepo(log, sshConfigFile, metaDataFile)
serverService := services.NewServerService(log, serverRepo)
- tui := ui.NewTUI(log, serverService, version, gitCommit, buildTime)
+ tui := ui.NewTUI(log, serverService, version, gitCommit)
rootCmd := &cobra.Command{
Use: ui.AppName,
diff --git a/internal/adapters/data/file/ssh_config_manager.go b/internal/adapters/data/file/ssh_config_manager.go
index 69b79ca..2dcd03b 100644
--- a/internal/adapters/data/file/ssh_config_manager.go
+++ b/internal/adapters/data/file/ssh_config_manager.go
@@ -16,6 +16,7 @@ package file
import (
"fmt"
+ "io"
"os"
"path/filepath"
@@ -56,16 +57,41 @@ func (m *sshConfigManager) writeServers(servers []domain.Server) error {
return err
}
- file, err := os.Create(m.filePath)
+ if err := m.backupCurrentConfig(); err != nil {
+ return err
+ }
+
+ dir := filepath.Dir(m.filePath)
+ tmp, err := os.CreateTemp(dir, ".lazyssh-tmp-*")
if err != nil {
return err
}
- defer func() {
- _ = file.Close()
- }()
+ defer func() { _ = os.Remove(tmp.Name()) }()
+
+ if err := os.Chmod(tmp.Name(), 0o600); err != nil {
+ _ = tmp.Close()
+ return err
+ }
writer := &SSHConfigWriter{}
- return writer.Write(file, servers)
+ if err := writer.Write(tmp, servers); err != nil {
+ _ = tmp.Close()
+ return err
+ }
+
+ if err := tmp.Sync(); err != nil {
+ _ = tmp.Close()
+ return err
+ }
+ if err := tmp.Close(); err != nil { // close after sync to ensure contents are persisted
+ return err
+ }
+
+ if err := os.Rename(tmp.Name(), m.filePath); err != nil {
+ return err
+ }
+
+ return nil
}
func (m *sshConfigManager) addServer(server domain.Server) error {
@@ -135,3 +161,49 @@ func (m *sshConfigManager) ensureDirectory() error {
dir := filepath.Dir(m.filePath)
return os.MkdirAll(dir, 0o700)
}
+
+// backupCurrentConfig creates ~/.lazyssh/backups/config.backup with 0600 perms,
+// overwriting it each time, but only if the source config exists.
+func (m *sshConfigManager) backupCurrentConfig() error {
+ // If source config does not exist, skip backup
+ if _, err := os.Stat(m.filePath); err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return err
+ }
+
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return err
+ }
+ backupDir := filepath.Join(home, ".lazyssh", "backups")
+ // Ensure directory with 0700
+ if err := os.MkdirAll(backupDir, 0o700); err != nil {
+ return err
+ }
+ backupPath := filepath.Join(backupDir, "config.backup")
+ // Copy file contents
+ src, err := os.Open(m.filePath)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = src.Close() }()
+
+ // #nosec G304 -- backupPath is generated internally and trusted
+ dst, err := os.OpenFile(backupPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
+ if err != nil {
+ return err
+ }
+
+ defer func() { _ = dst.Close() }()
+
+ if _, err := io.Copy(dst, src); err != nil {
+ return err
+ }
+
+ if err := dst.Sync(); err != nil {
+ return err
+ }
+ return nil
+}
diff --git a/internal/adapters/ui/header.go b/internal/adapters/ui/header.go
index 99a01af..45f7bd2 100644
--- a/internal/adapters/ui/header.go
+++ b/internal/adapters/ui/header.go
@@ -25,17 +25,15 @@ import (
type AppHeader struct {
*tview.Flex
version string
- buildTime string
gitCommit string
repoURL string
}
-func NewAppHeader(version, gitCommit, buildTime, repoURL string) *AppHeader {
+func NewAppHeader(version, gitCommit, repoURL string) *AppHeader {
header := &AppHeader{
Flex: tview.NewFlex(),
version: version,
repoURL: repoURL,
- buildTime: buildTime,
gitCommit: gitCommit,
}
header.build()
@@ -85,13 +83,11 @@ func (h *AppHeader) buildCenterSection(bg tcell.Color) *tview.TextView {
if commit != "" {
commitTag = makeTag(commit, "#A78BFA") // violet
}
- timeTag := makeTag(formatBuildTime(h.buildTime), "#3B82F6") // blue
text := versionTag
if commitTag != "" {
text += " " + commitTag
}
- text += " " + timeTag
center.SetText(text)
return center
@@ -126,30 +122,6 @@ func shortCommit(c string) string {
return c
}
-// formatBuildTime tries to parse common time formats and returns a concise human-readable string.
-func formatBuildTime(s string) string {
- s = strings.TrimSpace(s)
- if s == "" {
- return "unknown"
- }
- layouts := []string{
- "2006-01-02 15:04:05",
- time.RFC3339,
- time.RFC1123,
- time.RFC1123Z,
- }
- var t time.Time
- var err error
- for _, l := range layouts {
- t, err = time.Parse(l, s)
- if err == nil {
- return t.Format("Mon, 02 Jan 2006 15:04")
- }
- }
-
- return s
-}
-
// makeTag returns a rectangular-looking colored chip for the given text.
func makeTag(text, bg string) string {
text = strings.TrimSpace(text)
diff --git a/internal/adapters/ui/tui.go b/internal/adapters/ui/tui.go
index 494ca32..e35e2ea 100644
--- a/internal/adapters/ui/tui.go
+++ b/internal/adapters/ui/tui.go
@@ -27,9 +27,8 @@ import (
type tui struct {
logger *zap.SugaredLogger
- version string
- commit string
- buildDate string
+ version string
+ commit string
app *tview.Application
serverService ports.ServerService
@@ -49,14 +48,13 @@ type tui struct {
searchVisible bool
}
-func NewTUI(logger *zap.SugaredLogger, ss ports.ServerService, version, commit, buildDate string) *tui {
+func NewTUI(logger *zap.SugaredLogger, ss ports.ServerService, version, commit string) *tui {
return &tui{
logger: logger,
app: tview.NewApplication(),
serverService: ss,
version: version,
commit: commit,
- buildDate: buildDate,
}
}
@@ -68,7 +66,7 @@ func (t *tui) Run() error {
}()
t.app.EnableMouse(true)
t.initializeTheme().buildComponents().buildLayout().bindEvents().loadInitialData().loadSplashScreen()
- t.logger.Infow("starting TUI application", "version", t.version, "commit", t.commit, "buildDate", t.buildDate)
+ t.logger.Infow("starting TUI application", "version", t.version, "commit", t.commit)
if err := t.app.Run(); err != nil {
t.logger.Errorw("application run error", "error", err)
return err
@@ -89,7 +87,7 @@ func (t *tui) initializeTheme() *tui {
}
func (t *tui) buildComponents() *tui {
- t.header = NewAppHeader(t.version, t.commit, t.buildDate, RepoURL)
+ t.header = NewAppHeader(t.version, t.commit, RepoURL)
t.searchBar = NewSearchBar().
OnSearch(t.handleSearchInput).
OnEscape(t.hideSearchBar)
diff --git a/makefile b/makefile
index c281e98..624a874 100644
--- a/makefile
+++ b/makefile
@@ -20,7 +20,6 @@ SHELL = /usr/bin/env bash -o pipefail
# Project variables
PROJECT_NAME ?= $(shell basename $(CURDIR))
VERSION ?= v0.1.0
-BUILD_TIME ?= $(shell date -u '+%Y-%m-%d_%H:%M:%S')
GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
# Build variables
@@ -30,7 +29,7 @@ CMD_DIR ?= ./cmd
PKG_LIST := $(shell go list ./...)
# LDFLAGS for version information
-LDFLAGS = -ldflags "-X main.version=$(VERSION) -X main.buildTime=$(BUILD_TIME) -X main.gitCommit=$(GIT_COMMIT)"
+LDFLAGS = -ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT)"
##@ Dependencies