R','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
+
+
+
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
+}