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
128
|
# 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
@out.flush
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
|