Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Go Build and Test

on:
pull_request:
branches:
- main
types:
- edited
- opened
- reopened
- synchronize
push:
branches:
- main

permissions:
contents: read
pull-requests: read

jobs:
build-and-test:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache-dependency-path: go.sum
cache: true

- name: Run make build
run: make build

- name: Run make test
run: make test
43 changes: 43 additions & 0 deletions .github/workflows/semantic-prs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: Semantic PRs

on:
pull_request:
types:
- edited
- opened
- reopened
- synchronize

permissions:
pull-requests: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.number }}
cancel-in-progress: true

jobs:
validate_title:
name: Validate Title
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v5.5.3
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
with:
types: |
fix
feat
improve
refactor
revert
test
ci
docs
chore

scopes: |
ui
cli
config
parser
requireScope: false
9 changes: 8 additions & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,14 @@ issues:
- dupl
- lll


# exclude some linters for the test directory and test files
- path: test/.*|.*_test\.go
linters:
- dupl
- errcheck
- goconst
- gocyclo
- gosec

linters:
disable-all: true
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,35 @@ Contributions are welcome!

We love seeing the community make Lazyssh better 🚀

### Semantic Pull Requests

This repository enforces semantic PR titles via an automated GitHub Action. Please format your PR title as:

- type(scope): short descriptive subject
Notes:
- Scope is optional and should be one of: ui, cli, config, parser.

Allowed types in this repo:
- feat: a new feature
- fix: a bug fix
- improve: quality or UX improvements that are not a refactor or perf
- refactor: code change that neither fixes a bug nor adds a feature
- docs: documentation only changes
- test: adding or refactoring tests
- ci: CI/CD or automation changes
- chore: maintenance tasks, dependency bumps, non-code infra
- revert: reverts a previous commit

Examples:
- feat(ui): add server pinning and sorting options
- fix(parser): handle comments at end of Host blocks
- improve(cli): show friendly error when ssh binary missing
- refactor(config): simplify backup rotation logic
- docs: add installation instructions for Homebrew
- ci: cache Go toolchain and dependencies

Tip: If your PR touches multiple areas, pick the most relevant scope or omit the scope.

---

## ⭐ Support
Expand Down
25 changes: 25 additions & 0 deletions internal/adapters/ui/validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
package ui

import (
"os"
"path/filepath"
"testing"
)

Expand Down Expand Up @@ -134,6 +136,29 @@ func TestValidateBindAddress(t *testing.T) {
}

func TestValidateKeyPaths(t *testing.T) {
// Prepare an isolated HOME with a mock .ssh folder and key files
oldHome := os.Getenv("HOME")
t.Cleanup(func() {
_ = os.Setenv("HOME", oldHome)
})

tempHome := t.TempDir()
sshDir := filepath.Join(tempHome, ".ssh")
if err := os.MkdirAll(sshDir, 0o755); err != nil {
t.Fatalf("failed to create temp .ssh dir: %v", err)
}

shouldExistFiles := []string{"id_rsa", "id_ed25519"}
for _, name := range shouldExistFiles {
p := filepath.Join(sshDir, name)
if err := os.WriteFile(p, []byte("test"), 0o644); err != nil {
t.Fatalf("failed to create mock key file %s: %v", p, err)
}
}
if err := os.Setenv("HOME", tempHome); err != nil {
t.Fatalf("failed to set HOME: %v", err)
}

tests := []struct {
name string
keys string
Expand Down
82 changes: 78 additions & 4 deletions internal/core/services/server_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ package services

import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"net"
"os"
"os/exec"
Expand All @@ -34,13 +37,17 @@ import (
type serverService struct {
serverRepository ports.ServerRepository
logger *zap.SugaredLogger
newSSHCommand func(alias string) *exec.Cmd
}

// NewServerService creates a new instance of serverService.
func NewServerService(logger *zap.SugaredLogger, sr ports.ServerRepository) ports.ServerService {
return &serverService{
logger: logger,
serverRepository: sr,
newSSHCommand: func(alias string) *exec.Cmd {
return exec.Command("ssh", alias)
},
}
}

Expand Down Expand Up @@ -151,13 +158,23 @@ func (s *serverService) SetPinned(alias string, pinned bool) error {
// 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 := s.newSSHCommand(alias)
if cmd == nil {
err := fmt.Errorf("ssh command factory returned nil")
s.logger.Errorw("ssh command creation failed", "alias", alias, "error", err)
return err
}
stderrBuf := newLimitedBuffer(2048)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stderr = io.MultiWriter(os.Stderr, stderrBuf)
if err := cmd.Run(); err != nil {
s.logger.Errorw("ssh command failed", "alias", alias, "error", err)
return err
if isRemoteDisconnectError(err, stderrBuf.String()) {
s.logger.Infow("ssh session ended by remote", "alias", alias)
} else {
s.logger.Errorw("ssh command failed", "alias", alias, "error", err)
return err
}
}

if err := s.serverRepository.RecordSSH(alias); err != nil {
Expand All @@ -168,6 +185,63 @@ func (s *serverService) SSH(alias string) error {
return nil
}

func isRemoteDisconnectError(err error, stderr string) bool {
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
return false
}

lower := strings.ToLower(stderr)
disconnectSignals := []string{
"connection closed by remote host",
"connection closed by foreign host",
"closed by remote host",
"closed by foreign host",
"connection reset by peer",
}

for _, signal := range disconnectSignals {
if strings.Contains(lower, signal) {
return true
}
}

return false
}

type limitedBuffer struct {
buf bytes.Buffer
limit int
}

func newLimitedBuffer(limit int) *limitedBuffer {
return &limitedBuffer{limit: limit}
}

func (b *limitedBuffer) Write(p []byte) (int, error) {
if b == nil || b.limit <= 0 {
return len(p), nil
}

remaining := b.limit - b.buf.Len()
if remaining <= 0 {
return len(p), nil
}

if len(p) > remaining {
p = p[:remaining]
}

return b.buf.Write(p)
}

func (b *limitedBuffer) String() string {
if b == nil {
return ""
}
return b.buf.String()
}

// 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()
Expand Down
Loading