summaryrefslogtreecommitdiff
path: root/lib/hyperstack/ssh_runner.rb
blob: e4440b6cdc3f9283bc24d842778a9a498272c94d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# frozen_string_literal: true

require 'fileutils'
require 'open3'
require 'socket'

module HyperstackVM
  # Executes commands over SSH and manages host-key trust.
  class SshRunner
    def initialize(config:, out:)
      @config = config
      @out = out
    end

    def run(host, remote_script)
      Open3.capture3(*command(host), stdin_data: remote_script)
    end

    def run_streaming(host, remote_script)
      combined = +''
      Open3.popen2e(*command(host)) do |stdin, output, wait_thr|
        stdin.write(remote_script)
        stdin.close
        output.each do |line|
          combined << line
          @out.print(line)
        end
        return [combined, wait_thr.value]
      end
    end

    def tcp_open?(host, port)
      Socket.tcp(host, port, connect_timeout: @config.ssh_connect_timeout) do |sock|
        sock.close
        true
      end
    rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::EHOSTUNREACH,
           Errno::ENETUNREACH, SocketError, IOError
      false
    end

    def command(host)
      cmd = [
        'ssh',
        '-o', 'BatchMode=yes',
        '-o', 'StrictHostKeyChecking=yes',
        '-o', "UserKnownHostsFile=#{@config.ssh_known_hosts_path}",
        '-o', "ConnectTimeout=#{@config.ssh_connect_timeout}",
        '-p', @config.ssh_port.to_s
      ]
      if File.exist?(@config.ssh_private_key_path)
        cmd.concat(['-i', @config.ssh_private_key_path])
      else
        warn_out("SSH private key #{@config.ssh_private_key_path} does not exist; falling back to default ssh-agent identity.")
      end
      cmd << "#{@config.ssh_username}@#{host}"
      cmd << 'bash -se'
      cmd
    end

    # Trust / known-hosts management

    def ensure_trusted_host(host)
      scanned = scan_host_keys(host)
      return false if scanned.empty?

      existing = known_host_entries
      if existing.empty?
        write_known_host_entries(scanned)
        puts "Pinned SSH host key for #{host} in #{@config.ssh_known_hosts_path}."
        return true
      end

      return true if existing == scanned

      warn_out("SSH host key mismatch for #{host}. Replacing cached key (VM was likely recreated).")
      write_known_host_entries(scanned)
      true
    end

    def scan_host_keys(host)
      stdout, stderr, status = Open3.capture3(
        'ssh-keyscan', '-T', @config.ssh_connect_timeout.to_s,
        '-p', @config.ssh_port.to_s, host
      )
      unless status.success?
        warn_out("ssh-keyscan not ready yet: #{stderr.strip}") unless stderr.to_s.strip.empty?
        return []
      end
      stdout.lines.map(&:strip).reject { |l| l.empty? || l.start_with?('#') }.sort.uniq
    rescue Errno::ENOENT
      raise Error, 'ssh-keyscan is required to pin SSH host keys but was not found in PATH.'
    end

    def delete_known_hosts_file
      File.delete(@config.ssh_known_hosts_path) if File.exist?(@config.ssh_known_hosts_path)
    rescue Errno::EACCES => e
      raise Error, "Cannot delete SSH known_hosts file #{@config.ssh_known_hosts_path}: #{e.message}"
    end

    def known_host_entries
      path = @config.ssh_known_hosts_path
      return [] unless File.exist?(path)

      File.readlines(path, chomp: true).map(&:strip).reject(&:empty?).sort.uniq
    rescue Errno::EACCES => e
      raise Error, "Cannot read SSH known_hosts file #{path}: #{e.message}"
    end

    def write_known_host_entries(entries)
      path = @config.ssh_known_hosts_path
      FileUtils.mkdir_p(File.dirname(path))
      temp = "#{path}.tmp"
      File.write(temp, "#{entries.join("\n")}\n")
      File.chmod(0o600, temp)
      File.rename(temp, path)
    rescue Errno::EACCES => e
      raise Error, "Cannot write SSH known_hosts file #{path}: #{e.message}"
    end

    private

    def warn_out(msg)
      @out.puts("WARN: #{msg}")
    end
  end
end