goherence/internal/ssh/client.go
2026-09-11 10:17:25 +03:00

268 lines
8.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package ssh — тонкая обвязка над golang.org/x/crypto/ssh: подключение
// по ключу или паролю, выполнение команд, become/sudo, запись файлов
// на удалённый хост. Никакой модульной логики тут нет — это только
// транспорт, которым пользуются builtin- и external-модули.
package ssh
import (
"bytes"
"fmt"
"net"
"os"
"strings"
"time"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
)
// Conn — открытое SSH-соединение с одним хостом.
type Conn struct {
client *ssh.Client
Host string
Become bool // выполнять команды через sudo
}
// Config описывает, как подключаться к хосту.
type Config struct {
Host string
Port int // по умолчанию 22
User string
PrivateKeyPath string // путь к приватному ключу, если пусто — пробуем ssh-agent
Password string // используется только если PrivateKeyPath пуст
KnownHostsPath string // пусто — проверка отключена (см. предупреждение ниже)
Timeout time.Duration
}
// Dial открывает соединение по Config. Если KnownHostsPath не задан,
// используется ssh.InsecureIgnoreHostKey — это осознанный компромисс
// для первой версии, но должен быть заменён на реальную проверку
// перед использованием где-либо, кроме локальных тестов.
func Dial(cfg Config) (*Conn, error) {
auth, err := buildAuth(cfg)
if err != nil {
return nil, err
}
hostKeyCallback := ssh.InsecureIgnoreHostKey()
if cfg.KnownHostsPath != "" {
cb, err := knownhosts.New(cfg.KnownHostsPath)
if err != nil {
return nil, fmt.Errorf("known_hosts: %w", err)
}
hostKeyCallback = cb
}
timeout := cfg.Timeout
if timeout == 0 {
timeout = 10 * time.Second
}
port := cfg.Port
if port == 0 {
port = 22
}
clientCfg := &ssh.ClientConfig{
User: cfg.User,
Auth: auth,
HostKeyCallback: hostKeyCallback,
Timeout: timeout,
}
addr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", port))
client, err := ssh.Dial("tcp", addr, clientCfg)
if err != nil {
return nil, fmt.Errorf("ssh dial %s: %w", addr, err)
}
return &Conn{client: client, Host: cfg.Host}, nil
}
func buildAuth(cfg Config) ([]ssh.AuthMethod, error) {
if cfg.PrivateKeyPath != "" {
keyPath, err := expandHome(cfg.PrivateKeyPath)
if err != nil {
return nil, err
}
key, err := os.ReadFile(keyPath)
if err != nil {
return nil, fmt.Errorf("читаю приватный ключ: %w", err)
}
signer, err := ssh.ParsePrivateKey(key)
if err != nil {
return nil, fmt.Errorf("парсинг приватного ключа: %w", err)
}
return []ssh.AuthMethod{ssh.PublicKeys(signer)}, nil
}
if cfg.Password != "" {
return []ssh.AuthMethod{ssh.Password(cfg.Password)}, nil
}
return nil, fmt.Errorf("не задан ни PrivateKeyPath, ни Password")
}
// expandHome разворачивает ведущий "~" в путь к домашней директории —
// ни os.ReadFile, ни что-либо в стандартной библиотеке не делает этого
// само по себе, а путь к ключу в конфиге/флагах CLI обычно пишут с "~".
func expandHome(path string) (string, error) {
if !strings.HasPrefix(path, "~") {
return path, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("не удалось определить домашнюю директорию для %q: %w", path, err)
}
return home + strings.TrimPrefix(path, "~"), nil
}
// ExecResult — итог выполнения команды на хосте.
type ExecResult struct {
Stdout string
Stderr string
ExitCode int
}
// Run выполняет команду на хосте. Если c.Become — оборачивает в sudo -n.
func (c *Conn) Run(cmd string) (ExecResult, error) {
if c.Become {
cmd = "sudo -n -- " + cmd
}
session, err := c.client.NewSession()
if err != nil {
return ExecResult{}, fmt.Errorf("новая ssh-сессия: %w", err)
}
defer session.Close()
var stdout, stderr bytes.Buffer
session.Stdout = &stdout
session.Stderr = &stderr
err = session.Run(cmd)
exitCode := 0
if err != nil {
if exitErr, ok := err.(*ssh.ExitError); ok {
exitCode = exitErr.ExitStatus()
} else {
return ExecResult{}, fmt.Errorf("выполнение команды: %w", err)
}
}
return ExecResult{
Stdout: stdout.String(),
Stderr: stderr.String(),
ExitCode: exitCode,
}, nil
}
// WriteFile записывает content в remotePath с правами mode. Реализовано
// через `cat > file` по тому же SSH-каналу — без отдельного SFTP-подключения,
// чтобы не тянуть ещё одну зависимость в первой версии. Для больших файлов
// и бинарной доставки внешних модулей это стоит заменить на настоящий SFTP
// (см. internal/module/external — там уже отдельный контракт).
func (c *Conn) WriteFile(remotePath string, content []byte, mode string) error {
cmd := fmt.Sprintf("cat > %s && chmod %s %s", shellQuote(remotePath), mode, shellQuote(remotePath))
if c.Become {
cmd = "sudo -n -- sh -c " + shellQuote(cmd)
}
session, err := c.client.NewSession()
if err != nil {
return fmt.Errorf("новая ssh-сессия: %w", err)
}
defer session.Close()
var stderr bytes.Buffer
session.Stderr = &stderr
stdin, err := session.StdinPipe()
if err != nil {
return err
}
if err := session.Start(cmd); err != nil {
return err
}
_, writeErr := stdin.Write(content)
stdin.Close()
waitErr := session.Wait()
// сперва разбираем exit status/stderr удалённой команды — это
// содержательная причина (например "Permission denied"), тогда как
// ошибка самого stdin.Write (обычно EOF) — лишь её следствие и
// без exit-кода ничего не объясняет пользователю.
if waitErr != nil {
if exitErr, ok := waitErr.(*ssh.ExitError); ok {
return fmt.Errorf("запись %s: команда завершилась с кодом %d: %s",
remotePath, exitErr.ExitStatus(), strings.TrimSpace(stderr.String()))
}
return fmt.Errorf("запись %s: %w (stderr: %s)", remotePath, waitErr, strings.TrimSpace(stderr.String()))
}
if writeErr != nil {
return fmt.Errorf("запись %s: ошибка передачи данных: %w (stderr: %s)",
remotePath, writeErr, strings.TrimSpace(stderr.String()))
}
return nil
}
// ReadFile читает содержимое удалённого файла целиком.
func (c *Conn) ReadFile(remotePath string) ([]byte, error) {
res, err := c.Run(fmt.Sprintf("cat %s", shellQuote(remotePath)))
if err != nil {
return nil, err
}
if res.ExitCode != 0 {
return nil, fmt.Errorf("cat %s: exit %d: %s", remotePath, res.ExitCode, res.Stderr)
}
return []byte(res.Stdout), nil
}
// RunWithInput выполняет команду, передавая content в её stdin, и
// возвращает результат целиком. Используется external-модулями для
// обмена JSON по контракту stdin/stdout (см. internal/module/external).
func (c *Conn) RunWithInput(cmd string, content []byte) (ExecResult, error) {
if c.Become {
cmd = "sudo -n -- " + cmd
}
session, err := c.client.NewSession()
if err != nil {
return ExecResult{}, fmt.Errorf("новая ssh-сессия: %w", err)
}
defer session.Close()
var stdout, stderr bytes.Buffer
session.Stdout = &stdout
session.Stderr = &stderr
stdin, err := session.StdinPipe()
if err != nil {
return ExecResult{}, err
}
if err := session.Start(cmd); err != nil {
return ExecResult{}, err
}
if _, err := stdin.Write(content); err != nil {
return ExecResult{}, err
}
stdin.Close()
err = session.Wait()
exitCode := 0
if err != nil {
if exitErr, ok := err.(*ssh.ExitError); ok {
exitCode = exitErr.ExitStatus()
} else {
return ExecResult{}, fmt.Errorf("выполнение команды: %w", err)
}
}
return ExecResult{Stdout: stdout.String(), Stderr: stderr.String(), ExitCode: exitCode}, nil
}
func (c *Conn) Close() error {
return c.client.Close()
}
// shellQuote — минимальное экранирование пути для подстановки в shell-команду.
func shellQuote(s string) string {
return "'" + string(bytes.ReplaceAll([]byte(s), []byte("'"), []byte(`'\''`))) + "'"
}