mirror of
https://github.com/Adembc/lazyssh.git
synced 2026-07-14 12:13:34 +02:00
feat(parser): refactor ssh_config parser and writer to preserve unmanaged fields, comments, and directives (#45)
This PR introduces a major refactor of the SSH config parsing and writing logic. The new implementation is more robust and secure, ensuring that only the intended changes are applied while preserving the original file’s structure. Key changes - Lossless parsing/writing: Preserve unmanaged fields (e.g., `ProxyJump`), comments, and directives such as `Include` and `Match`. - Library update: Switched to [github.com/kevinburke/ssh_config](https://github.com/kevinburke/ssh_config) as the base parser, with a custom fork to support required modifications https://github.com/adembc/ssh_config. - Backup policy: Before any modification, create a backup of the SSH config file. Maintain up to 10 backups (configurable in the future) and automatically delete older ones. - IdentityFile handling: Parse IdentityFile as an array instead of a single string, with improved update logic. - Bug fix: Resolve issue where tags could not be fully removed. - Multiple aliases: Support defining and managing multiple aliases for a single server. - Testability: Code has been refactored with testability in mind. Follow-up PRs will include dedicated tests.
This commit is contained in:
+2
-2
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,132 +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 memory
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/Adembc/lazyssh/internal/core/domain"
|
||||
)
|
||||
|
||||
type serverRepository struct {
|
||||
logger *zap.SugaredLogger
|
||||
}
|
||||
|
||||
var servers = []domain.Server{
|
||||
{Alias: "web-01", Host: "192.168.1.10", User: "root", Port: 22, Key: "~/.ssh/id_rsa", Tags: []string{"prod", "web"}, LastSeen: time.Now().Add(-2 * time.Hour)},
|
||||
{Alias: "web-02", Host: "192.168.1.11", User: "ubuntu", Port: 22, Key: "~/.ssh/id_ed25519", Tags: []string{"prod", "web"}, LastSeen: time.Now().Add(-30 * time.Minute)},
|
||||
{Alias: "db-01", Host: "192.168.1.20", User: "postgres", Port: 22, Key: "~/.ssh/id_rsa", Tags: []string{"prod", "db"}, LastSeen: time.Now().Add(-26 * time.Hour)},
|
||||
{Alias: "api-01", Host: "192.168.1.30", User: "deploy", Port: 22, Key: "~/.ssh/id_ed25519", Tags: []string{"prod", "api"}, LastSeen: time.Now().Add(-10 * time.Minute)},
|
||||
{Alias: "cache-01", Host: "192.168.1.40", User: "redis", Port: 22, Key: "~/.ssh/id_rsa", Tags: []string{"prod", "cache"}, LastSeen: time.Now().Add(-1 * time.Hour)},
|
||||
{Alias: "dev-web", Host: "10.0.1.10", User: "dev", Port: 22, Key: "~/.ssh/id_ed25519", Tags: []string{"dev", "web"}, LastSeen: time.Now().Add(-5 * time.Minute)},
|
||||
{Alias: "dev-db", Host: "10.0.1.20", User: "dev", Port: 22, Key: "~/.ssh/id_ed25519", Tags: []string{"dev", "db"}, LastSeen: time.Now().Add(-15 * time.Minute)},
|
||||
{Alias: "staging", Host: "staging.example.com", User: "ubuntu", Port: 22, Key: "~/.ssh/id_ed25519", Tags: []string{"test"}, LastSeen: time.Now().Add(-45 * time.Minute)},
|
||||
}
|
||||
|
||||
// NewServerRepository creates a new server repository with the given file path.
|
||||
func NewServerRepository(logger *zap.SugaredLogger) *serverRepository {
|
||||
return &serverRepository{
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ListServers returns a list of servers from the repository.
|
||||
func (r *serverRepository) ListServers(query string) ([]domain.Server, error) {
|
||||
if query == "" {
|
||||
return servers, nil
|
||||
}
|
||||
q := strings.ToLower(strings.TrimSpace(query))
|
||||
|
||||
var filteredServers []domain.Server
|
||||
for _, server := range servers {
|
||||
alias := strings.ToLower(server.Alias)
|
||||
host := strings.ToLower(server.Host)
|
||||
user := strings.ToLower(server.User)
|
||||
port := strconv.Itoa(server.Port)
|
||||
|
||||
match := false
|
||||
if strings.Contains(alias, q) || strings.Contains(host, q) || strings.Contains(user, q) || strings.Contains(port, q) {
|
||||
match = true
|
||||
}
|
||||
if !match {
|
||||
for _, tag := range server.Tags {
|
||||
if strings.Contains(strings.ToLower(tag), q) {
|
||||
match = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if match {
|
||||
filteredServers = append(filteredServers, server)
|
||||
}
|
||||
}
|
||||
return filteredServers, nil
|
||||
}
|
||||
|
||||
// UpdateServer updates an existing server with new details.
|
||||
func (r *serverRepository) UpdateServer(server domain.Server, newServer domain.Server) error {
|
||||
for i, s := range servers {
|
||||
if s.Alias == server.Alias {
|
||||
servers[i] = newServer
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddServer adds a new server to the repository.
|
||||
func (r *serverRepository) AddServer(server domain.Server) error {
|
||||
servers = append(servers, server)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteServer removes a server from the repository.
|
||||
func (r *serverRepository) DeleteServer(server domain.Server) error {
|
||||
for i, s := range servers {
|
||||
if s.Alias == server.Alias {
|
||||
servers = append(servers[:i], servers[i+1:]...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *serverRepository) SetPinned(alias string, pinned bool) error {
|
||||
for i, s := range servers {
|
||||
if s.Alias == alias {
|
||||
if pinned {
|
||||
servers[i].PinnedAt = time.Now()
|
||||
} else {
|
||||
servers[i].PinnedAt = time.Time{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *serverRepository) RecordSSH(alias string) error {
|
||||
for i, s := range servers {
|
||||
if s.Alias == alias {
|
||||
servers[i].LastSeen = time.Now()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// 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"
|
||||
"strings"
|
||||
"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) {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// 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"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"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)
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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 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
|
||||
}
|
||||
if cerr := f.Close(); cerr != nil {
|
||||
r.logger.Warnf("failed to close temporary file %s: %v", tempFilePath, cerr)
|
||||
}
|
||||
|
||||
return tempFilePath, nil
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// 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"
|
||||
"strings"
|
||||
|
||||
"github.com/Adembc/lazyssh/internal/core/domain"
|
||||
"github.com/kevinburke/ssh_config"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxBackups = 10
|
||||
TempSuffix = ".tmp"
|
||||
BackupSuffix = "lazyssh.backup"
|
||||
SSHConfigPerms = 0o600
|
||||
)
|
||||
|
||||
// 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: 4,
|
||||
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))
|
||||
for _, identityFile := range server.IdentityFiles {
|
||||
r.addKVNodeIfNotEmpty(host, "IdentityFile", identityFile)
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
for key, value := range updates {
|
||||
if value != "" {
|
||||
r.updateOrAddKVNode(host, key, value)
|
||||
}
|
||||
}
|
||||
// Replace IdentityFile entries entirely to reflect the new state.
|
||||
// This ensures removing/clearing identity files works as expected.
|
||||
|
||||
removeKey := func(nodes []ssh_config.Node, key string) []ssh_config.Node {
|
||||
filtered := make([]ssh_config.Node, 0, len(nodes))
|
||||
for _, node := range nodes {
|
||||
if kv, ok := node.(*ssh_config.KV); ok {
|
||||
if strings.EqualFold(kv.Key, key) {
|
||||
continue // skip existing IdentityFile
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, node)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
host.Nodes = removeKey(host.Nodes, "IdentityFile")
|
||||
|
||||
for _, identityFile := range newServer.IdentityFiles {
|
||||
r.addKVNodeIfNotEmpty(host, "IdentityFile", identityFile)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -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 -- the file path is controlled internally, not user-supplied
|
||||
return os.Open(name)
|
||||
}
|
||||
|
||||
func (fs DefaultFileSystem) Create(name string) (io.WriteCloser, error) {
|
||||
// #nosec G304 -- the 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 -- the 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)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// 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 (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Adembc/lazyssh/internal/core/domain"
|
||||
"github.com/kevinburke/ssh_config"
|
||||
)
|
||||
|
||||
// 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,
|
||||
IdentityFiles: []string{},
|
||||
}
|
||||
|
||||
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 an 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.IdentityFiles = append(server.IdentityFiles, 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
|
||||
}
|
||||
+39
-17
@@ -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,7 @@ func (m *metadataManager) loadAll() (map[string]ServerMetadata, error) {
|
||||
|
||||
data, err := os.ReadFile(m.filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("read metadata '%s': %w", m.filePath, err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
@@ -56,7 +58,7 @@ 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)
|
||||
return nil, fmt.Errorf("parse metadata JSON '%s': %w", m.filePath, err)
|
||||
}
|
||||
|
||||
return metadata, nil
|
||||
@@ -64,29 +66,43 @@ 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]
|
||||
merged := existing
|
||||
|
||||
if server.Tags != nil {
|
||||
merged.Tags = server.Tags
|
||||
}
|
||||
merged.Tags = server.Tags
|
||||
|
||||
if !server.LastSeen.IsZero() {
|
||||
merged.LastSeen = server.LastSeen.Format(time.RFC3339)
|
||||
@@ -107,7 +123,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 +134,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 +152,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 +166,8 @@ 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 {
|
||||
return fmt.Errorf("mkdir '%s': %w", dir, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 = `
|
||||
|
||||
$$\ $$\
|
||||
$$ | $$ |
|
||||
|
||||
@@ -332,13 +332,13 @@ func (t *tui) showEditTagsForm(server domain.Server) {
|
||||
form.AddButton("Save", func() {
|
||||
text := strings.TrimSpace(form.GetFormItem(0).(*tview.InputField).GetText())
|
||||
var tags []string
|
||||
if text != "" {
|
||||
for _, part := range strings.Split(text, ",") {
|
||||
if s := strings.TrimSpace(part); s != "" {
|
||||
tags = append(tags, s)
|
||||
}
|
||||
|
||||
for _, part := range strings.Split(text, ",") {
|
||||
if s := strings.TrimSpace(part); s != "" {
|
||||
tags = append(tags, s)
|
||||
}
|
||||
}
|
||||
|
||||
newServer := server
|
||||
newServer.Tags = tags
|
||||
_ = t.serverService.UpdateServer(server, newServer)
|
||||
|
||||
@@ -61,10 +61,8 @@ func (sd *ServerDetails) UpdateServer(server domain.Server) {
|
||||
if server.LastSeen.IsZero() {
|
||||
lastSeen = "Never"
|
||||
}
|
||||
serverKey := server.Key
|
||||
if serverKey == "" {
|
||||
serverKey = "(default: ~/.ssh/id_{rsa,ed25519,ecdsa})"
|
||||
}
|
||||
serverKey := strings.Join(server.IdentityFiles, ", ")
|
||||
|
||||
pinnedStr := "true"
|
||||
if server.PinnedAt.IsZero() {
|
||||
pinnedStr = "false"
|
||||
@@ -72,7 +70,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)
|
||||
|
||||
@@ -82,7 +82,7 @@ func (sf *ServerForm) addFormFields() {
|
||||
Host: sf.original.Host,
|
||||
User: sf.original.User,
|
||||
Port: fmt.Sprint(sf.original.Port),
|
||||
Key: sf.original.Key,
|
||||
Key: strings.Join(sf.original.IdentityFiles, ", "),
|
||||
Tags: strings.Join(sf.original.Tags, ", "),
|
||||
}
|
||||
} else {
|
||||
@@ -97,7 +97,7 @@ func (sf *ServerForm) addFormFields() {
|
||||
sf.Form.AddInputField("Host/IP:", defaultValues.Host, 20, nil, nil)
|
||||
sf.Form.AddInputField("User:", defaultValues.User, 20, nil, nil)
|
||||
sf.Form.AddInputField("Port:", defaultValues.Port, 20, nil, nil)
|
||||
sf.Form.AddInputField("Key:", defaultValues.Key, 40, nil, nil)
|
||||
sf.Form.AddInputField("Key (Comma):", defaultValues.Key, 40, nil, nil)
|
||||
sf.Form.AddInputField("Tags (comma):", defaultValues.Tags, 30, nil, nil)
|
||||
}
|
||||
|
||||
@@ -163,13 +163,22 @@ func (sf *ServerForm) dataToServer(data ServerFormData) domain.Server {
|
||||
}
|
||||
}
|
||||
|
||||
keys := make([]string, 0)
|
||||
if data.Key != "" {
|
||||
parts := strings.Split(data.Key, ",")
|
||||
for _, p := range parts {
|
||||
if k := strings.TrimSpace(p); k != "" {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
return domain.Server{
|
||||
Alias: data.Alias,
|
||||
Host: data.Host,
|
||||
User: data.User,
|
||||
Port: port,
|
||||
Key: data.Key,
|
||||
Tags: tags,
|
||||
Alias: data.Alias,
|
||||
Host: data.Host,
|
||||
User: data.User,
|
||||
Port: port,
|
||||
IdentityFiles: keys,
|
||||
Tags: tags,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ import (
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
type App interface {
|
||||
Run() error
|
||||
}
|
||||
|
||||
type tui struct {
|
||||
logger *zap.SugaredLogger
|
||||
|
||||
@@ -46,7 +50,7 @@ type tui struct {
|
||||
searchVisible bool
|
||||
}
|
||||
|
||||
func NewTUI(logger *zap.SugaredLogger, ss ports.ServerService, version, commit string) *tui {
|
||||
func NewTUI(logger *zap.SugaredLogger, ss ports.ServerService, version, commit string) App {
|
||||
return &tui{
|
||||
logger: logger,
|
||||
app: tview.NewApplication(),
|
||||
|
||||
@@ -124,8 +124,8 @@ func BuildSSHCommand(s domain.Server) string {
|
||||
if s.Port != 0 && s.Port != 22 {
|
||||
parts = append(parts, "-p", fmt.Sprintf("%d", s.Port))
|
||||
}
|
||||
if s.Key != "" {
|
||||
parts = append(parts, "-i", quoteIfNeeded(s.Key))
|
||||
if len(s.IdentityFiles) > 0 {
|
||||
parts = append(parts, "-i", quoteIfNeeded(s.IdentityFiles[0]))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
@@ -17,13 +17,14 @@ package domain
|
||||
import "time"
|
||||
|
||||
type Server struct {
|
||||
Alias string
|
||||
Host string
|
||||
User string
|
||||
Port int
|
||||
Key string
|
||||
Tags []string
|
||||
LastSeen time.Time
|
||||
PinnedAt time.Time
|
||||
SSHCount int
|
||||
Alias string
|
||||
Aliases []string
|
||||
Host string
|
||||
User string
|
||||
Port int
|
||||
IdentityFiles []string
|
||||
Tags []string
|
||||
LastSeen time.Time
|
||||
PinnedAt time.Time
|
||||
SSHCount int
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ type serverService struct {
|
||||
}
|
||||
|
||||
// NewServerService creates a new instance of serverService.
|
||||
func NewServerService(logger *zap.SugaredLogger, sr ports.ServerRepository) *serverService {
|
||||
func NewServerService(logger *zap.SugaredLogger, sr ports.ServerRepository) ports.ServerService {
|
||||
return &serverService{
|
||||
logger: logger,
|
||||
serverRepository: sr,
|
||||
|
||||
Reference in New Issue
Block a user