// 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(`'\''`))) + "'" }