vcom/internal/fs/remote/host.go
vrubelroman 1ed2d3defb 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
2026-04-29 03:11:53 +03:00

305 lines
7.2 KiB
Go

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)
}