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