Phase 4: SFTP file browser, SSH config import, and update checking

SFTP file browser:
- Created internal/sftpclient wrapping github.com/pkg/sftp for remote file
  operations (list, stat, mkdir, remove, rename, download, upload, pwd)
- Integrated SFTP into session manager with lazy client creation per session
- Added API methods for all SFTP operations with native file dialogs
- Built SFTPBrowser frontend component — right-side resizable panel with
  breadcrumb navigation, sorted file listing, context actions (download,
  rename, delete), upload, new folder, and custom modal dialogs
- Toggle via Cmd+Shift+F on active SSH sessions
- Auto-refits terminal when panel opens/closes/resizes
- Panel cleaned up automatically when session closes

OpenSSH config import:
- Created internal/sshconfig parser for ~/.ssh/config Host blocks
- Converts to profiles with hostname, port, user, identity file,
  ProxyJump (first hop), ForwardAgent, and Compression support
- Skips wildcard-only hosts and Match blocks
- Import button added to Settings > About tab

Automatic update checking:
- Created internal/updater querying GitHub Releases API
- Simple semver comparison, 10-second timeout
- Dev builds never trigger update prompts
- Check button added to Settings > About tab
This commit is contained in:
2026-04-16 00:00:00 +01:00
parent 8c9ce67684
commit 5e40d06a6c
17 changed files with 2472 additions and 16 deletions
+274
View File
@@ -0,0 +1,274 @@
// Package sshconfig parses OpenSSH ~/.ssh/config files and converts
// Host blocks into Nexus Terminal connection profiles. Supports a useful
// subset of SSH config directives: HostName, Port, User, IdentityFile,
// ProxyJump, ForwardAgent, and Compression.
//
// Limitations (future work):
// - Match blocks are ignored
// - Wildcards in Host patterns are skipped
// - Include directives are not followed
// - Token expansion (%h, %p, etc.) is not performed
package sshconfig
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/copilot-ssh-client/app/internal/models"
"github.com/google/uuid"
)
// HostBlock represents a parsed SSH config Host section.
type HostBlock struct {
Aliases []string
HostName string
Port int
User string
IdentityFile string
ProxyJump string
ForwardAgent bool
Compression bool
}
// ParseFile reads and parses an OpenSSH config file.
func ParseFile(path string) ([]HostBlock, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("sshconfig: failed to open %s: %w", path, err)
}
defer f.Close()
var blocks []HostBlock
var current *HostBlock
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Skip empty lines and comments.
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// Split into directive and value.
key, value := splitDirective(line)
key = strings.ToLower(key)
switch key {
case "host":
// Commit previous block.
if current != nil {
blocks = append(blocks, *current)
}
aliases := strings.Fields(value)
// Skip wildcard-only hosts (e.g. "Host *").
hasWildcard := false
for _, a := range aliases {
if strings.Contains(a, "*") || strings.Contains(a, "?") {
hasWildcard = true
}
}
if hasWildcard && len(aliases) == 1 {
current = nil
continue
}
current = &HostBlock{
Aliases: aliases,
Port: 22,
}
case "match":
// Commit previous block and skip Match blocks.
if current != nil {
blocks = append(blocks, *current)
}
current = nil
case "hostname":
if current != nil {
current.HostName = value
}
case "port":
if current != nil {
if p, err := strconv.Atoi(value); err == nil {
current.Port = p
}
}
case "user":
if current != nil {
current.User = value
}
case "identityfile":
if current != nil {
current.IdentityFile = expandTilde(value)
}
case "proxyjump":
if current != nil {
current.ProxyJump = value
}
case "forwardagent":
if current != nil {
current.ForwardAgent = isTrueish(value)
}
case "compression":
if current != nil {
current.Compression = isTrueish(value)
}
}
}
// Commit final block.
if current != nil {
blocks = append(blocks, *current)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("sshconfig: read error: %w", err)
}
return blocks, nil
}
// DefaultConfigPath returns the standard OpenSSH config file path.
func DefaultConfigPath() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".ssh", "config")
}
// ToProfiles converts parsed Host blocks into Nexus Terminal profiles.
// Each block produces one profile; the first alias becomes the display name.
func ToProfiles(blocks []HostBlock, folder string) []models.Profile {
profiles := make([]models.Profile, 0, len(blocks))
for _, b := range blocks {
if len(b.Aliases) == 0 {
continue
}
host := b.HostName
if host == "" {
host = b.Aliases[0]
}
displayName := b.Aliases[0]
username := b.User
sshConf := &models.SSHConfig{
AuthMethods: []string{"publickey", "password"},
SecretMode: models.SecretModeAskEveryTime,
HostKeyPolicy: models.HostKeyAsk,
TerminalType: "xterm-256color",
AgentAuth: b.ForwardAgent,
AgentForwarding: b.ForwardAgent,
Compression: b.Compression,
}
if b.IdentityFile != "" {
sshConf.PrivateKeyPath = b.IdentityFile
}
// Parse ProxyJump into a jump host.
if b.ProxyJump != "" && b.ProxyJump != "none" {
jh := parseProxyJump(b.ProxyJump)
if jh != nil {
sshConf.JumpHosts = []models.JumpHost{*jh}
}
}
profile := models.NewProfile(models.ProtocolSSH)
profile.ID = uuid.New().String()
profile.DisplayName = displayName
profile.Host = host
profile.Port = b.Port
profile.Username = username
profile.Folder = folder
profile.SSH = sshConf
profile.Tags = []string{"ssh-config-import"}
profiles = append(profiles, profile)
}
return profiles
}
// parseProxyJump parses a simple ProxyJump value (user@host:port format).
// Only the first hop is parsed; chained jumps are future work.
func parseProxyJump(value string) *models.JumpHost {
// Take only the first hop if comma-separated.
if idx := strings.Index(value, ","); idx >= 0 {
value = value[:idx]
}
value = strings.TrimSpace(value)
if value == "" || value == "none" {
return nil
}
jh := &models.JumpHost{
Port: 22,
AuthMethod: "publickey",
SecretMode: models.SecretModeAskEveryTime,
}
// Extract user@ prefix.
if at := strings.Index(value, "@"); at >= 0 {
jh.Username = value[:at]
value = value[at+1:]
}
// Extract :port suffix.
if colon := strings.LastIndex(value, ":"); colon >= 0 {
if p, err := strconv.Atoi(value[colon+1:]); err == nil {
jh.Port = p
value = value[:colon]
}
}
jh.Host = value
return jh
}
// splitDirective splits a line into key and value, handling both
// "Key Value" and "Key=Value" formats.
func splitDirective(line string) (string, string) {
// Try = separator first.
if idx := strings.Index(line, "="); idx >= 0 {
return strings.TrimSpace(line[:idx]), strings.TrimSpace(line[idx+1:])
}
// Fall back to whitespace separator.
parts := strings.SplitN(line, " ", 2)
if len(parts) == 1 {
parts = strings.SplitN(line, "\t", 2)
}
if len(parts) < 2 {
return parts[0], ""
}
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
}
// expandTilde replaces a leading ~ with the user's home directory.
func expandTilde(path string) string {
if !strings.HasPrefix(path, "~/") {
return path
}
home, err := os.UserHomeDir()
if err != nil {
return path
}
return filepath.Join(home, path[2:])
}
// isTrueish returns true for "yes", "true", "on", and "1".
func isTrueish(val string) bool {
switch strings.ToLower(val) {
case "yes", "true", "on", "1":
return true
}
return false
}
+172
View File
@@ -0,0 +1,172 @@
package sshconfig
import (
"os"
"path/filepath"
"testing"
)
const testConfig = `# Test SSH config
Host *
ServerAliveInterval 60
Host webserver
HostName web.example.com
Port 2222
User deploy
IdentityFile ~/.ssh/deploy_key
ForwardAgent yes
Compression yes
Host dbserver
HostName db.internal.example.com
User postgres
ProxyJump bastion@jump.example.com:2200
Host simple
HostName 10.0.0.5
Host wildcard-*
User admin
`
func TestParseFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config")
if err := os.WriteFile(path, []byte(testConfig), 0600); err != nil {
t.Fatal(err)
}
blocks, err := ParseFile(path)
if err != nil {
t.Fatalf("ParseFile failed: %v", err)
}
// Should have 3 blocks: webserver, dbserver, simple.
// "Host *" and "Host wildcard-*" should be skipped.
if len(blocks) != 3 {
t.Fatalf("expected 3 blocks, got %d", len(blocks))
}
// Verify webserver block.
wb := blocks[0]
if wb.Aliases[0] != "webserver" {
t.Errorf("expected alias 'webserver', got %q", wb.Aliases[0])
}
if wb.HostName != "web.example.com" {
t.Errorf("expected HostName 'web.example.com', got %q", wb.HostName)
}
if wb.Port != 2222 {
t.Errorf("expected port 2222, got %d", wb.Port)
}
if wb.User != "deploy" {
t.Errorf("expected user 'deploy', got %q", wb.User)
}
if !wb.ForwardAgent {
t.Error("expected ForwardAgent to be true")
}
if !wb.Compression {
t.Error("expected Compression to be true")
}
// Verify dbserver block with ProxyJump.
db := blocks[1]
if db.Aliases[0] != "dbserver" {
t.Errorf("expected alias 'dbserver', got %q", db.Aliases[0])
}
if db.ProxyJump != "bastion@jump.example.com:2200" {
t.Errorf("unexpected ProxyJump: %q", db.ProxyJump)
}
// Verify simple block.
sb := blocks[2]
if sb.HostName != "10.0.0.5" {
t.Errorf("expected HostName '10.0.0.5', got %q", sb.HostName)
}
if sb.Port != 22 {
t.Errorf("expected default port 22, got %d", sb.Port)
}
}
func TestToProfiles(t *testing.T) {
blocks := []HostBlock{
{
Aliases: []string{"myserver"},
HostName: "srv.example.com",
Port: 22,
User: "admin",
ForwardAgent: true,
},
{
Aliases: []string{"jumpbox"},
HostName: "jump.example.com",
Port: 2200,
User: "jumper",
ProxyJump: "gateway@gw.example.com",
},
}
profiles := ToProfiles(blocks, "Imported")
if len(profiles) != 2 {
t.Fatalf("expected 2 profiles, got %d", len(profiles))
}
p := profiles[0]
if p.DisplayName != "myserver" {
t.Errorf("expected DisplayName 'myserver', got %q", p.DisplayName)
}
if p.Host != "srv.example.com" {
t.Errorf("expected host 'srv.example.com', got %q", p.Host)
}
if p.SSH == nil {
t.Fatal("expected SSH config to be set")
}
if !p.SSH.AgentAuth {
t.Error("expected AgentAuth to be true for ForwardAgent host")
}
jp := profiles[1]
if jp.SSH == nil || len(jp.SSH.JumpHosts) == 0 {
t.Fatal("expected jump host from ProxyJump")
}
jh := jp.SSH.JumpHosts[0]
if jh.Host != "gw.example.com" {
t.Errorf("expected jump host 'gw.example.com', got %q", jh.Host)
}
if jh.Username != "gateway" {
t.Errorf("expected jump user 'gateway', got %q", jh.Username)
}
}
func TestParseProxyJump(t *testing.T) {
tests := []struct {
input string
wantHost string
wantUser string
wantPort int
}{
{"bastion.example.com", "bastion.example.com", "", 22},
{"user@bastion.example.com", "bastion.example.com", "user", 22},
{"user@bastion.example.com:2200", "bastion.example.com", "user", 2200},
{"host:443", "host", "", 443},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
jh := parseProxyJump(tt.input)
if jh == nil {
t.Fatal("expected non-nil result")
}
if jh.Host != tt.wantHost {
t.Errorf("host: got %q, want %q", jh.Host, tt.wantHost)
}
if jh.Username != tt.wantUser {
t.Errorf("user: got %q, want %q", jh.Username, tt.wantUser)
}
if jh.Port != tt.wantPort {
t.Errorf("port: got %d, want %d", jh.Port, tt.wantPort)
}
})
}
}