268 lines
8.9 KiB
Go
268 lines
8.9 KiB
Go
// 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(`'\''`))) + "'"
|
||
}
|