122 lines
4.9 KiB
Go
122 lines
4.9 KiB
Go
package lexicomai
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// shellQuote возвращает сегмент, безопасный для передачи в удалённый shell
|
|
// (одинарные кавычки + экранирование встроенных '). Пользовательский ввод
|
|
// никогда не собирается в команду строковой конкатенацией — только через
|
|
// эту функцию.
|
|
func shellQuote(s string) string {
|
|
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
|
}
|
|
|
|
// ExecResult — результат удалённой команды.
|
|
type ExecResult struct {
|
|
// Output — stdout (обрезан по maxBytes при необходимости).
|
|
Output string
|
|
// Combined — stdout+stderr (для ошибок и логов).
|
|
Combined string
|
|
}
|
|
|
|
// newSSHCmd строит выполняемую ssh-команду с нужными опциями подключения.
|
|
// input — поток stdin на удалённую команду (nil = без ввода). Команда
|
|
// передаётся одним argv-элементом: ssh склеивает аргументы и отдаёт их
|
|
// удалённому shell; пользовательские сегменты уже заквотированы вызывающим.
|
|
func (m *Manager) newSSHCmd(ctx context.Context, target *Target, command string, input io.Reader) (*exec.Cmd, context.CancelFunc) {
|
|
timeout := m.timeout()
|
|
if deadline, ok := ctx.Deadline(); ok {
|
|
if until := time.Until(deadline); until < timeout {
|
|
timeout = until
|
|
}
|
|
}
|
|
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
|
|
argv := []string{
|
|
"-o", "StrictHostKeyChecking=accept-new",
|
|
"-o", "ConnectTimeout=10",
|
|
"-o", "BatchMode=yes",
|
|
"-p", sshPort(target),
|
|
}
|
|
if target.SSH.Key != "" {
|
|
argv = append(argv, "-i", target.SSH.Key)
|
|
}
|
|
argv = append(argv, target.SSHAddr(), command)
|
|
|
|
cmd := exec.CommandContext(runCtx, "ssh", argv...)
|
|
cmd.Stdin = input
|
|
return cmd, cancel
|
|
}
|
|
|
|
// ExecOnTarget выполняет команду на машине через внешний ssh-клиент.
|
|
//
|
|
// Команда всегда запускается через sudo -n (non-interactive; пароль не
|
|
// запрашивается), под пользователем из конфига. Секреты в строку команды не
|
|
// попадают — только из конфига/окружения. Таймаут ограничен context.
|
|
//
|
|
// Потокобезопасно: не хранит состояния, каждый вызов создаёт свой процесс.
|
|
func (m *Manager) ExecOnTarget(ctx context.Context, target *Target, command string) (*ExecResult, error) {
|
|
cmd, cancel := m.newSSHCmd(ctx, target, command, nil)
|
|
defer cancel()
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
// Инфраструктурная ошибка (нет соединения, отказ ключа) — вернём как
|
|
// есть; инструменты превратят её в non-recoverable (нужен администратор).
|
|
msg := stderr.String()
|
|
if msg == "" {
|
|
msg = err.Error()
|
|
}
|
|
return &ExecResult{Output: stdout.String(), Combined: msg}, fmt.Errorf("ssh %s: %w", target.SSHAddr(), err)
|
|
}
|
|
return &ExecResult{Output: stdout.String(), Combined: stdout.String() + stderr.String()}, nil
|
|
}
|
|
|
|
// ExecWithInput выполняет команду на машине, прогоняя input в её stdin.
|
|
// Используется для стриминга больших файлов (например дистрибутива) на машину
|
|
// без буферизации целиком: `sudo tee /path`.
|
|
func (m *Manager) ExecWithInput(ctx context.Context, target *Target, command string, input io.Reader) (*ExecResult, error) {
|
|
cmd, cancel := m.newSSHCmd(ctx, target, command, input)
|
|
defer cancel()
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
msg := stderr.String()
|
|
if msg == "" {
|
|
msg = err.Error()
|
|
}
|
|
return &ExecResult{Output: stdout.String(), Combined: msg}, fmt.Errorf("ssh %s: %w", target.SSHAddr(), err)
|
|
}
|
|
return &ExecResult{Output: stdout.String(), Combined: stdout.String() + stderr.String()}, nil
|
|
}
|
|
|
|
// runSudo выполняет команду от root (sudo -n) на машине и возвращает stdout.
|
|
func (m *Manager) runSudo(ctx context.Context, target *Target, command string) (string, error) {
|
|
res, err := m.ExecOnTarget(ctx, target, "sudo -n "+command)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return res.Output, nil
|
|
}
|
|
|
|
// sshPort возвращает SSH-порт таргета (22 по умолчанию).
|
|
func sshPort(t *Target) string {
|
|
if t.SSH.Port > 0 {
|
|
return fmt.Sprintf("%d", t.SSH.Port)
|
|
}
|
|
return "22"
|
|
}
|