mirror of
https://github.com/Adembc/lazyssh.git
synced 2026-07-14 12:13:34 +02:00
rewrite ssh config parser and writer to protect file
This commit is contained in:
+2
-2
@@ -19,7 +19,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"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/logger"
|
||||||
|
|
||||||
"github.com/Adembc/lazyssh/internal/adapters/ui"
|
"github.com/Adembc/lazyssh/internal/adapters/ui"
|
||||||
@@ -51,7 +51,7 @@ func main() {
|
|||||||
sshConfigFile := filepath.Join(home, ".ssh", "config")
|
sshConfigFile := filepath.Join(home, ".ssh", "config")
|
||||||
metaDataFile := filepath.Join(home, ".lazyssh", "metadata.json")
|
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)
|
serverService := services.NewServerService(log, serverRepo)
|
||||||
tui := ui.NewTUI(log, serverService, version, gitCommit)
|
tui := ui.NewTUI(log, serverService, version, gitCommit)
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ module github.com/Adembc/lazyssh
|
|||||||
|
|
||||||
go 1.24.6
|
go 1.24.6
|
||||||
|
|
||||||
|
replace github.com/kevinburke/ssh_config => github.com/adembc/ssh_config v1.4.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/atotto/clipboard v0.1.4
|
github.com/atotto/clipboard v0.1.4
|
||||||
github.com/gdamore/tcell/v2 v2.9.0
|
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/mattn/go-runewidth v0.0.16
|
||||||
github.com/rivo/tview v0.0.0-20250625164341-a4a78f1e05cb
|
github.com/rivo/tview v0.0.0-20250625164341-a4a78f1e05cb
|
||||||
github.com/spf13/cobra v1.9.1
|
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 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
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
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
+42
-14
@@ -12,7 +12,7 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
package file
|
package ssh_config_file
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Adembc/lazyssh/internal/core/domain"
|
"github.com/Adembc/lazyssh/internal/core/domain"
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ServerMetadata struct {
|
type ServerMetadata struct {
|
||||||
@@ -33,10 +34,11 @@ type ServerMetadata struct {
|
|||||||
|
|
||||||
type metadataManager struct {
|
type metadataManager struct {
|
||||||
filePath string
|
filePath string
|
||||||
|
logger *zap.SugaredLogger
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMetadataManager(filePath string) *metadataManager {
|
func newMetadataManager(filePath string, logger *zap.SugaredLogger) *metadataManager {
|
||||||
return &metadataManager{filePath: filePath}
|
return &metadataManager{filePath: filePath, logger: logger}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *metadataManager) loadAll() (map[string]ServerMetadata, error) {
|
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)
|
data, err := os.ReadFile(m.filePath)
|
||||||
if err != nil {
|
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 {
|
if len(data) == 0 {
|
||||||
@@ -56,7 +59,9 @@ func (m *metadataManager) loadAll() (map[string]ServerMetadata, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(data, &metadata); err != nil {
|
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
|
return metadata, nil
|
||||||
@@ -64,21 +69,37 @@ func (m *metadataManager) loadAll() (map[string]ServerMetadata, error) {
|
|||||||
|
|
||||||
func (m *metadataManager) saveAll(metadata map[string]ServerMetadata) error {
|
func (m *metadataManager) saveAll(metadata map[string]ServerMetadata) error {
|
||||||
if err := m.ensureDirectory(); err != nil {
|
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, "", " ")
|
data, err := json.MarshalIndent(metadata, "", " ")
|
||||||
if err != nil {
|
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()
|
metadata, err := m.loadAll()
|
||||||
if err != nil {
|
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]
|
existing := metadata[server.Alias]
|
||||||
@@ -107,7 +128,8 @@ func (m *metadataManager) updateServer(server domain.Server) error {
|
|||||||
func (m *metadataManager) deleteServer(alias string) error {
|
func (m *metadataManager) deleteServer(alias string) error {
|
||||||
metadata, err := m.loadAll()
|
metadata, err := m.loadAll()
|
||||||
if err != nil {
|
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)
|
delete(metadata, alias)
|
||||||
@@ -117,7 +139,8 @@ func (m *metadataManager) deleteServer(alias string) error {
|
|||||||
func (m *metadataManager) setPinned(alias string, pinned bool) error {
|
func (m *metadataManager) setPinned(alias string, pinned bool) error {
|
||||||
metadata, err := m.loadAll()
|
metadata, err := m.loadAll()
|
||||||
if err != nil {
|
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]
|
meta := metadata[alias]
|
||||||
@@ -134,7 +157,8 @@ func (m *metadataManager) setPinned(alias string, pinned bool) error {
|
|||||||
func (m *metadataManager) recordSSH(alias string) error {
|
func (m *metadataManager) recordSSH(alias string) error {
|
||||||
metadata, err := m.loadAll()
|
metadata, err := m.loadAll()
|
||||||
if err != nil {
|
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]
|
meta := metadata[alias]
|
||||||
@@ -147,5 +171,9 @@ func (m *metadataManager) recordSSH(alias string) error {
|
|||||||
|
|
||||||
func (m *metadataManager) ensureDirectory() error {
|
func (m *metadataManager) ensureDirectory() error {
|
||||||
dir := filepath.Dir(m.filePath)
|
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
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
package ui
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
AppName = "lazyssh"
|
AppName = "lazyssh"
|
||||||
RepoURL = "github.com/adembc/lazyssh"
|
RepoURL = "github.com/adembc/lazyssh"
|
||||||
SplashScreenDuration = 1 * time.Second
|
Banner = `
|
||||||
Banner = `
|
|
||||||
|
|
||||||
$$\ $$\
|
$$\ $$\
|
||||||
$$ | $$ |
|
$$ | $$ |
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ func (sd *ServerDetails) UpdateServer(server domain.Server) {
|
|||||||
tagsText := renderTagChips(server.Tags)
|
tagsText := renderTagChips(server.Tags)
|
||||||
text := fmt.Sprintf(
|
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",
|
"[::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,
|
serverKey, tagsText, pinnedStr,
|
||||||
lastSeen, server.SSHCount)
|
lastSeen, server.SSHCount)
|
||||||
sd.TextView.SetText(text)
|
sd.TextView.SetText(text)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import "time"
|
|||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
Alias string
|
Alias string
|
||||||
|
Aliases []string
|
||||||
Host string
|
Host string
|
||||||
User string
|
User string
|
||||||
Port int
|
Port int
|
||||||
|
|||||||
Reference in New Issue
Block a user