SSH connection status indicators
- Add Connected bool field to vfs.Entry and RemoteMount - Track connection status in sshState.connectedHosts - Show status icon (connected/disconnected) in pane header when browsing remote host - Async SSH connection test with cancel support for Add Host dialog - Colored labels and styled help text in SSH dialogs - Confirmation dialog when deleting manually-added SSH hosts
This commit is contained in:
parent
df4df6b8f6
commit
1ed2d3defb
224 changed files with 33447 additions and 236 deletions
305
internal/fs/remote/host.go
Normal file
305
internal/fs/remote/host.go
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
package remote
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SSHHost represents a single SSH host configuration.
|
||||
type SSHHost struct {
|
||||
// Name is the host alias (e.g. "myserver").
|
||||
Name string `json:"name"`
|
||||
// HostName is the actual hostname or IP address.
|
||||
HostName string `json:"hostname"`
|
||||
// Port is the SSH port (default 22).
|
||||
Port string `json:"port,omitempty"`
|
||||
// User is the SSH username.
|
||||
User string `json:"user,omitempty"`
|
||||
// IdentityFile is the path to the private key file (for key-based auth).
|
||||
IdentityFile string `json:"identity_file,omitempty"`
|
||||
// Password is stored encrypted (for password-based auth, user-added hosts).
|
||||
Password string `json:"password,omitempty"`
|
||||
// FromSSHConfig indicates this host came from ~/.ssh/config.
|
||||
FromSSHConfig bool `json:"from_ssh_config"`
|
||||
}
|
||||
|
||||
// DisplayName returns the host display name.
|
||||
func (h SSHHost) DisplayName() string {
|
||||
addr := h.HostName
|
||||
if h.Port != "" && h.Port != "22" {
|
||||
addr = fmt.Sprintf("%s:%s", addr, h.Port)
|
||||
}
|
||||
if h.User != "" {
|
||||
return fmt.Sprintf("%s (%s@%s)", h.Name, h.User, addr)
|
||||
}
|
||||
return fmt.Sprintf("%s (%s)", h.Name, addr)
|
||||
}
|
||||
|
||||
// Addr returns the SSH address string (host:port).
|
||||
func (h SSHHost) Addr() string {
|
||||
if h.Port == "" || h.Port == "22" {
|
||||
return h.HostName + ":22"
|
||||
}
|
||||
return h.HostName + ":" + h.Port
|
||||
}
|
||||
|
||||
// HostStore manages SSH hosts from both ~/.ssh/config and user-added hosts.
|
||||
type HostStore struct {
|
||||
customHosts []SSHHost
|
||||
configPath string
|
||||
cipherKey []byte
|
||||
}
|
||||
|
||||
// NewHostStore creates a new HostStore.
|
||||
func NewHostStore() (*HostStore, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("home dir: %w", err)
|
||||
}
|
||||
|
||||
store := &HostStore{
|
||||
configPath: filepath.Join(home, ".config", "vcom", "hosts.dat"),
|
||||
}
|
||||
|
||||
// Load or create encryption key
|
||||
keyPath := filepath.Join(home, ".config", "vcom", ".hosts-key")
|
||||
store.cipherKey, err = loadOrCreateKey(keyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encryption key: %w", err)
|
||||
}
|
||||
|
||||
// Load custom hosts
|
||||
if err := store.load(); err != nil {
|
||||
// Ignore load errors for missing file
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// loadOrCreateKey loads an existing AES key or creates a new one.
|
||||
func loadOrCreateKey(path string) ([]byte, error) {
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(data)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(key) == 32 {
|
||||
return key, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new 32-byte key for AES-256
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, fmt.Errorf("generate key: %w", err)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("mkdir: %w", err)
|
||||
}
|
||||
|
||||
encoded := base64.StdEncoding.EncodeToString(key)
|
||||
if err := os.WriteFile(path, []byte(encoded), 0o600); err != nil {
|
||||
return nil, fmt.Errorf("write key: %w", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
type storedHosts struct {
|
||||
Hosts []storedHost `json:"hosts"`
|
||||
}
|
||||
|
||||
type storedHost struct {
|
||||
Name string `json:"name"`
|
||||
HostName string `json:"hostname"`
|
||||
Port string `json:"port,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
Password string `json:"password,omitempty"` // encrypted
|
||||
IdentityFile string `json:"identity_file,omitempty"`
|
||||
}
|
||||
|
||||
func (s *HostStore) load() error {
|
||||
data, err := os.ReadFile(s.configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Decrypt
|
||||
decrypted, err := decrypt(data, s.cipherKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decrypt hosts: %w", err)
|
||||
}
|
||||
|
||||
var stored storedHosts
|
||||
if err := json.Unmarshal(decrypted, &stored); err != nil {
|
||||
return fmt.Errorf("parse hosts: %w", err)
|
||||
}
|
||||
|
||||
s.customHosts = make([]SSHHost, len(stored.Hosts))
|
||||
for i, h := range stored.Hosts {
|
||||
password := ""
|
||||
if h.Password != "" {
|
||||
pwd, err := decrypt([]byte(h.Password), s.cipherKey)
|
||||
if err == nil {
|
||||
password = string(pwd)
|
||||
}
|
||||
}
|
||||
s.customHosts[i] = SSHHost{
|
||||
Name: h.Name,
|
||||
HostName: h.HostName,
|
||||
Port: h.Port,
|
||||
User: h.User,
|
||||
Password: password,
|
||||
IdentityFile: h.IdentityFile,
|
||||
FromSSHConfig: false,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save persists custom hosts to disk (encrypted).
|
||||
func (s *HostStore) Save() error {
|
||||
stored := storedHosts{
|
||||
Hosts: make([]storedHost, len(s.customHosts)),
|
||||
}
|
||||
for i, h := range s.customHosts {
|
||||
password := ""
|
||||
if h.Password != "" {
|
||||
enc, err := encrypt([]byte(h.Password), s.cipherKey)
|
||||
if err == nil {
|
||||
password = string(enc)
|
||||
}
|
||||
}
|
||||
stored.Hosts[i] = storedHost{
|
||||
Name: h.Name,
|
||||
HostName: h.HostName,
|
||||
Port: h.Port,
|
||||
User: h.User,
|
||||
Password: password,
|
||||
IdentityFile: h.IdentityFile,
|
||||
}
|
||||
}
|
||||
|
||||
data, err := json.Marshal(stored)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal hosts: %w", err)
|
||||
}
|
||||
|
||||
encrypted, err := encrypt(data, s.cipherKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt hosts: %w", err)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(s.configPath)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("mkdir: %w", err)
|
||||
}
|
||||
|
||||
return os.WriteFile(s.configPath, encrypted, 0o600)
|
||||
}
|
||||
|
||||
// AddHost adds a custom host and saves.
|
||||
func (s *HostStore) AddHost(host SSHHost) error {
|
||||
host.FromSSHConfig = false
|
||||
s.customHosts = append(s.customHosts, host)
|
||||
return s.Save()
|
||||
}
|
||||
|
||||
// RemoveHost removes a custom host by name.
|
||||
func (s *HostStore) RemoveHost(name string) error {
|
||||
for i, h := range s.customHosts {
|
||||
if h.Name == name {
|
||||
s.customHosts = append(s.customHosts[:i], s.customHosts[i+1:]...)
|
||||
return s.Save()
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("host %q not found", name)
|
||||
}
|
||||
|
||||
// AllHosts returns all hosts (from ssh config + custom).
|
||||
func (s *HostStore) AllHosts() []SSHHost {
|
||||
sshConfigHosts := ParseSSHConfig()
|
||||
result := make([]SSHHost, 0, len(sshConfigHosts)+len(s.customHosts))
|
||||
|
||||
// Build a set of names from ssh config to avoid duplicates
|
||||
seen := make(map[string]bool)
|
||||
for _, h := range sshConfigHosts {
|
||||
lower := strings.ToLower(h.Name)
|
||||
seen[lower] = true
|
||||
result = append(result, h)
|
||||
}
|
||||
|
||||
for _, h := range s.customHosts {
|
||||
lower := strings.ToLower(h.Name)
|
||||
if !seen[lower] {
|
||||
result = append(result, h)
|
||||
seen[lower] = true
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// FindByName looks up a host by its Name field. Returns nil if not found.
|
||||
func (s *HostStore) FindByName(name string) *SSHHost {
|
||||
all := s.AllHosts()
|
||||
for i := range all {
|
||||
if strings.EqualFold(all[i].Name, name) {
|
||||
return &all[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func encrypt(plaintext []byte, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return gcm.Seal(nonce, nonce, plaintext, nil), nil
|
||||
}
|
||||
|
||||
func decrypt(ciphertext []byte, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(ciphertext) < nonceSize {
|
||||
return nil, fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
|
||||
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
||||
return gcm.Open(nil, nonce, ciphertext, nil)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue