Files
2026-10-01 09:50:59 +07:00

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"
}