Skip to content
3 changes: 3 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ linters:
linters:
- dupl # Exclude duplicate check from tests
- goconst # Exclude constant check from tests
- path: internal/constants/
linters:
- revive

issues:
max-issues-per-linter: 0
Expand Down
45 changes: 45 additions & 0 deletions internal/config/editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,51 @@ func (m *Manager) AddHost(targetFile string, h *Host) error {
return m.SaveFile(absTarget)
}

// AddServiceHost writes a service host block (e.g. GitHub, GitLab) directly to the
// primary SSH config file with a # tusshi: service marker, keeping it invisible in
// the tuSSHi connection list while remaining fully functional for git and SSH auth.
func (m *Manager) AddServiceHost(h *Host) error {
hostBlockStr := buildHostString(h)
decoded, err := ssh_config.Decode(strings.NewReader(hostBlockStr))
if err != nil {
return err
}

var newASTHost *ssh_config.Host
for _, astHost := range decoded.Hosts {
val := reflect.ValueOf(astHost)
isImplicit := false
if val.Kind() == reflect.Pointer && !val.IsNil() {
elem := val.Elem()
implicitField := elem.FieldByName("implicit")
if implicitField.IsValid() && implicitField.Kind() == reflect.Bool && implicitField.Bool() {
isImplicit = true
}
}
if !isImplicit && len(astHost.Patterns) > 0 {
newASTHost = astHost
break
}
}

if newASTHost == nil {
return fmt.Errorf("could not parse host block for alias %q", h.Alias)
}

if err := WriteServiceMarker(newASTHost); err != nil {
return err
}

primaryCfg, exists := m.Configs[m.PrimaryPath]
if !exists {
primaryCfg = &ssh_config.Config{Hosts: []*ssh_config.Host{}}
m.Configs[m.PrimaryPath] = primaryCfg
}

primaryCfg.Hosts = append(primaryCfg.Hosts, newASTHost)
return m.SaveFile(m.PrimaryPath)
}

// UpdateHost edits an existing Host block matched by its original alias.
// It preserves formatting, comments, and other unrecognized nodes in the block.
func (m *Manager) UpdateHost(originalAlias string, h *Host) error {
Expand Down
1 change: 1 addition & 0 deletions internal/config/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ func (m *Manager) GetHosts() []*Host {
Alias: alias,
SourceFile: filePath,
IsWildcard: isWildcard,
IsService: ExtractServiceMarker(astHost.Nodes),
Properties: make(map[string]string),
}

Expand Down
4 changes: 4 additions & 0 deletions internal/config/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ type Host struct {
// (e.g., "Host *") rather than a specific destination connection.
IsWildcard bool

// IsService marks host blocks that exist for key-based service auth (e.g. GitHub, GitLab)
// but should not appear in the interactive connection list.
IsService bool

// Tags holds custom metadata tags extracted losslessly from host comments.
Tags []string

Expand Down
33 changes: 33 additions & 0 deletions internal/config/tags.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,36 @@ func createTagCommentNode(tags []string) (ssh_config.Node, error) {
}
return decoded.Hosts[0].Nodes[0], nil
}

const serviceMarker = "tusshi: service"

// ExtractServiceMarker returns true when any comment node inside the host block
// contains the "tusshi: service" marker (case-insensitive).
func ExtractServiceMarker(nodes []ssh_config.Node) bool {
for _, node := range nodes {
if empty, ok := node.(*ssh_config.Empty); ok {
line := strings.ToLower(strings.TrimSpace(empty.String()))
if strings.Contains(line, serviceMarker) {
return true
}
}
}
return false
}

// WriteServiceMarker prepends a "# tusshi: service" comment node to the host block
// so the entry is hidden from the tuSSHi connection list on next load.
func WriteServiceMarker(astHost *ssh_config.Host) error {
if astHost == nil {
return fmt.Errorf("astHost cannot be nil")
}

decoded, err := ssh_config.Decode(strings.NewReader(" # " + serviceMarker + "\n"))
if err != nil || len(decoded.Hosts) == 0 || len(decoded.Hosts[0].Nodes) == 0 {
return fmt.Errorf("failed to create service marker AST node: %w", err)
}

node := decoded.Hosts[0].Nodes[0]
astHost.Nodes = append([]ssh_config.Node{node}, astHost.Nodes...)
return nil
}
21 changes: 21 additions & 0 deletions internal/constants/keybinds.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Package constants holds global constants used across all packages
package constants

const (
KeyEsc = "esc"
KeyEnter = "enter"
KeyAdd = "a"
KeyDelete = "d"
KeyEdit = "e"
KeyHelp = "?"
KeyQuit = "q"
KeySearch = "/"
KeyCommand = ":"
KeyUp = "up, k"
KeyDown = "down, j"
KeyLeft = "left, h"
KeyRight = "right, l"
KeyPing = "p"
KeyPingAll = "P"
KeyCopy = "c"
)
55 changes: 55 additions & 0 deletions internal/ssh/authcheck.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package ssh

import (
"errors"
"os/exec"
"strings"
)

// AuthResult holds the outcome of an SSH service authentication check.
type AuthResult struct {
OK bool
Error string // first line of stderr, only populated on exit code 255
}

// CheckAuth tests whether the SSH key for the given host alias authenticates
// successfully. It uses BatchMode to suppress interactive prompts and interprets
// exit code 255 as an SSH-level failure. Exit codes 0 and 1 both indicate the
// handshake succeeded — the server simply closed the session without opening a shell,
// which is the expected behaviour for Git hosting services.
func CheckAuth(alias string) AuthResult {
// #nosec G204 — alias is a Host block name from the user's own ssh config
cmd := exec.Command("ssh",
"-T",
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=5",
"-o", "StrictHostKeyChecking=accept-new",
alias,
)

var stderr strings.Builder
cmd.Stderr = &stderr

err := cmd.Run()
if err == nil {
return AuthResult{OK: true}
}

var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() != 255 {
return AuthResult{OK: true}
}

errMsg := firstLine(strings.TrimSpace(stderr.String()))
if errMsg == "" {
errMsg = err.Error()
}
return AuthResult{OK: false, Error: errMsg}
}

func firstLine(s string) string {
if before, _, ok := strings.Cut(s, "\n"); ok {
return before
}
return s
}
50 changes: 50 additions & 0 deletions internal/ssh/keygen.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Package ssh provides helpers for SSH key generation and service authentication checks.
package ssh

import (
"fmt"
"os"
"os/exec"
"strings"
)

// Key type identifiers supported by ssh-keygen.
const (
KeyTypeED25519 = "ed25519"
KeyTypeRSA = "rsa"
KeyTypeECDSA = "ecdsa"
)

// KeyTypeOptions contains the list of supported SSH key types.
var KeyTypeOptions = []string{KeyTypeED25519, KeyTypeRSA, KeyTypeECDSA}

// GenerateKey runs ssh-keygen to create a new keypair at the given path.
// keyType must be one of: ed25519, rsa, ecdsa. An empty passphrase is always used.
func GenerateKey(path, keyType, comment string) error {
if keyType == "" {
keyType = KeyTypeED25519
}

args := []string{"-t", keyType, "-f", path, "-N", "", "-C", comment}
if keyType == KeyTypeRSA {
args = append(args, "-b", "4096")
}

// #nosec G204 — path and args are controlled by the user via the TUI wizard
cmd := exec.Command("ssh-keygen", args...)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("ssh-keygen failed: %w\n%s", err, strings.TrimSpace(string(out)))
}
return nil
}

// ReadPublicKey reads the public key file corresponding to the given private key path.
func ReadPublicKey(privateKeyPath string) (string, error) {
pubPath := privateKeyPath + ".pub"
data, err := os.ReadFile(pubPath) // #nosec G304 — user-provided path from TUI
if err != nil {
return "", fmt.Errorf("reading public key %q: %w", pubPath, err)
}
return strings.TrimSpace(string(data)), nil
}
35 changes: 35 additions & 0 deletions internal/ssh/keygen_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package ssh_test

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

"tusshi/internal/ssh"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestKeygen(t *testing.T) {
t.Run("generate and read ed25519 keypair", func(t *testing.T) {
tempDir := t.TempDir()
keyPath := filepath.Join(tempDir, "id_ed25519_test")

err := ssh.GenerateKey(keyPath, ssh.KeyTypeED25519, "test-key")
require.NoError(t, err)

_, err = os.Stat(keyPath)
assert.NoError(t, err, "private key file should exist")

pubKey, err := ssh.ReadPublicKey(keyPath)
assert.NoError(t, err, "reading public key should succeed")
assert.Contains(t, pubKey, "ssh-ed25519")
assert.Contains(t, pubKey, "test-key")
})

t.Run("returns error when reading non-existent public key", func(t *testing.T) {
_, err := ssh.ReadPublicKey("/non/existent/path/id_ed25519")
assert.Error(t, err)
})
}
33 changes: 33 additions & 0 deletions internal/ssh/presets.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package ssh

const (
// PresetCustom represents the custom service preset identifier.
PresetCustom = "custom"
defaultUserGit = "git"
)

// ServicePreset describes a known Git/SSH service that uses key-based auth.
type ServicePreset struct {
Name string // display name, e.g. "GitHub"
KeyName string // base name for SSH key file, e.g. "github"
HostName string // SSH Host pattern and destination hostname, e.g. "github.com"
User string // remote user, always "git" for hosting services
}

// Presets contains built-in service definitions for the key wizard.
var Presets = []ServicePreset{
{Name: "GitHub", KeyName: "github", HostName: "github.com", User: defaultUserGit},
{Name: "GitLab", KeyName: "gitlab", HostName: "gitlab.com", User: defaultUserGit},
// TODO: add more
{Name: "Custom", KeyName: "service", HostName: "service", User: defaultUserGit},
}

// FindPreset returns the preset matching the given name, hostname, or keyname.
func FindPreset(query string) (ServicePreset, bool) {
for _, p := range Presets {
if p.HostName == query || p.Name == query || p.KeyName == query {
return p, true
}
}
return ServicePreset{}, false
}
34 changes: 34 additions & 0 deletions internal/ssh/presets_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package ssh_test

import (
"testing"

"tusshi/internal/ssh"

"github.com/stretchr/testify/assert"
)

func TestFindPreset(t *testing.T) {
t.Run("returns built-in github preset", func(t *testing.T) {
preset, ok := ssh.FindPreset("github.com")
assert.True(t, ok)
assert.Equal(t, "GitHub", preset.Name)
assert.Equal(t, "github", preset.KeyName)
assert.Equal(t, "github.com", preset.HostName)
assert.Equal(t, "git", preset.User)
})

t.Run("returns built-in gitlab preset", func(t *testing.T) {
preset, ok := ssh.FindPreset("gitlab.com")
assert.True(t, ok)
assert.Equal(t, "GitLab", preset.Name)
assert.Equal(t, "gitlab", preset.KeyName)
assert.Equal(t, "gitlab.com", preset.HostName)
assert.Equal(t, "git", preset.User)
})

t.Run("returns false for unknown preset", func(t *testing.T) {
_, ok := ssh.FindPreset("unknown-service")
assert.False(t, ok)
})
}
Loading
Loading