first commit
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
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"
|
||||
}
|
||||
Reference in New Issue
Block a user