SSH con Go: Conexiones, Comandos, Gestión de Usuarios y Patrones de Producción
Backend

SSH con Go: Conexiones, Comandos, Gestión de Usuarios y Patrones de Producción

Domina la programación de clientes SSH en Go. Aprende pooling de conexiones, ejecución de comandos, autenticación con claves, manipulación de usuarios y datos, y patrones listos para producción en sistemas remotos a escala.

Por Omar Flores
#go #golang #ssh #remote-execution #system-management #connections #authentication #cli #best-practices #security #devops #automation #backend

El Superpoder Oculto: SSH en Tu Código

La mayoría de desarrolladores usa SSH para conectarse a servidores manualmente. Escriben ssh usuario@host, ejecutan un comando, desconectan.

Pero SSH no es solo para humanos. SSH es una API.

Imagina que desde código Go puedes:

  • Conectarte a 100 servidores simultáneamente
  • Ejecutar comandos en paralelo
  • Capturar y parsear output
  • Crear usuarios, modificar archivos, reiniciar servicios
  • Todo de forma segura, todo programáticamente, todo a escala

Esto no es exótico. Así es como las organizaciones más grandes automatizan su infraestructura.

Esta guía te enseña a hacerlo.


Parte 1: Fundamentos de SSH — El Protocolo

SSH no es mágico. Es un protocolo bien definido con capacidades claras.

Qué es SSH Realmente

SSH hace tres cosas:

  1. Transporte seguro — Canal de comunicación encriptado
  2. Autenticación — Demostrar quién eres (contraseña, claves, certificados)
  3. Sesión — Ejecutar comandos, reenviar puertos, transferir archivos

La mayoría de desarrolladores solo usan #1 y #3. Necesitas los tres para entender qué es posible.

Métodos de Autenticación (En Orden de Uso en Producción)

Método 1: Autenticación por Clave Pública (Mejor)

Tienes una clave privada. El servidor tiene tu clave pública. Demuestras que tienes la clave privada sin enviarla.

import (
	"golang.org/x/crypto/ssh"
	"os"
)

// Leer clave privada
keyBytes, _ := os.ReadFile("/home/usuario/.ssh/id_rsa")

// Parsear clave
signer, _ := ssh.ParsePrivateKey(keyBytes)

// Crear configuración con autenticación por clave
config := &ssh.ClientConfig{
	User: "deployer",
	Auth: []ssh.AuthMethod{
		ssh.PublicKeys(signer),
	},
	HostKeyCallback: verifyHostKey,
}

Método 2: Autenticación por Contraseña (Aceptable para Interno)

Simple pero menos seguro. Usuario + contraseña sobre canal encriptado.

config := &ssh.ClientConfig{
	User: "deployer",
	Auth: []ssh.AuthMethod{
		ssh.Password("SecurePassword123"),
	},
	HostKeyCallback: verifyHostKey,
}

Método 3: Keyboard Interactive (Raro)

Usado para autenticación de dos factores o personalizada. Complejo de implementar.

Método 4: Sin Autenticación (Nunca en Producción)

config := &ssh.ClientConfig{
	HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}

Solo para desarrollo. Nunca para producción.

Verificación de Claves de Host (La Parte Crítica)

La mayoría de desarrolladores omite esto:

// INCORRECTO: Ignora verificación de clave de host
config.HostKeyCallback = ssh.InsecureIgnoreHostKey()

Te deja vulnerable a ataques man-in-the-middle.

Forma correcta:

// Verificar contra archivo known_hosts
hostKeyCallback, _ := knownhosts.New("/home/usuario/.ssh/known_hosts")
config.HostKeyCallback = hostKeyCallback

O verifica contra huellas digitales hardcodeadas para servidores críticos:

config.HostKeyCallback = func(hostname string, remote net.Addr, key ssh.PublicKey) error {
	expectedFingerprint := "SHA256:AAAAB3NzaC1yc2EAAA..."
	actualFingerprint := ssh.FingerprintSHA256(key)

	if actualFingerprint != expectedFingerprint {
		return fmt.Errorf("host key mismatch")
	}
	return nil
}

Parte 2: Construyendo Connection Pools — Los Cimientos

Como con SFTP, abrir conexiones SSH es costoso. Necesitas pooling.

Implementación Completa de Connection Pool

// sshpool/pool.go
package sshpool

import (
	"fmt"
	"sync"

	"golang.org/x/crypto/ssh"
)

type Connection struct {
	client *ssh.Client
	closed bool
}

type Pool struct {
	host     string
	config   *ssh.ClientConfig
	mu       sync.RWMutex
	conns    []*Connection
	maxConns int
	inUse    int
}

func NewPool(host string, config *ssh.ClientConfig, maxConns int) *Pool {
	return &Pool{
		host:     host,
		config:   config,
		conns:    make([]*Connection, 0, maxConns),
		maxConns: maxConns,
	}
}

// Get obtiene o crea una conexión SSH
func (p *Pool) Get() (*Connection, error) {
	p.mu.Lock()
	defer p.mu.Unlock()

	// Intentar reutilizar
	for i, conn := range p.conns {
		if !conn.closed {
			p.conns = append(p.conns[:i], p.conns[i+1:]...)
			p.inUse++
			return conn, nil
		}
	}

	// Crear nueva
	if p.inUse < p.maxConns {
		client, err := ssh.Dial("tcp", p.host+":22", p.config)
		if err != nil {
			return nil, err
		}
		p.inUse++
		return &Connection{client: client}, nil
	}

	return nil, fmt.Errorf("pool exhausted")
}

// Return devuelve la conexión al pool
func (p *Pool) Return(conn *Connection) {
	p.mu.Lock()
	defer p.mu.Unlock()

	if conn.closed {
		p.inUse--
		return
	}

	p.conns = append(p.conns, conn)
	p.inUse--
}

// Close cierra todas las conexiones
func (p *Pool) Close() error {
	p.mu.Lock()
	defer p.mu.Unlock()

	for _, conn := range p.conns {
		conn.client.Close()
	}
	return nil
}

// Exec ejecuta un comando y devuelve output
func (c *Connection) Exec(cmd string) (string, error) {
	session, err := c.client.NewSession()
	if err != nil {
		return "", err
	}
	defer session.Close()

	output, err := session.CombinedOutput(cmd)
	return string(output), err
}

Parte 3: Patrones de Ejecución de Comandos — Más Allá del Exec Simple

SSH no es solo “ejecuta un comando y obtén output”. Hay patrones para escenarios del mundo real.

Patrón 1: Ejecución Simple de Comandos

// Ejecutar un comando, obtener output
conn, _ := pool.Get()
defer pool.Return(conn)

output, err := conn.Exec("ls -la /home/deployer")
if err != nil {
	log.Println("Command failed:", err)
}
log.Println(output)

Patrón 2: Stream de Output Grande

Para comandos que devuelven output enorme, haz stream en lugar de buffering:

func (c *Connection) ExecStream(cmd string, stdout io.Writer) error {
	session, err := c.client.NewSession()
	if err != nil {
		return err
	}
	defer session.Close()

	// Stream stdout en tiempo real
	session.Stdout = stdout
	session.Stderr = os.Stderr

	return session.Run(cmd)
}

// Uso
conn, _ := pool.Get()
defer pool.Return(conn)

err := conn.ExecStream("tail -f /var/log/app.log", os.Stdout)
// Log output se transmite a la terminal en tiempo real

Patrón 3: Ejecutar Múltiples Comandos en Una Sesión

func (c *Connection) ExecMultiple(cmds []string) ([]string, error) {
	session, err := c.client.NewSession()
	if err != nil {
		return nil, err
	}
	defer session.Close()

	results := make([]string, len(cmds))

	for i, cmd := range cmds {
		output, err := session.CombinedOutput(cmd)
		if err != nil {
			return nil, fmt.Errorf("cmd %d failed: %w", i, err)
		}
		results[i] = string(output)
	}

	return results, nil
}

// Uso
conn, _ := pool.Get()
defer pool.Return(conn)

results, _ := conn.ExecMultiple([]string{
	"whoami",
	"pwd",
	"date",
})

// Todos ejecutados en una sesión
for _, r := range results {
	log.Println(r)
}

Patrón 4: Capturar Stdout y Stderr por Separado

func (c *Connection) ExecSeparate(cmd string) (stdout, stderr string, err error) {
	session, err := c.client.NewSession()
	if err != nil {
		return "", "", err
	}
	defer session.Close()

	outBuf := &bytes.Buffer{}
	errBuf := &bytes.Buffer{}

	session.Stdout = outBuf
	session.Stderr = errBuf

	err = session.Run(cmd)
	return outBuf.String(), errBuf.String(), err
}

// Uso
stdout, stderr, err := conn.ExecSeparate("docker ps --format json")
if err != nil {
	log.Println("Error:", stderr)
} else {
	log.Println("Output:", stdout)
}

Parte 4: Manipulación de Usuarios y Datos — Administración de Sistemas Real

Creación y Gestión de Usuarios

// AddUser crea una nueva cuenta de usuario
func (c *Connection) AddUser(username string, uid int, groups []string) error {
	// Crear usuario
	cmd := fmt.Sprintf("sudo useradd -u %d -m -s /bin/bash %s", uid, username)
	_, err := c.Exec(cmd)
	if err != nil {
		return err
	}

	// Agregar a grupos
	for _, group := range groups {
		cmd := fmt.Sprintf("sudo usermod -aG %s %s", group, username)
		_, err := c.Exec(cmd)
		if err != nil {
			return err
		}
	}

	return nil
}

// SetPassword establece la contraseña del usuario (deberías usar autenticación basada en claves)
func (c *Connection) SetPassword(username, password string) error {
	// Usar echo y chpasswd para establecimiento más seguro
	cmd := fmt.Sprintf("echo '%s:%s' | sudo chpasswd", username, password)
	_, err := c.Exec(cmd)
	return err
}

// AddSSHKey agrega una clave pública para un usuario
func (c *Connection) AddSSHKey(username, publicKey string) error {
	cmd := fmt.Sprintf(
		"sudo -u %s sh -c 'mkdir -p ~/.ssh && echo %q >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys'",
		username, publicKey,
	)
	_, err := c.Exec(cmd)
	return err
}

// DeleteUser elimina un usuario
func (c *Connection) DeleteUser(username string, removeHome bool) error {
	flag := ""
	if removeHome {
		flag = " -r"
	}
	cmd := fmt.Sprintf("sudo userdel%s %s", flag, username)
	_, err := c.Exec(cmd)
	return err
}

// Uso
conn, _ := pool.Get()
defer pool.Return(conn)

_ = conn.AddUser("deployer", 1001, []string{"sudo", "docker"})
_ = conn.AddSSHKey("deployer", "ssh-rsa AAAA...")

Operaciones de Archivos y Datos

// WriteFile escribe datos en un archivo remoto
func (c *Connection) WriteFile(path string, data []byte) error {
	// Usar encoding base64 para transferir datos binarios de forma segura
	encoded := base64.StdEncoding.EncodeToString(data)
	cmd := fmt.Sprintf(
		"echo '%s' | base64 -d > %s && chmod 644 %s",
		encoded, path, path,
	)
	_, err := c.Exec(cmd)
	return err
}

// ReadFile lee un archivo remoto
func (c *Connection) ReadFile(path string) ([]byte, error) {
	cmd := fmt.Sprintf("cat %s | base64", path)
	output, err := c.Exec(cmd)
	if err != nil {
		return nil, err
	}

	data, err := base64.StdEncoding.DecodeString(strings.TrimSpace(output))
	return data, err
}

// AppendFile agrega contenido a un archivo remoto
func (c *Connection) AppendFile(path, content string) error {
	cmd := fmt.Sprintf("echo %q >> %s", content, path)
	_, err := c.Exec(cmd)
	return err
}

// GetFileInfo obtiene metadatos del archivo
func (c *Connection) GetFileInfo(path string) (os.FileInfo, error) {
	output, err := c.Exec(fmt.Sprintf("stat -c '%%s %%Y %%a' %s", path))
	if err != nil {
		return nil, err
	}

	var size int64
	var mtime int64
	var mode string
	_, err = fmt.Sscanf(output, "%d %d %s", &size, &mtime, &mode)
	if err != nil {
		return nil, err
	}

	// Devolver FileInfo simplificado
	return &remoteFileInfo{
		name:  filepath.Base(path),
		size:  size,
		mtime: time.Unix(mtime, 0),
	}, nil
}

type remoteFileInfo struct {
	name  string
	size  int64
	mtime time.Time
}

func (f *remoteFileInfo) Name() string       { return f.name }
func (f *remoteFileInfo) Size() int64        { return f.size }
func (f *remoteFileInfo) ModTime() time.Time { return f.mtime }
func (f *remoteFileInfo) Mode() os.FileMode  { return 0644 }
func (f *remoteFileInfo) IsDir() bool        { return false }
func (f *remoteFileInfo) Sys() interface{}   { return nil }

Información del Sistema y Búsqueda

// GetSystemInfo obtiene información del sistema
func (c *Connection) GetSystemInfo() (map[string]string, error) {
	info := make(map[string]string)

	commands := map[string]string{
		"hostname": "hostname",
		"kernel":   "uname -r",
		"cpu":      "nproc",
		"memory":   "free -h | grep Mem",
		"disk":     "df -h / | tail -1",
	}

	for key, cmd := range commands {
		output, err := c.Exec(cmd)
		if err != nil {
			return nil, err
		}
		info[key] = strings.TrimSpace(output)
	}

	return info, nil
}

// SearchFiles encuentra archivos que coincidan con un patrón
func (c *Connection) SearchFiles(dir, pattern string) ([]string, error) {
	cmd := fmt.Sprintf("find %s -type f -name %q 2>/dev/null", dir, pattern)
	output, err := c.Exec(cmd)
	if err != nil {
		return nil, err
	}

	var files []string
	for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
		if line != "" {
			files = append(files, line)
		}
	}
	return files, nil
}

// SearchFilesContent busca contenido en archivos
func (c *Connection) SearchFilesContent(dir, pattern string) ([]string, error) {
	cmd := fmt.Sprintf("grep -r %q %s 2>/dev/null | cut -d: -f1 | sort -u", pattern, dir)
	output, err := c.Exec(cmd)
	if err != nil {
		return nil, err
	}

	var files []string
	for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
		if line != "" {
			files = append(files, line)
		}
	}
	return files, nil
}

Parte 5: Operaciones en Paralelo a Escala

Ejecuta comandos en múltiples servidores simultáneamente:

// ParallelExec ejecuta un comando en múltiples servidores
func ParallelExec(pools map[string]*Pool, cmd string, workers int) map[string]string {
	results := make(map[string]string)
	mu := sync.Mutex{}

	workChan := make(chan string, 100)
	var wg sync.WaitGroup

	// Iniciar workers
	for i := 0; i < workers; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for host := range workChan {
				pool := pools[host]
				conn, err := pool.Get()
				if err != nil {
					mu.Lock()
					results[host] = "ERROR: " + err.Error()
					mu.Unlock()
					continue
				}

				output, err := conn.Exec(cmd)
				pool.Return(conn)

				mu.Lock()
				if err != nil {
					results[host] = "ERROR: " + err.Error()
				} else {
					results[host] = strings.TrimSpace(output)
				}
				mu.Unlock()
			}
		}()
	}

	// Enviar trabajo
	go func() {
		for host := range pools {
			workChan <- host
		}
		close(workChan)
	}()

	wg.Wait()
	return results
}

// Uso
pools := map[string]*Pool{
	"server1.com": pool1,
	"server2.com": pool2,
	"server3.com": pool3,
}

results := ParallelExec(pools, "uptime", 4)
for host, output := range results {
	log.Printf("%s: %s\n", host, output)
}

Parte 6: Ejemplo de Herramienta CLI

Construye una herramienta de gestión SSH lista para producción:

// ssh-tool/main.go
package main

import (
	"flag"
	"fmt"
	"log"
	"golang.org/x/crypto/ssh"
	"os"
)

func main() {
	cmd := flag.NewFlagSet("ssh-tool", flag.ExitOnError)
	host := cmd.String("host", "", "Remote host")
	user := cmd.String("user", "", "Username")
	key := cmd.String("key", os.ExpandEnv("$HOME/.ssh/id_rsa"), "Private key path")
	operation := cmd.String("op", "", "Operation: exec, user-add, file-write, search")
	param1 := cmd.String("p1", "", "Parameter 1")
	param2 := cmd.String("p2", "", "Parameter 2")

	cmd.Parse(flag.Args())

	// Cargar clave privada
	keyBytes, _ := os.ReadFile(*key)
	signer, _ := ssh.ParsePrivateKey(keyBytes)

	// Crear configuración
	config := &ssh.ClientConfig{
		User: *user,
		Auth: []ssh.AuthMethod{
			ssh.PublicKeys(signer),
		},
		HostKeyCallback: ssh.InsecureIgnoreHostKey(),
	}

	// Conectar
	client, err := ssh.Dial("tcp", *host+":22", config)
	if err != nil {
		log.Fatal(err)
	}
	defer client.Close()

	// Ejecutar operación
	switch *operation {
	case "exec":
		session, _ := client.NewSession()
		session.Run(*param1)
		session.Close()

	case "user-add":
		cmd := fmt.Sprintf("sudo useradd -m -s /bin/bash %s", *param1)
		session, _ := client.NewSession()
		session.Run(cmd)
		session.Close()

	case "file-write":
		cmd := fmt.Sprintf("echo %q > %s", *param2, *param1)
		session, _ := client.NewSession()
		session.Run(cmd)
		session.Close()

	case "search":
		cmd := fmt.Sprintf("find %s -name %q", *param1, *param2)
		session, _ := client.NewSession()
		output, _ := session.Output(cmd)
		fmt.Println(string(output))
		session.Close()

	default:
		fmt.Println("Unknown operation")
	}
}

Uso:

# Ejecutar comando
./ssh-tool -host example.com -user deployer -op exec -p1 "ls -la"

# Agregar usuario
./ssh-tool -host example.com -user deployer -op user-add -p1 "newuser"

# Escribir archivo
./ssh-tool -host example.com -user deployer -op file-write -p1 "/tmp/config.txt" -p2 "content"

# Buscar archivos
./ssh-tool -host example.com -user deployer -op search -p1 "/home" -p2 "*.log"

Parte 7: Mejores Prácticas de Seguridad

Lista de Verificación de Seguridad SSH

Autenticación:

  • ✅ Usa autenticación basada en claves, nunca contraseñas en código
  • ✅ Almacena claves privadas de forma segura (encriptadas, permisos restringidos)
  • ✅ Rota claves regularmente
  • ✅ Usa passphrases en claves

Verificación de Host:

  • ✅ Siempre verifica claves de host en producción
  • ✅ Usa known_hosts o huellas digitales fijadas
  • ✅ Nunca uses InsecureIgnoreHostKey en producción

Ejecución de Comandos:

  • ✅ Nunca hagas shell-escape de entrada de usuario, usa APIs correctas
  • ✅ Valida todos los parámetros de comandos
  • ✅ Usa restricciones sudoers para escalada de privilegios
  • ✅ Registra todos los comandos remotos ejecutados

Red:

  • ✅ Usa algoritmos de intercambio de claves SSH (ed25519, ecdsa)
  • ✅ Deshabilita algoritmos débiles
  • ✅ Considera hosts bastion para acceder a servidores internos
  • ✅ Usa certificados SSH para despliegues grandes

Carga Segura de Claves

// Cargar y validar clave privada
func loadPrivateKey(keyPath string, passphrase string) (ssh.Signer, error) {
	// Leer archivo de clave
	keyBytes, err := os.ReadFile(keyPath)
	if err != nil {
		return nil, err
	}

	// Validar permisos de archivo (deberían ser 600)
	info, _ := os.Stat(keyPath)
	if info.Mode()&0077 != 0 {
		return nil, fmt.Errorf("private key has unsafe permissions: %o", info.Mode())
	}

	// Parsear con passphrase si es necesario
	var signer ssh.Signer
	if passphrase != "" {
		signer, err = ssh.ParsePrivateKeyWithPassphrase(keyBytes, []byte(passphrase))
	} else {
		signer, err = ssh.ParsePrivateKey(keyBytes)
	}

	return signer, err
}

Parte 8: Patrones Comunes y Recetas

Desplegar Código de Aplicación

func DeployApplication(host, user, keyPath, appPath, deployPath string) error {
	// 1. Copiar código
	if err := copyDirectory(host, user, keyPath, appPath, deployPath); err != nil {
		return err
	}

	// 2. Instalar dependencias
	pool := createPool(host, user, keyPath)
	conn, _ := pool.Get()
	defer pool.Return(conn)

	if _, err := conn.Exec(fmt.Sprintf("cd %s && go mod download", deployPath)); err != nil {
		return err
	}

	// 3. Construir
	if _, err := conn.Exec(fmt.Sprintf("cd %s && go build -o app", deployPath)); err != nil {
		return err
	}

	// 4. Reiniciar servicio
	if _, err := conn.Exec("sudo systemctl restart app"); err != nil {
		return err
	}

	return nil
}

Health Check

func HealthCheck(host, user, keyPath string) bool {
	pool := createPool(host, user, keyPath)
	conn, err := pool.Get()
	if err != nil {
		return false
	}
	defer pool.Return(conn)

	output, err := conn.Exec("curl -s http://localhost:8080/health")
	return err == nil && strings.Contains(output, "ok")
}

Parte 9: El Costo Real del Mal Código SSH

Los desarrolladores a menudo tratan SSH como fire-and-forget:

// ANTIPATRÓN: Nueva conexión para cada operación
ssh.Dial("tcp", host+":22", config)
// Ejecutar comando
ssh.Dial("tcp", host+":22", config)
// Ejecutar otro comando

A escala:

  • Handshake por conexión: 100-500ms cada uno
  • 100 comandos: 10+ segundos en serie
  • Conexiones rotas: Sin retry, la tarea falla
  • Sin verificación de host: Vulnerabilidad a MITM

Los patrones correctos (pooling, verificación, ejecución segura) cuestan algunas cientos de líneas de código. Te ahorran horas de debugging y previenen brechas de seguridad.

La diferencia entre código SSH que funciona y código SSH listo para producción no es complejidad. Es disciplina. Pooling de conexiones. Verificación de hosts. Ejecución segura de comandos. Estos no son opcionales. Son la diferencia entre un script y una herramienta de infraestructura.