diff options
author | Nick Thomas <nick@gitlab.com> | 2018-10-24 18:03:00 +0300 |
---|---|---|
committer | Nick Thomas <nick@gitlab.com> | 2018-10-25 15:51:45 +0300 |
commit | 324ff19571cada7e148c53bb70e70f823eff4335 (patch) | |
tree | a9f906cda57278e97c2f78c4a734f750091e19f7 /app | |
parent | a1ee2072f1a7c197e13bd2d5f8ca59ad1deb1c49 (diff) |
Backport SSH host key detection code to CE
This functionality is needed for SSH push mirroring support, which is a
CE feature.
Diffstat (limited to 'app')
-rw-r--r-- | app/controllers/projects/mirrors_controller.rb | 16 | ||||
-rw-r--r-- | app/models/ssh_host_key.rb | 130 |
2 files changed, 146 insertions, 0 deletions
diff --git a/app/controllers/projects/mirrors_controller.rb b/app/controllers/projects/mirrors_controller.rb index 78d5faf2326..53176978416 100644 --- a/app/controllers/projects/mirrors_controller.rb +++ b/app/controllers/projects/mirrors_controller.rb @@ -44,6 +44,22 @@ class Projects::MirrorsController < Projects::ApplicationController redirect_to_repository_settings(project, anchor: 'js-push-remote-settings') end + def ssh_host_keys + lookup = SshHostKey.new(project: project, url: params[:ssh_url], compare_host_keys: params[:compare_host_keys]) + + if lookup.error.present? + # Failed to read keys + render json: { message: lookup.error }, status: :bad_request + elsif lookup.known_hosts.nil? + # Still working, come back later + render body: nil, status: :no_content + else + render json: lookup + end + rescue ArgumentError => err + render json: { message: err.message }, status: :bad_request + end + private def remote_mirror 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 |