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