From b71ef74aaa2c58a1d751db06c69eb95410e455d1 Mon Sep 17 00:00:00 2001 From: Adem Baccara <71262172+Adembc@users.noreply.github.com> Date: Wed, 10 Sep 2025 21:10:10 +0100 Subject: [PATCH] rewrite ssh config parser and writer to protect file --- cmd/main.go | 4 +- go.mod | 3 + go.sum | 2 + internal/adapters/data/file/parser.go | 119 ----- internal/adapters/data/file/server_repo.go | 144 ------ .../adapters/data/file/ssh_config_manager.go | 209 -------- internal/adapters/data/file/writer.go | 80 --- .../data/ssh_config_file/file_system.go | 75 +++ .../metadata_manager.go | 56 +- .../adapters/data/ssh_config_file/parser.go | 477 ++++++++++++++++++ .../data/ssh_config_file/server_repo.go | 166 ++++++ internal/adapters/ui/const.go | 9 +- internal/adapters/ui/server_details.go | 2 +- internal/core/domain/server.go | 1 + 14 files changed, 772 insertions(+), 575 deletions(-) delete mode 100644 internal/adapters/data/file/parser.go delete mode 100644 internal/adapters/data/file/server_repo.go delete mode 100644 internal/adapters/data/file/ssh_config_manager.go delete mode 100644 internal/adapters/data/file/writer.go create mode 100644 internal/adapters/data/ssh_config_file/file_system.go rename internal/adapters/data/{file => ssh_config_file}/metadata_manager.go (56%) create mode 100644 internal/adapters/data/ssh_config_file/parser.go create mode 100644 internal/adapters/data/ssh_config_file/server_repo.go diff --git a/cmd/main.go b/cmd/main.go index 386ae75..ee9cf15 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -19,7 +19,7 @@ import ( "os" "path/filepath" - "github.com/Adembc/lazyssh/internal/adapters/data/file" + "github.com/Adembc/lazyssh/internal/adapters/data/ssh_config_file" "github.com/Adembc/lazyssh/internal/logger" "github.com/Adembc/lazyssh/internal/adapters/ui" @@ -51,7 +51,7 @@ func main() { sshConfigFile := filepath.Join(home, ".ssh", "config") metaDataFile := filepath.Join(home, ".lazyssh", "metadata.json") - serverRepo := file.NewServerRepo(log, sshConfigFile, metaDataFile) + serverRepo := ssh_config_file.NewRepository(log, sshConfigFile, metaDataFile) serverService := services.NewServerService(log, serverRepo) tui := ui.NewTUI(log, serverService, version, gitCommit) diff --git a/go.mod b/go.mod index e6c2d5e..243daba 100644 --- a/go.mod +++ b/go.mod @@ -2,9 +2,12 @@ module github.com/Adembc/lazyssh go 1.24.6 +replace github.com/kevinburke/ssh_config => github.com/adembc/ssh_config v1.4.2 + require ( github.com/atotto/clipboard v0.1.4 github.com/gdamore/tcell/v2 v2.9.0 + github.com/kevinburke/ssh_config v1.4.0 github.com/mattn/go-runewidth v0.0.16 github.com/rivo/tview v0.0.0-20250625164341-a4a78f1e05cb github.com/spf13/cobra v1.9.1 diff --git a/go.sum b/go.sum index d92f4db..9c794df 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/adembc/ssh_config v1.4.2 h1:Q0GMGDTvddd9QqdCri/M6SoBzPhmc1gjsXXEc9wpHTM= +github.com/adembc/ssh_config v1.4.2/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= diff --git a/internal/adapters/data/file/parser.go b/internal/adapters/data/file/parser.go deleted file mode 100644 index 8d2e0f5..0000000 --- a/internal/adapters/data/file/parser.go +++ /dev/null @@ -1,119 +0,0 @@ -// Copyright 2025. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package file - -import ( - "bufio" - "io" - "os" - "path/filepath" - "strconv" - "strings" - - "github.com/Adembc/lazyssh/internal/core/domain" -) - -const ( - sshConfigAliasField = "host" - sshConfigIPField = "hostname" - sshConfigUserField = "user" - sshConfigPortField = "port" - sshConfigKeyField = "identityfile" -) - -type SSHConfigParser struct{} - -func (p *SSHConfigParser) Parse(reader io.Reader) ([]domain.Server, error) { - servers := make([]domain.Server, 0) - var currentServer *domain.Server - - scanner := bufio.NewScanner(reader) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - - if p.shouldSkipLine(line) { - continue - } - - key, value := p.parseKeyValue(line) - if key == "" { - continue - } - - switch key { - case sshConfigAliasField: - if currentServer != nil { - servers = append(servers, *currentServer) - } - currentServer = &domain.Server{ - Alias: value, - Port: DefaultPort, - } - case sshConfigIPField: - if currentServer != nil { - currentServer.Host = value - } - case sshConfigUserField: - if currentServer != nil { - currentServer.User = value - } - case sshConfigPortField: - if currentServer != nil { - currentServer.Port = p.parsePort(value) - } - case sshConfigKeyField: - if currentServer != nil { - currentServer.Key = p.expandPath(value) - } - } - } - - if currentServer != nil { - servers = append(servers, *currentServer) - } - - return servers, scanner.Err() -} - -func (p *SSHConfigParser) shouldSkipLine(line string) bool { - return line == "" || strings.HasPrefix(line, "#") -} - -func (p *SSHConfigParser) parseKeyValue(line string) (string, string) { - parts := strings.Fields(line) - if len(parts) < 2 { - return "", "" - } - - key := strings.ToLower(parts[0]) - value := strings.Join(parts[1:], " ") - return key, value -} - -func (p *SSHConfigParser) parsePort(value string) int { - if port, err := strconv.Atoi(value); err == nil { - return port - } - return DefaultPort -} - -func (p *SSHConfigParser) expandPath(path string) string { - if strings.HasPrefix(path, "~/") { - if home, err := os.UserHomeDir(); err == nil { - return filepath.Join(home, path[2:]) - } - } - return path -} diff --git a/internal/adapters/data/file/server_repo.go b/internal/adapters/data/file/server_repo.go deleted file mode 100644 index aed37e6..0000000 --- a/internal/adapters/data/file/server_repo.go +++ /dev/null @@ -1,144 +0,0 @@ -// Copyright 2025. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package file - -import ( - "fmt" - "strings" - "time" - - "github.com/Adembc/lazyssh/internal/core/domain" - "go.uber.org/zap" -) - -type serverRepo struct { - sshConfigManager *sshConfigManager - metadataManager *metadataManager - logger *zap.SugaredLogger -} - -func NewServerRepo(logger *zap.SugaredLogger, sshPath, metaDataPath string) *serverRepo { - return &serverRepo{ - sshConfigManager: newSSHConfigManager(sshPath), - metadataManager: newMetadataManager(metaDataPath), - logger: logger, - } -} - -func (s *serverRepo) ListServers(query string) ([]domain.Server, error) { - servers, err := s.sshConfigManager.parseServers() - if err != nil { - return nil, fmt.Errorf("failed to parse SSH config: %w", err) - } - - metadata, err := s.metadataManager.loadAll() - if err != nil { - s.logger.Warnf("Failed to load metadata: %v", err) - metadata = make(map[string]ServerMetadata) - } - - servers = s.mergeMetadata(servers, metadata) - - if query != "" { - servers = s.filterServers(servers, query) - } - - return servers, nil -} - -func (s *serverRepo) UpdateServer(server domain.Server, newServer domain.Server) error { - if err := s.sshConfigManager.updateServer(server.Alias, newServer); err != nil { - return fmt.Errorf("failed to update SSH config: %w", err) - } - - return s.metadataManager.updateServer(newServer) -} - -func (s *serverRepo) AddServer(server domain.Server) error { - if err := s.sshConfigManager.addServer(server); err != nil { - return fmt.Errorf("failed to add to SSH config: %w", err) - } - - return s.metadataManager.updateServer(server) -} - -func (s *serverRepo) DeleteServer(server domain.Server) error { - if err := s.sshConfigManager.deleteServer(server.Alias); err != nil { - return fmt.Errorf("failed to delete from SSH config: %w", err) - } - - return s.metadataManager.deleteServer(server.Alias) -} - -func (s *serverRepo) SetPinned(alias string, pinned bool) error { - return s.metadataManager.setPinned(alias, pinned) -} - -func (s *serverRepo) RecordSSH(alias string) error { - return s.metadataManager.recordSSH(alias) -} - -func (s *serverRepo) mergeMetadata(servers []domain.Server, metadata map[string]ServerMetadata) []domain.Server { - for i, server := range servers { - servers[i].LastSeen = time.Time{} - - if meta, exists := metadata[server.Alias]; exists { - servers[i].Tags = meta.Tags - servers[i].SSHCount = meta.SSHCount - - if meta.LastSeen != "" { - if lastSeen, err := time.Parse(time.RFC3339, meta.LastSeen); err == nil { - servers[i].LastSeen = lastSeen - } - } - - if meta.PinnedAt != "" { - if pinnedAt, err := time.Parse(time.RFC3339, meta.PinnedAt); err == nil { - servers[i].PinnedAt = pinnedAt - } - } - } - } - return servers -} - -func (s *serverRepo) filterServers(servers []domain.Server, query string) []domain.Server { - queryLower := strings.ToLower(query) - filtered := make([]domain.Server, 0) - - for _, server := range servers { - if s.matchesQuery(server, queryLower) { - filtered = append(filtered, server) - } - } - - return filtered -} - -func (s *serverRepo) matchesQuery(server domain.Server, queryLower string) bool { - if strings.Contains(strings.ToLower(server.Alias), queryLower) || - strings.Contains(strings.ToLower(server.Host), queryLower) || - strings.Contains(strings.ToLower(server.User), queryLower) { - return true - } - - for _, tag := range server.Tags { - if strings.Contains(strings.ToLower(tag), queryLower) { - return true - } - } - - return false -} diff --git a/internal/adapters/data/file/ssh_config_manager.go b/internal/adapters/data/file/ssh_config_manager.go deleted file mode 100644 index 2dcd03b..0000000 --- a/internal/adapters/data/file/ssh_config_manager.go +++ /dev/null @@ -1,209 +0,0 @@ -// Copyright 2025. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package file - -import ( - "fmt" - "io" - "os" - "path/filepath" - - "github.com/Adembc/lazyssh/internal/core/domain" -) - -const ( - ManagedByComment = "# Managed by lazyssh" - DefaultPort = 22 -) - -type sshConfigManager struct { - filePath string -} - -func newSSHConfigManager(filePath string) *sshConfigManager { - return &sshConfigManager{filePath: filePath} -} - -func (m *sshConfigManager) parseServers() ([]domain.Server, error) { - file, err := os.Open(m.filePath) - if err != nil { - if os.IsNotExist(err) { - return []domain.Server{}, nil - } - return nil, err - } - defer func() { - _ = file.Close() - }() - - parser := &SSHConfigParser{} - return parser.Parse(file) -} - -func (m *sshConfigManager) writeServers(servers []domain.Server) error { - if err := m.ensureDirectory(); err != nil { - return err - } - - 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() { _ = os.Remove(tmp.Name()) }() - - if err := os.Chmod(tmp.Name(), 0o600); err != nil { - _ = tmp.Close() - return err - } - - writer := &SSHConfigWriter{} - 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 { - servers, err := m.parseServers() - if err != nil { - return err - } - - // Check for duplicates - for _, srv := range servers { - if srv.Alias == server.Alias { - return fmt.Errorf("server with alias '%s' already exists", server.Alias) - } - } - - servers = append(servers, server) - return m.writeServers(servers) -} - -func (m *sshConfigManager) updateServer(alias string, newServer domain.Server) error { - servers, err := m.parseServers() - if err != nil { - return err - } - - found := false - for i, srv := range servers { - if srv.Alias == alias { - servers[i] = newServer - found = true - break - } - } - - if !found { - return fmt.Errorf("server with alias '%s' not found", alias) - } - - return m.writeServers(servers) -} - -func (m *sshConfigManager) deleteServer(alias string) error { - servers, err := m.parseServers() - if err != nil { - return err - } - - newServers := make([]domain.Server, 0, len(servers)) - found := false - - for _, srv := range servers { - if srv.Alias != alias { - newServers = append(newServers, srv) - } else { - found = true - } - } - - if !found { - return fmt.Errorf("server with alias '%s' not found", alias) - } - - return m.writeServers(newServers) -} - -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/data/file/writer.go b/internal/adapters/data/file/writer.go deleted file mode 100644 index fce4eca..0000000 --- a/internal/adapters/data/file/writer.go +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright 2025. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package file - -import ( - "bufio" - "fmt" - "io" - - "github.com/Adembc/lazyssh/internal/core/domain" -) - -type SSHConfigWriter struct{} - -func (w *SSHConfigWriter) Write(writer io.Writer, servers []domain.Server) error { - bufWriter := bufio.NewWriter(writer) - defer func() { - _ = bufWriter.Flush() - }() - - if _, err := fmt.Fprintf(bufWriter, "%s\n\n", ManagedByComment); err != nil { - return err - } - - for i, server := range servers { - if i > 0 { - if _, err := bufWriter.WriteString("\n"); err != nil { - return err - } - } - if err := w.writeServer(bufWriter, server); err != nil { - return err - } - } - - return nil -} - -func (w *SSHConfigWriter) writeServer(writer *bufio.Writer, server domain.Server) error { - if _, err := fmt.Fprintf(writer, "Host %s\n", server.Alias); err != nil { - return err - } - - if server.Host != "" { - if _, err := fmt.Fprintf(writer, " HostName %s\n", server.Host); err != nil { - return err - } - } - - if server.User != "" { - if _, err := fmt.Fprintf(writer, " User %s\n", server.User); err != nil { - return err - } - } - - if server.Port != 0 && server.Port != DefaultPort { - if _, err := fmt.Fprintf(writer, " Port %d\n", server.Port); err != nil { - return err - } - } - - if server.Key != "" { - if _, err := fmt.Fprintf(writer, " IdentityFile %s\n", server.Key); err != nil { - return err - } - } - return nil -} diff --git a/internal/adapters/data/ssh_config_file/file_system.go b/internal/adapters/data/ssh_config_file/file_system.go new file mode 100644 index 0000000..e25d80d --- /dev/null +++ b/internal/adapters/data/ssh_config_file/file_system.go @@ -0,0 +1,75 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ssh_config_file + +import ( + "io" + "os" +) + +// FileSystem interface for file operations to enable testing. +type FileSystem interface { + Open(name string) (io.ReadCloser, error) + Create(name string) (io.WriteCloser, error) + Stat(name string) (os.FileInfo, error) + IsNotExist(err error) bool + Remove(file string) error + Rename(file string, path string) error + Chmod(path string, perms os.FileMode) error + OpenFile(path string, i int, perms os.FileMode) (*os.File, error) + ReadDir(dir string) ([]os.DirEntry, error) +} + +// DefaultFileSystem implements FileSystem using standard os package. +type DefaultFileSystem struct{} + +func (fs DefaultFileSystem) Open(name string) (io.ReadCloser, error) { + // #nosec G304 -- file path is controlled internally, not user-supplied + return os.Open(name) +} + +func (fs DefaultFileSystem) Create(name string) (io.WriteCloser, error) { + // #nosec G304 -- file path is controlled internally, not user-supplied + return os.Create(name) +} + +func (fs DefaultFileSystem) Stat(name string) (os.FileInfo, error) { + return os.Stat(name) +} + +func (fs DefaultFileSystem) IsNotExist(err error) bool { + return os.IsNotExist(err) +} + +func (fs DefaultFileSystem) Remove(file string) error { + return os.Remove(file) +} + +func (fs DefaultFileSystem) Rename(file string, path string) error { + return os.Rename(file, path) +} + +func (fs DefaultFileSystem) Chmod(path string, perms os.FileMode) error { + return os.Chmod(path, perms) +} + +func (fs DefaultFileSystem) OpenFile(path string, i int, perms os.FileMode) (*os.File, error) { + // #nosec G304 -- file path is controlled internally, not user-supplied + return os.OpenFile(path, i, perms) +} + +func (fs DefaultFileSystem) ReadDir(dir string) ([]os.DirEntry, error) { + return os.ReadDir(dir) +} diff --git a/internal/adapters/data/file/metadata_manager.go b/internal/adapters/data/ssh_config_file/metadata_manager.go similarity index 56% rename from internal/adapters/data/file/metadata_manager.go rename to internal/adapters/data/ssh_config_file/metadata_manager.go index 2b39f53..0c1a854 100644 --- a/internal/adapters/data/file/metadata_manager.go +++ b/internal/adapters/data/ssh_config_file/metadata_manager.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package file +package ssh_config_file import ( "encoding/json" @@ -22,6 +22,7 @@ import ( "time" "github.com/Adembc/lazyssh/internal/core/domain" + "go.uber.org/zap" ) type ServerMetadata struct { @@ -33,10 +34,11 @@ type ServerMetadata struct { type metadataManager struct { filePath string + logger *zap.SugaredLogger } -func newMetadataManager(filePath string) *metadataManager { - return &metadataManager{filePath: filePath} +func newMetadataManager(filePath string, logger *zap.SugaredLogger) *metadataManager { + return &metadataManager{filePath: filePath, logger: logger} } func (m *metadataManager) loadAll() (map[string]ServerMetadata, error) { @@ -48,7 +50,8 @@ func (m *metadataManager) loadAll() (map[string]ServerMetadata, error) { data, err := os.ReadFile(m.filePath) if err != nil { - return nil, err + m.logger.Errorw("failed to read metadata file", "path", m.filePath, "error", err) + return nil, fmt.Errorf("read metadata '%s': %w", m.filePath, err) } if len(data) == 0 { @@ -56,7 +59,9 @@ func (m *metadataManager) loadAll() (map[string]ServerMetadata, error) { } if err := json.Unmarshal(data, &metadata); err != nil { - return nil, fmt.Errorf("failed to parse metadata JSON: %w", err) + m.logger.Errorw("failed to parse metadata JSON", "path", m.filePath, "error", err) + + return nil, fmt.Errorf("parse metadata JSON '%s': %w", m.filePath, err) } return metadata, nil @@ -64,21 +69,37 @@ func (m *metadataManager) loadAll() (map[string]ServerMetadata, error) { func (m *metadataManager) saveAll(metadata map[string]ServerMetadata) error { if err := m.ensureDirectory(); err != nil { - return err + m.logger.Errorw("failed to ensure metadata directory", "path", m.filePath, "error", err) + + return fmt.Errorf("ensure metadata directory for '%s': %w", m.filePath, err) } data, err := json.MarshalIndent(metadata, "", " ") if err != nil { - return err + m.logger.Errorw("failed to marshal metadata", "path", m.filePath, "error", err) + return fmt.Errorf("marshal metadata for '%s': %w", m.filePath, err) } - return os.WriteFile(m.filePath, data, 0o600) + if err := os.WriteFile(m.filePath, data, 0o600); err != nil { + m.logger.Errorw("failed to write metadata file", "path", m.filePath, "error", err) + return fmt.Errorf("write metadata '%s': %w", m.filePath, err) + } + return nil } -func (m *metadataManager) updateServer(server domain.Server) error { +func (m *metadataManager) updateServer(server domain.Server, oldAlias string) error { metadata, err := m.loadAll() if err != nil { - metadata = make(map[string]ServerMetadata) + m.logger.Errorw("failed to load metadata in updateServer", "path", m.filePath, "alias", server.Alias, "old_alias", oldAlias, "error", err) + return fmt.Errorf("load metadata: %w", err) + } + + if oldAlias != server.Alias { + oldMeta, ok := metadata[oldAlias] + if ok { + metadata[server.Alias] = oldMeta + } + delete(metadata, oldAlias) } existing := metadata[server.Alias] @@ -107,7 +128,8 @@ func (m *metadataManager) updateServer(server domain.Server) error { func (m *metadataManager) deleteServer(alias string) error { metadata, err := m.loadAll() if err != nil { - return nil + m.logger.Errorw("failed to load metadata in deleteServer", "path", m.filePath, "alias", alias, "error", err) + return fmt.Errorf("load metadata: %w", err) } delete(metadata, alias) @@ -117,7 +139,8 @@ func (m *metadataManager) deleteServer(alias string) error { func (m *metadataManager) setPinned(alias string, pinned bool) error { metadata, err := m.loadAll() if err != nil { - metadata = make(map[string]ServerMetadata) + m.logger.Errorw("failed to load metadata in setPinned", "path", m.filePath, "alias", alias, "pinned", pinned, "error", err) + return fmt.Errorf("load metadata: %w", err) } meta := metadata[alias] @@ -134,7 +157,8 @@ func (m *metadataManager) setPinned(alias string, pinned bool) error { func (m *metadataManager) recordSSH(alias string) error { metadata, err := m.loadAll() if err != nil { - metadata = make(map[string]ServerMetadata) + m.logger.Errorw("failed to load metadata in recordSSH", "path", m.filePath, "alias", alias, "error", err) + return fmt.Errorf("load metadata: %w", err) } meta := metadata[alias] @@ -147,5 +171,9 @@ func (m *metadataManager) recordSSH(alias string) error { func (m *metadataManager) ensureDirectory() error { dir := filepath.Dir(m.filePath) - return os.MkdirAll(dir, 0o750) + if err := os.MkdirAll(dir, 0o750); err != nil { + m.logger.Errorw("failed to create metadata directory", "dir", dir, "error", err) + return fmt.Errorf("mkdir '%s': %w", dir, err) + } + return nil } diff --git a/internal/adapters/data/ssh_config_file/parser.go b/internal/adapters/data/ssh_config_file/parser.go new file mode 100644 index 0000000..5911084 --- /dev/null +++ b/internal/adapters/data/ssh_config_file/parser.go @@ -0,0 +1,477 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ssh_config_file + +import ( + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/Adembc/lazyssh/internal/core/domain" + "github.com/kevinburke/ssh_config" +) + +const ( + MaxBackups = 10 + TempSuffix = ".tmp" + BackupSuffix = "lazyssh.backup" + SSHConfigPerms = 0o600 +) + +// Private methods + +// 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) + 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) + } + defer func() { + if cerr := file.Close(); cerr != nil { + r.logger.Warnf("failed to close config file: %v", cerr) + } + }() + + cfg, err := ssh_config.Decode(file) + if err != nil { + return nil, fmt.Errorf("failed to decode config: %w", err) + } + + return cfg, 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) + + tempFile, err := r.createTempFile(configDir) + if err != nil { + return fmt.Errorf("failed to create temporary file: %w", err) + } + + defer func() { + if removeErr := r.fileSystem.Remove(tempFile); removeErr != nil { + r.logger.Warnf("failed to remove temporary file %s: %v", tempFile, removeErr) + } + }() + + if err := r.writeConfigToFile(tempFile, cfg); err != nil { + return fmt.Errorf("failed to write config to temporary file: %w", err) + } + + if err := r.createBackup(); err != nil { + return fmt.Errorf("failed to create backup: %w", err) + } + + if err := r.fileSystem.Rename(tempFile, r.configPath); err != nil { + return fmt.Errorf("failed to atomically replace config file: %w", err) + } + + r.logger.Infof("SSH config successfully updated: %s", r.configPath) + return nil +} + +// createTempFile creates a temporary file in the specified directory +func (r *Repository) createTempFile(dir string) (string, error) { + timestamp := time.Now().Format("20060102150405") + tempFileName := fmt.Sprintf("config%s%s", timestamp, TempSuffix) + tempFilePath := filepath.Join(dir, tempFileName) + + // Create the temp file + file, err := r.fileSystem.Create(tempFilePath) + if err != nil { + return "", err + } + + if err := file.Close(); err != nil { + r.logger.Warnf("failed to close temporary file %s: %v", tempFilePath, err) + } + + return tempFilePath, nil +} + +// 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 { + return fmt.Errorf("failed to open file for writing: %w", err) + } + defer func() { + if cerr := file.Close(); cerr != nil { + r.logger.Warnf("failed to close file %s: %v", filePath, cerr) + } + }() + + configContent := cfg.String() + if _, err := file.WriteString(configContent); err != nil { + return fmt.Errorf("failed to write config content: %w", err) + } + + if err := file.Sync(); err != nil { + return fmt.Errorf("failed to sync file to disk: %w", err) + } + + return nil +} + +// 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) { + 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) + + if err := r.copyFile(r.configPath, 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) + + backupFiles, err := r.findBackupFiles(configDir) + if err != nil { + return err + } + + if len(backupFiles) <= MaxBackups { + return nil + } + + sort.Slice(backupFiles, func(i, j int) bool { + return backupFiles[i].ModTime().After(backupFiles[j].ModTime()) + }) + + for i := MaxBackups; i < len(backupFiles); i++ { + backupPath := filepath.Join(configDir, backupFiles[i].Name()) + if err := r.fileSystem.Remove(backupPath); err != nil { + r.logger.Warnf("failed to remove old backup %s: %v", backupPath, err) + continue + } + r.logger.Infof("Removed old backup: %s", backupPath) + } + return nil +} + +// copyFile copies a file from src to dst +func (r *Repository) copyFile(src, dst string) error { + srcFile, err := r.fileSystem.Open(src) + if err != nil { + return err + } + defer func() { + if cerr := srcFile.Close(); cerr != nil { + r.logger.Warnf("failed to close source file %s: %v", src, cerr) + } + }() + + srcInfo, err := r.fileSystem.Stat(src) + if err != nil { + return err + } + + destFile, err := r.fileSystem.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, srcInfo.Mode()) + if err != nil { + return err + } + defer func() { + if cerr := destFile.Close(); cerr != nil { + r.logger.Warnf("failed to close destination file %s: %v", dst, cerr) + } + }() + + _, err = io.Copy(destFile, srcFile) + if err != nil { + return err + } + + return destFile.Sync() +} + +// findBackupFiles finds all backup files for the given config file +func (r *Repository) findBackupFiles(dir string) ([]os.FileInfo, error) { + entries, err := r.fileSystem.ReadDir(dir) + if err != nil { + return nil, err + } + + var backupFiles []os.FileInfo + + 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) + } + } + + return backupFiles, nil +} + +// toDomainServer converts ssh_config.Config to a slice of domain.Server. +func (r *Repository) toDomainServer(cfg *ssh_config.Config) []domain.Server { + servers := make([]domain.Server, 0, len(cfg.Hosts)) + for _, host := range cfg.Hosts { + + aliases := make([]string, 0, len(host.Patterns)) + + for _, pattern := range host.Patterns { + alias := pattern.String() + // Skip if alias contains wildcards (not a concrete Host) + if strings.ContainsAny(alias, "!*?[]") { + continue + } + aliases = append(aliases, alias) + } + if len(aliases) == 0 { + continue + } + server := domain.Server{ + Alias: aliases[0], + Aliases: aliases, + Port: 22, + } + + for _, node := range host.Nodes { + kvNode, ok := node.(*ssh_config.KV) + if !ok { + continue + } + + r.mapKVToServer(&server, kvNode) + } + + servers = append(servers, server) + } + + return servers +} + +// mapKVToServer maps a ssh_config.KV node to the corresponding fields in domain.Server. +func (r *Repository) mapKVToServer(server *domain.Server, kvNode *ssh_config.KV) { + switch strings.ToLower(kvNode.Key) { + case "hostname": + server.Host = kvNode.Value + case "user": + server.User = kvNode.Value + case "port": + port, err := strconv.Atoi(kvNode.Value) + if err == nil { + server.Port = port + } + case "identityfile": + server.Key = kvNode.Value + } +} + +// mergeMetadata merges additional metadata into the servers. +func (r *Repository) mergeMetadata(servers []domain.Server, metadata map[string]ServerMetadata) []domain.Server { + for i, server := range servers { + servers[i].LastSeen = time.Time{} + + if meta, exists := metadata[server.Alias]; exists { + servers[i].Tags = meta.Tags + servers[i].SSHCount = meta.SSHCount + + if meta.LastSeen != "" { + if lastSeen, err := time.Parse(time.RFC3339, meta.LastSeen); err == nil { + servers[i].LastSeen = lastSeen + } + } + + if meta.PinnedAt != "" { + if pinnedAt, err := time.Parse(time.RFC3339, meta.PinnedAt); err == nil { + servers[i].PinnedAt = pinnedAt + } + } + } + } + return servers +} + +// filterServers filters servers based on the query string. +func (r *Repository) filterServers(servers []domain.Server, query string) []domain.Server { + query = strings.ToLower(query) + filtered := make([]domain.Server, 0) + + for _, server := range servers { + if r.matchesQuery(server, query) { + filtered = append(filtered, server) + } + } + + return filtered +} + +// matchesQuery checks if any field of the server matches the query string. +func (r *Repository) matchesQuery(server domain.Server, query string) bool { + fields := []string{ + strings.ToLower(server.Host), + strings.ToLower(server.User), + } + for _, tag := range server.Tags { + fields = append(fields, strings.ToLower(tag)) + } + for _, alias := range server.Aliases { + fields = append(fields, strings.ToLower(alias)) + } + + for _, field := range fields { + if strings.Contains(field, query) { + return true + } + } + + return false +} + +// serverExists checks if a server with the given alias already exists in the config. +func (r *Repository) serverExists(cfg *ssh_config.Config, alias string) bool { + return r.findHostByAlias(cfg, alias) != nil +} + +// findHostByAlias finds a host by its alias in the SSH config. +func (r *Repository) findHostByAlias(cfg *ssh_config.Config, alias string) *ssh_config.Host { + for _, host := range cfg.Hosts { + if r.hostContainsPattern(host, alias) { + return host + } + } + return nil +} + +// hostContainsPattern checks if a host contains a specific pattern. +func (r *Repository) hostContainsPattern(host *ssh_config.Host, target string) bool { + for _, pattern := range host.Patterns { + if pattern.String() == target { + return true + } + } + return false +} + +// createHostFromServer creates a new ssh_config.Host from a domain.Server. +func (r *Repository) createHostFromServer(server domain.Server) *ssh_config.Host { + host := &ssh_config.Host{ + Patterns: []*ssh_config.Pattern{ + {Str: server.Alias}, + }, + Nodes: make([]ssh_config.Node, 0), + LeadingSpace: 1, + EOLComment: "Added by lazyssh", + SpaceBeforeComment: strings.Repeat(" ", 4), + } + + r.addKVNodeIfNotEmpty(host, "HostName", server.Host) + r.addKVNodeIfNotEmpty(host, "User", server.User) + r.addKVNodeIfNotEmpty(host, "Port", fmt.Sprintf("%d", server.Port)) + r.addKVNodeIfNotEmpty(host, "IdentityFile", server.Key) + + return host +} + +// addKVNodeIfNotEmpty adds a key-value node to the host if the value is not empty. +func (r *Repository) addKVNodeIfNotEmpty(host *ssh_config.Host, key, value string) { + if value == "" { + return + } + + kvNode := &ssh_config.KV{ + Key: key, + Value: value, + LeadingSpace: 4, + } + host.Nodes = append(host.Nodes, kvNode) +} + +// updateHostNodes updates the nodes of an existing host with new server details. +func (r *Repository) updateHostNodes(host *ssh_config.Host, newServer domain.Server) { + updates := map[string]string{ + "hostname": newServer.Host, + "user": newServer.User, + "port": fmt.Sprintf("%d", newServer.Port), + "identityfile": newServer.Key, + } + for key, value := range updates { + if value != "" { + r.updateOrAddKVNode(host, key, value) + } + } +} + +// updateOrAddKVNode updates an existing key-value node or adds a new one if it doesn't exist. +func (r *Repository) updateOrAddKVNode(host *ssh_config.Host, key, newValue string) { + keyLower := strings.ToLower(key) + + // Try to update existing node + for _, node := range host.Nodes { + kvNode, ok := node.(*ssh_config.KV) + if ok && strings.EqualFold(kvNode.Key, keyLower) { + kvNode.Value = newValue + return + } + } + + // Add new node if not found + kvNode := &ssh_config.KV{ + Key: r.getProperKeyCase(key), + Value: newValue, + LeadingSpace: 4, + } + host.Nodes = append(host.Nodes, kvNode) +} + +// getProperKeyCase returns the proper case for known SSH config keys. +// Reference: https://www.ssh.com/academy/ssh/config +func (r *Repository) getProperKeyCase(key string) string { + keyMap := map[string]string{ + "hostname": "HostName", + "user": "User", + "port": "Port", + "identityfile": "IdentityFile", + } + + if properCase, exists := keyMap[strings.ToLower(key)]; exists { + return properCase + } + return key +} + +// removeHostByAlias removes a host by its alias from the list of hosts. +func (r *Repository) removeHostByAlias(hosts []*ssh_config.Host, alias string) []*ssh_config.Host { + for i, host := range hosts { + if r.hostContainsPattern(host, alias) { + return append(hosts[:i], hosts[i+1:]...) + } + } + return hosts +} diff --git a/internal/adapters/data/ssh_config_file/server_repo.go b/internal/adapters/data/ssh_config_file/server_repo.go new file mode 100644 index 0000000..37e8004 --- /dev/null +++ b/internal/adapters/data/ssh_config_file/server_repo.go @@ -0,0 +1,166 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ssh_config_file + +import ( + "fmt" + + "github.com/Adembc/lazyssh/internal/core/domain" + "github.com/Adembc/lazyssh/internal/core/ports" + "github.com/kevinburke/ssh_config" + "go.uber.org/zap" +) + +// Repository implements ServerRepository interface for SSH config file operations. +type Repository struct { + configPath string + fileSystem FileSystem + metadataManager *metadataManager + logger *zap.SugaredLogger +} + +// NewRepository creates a new SSH config repository. +func NewRepository(logger *zap.SugaredLogger, configPath, metaDataPath string) ports.ServerRepository { + return &Repository{ + logger: logger, + configPath: configPath, + fileSystem: DefaultFileSystem{}, + metadataManager: newMetadataManager(metaDataPath, logger), + } +} + +// NewRepositoryWithFS creates a new SSH config repository with a custom filesystem. +func NewRepositoryWithFS(logger *zap.SugaredLogger, configPath string, metaDataPath string, fs FileSystem) ports.ServerRepository { + return &Repository{ + logger: logger, + configPath: configPath, + fileSystem: fs, + metadataManager: newMetadataManager(metaDataPath, logger), + } +} + +// ListServers returns all servers matching the query pattern. +// Empty query returns all servers. +func (r *Repository) ListServers(query string) ([]domain.Server, error) { + cfg, err := r.loadConfig() + if err != nil { + return nil, fmt.Errorf("failed to load config: %w", err) + } + + servers := r.toDomainServer(cfg) + metadata, err := r.metadataManager.loadAll() + if err != nil { + r.logger.Warnf("Failed to load metadata: %v", err) + metadata = make(map[string]ServerMetadata) + } + servers = r.mergeMetadata(servers, metadata) + if query == "" { + return servers, nil + } + + return r.filterServers(servers, query), nil +} + +// AddServer adds a new server to the SSH config. +func (r *Repository) AddServer(server domain.Server) error { + cfg, err := r.loadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + if r.serverExists(cfg, server.Alias) { + return fmt.Errorf("server with alias '%s' already exists", server.Alias) + } + + host := r.createHostFromServer(server) + cfg.Hosts = append(cfg.Hosts, host) + + if err := r.saveConfig(cfg); err != nil { + r.logger.Warnf("Failed to save config while adding new server: %v", err) + return fmt.Errorf("failed to save config: %w", err) + } + return r.metadataManager.updateServer(server, server.Alias) +} + +// UpdateServer updates an existing server in the SSH config. +func (r *Repository) UpdateServer(server domain.Server, newServer domain.Server) error { + cfg, err := r.loadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + host := r.findHostByAlias(cfg, server.Alias) + if host == nil { + return fmt.Errorf("server with alias '%s' not found", server.Alias) + } + + if server.Alias != newServer.Alias { + if r.serverExists(cfg, newServer.Alias) { + return fmt.Errorf("server with alias '%s' already exists", newServer.Alias) + } + + newPatterns := make([]*ssh_config.Pattern, 0, len(host.Patterns)) + for _, pattern := range host.Patterns { + if pattern.Str == server.Alias { + newPatterns = append(newPatterns, &ssh_config.Pattern{Str: newServer.Alias}) + } else { + newPatterns = append(newPatterns, pattern) + } + } + + host.Patterns = newPatterns + + } + + r.updateHostNodes(host, newServer) + + if err := r.saveConfig(cfg); err != nil { + r.logger.Warnf("Failed to save config while updating server: %v", err) + return fmt.Errorf("failed to save config: %w", err) + } + // Update metadata; pass old alias to allow inline migration + return r.metadataManager.updateServer(newServer, server.Alias) +} + +// DeleteServer removes a server from the SSH config. +func (r *Repository) DeleteServer(server domain.Server) error { + cfg, err := r.loadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + initialCount := len(cfg.Hosts) + cfg.Hosts = r.removeHostByAlias(cfg.Hosts, server.Alias) + + if len(cfg.Hosts) == initialCount { + return fmt.Errorf("server with alias '%s' not found", server.Alias) + } + + if err := r.saveConfig(cfg); err != nil { + r.logger.Warnf("Failed to save config while deleting server: %v", err) + return fmt.Errorf("failed to save config: %w", err) + } + return r.metadataManager.deleteServer(server.Alias) +} + +// SetPinned sets or unsets the pinned status of a server. +func (r *Repository) SetPinned(alias string, pinned bool) error { + return r.metadataManager.setPinned(alias, pinned) +} + +// RecordSSH increments the SSH access count and updates the last seen timestamp for a server. +func (r *Repository) RecordSSH(alias string) error { + return r.metadataManager.recordSSH(alias) +} diff --git a/internal/adapters/ui/const.go b/internal/adapters/ui/const.go index a65564c..beaa007 100644 --- a/internal/adapters/ui/const.go +++ b/internal/adapters/ui/const.go @@ -14,13 +14,10 @@ package ui -import "time" - const ( - AppName = "lazyssh" - RepoURL = "github.com/adembc/lazyssh" - SplashScreenDuration = 1 * time.Second - Banner = ` + AppName = "lazyssh" + RepoURL = "github.com/adembc/lazyssh" + Banner = ` $$\ $$\ $$ | $$ | diff --git a/internal/adapters/ui/server_details.go b/internal/adapters/ui/server_details.go index ab0a7bb..15bf68f 100644 --- a/internal/adapters/ui/server_details.go +++ b/internal/adapters/ui/server_details.go @@ -72,7 +72,7 @@ func (sd *ServerDetails) UpdateServer(server domain.Server) { tagsText := renderTagChips(server.Tags) text := fmt.Sprintf( "[::b]%s[-]\n\nHost: [white]%s[-]\nUser: [white]%s[-]\nPort: [white]%d[-]\nKey: [white]%s[-]\nTags: %s\nPinned: [white]%s[-]\nLast SSH: %s\nSSH Count: [white]%d[-]\n\n[::b]Commands:[-]\n Enter: SSH connect\n c: Copy SSH command\n g: Ping server\n r: Refresh list\n a: Add new server\n e: Edit entry\n t: Edit tags\n d: Delete entry\n p: Pin/Unpin", - server.Alias, server.Host, server.User, server.Port, + strings.Join(server.Aliases, ", "), server.Host, server.User, server.Port, serverKey, tagsText, pinnedStr, lastSeen, server.SSHCount) sd.TextView.SetText(text) diff --git a/internal/core/domain/server.go b/internal/core/domain/server.go index f69bb7a..e450bea 100644 --- a/internal/core/domain/server.go +++ b/internal/core/domain/server.go @@ -18,6 +18,7 @@ import "time" type Server struct { Alias string + Aliases []string Host string User string Port int