diff options
Diffstat (limited to 'app/models/ssh_host_key.rb')
-rw-r--r-- | app/models/ssh_host_key.rb | 130 |
1 files changed, 130 insertions, 0 deletions
diff --git a/app/models/ssh_host_key.rb b/app/models/ssh_host_key.rb new file mode 100644 index 00000000000..b6844dbe870 --- /dev/null +++ b/app/models/ssh_host_key.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +# Detected SSH host keys are transiently stored in Redis +class SshHostKey + class Fingerprint < Gitlab::SSHPublicKey + attr_reader :index + + def initialize(key, index: nil) + super(key) + + @index = index + end + + def as_json(*) + { bits: bits, fingerprint: fingerprint, type: type, index: index } + end + end + + include ReactiveCaching + + self.reactive_cache_key = ->(key) { [key.class.to_s, key.id] } + + # Do not refresh the data in the background - it is not expected to change. + # This is achieved by making the lifetime shorter than the refresh interval. + self.reactive_cache_refresh_interval = 15.minutes + self.reactive_cache_lifetime = 10.minutes + + def self.find_by(opts = {}) + return nil unless opts.key?(:id) + + project_id, url = opts[:id].split(':', 2) + project = Project.find_by(id: project_id) + + project.presence && new(project: project, url: url) + end + + def self.fingerprint_host_keys(data) + return [] unless data.is_a?(String) + + data + .each_line + .each_with_index + .map { |line, index| Fingerprint.new(line, index: index) } + .select(&:valid?) + end + + attr_reader :project, :url, :compare_host_keys + + def initialize(project:, url:, compare_host_keys: nil) + @project = project + @url = normalize_url(url) + @compare_host_keys = compare_host_keys + end + + def id + [project.id, url].join(':') + end + + def as_json(*) + { + host_keys_changed: host_keys_changed?, + fingerprints: fingerprints, + known_hosts: known_hosts + } + end + + def known_hosts + with_reactive_cache { |data| data[:known_hosts] } + end + + def fingerprints + @fingerprints ||= self.class.fingerprint_host_keys(known_hosts) + end + + # Returns true if the known_hosts data differs from the version passed in at + # initialization as `compare_host_keys`. Comments, ordering, etc, is ignored + def host_keys_changed? + cleanup(known_hosts) != cleanup(compare_host_keys) + end + + def error + with_reactive_cache { |data| data[:error] } + end + + def calculate_reactive_cache + known_hosts, errors, status = + Open3.popen3({}, *%W[ssh-keyscan -T 5 -p #{url.port} -f-]) do |stdin, stdout, stderr, wait_thr| + stdin.puts(url.host) + stdin.close + + [ + cleanup(stdout.read), + cleanup(stderr.read), + wait_thr.value + ] + end + + # ssh-keyscan returns an exit code 0 in several error conditions, such as an + # unknown hostname, so check both STDERR and the exit code + if status.success? && !errors.present? + { known_hosts: known_hosts } + else + Rails.logger.debug("Failed to detect SSH host keys for #{id}: #{errors}") + + { error: 'Failed to detect SSH host keys' } + end + end + + private + + # Remove comments and duplicate entries + def cleanup(data) + data + .to_s + .each_line + .reject { |line| line.start_with?('#') || line.chomp.empty? } + .uniq + .sort + .join + end + + def normalize_url(url) + full_url = ::Addressable::URI.parse(url) + raise ArgumentError.new("Invalid URL") unless full_url&.scheme == 'ssh' + + Addressable::URI.parse("ssh://#{full_url.host}:#{full_url.inferred_port}") + rescue Addressable::URI::InvalidURIError + raise ArgumentError.new("Invalid URL") + end +end |