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.
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:
- Transporte seguro — Canal de comunicación encriptado
- Autenticación — Demostrar quién eres (contraseña, claves, certificados)
- 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.
Tags
Artículos relacionados
API Versioning Strategies: Cómo Evolucionar APIs sin Romper Clientes
Una guía exhaustiva sobre estrategias de versionado de APIs: URL versioning vs Header versioning, cómo deprecar endpoints sin shock, migration patterns reales, handling de cambios backwards-incompatibles, y decisiones arquitectónicas que importan. Con 50+ ejemplos de código en Go.
Arquitectura de software: Más allá del código
Una guía completa sobre arquitectura de software explicada en lenguaje humano: patrones, organización, estructura y cómo construir sistemas que escalen con tu negocio.
Automatizando tu vida con Go CLI: Guía profesional para crear herramientas de línea de comandos escalables
Una guía exhaustiva y paso a paso sobre cómo crear herramientas CLI escalables con Go 1.25.5: desde lo básico hasta proyectos empresariales complejos con flags, configuración, logging, y ejemplos prácticos para Windows y Linux.