diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb index 1638a9307b482c3ef9e99122a9e1b3cfa8414919..3b6f0984d84e1c50fe59d52a02badf409927d29d 100644 --- a/app/models/remote_mirror.rb +++ b/app/models/remote_mirror.rb @@ -24,6 +24,7 @@ class RemoteMirror < ApplicationRecord validates :project, presence: true validates :url, presence: true, public_url: { schemes: Project::VALID_MIRROR_PROTOCOLS, allow_blank: true, enforce_user: true } validates :only_protected_branches, inclusion: { in: [true, false], message: :blank } + validate :validate_ssh_known_hosts_format before_validation :store_credentials after_update :reset_fields, if: :saved_change_to_mirror_url? @@ -253,6 +254,20 @@ def store_credentials self.credentials = self.credentials end + def validate_ssh_known_hosts_format + return if ssh_known_hosts.blank? + + fingerprints = ::SshHostKey.fingerprint_host_keys(ssh_known_hosts) + + # Count valid lines vs total lines + total_lines = ssh_known_hosts.split("\n").reject(&:blank?).count + valid_count = fingerprints.count + + if valid_count < total_lines + errors.add(:ssh_known_hosts, _('contains invalid SSH public key format')) + end +end + # The remote URL omits any password if SSH public-key authentication is in use def remote_url return url unless ssh_key_auth? && password.present? diff --git a/app/models/remote_mirrors/attributes.rb b/app/models/remote_mirrors/attributes.rb index 250dc943803945895549690777c9b2ebe05b4739..22aad673490d74a37b8a87ec445e733217d84114 100644 --- a/app/models/remote_mirrors/attributes.rb +++ b/app/models/remote_mirrors/attributes.rb @@ -19,7 +19,14 @@ def initialize(attrs) end def allowed - attrs.slice(*keys) + allowed_attrs = attrs.slice(*keys) + + # Convert ssh_known_hosts array to newline-separated string + if allowed_attrs[:ssh_known_hosts].is_a?(Array) + # Filter out nil/empty values and validate format + valid_keys = allowed_attrs[:ssh_known_hosts].compact.reject(&:blank?) + allowed_attrs[:ssh_known_hosts] = valid_keys.join("\n") + end end def keys diff --git a/app/serializers/remote_mirror_entity.rb b/app/serializers/remote_mirror_entity.rb index 7eddb3fef4a43c2f4e2408c623c94b3da97fd5d8..e3d3e632bfc5b7f34534a767c84878876c9438ba 100644 --- a/app/serializers/remote_mirror_entity.rb +++ b/app/serializers/remote_mirror_entity.rb @@ -12,6 +12,10 @@ class RemoteMirrorEntity < Grape::Entity expose :ssh_known_hosts_fingerprints do |remote_mirror| remote_mirror.ssh_known_hosts_fingerprints.as_json end + + expose :host_keys do |remote_mirror| + remote_mirror.ssh_known_hosts_fingerprints.as_json + end end RemoteMirrorEntity.prepend_mod diff --git a/app/services/remote_mirrors/create_service.rb b/app/services/remote_mirrors/create_service.rb index 8819cdfc0fb76fee2250d0f220dc510a645d0c5d..0e31052d70a1fff1753a91959f757d18c7974c85 100644 --- a/app/services/remote_mirrors/create_service.rb +++ b/app/services/remote_mirrors/create_service.rb @@ -5,9 +5,15 @@ class CreateService < BaseService def execute return ServiceResponse.error(message: _('Access Denied')) unless allowed? - remote_mirror = project.remote_mirrors.create(allowed_attributes) + remote_mirror = project.remote_mirrors.build + remote_mirror.assign_attributes(allowed_attributes) - if remote_mirror.persisted? + # Auto-detect host keys if ssh_known_hosts is empty/omitted and URL is SSH + if remote_mirror.ssh_mirror_url? && remote_mirror.ssh_known_hosts.blank? + detect_and_set_host_keys(remote_mirror) + end + + if remote_mirror.save ServiceResponse.success(payload: { remote_mirror: remote_mirror }) else ServiceResponse.error(message: remote_mirror.errors) @@ -16,6 +22,18 @@ def execute private + def detect_and_set_host_keys(remote_mirror) + lookup = ::SshHostKey.new(project: project, url: remote_mirror.url) + remote_mirror.ssh_known_hosts = lookup.known_hosts if lookup.known_hosts.present? + rescue StandardError => e + Gitlab::AppJsonLogger.warn( + message: 'Failed to auto-detect SSH host keys', + error: e.message, + project_id: project.id, + url: remote_mirror.url + ) + end + def allowed_attributes RemoteMirrors::Attributes.new(params).allowed end diff --git a/app/services/remote_mirrors/update_service.rb b/app/services/remote_mirrors/update_service.rb index dc7de04576c85cdbd9dabe730ceb1ae58e48d08e..814ecb53c02dea9ae7dceb0cfe73c262a4e8d4b0 100644 --- a/app/services/remote_mirrors/update_service.rb +++ b/app/services/remote_mirrors/update_service.rb @@ -7,7 +7,9 @@ def execute(remote_mirror) return ServiceResponse.error(message: _('Remote mirror is missing')) unless remote_mirror return ServiceResponse.error(message: _('Project mismatch')) unless remote_mirror.project == project - if remote_mirror.update(allowed_attributes) + remote_mirror.assign_attributes(allowed_attributes) + + if remote_mirror.save ServiceResponse.success(payload: { remote_mirror: remote_mirror }) else ServiceResponse.error(message: remote_mirror.errors) diff --git a/doc/api/remote_mirrors.md b/doc/api/remote_mirrors.md index 9b02e780adb2574b4477918e0dc57b6dec826db1..6aaec44cd9051e4a83b3c2a7dfd6d936e93747f9 100644 --- a/doc/api/remote_mirrors.md +++ b/doc/api/remote_mirrors.md @@ -294,6 +294,88 @@ Example response: } ``` +## SSH Host Keys + +When creating a remote mirror with an SSH URL, GitLab can automatically detect the remote server's SSH host keys. This matches the behavior of the web interface where "Detect host keys" is the default option. + +### Auto-detection (Recommended) + +When creating a mirror with an SSH URL and no explicit `ssh_known_hosts`, GitLab automatically detects and stores the host keys: + +```shell +curl --request POST \ + --header "PRIVATE-TOKEN: " \ + --header "Content-Type: application/json" \ + --data '{ + "url": "ssh://git@gitlab.com/user/repo.git", + "auth_method": "ssh_public_key", + "enabled": true + }' \ + --url "https://gitlab.example.com/api/v4/projects/42/remote_mirrors" +``` + +Response includes auto-detected host keys: + +```json +{ + "id": 101486, + "url": "ssh://git@gitlab.com/user/repo.git", + "auth_method": "ssh_public_key", + "host_keys": [ + { + "fingerprint_sha256": "SHA256:nThbg6kXUpJWGl7E1IGOCspRomTxdCARLviKw6E5SY8" + }, + { + "fingerprint_sha256": "SHA256:HbW3g8zUjNSksFbqTiUWPWg2Bq1x8xdGUrliXFzSnUw" + }, + { + "fingerprint_sha256": "SHA256:p2QAMXNIC1TJYWeIOttrVc98/R1BUFWu3/LiyKgUfQM" + } + ] +} +``` + +### Explicit Host Keys + +You can also provide SSH public keys explicitly when creating or updating a mirror: + +```shell +curl --request POST \ + --header "PRIVATE-TOKEN: " \ + --header "Content-Type: application/json" \ + --data '{ + "url": "ssh://git@gitlab.com/user/repo.git", + "auth_method": "ssh_public_key", + "ssh_known_hosts": [ + "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCsj2bNKTBSpIYDEGk9KxsGh3mySTRgMtXL583qmBpzeQ+jqCMRgBqB98u3z++J1sKlXHWfM9dyhSevkMwSbhoR8XIq/U0tCNyokEi/ueaBMCvbcTHhO7FcwzY92WK4Yt0aGROY5qX2UKSeOvuP4D6TPqKF1onrSzH9bx9XUf2lEdWT/ia1NEKjunUqu1xOB/StKDHMoX4/OKyIzuS0q/T1zOATthvasJFoPrAjkohTyaDUz2LN5JoH839hViyEG82yB+MjcFV5MU3N1l1QL3cVUCh93xSaua1N85qivl+siMkPGbO5xR/En4iEY6K2XPASUEMaieWVNTRCtJ4S8H+9", + "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFSMqzJeV9rUzU4kWitGjeR4PWSa29SPqJ1fVkhtj3Hw9xjLVXVYrU9QlYWrOLXBpQ6KWjbjTDTdDkoohFzgbEY=", + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfuCHKVTjquxvt6CM6tdG4SLp1Btn/nOeHHE5UOzRdf" + ], + "enabled": true + }' \ + --url "https://gitlab.example.com/api/v4/projects/42/remote_mirrors" +``` + +The API derives SHA256 fingerprints from the provided keys and returns them as `host_keys`. + +### Updating Host Keys + +To update the host keys for an existing mirror: + +```shell +curl --request PUT \ + --header "PRIVATE-TOKEN: " \ + --header "Content-Type: application/json" \ + --data '{ + "ssh_known_hosts": [ + "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCsj2bNKTBSpIYDEGk9KxsGh3mySTRgMtXL583qmBpzeQ+jqCMRgBqB98u3z++J1sKlXHWfM9dyhSevkMwSbhoR8XIq/U0tCNyokEi/ueaBMCvbcTHhO7FcwzY92WK4Yt0aGROY5qX2UKSeOvuP4D6TPqKF1onrSzH9bx9XUf2lEdWT/ia1NEKjunUqu1xOB/StKDHMoX4/OKyIzuS0q/T1zOATthvasJFoPrAjkohTyaDUz2LN5JoH839hViyEG82yB+MjcFV5MU3N1l1QL3cVUCh93xSaua1N85qivl+siMkPGbO5xR/En4iEY6K2XPASUEMaieWVNTRCtJ4S8H+9", + "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFSMqzJeV9rUzU4kWitGjeR4PWSa29SPqJ1fVkhtj3Hw9xjLVXVYrU9QlYWrOLXBpQ6KWjbjTDTdDkoohFzgbEY=", + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfuCHKVTjquxvt6CM6tdG4SLp1Btn/nOeHHE5UOzRdf" + ] + }' \ + --url "https://gitlab.example.com/api/v4/projects/42/remote_mirrors/101486" +``` + ## Update a remote mirror's attributes {{< history >}} diff --git a/lib/api/remote_mirrors.rb b/lib/api/remote_mirrors.rb index 4599f9c2ce6363a719fe7394b13bf49540b95b3f..34f9eff1afca85abc43e511bfd79c8782757fa54 100644 --- a/lib/api/remote_mirrors.rb +++ b/lib/api/remote_mirrors.rb @@ -94,6 +94,8 @@ def find_remote_mirror optional :enabled, type: Boolean, desc: 'Determines if the mirror is enabled', documentation: { example: false } optional :auth_method, type: String, desc: 'Determines the mirror authentication method', values: %w[ssh_public_key password] + optional :ssh_known_hosts, type: Array[String], desc: 'Array of SSH public keys for the remote mirror', + documentation: { example: ['ssh-rsa AAAAB3NzaC1yc2E...', 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5...'] } optional :keep_divergent_refs, type: Boolean, desc: 'Determines if divergent refs are kept on the target', documentation: { example: false } use :mirror_branches_setting @@ -127,6 +129,8 @@ def find_remote_mirror requires :mirror_id, type: String, desc: 'The ID of a remote mirror' optional :enabled, type: Boolean, desc: 'Determines if the mirror is enabled', documentation: { example: true } optional :auth_method, type: String, desc: 'Determines the mirror authentication method' + optional :ssh_known_hosts, type: Array[String], desc: 'Array of SSH public keys for the remote mirror', + documentation: { example: ['ssh-rsa AAAAB3NzaC1yc2E...', 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5...'] } optional :keep_divergent_refs, type: Boolean, desc: 'Determines if divergent refs are kept on the target', documentation: { example: false } use :mirror_branches_setting diff --git a/spec/models/remote_mirror_spec.rb b/spec/models/remote_mirror_spec.rb index 2e79daf7a281060943746df737ead518c2cbdc4b..4c7768243c7862a39eddde570310350f1dd0983b 100644 --- a/spec/models/remote_mirror_spec.rb +++ b/spec/models/remote_mirror_spec.rb @@ -11,6 +11,29 @@ it { is_expected.to allow_value(true, false).for(:only_protected_branches) } it { is_expected.not_to allow_value(nil).for(:only_protected_branches) } it { is_expected.to validate_presence_of(:project) } + + describe 'ssh_known_hosts validation' do + it 'accepts valid SSH public keys' do + ssh_key = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCsj2bNKTBSpIYDEGk9KxsGh3mySTRgMtXL583qmBpzeQ+jqCMRgBqB98u3z++J1sKlXHWfM9dyhSevkMwSbhoR8XIq/U0tCNyokEi/ueaBMCvbcTHhO7FcwzY92WK4Yt0aGROY5qX2UKSeOvuP4D6TPqKF1onrSzH9bx9XUf2lEdWT/ia1NEKjunUqu1xOB/StKDHMoX4/OKyIzuS0q/T1zOATthvasJFoPrAjkohTyaDUz2LN5JoH839hViyEG82yB+MjcFV5MU3N1l1QL3cVUCh93xSaua1N85qivl+siMkPGbO5xR/En4iEY6K2XPASUEMaieWVNTRCtJ4S8H+9' + mirror = build(:remote_mirror, ssh_known_hosts: ssh_key) + + expect(mirror).to be_valid + end + + it 'rejects invalid SSH key format' do + mirror = build(:remote_mirror, ssh_known_hosts: 'invalid-key-format') + + expect(mirror).not_to be_valid + expect(mirror.errors[:ssh_known_hosts]).to match_array(['contains invalid SSH public key format']) + end + + it 'accepts multiple valid SSH keys' do + ssh_keys = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCsj2bNKTBSpIYDEGk9KxsGh3mySTRgMtXL583qmBpzeQ+jqCMRgBqB98u3z++J1sKlXHWfM9dyhSevkMwSbhoR8XIq/U0tCNyokEi/ueaBMCvbcTHhO7FcwzY92WK4Yt0aGROY5qX2UKSeOvuP4D6TPqKF1onrSzH9bx9XUf2lEdWT/ia1NEKjunUqu1xOB/StKDHMoX4/OKyIzuS0q/T1zOATthvasJFoPrAjkohTyaDUz2LN5JoH839hViyEG82yB+MjcFV5MU3N1l1QL3cVUCh93xSaua1N85qivl+siMkPGbO5xR/En4iEY6K2XPASUEMaieWVNTRCtJ4S8H+9\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfuCHKVTjquxvt6CM6tdG4SLp1Btn/nOeHHE5UOzRdf" + mirror = build(:remote_mirror, ssh_known_hosts: ssh_keys) + + expect(mirror).to be_valid + end + end end describe 'URL validation' do diff --git a/spec/requests/api/remote_mirrors_spec.rb b/spec/requests/api/remote_mirrors_spec.rb index d14e8ee62a797b2b594392b4e61d67796ea94a56..864dfe597deab56fa962fc6b2be7616b44ce990e 100644 --- a/spec/requests/api/remote_mirrors_spec.rb +++ b/spec/requests/api/remote_mirrors_spec.rb @@ -193,6 +193,39 @@ expect(json_response['message']['only_protected_branches']).to match_array(["can't be blank"]) end end + + context 'with ssh_known_hosts parameter' do + let(:ssh_key_rsa) { 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCsj2bNKTBSpIYDEGk9KxsGh3mySTRgMtXL583qmBpzeQ+jqCMRgBqB98u3z++J1sKlXHWfM9dyhSevkMwSbhoR8XIq/U0tCNyokEi/ueaBMCvbcTHhO7FcwzY92WK4Yt0aGROY5qX2UKSeOvuP4D6TPqKF1onrSzH9bx9XUf2lEdWT/ia1NEKjunUqu1xOB/StKDHMoX4/OKyIzuS0q/T1zOATthvasJFoPrAjkohTyaDUz2LN5JoH839hViyEG82yB+MjcFV5MU3N1l1QL3cVUCh93xSaua1N85qivl+siMkPGbO5xR/En4iEY6K2XPASUEMaieWVNTRCtJ4S8H+9' } + let(:ssh_key_ed25519) { 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfuCHKVTjquxvt6CM6tdG4SLp1Btn/nOeHHE5UOzRdf' } + + it 'creates a remote mirror with valid ssh_known_hosts' do + project.add_maintainer(user) + + post api(route, user), params: { + url: 'ssh://git@example.com/user/repo.git', + auth_method: 'ssh_public_key', + ssh_known_hosts: [ssh_key_rsa, ssh_key_ed25519], + enabled: true + } + + expect(response).to have_gitlab_http_status(:success) + expect(json_response['host_keys']).to have_length(2) + end + + it 'rejects invalid ssh_known_hosts' do + project.add_maintainer(user) + + post api(route, user), params: { + url: 'ssh://git@example.com/user/repo.git', + auth_method: 'ssh_public_key', + ssh_known_hosts: ['invalid-key-format'], + enabled: true + } + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['message']['ssh_known_hosts']).to match_array(['contains invalid SSH public key format']) + end + end end describe 'PUT /projects/:id/remote_mirrors/:mirror_id' do @@ -245,6 +278,32 @@ expect(json_response['message']['only_protected_branches']).to match_array(["can't be blank"]) end end + + context 'with ssh_known_hosts parameter' do + let(:ssh_key_rsa) { 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCsj2bNKTBSpIYDEGk9KxsGh3mySTRgMtXL583qmBpzeQ+jqCMRgBqB98u3z++J1sKlXHWfM9dyhSevkMwSbhoR8XIq/U0tCNyokEi/ueaBMCvbcTHhO7FcwzY92WK4Yt0aGROY5qX2UKSeOvuP4D6TPqKF1onrSzH9bx9XUf2lEdWT/ia1NEKjunUqu1xOB/StKDHMoX4/OKyIzuS0q/T1zOATthvasJFoPrAjkohTyaDUz2LN5JoH839hViyEG82yB+MjcFV5MU3N1l1QL3cVUCh93xSaua1N85qivl+siMkPGbO5xR/En4iEY6K2XPASUEMaieWVNTRCtJ4S8H+9' } + + it 'updates ssh_known_hosts' do + project.add_maintainer(user) + + put api(route, user), params: { + ssh_known_hosts: [ssh_key_rsa] + } + + expect(response).to have_gitlab_http_status(:success) + expect(json_response['host_keys']).to have_length(1) + end + + it 'rejects invalid ssh_known_hosts' do + project.add_maintainer(user) + + put api(route, user), params: { + ssh_known_hosts: ['not-a-valid-key'] + } + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['message']['ssh_known_hosts']).to match_array(['contains invalid SSH public key format']) + end + end end describe 'DELETE /projects/:id/remote_mirrors/:mirror_id' do