diff --git a/etc/openqa/openqa.ini b/etc/openqa/openqa.ini index 69d23008b9b9..c372681f9295 100644 --- a/etc/openqa/openqa.ini +++ b/etc/openqa/openqa.ini @@ -296,3 +296,14 @@ concurrent = 0 # lookup_depth = 10 # Specify at how many state changes the search will be aborted (state = combination of failed/softfailed/skipped modules): # state_changes_limit = 3 + +# Configuration for the OBS rsync plugin +[obs_rsync] +project_status_url="$obs_instance/public/build/%%PROJECT/_result"; +concurrency=2 +queue_limit=200 +retry_interval=60 +retry_max_count=2 +home= +username=openqa-user +ssh_key_file=~/.ssh/id_rsa diff --git a/lib/OpenQA/Setup.pm b/lib/OpenQA/Setup.pm index 41bff36dfe5c..8dfb4f6071bf 100644 --- a/lib/OpenQA/Setup.pm +++ b/lib/OpenQA/Setup.pm @@ -132,6 +132,8 @@ sub read_config ($app) { queue_limit => 200, concurrency => 2, project_status_url => '', + username => '', + ssh_key_file => '' }, cleanup => { concurrent => 0, diff --git a/lib/OpenQA/WebAPI/Plugin/ObsRsync.pm b/lib/OpenQA/WebAPI/Plugin/ObsRsync.pm index 6ac8edd5317d..dba286f10196 100644 --- a/lib/OpenQA/WebAPI/Plugin/ObsRsync.pm +++ b/lib/OpenQA/WebAPI/Plugin/ObsRsync.pm @@ -8,6 +8,7 @@ use Mojo::File; use Mojo::URL; use Mojo::UserAgent; use POSIX 'strftime'; +use experimental 'signatures'; use OpenQA::Log qw(log_error); @@ -45,6 +46,8 @@ sub register { } else { $app->helper('obs_rsync.home' => sub { shift->app->config->{obs_rsync}->{home} }); + $app->helper('obs_rsync.username' => sub { shift->app->config->{obs_rsync}->{username} }); + $app->helper('obs_rsync.ssh_key_file' => sub { shift->app->config->{obs_rsync}->{ssh_key_file} }); $app->helper('obs_rsync.concurrency' => sub { shift->app->config->{obs_rsync}->{concurrency} }); $app->helper('obs_rsync.retry_interval' => sub { shift->app->config->{obs_rsync}->{retry_interval} }); $app->helper('obs_rsync.retry_max_count' => sub { shift->app->config->{obs_rsync}->{retry_max_count} }); @@ -58,7 +61,7 @@ sub register { my $repo = $helper->get_api_repo($alias); my $url = $helper->get_api_dirty_status_url($project); return undef unless $url; - my @res = $self->_is_obs_project_status_dirty($url, $project, $repo); + my @res = $self->_is_obs_project_status_dirty($url, $project, $repo, $helper); if ($trace && scalar @res > 1 && $res[1]) { # ignore potential errors because we use this only for cosmetic rendering open(my $fh, '>', Mojo::File->new($c->obs_rsync->home, $project, $dirty_status_filename)) @@ -165,11 +168,29 @@ sub register { # try to determine whether project is dirty # undef means status is unknown sub _is_obs_project_status_dirty { - my ($self, $url, $project, $repo) = @_; + my ($self, $url, $project, $repo, $helper) = @_; return undef unless $url; + # Use only one UserAgent my $ua = $self->{ua} ||= Mojo::UserAgent->new; - my $res = $ua->get($url)->result; + my $tx = $ua->get($url); + my $res = $tx->res; + # Retry if authentication is required + if ($res->code == 401) { + my $username = $helper->username; + my $ssh_key_file = $helper->ssh_key_file; + + my $auth_header = _bs_ssh_auth($res->headers->www_authenticate, $username, $ssh_key_file); + + # Reassign the results + $tx = $ua->get( + $url, + { + Authorization => $auth_header + }); + $res = $tx->result; + } + return undef unless $res->is_success; return _parse_obs_response_dirty($res, $repo); } @@ -515,4 +536,26 @@ sub _for_every_batch { return @ret; } +# Based on https://www.suse.com/c/multi-factor-authentication-on-suses-build-service/ +sub _bs_ssh_sign ($key, $realm, $value) { + + # This needs to be a bit portable for CI testing + my $tmp = Mojo::File::tempfile->spew($value); + my @lines = split "\n", qx/ssh-keygen -Y sign -f "$key" -q -n "$realm" < $tmp/; + shift @lines; + pop @lines; + return join '', @lines; +} + +sub _bs_ssh_auth ($challenge, $user, $key) { + die "Unexpected OBS challenge: $challenge" unless $challenge =~ /realm="([^"]+)".*headers="\(created\)"/; + my $realm = $1; + + my $now = time(); + my $signature = _bs_ssh_sign($key, $realm, "(created): $now"); + return qq{Signature keyId="$user",algorithm="ssh",signature="$signature",headers="(created)",created="$now"}; +} + + + 1; diff --git a/t/ui/27-plugin_obs_rsync_obs_status.t b/t/ui/27-plugin_obs_rsync_obs_status.t index d9e47c076b1b..ecb75e87d3a7 100644 --- a/t/ui/27-plugin_obs_rsync_obs_status.t +++ b/t/ui/27-plugin_obs_rsync_obs_status.t @@ -15,6 +15,16 @@ use Mojo::Server::Daemon; use Mojo::IOLoop::Server; use Mojo::IOLoop::ReadWriteProcess 'process'; use Mojo::IOLoop::ReadWriteProcess::Session 'session'; +use Test::MockModule; + +my $mocked_time = 0; + +BEGIN { + *CORE::GLOBAL::time = sub { + return $mocked_time if $mocked_time; + return time(); + }; +} $SIG{INT} = sub { session->clean }; END { session->clean } @@ -71,10 +81,39 @@ my %fake_response_by_project = ( Proj0 => 'invalid XML', ); +my $auth_header_exact + = qq(Signature keyId="dummy-username",algorithm="ssh",) + . qq(signature="U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgSKpcECPm8Vjo9UznZS+M/QLjmXXmLzoBxkIbZ8Z/oPkAAAAaVXNlIHlvdXIgZGV2ZWxvcGVyIGFjY291bnQAAAAAAAAABnNoYTUxMgAAAFMAAAALc3NoLWVkMjU1MTkAAABA8cmvTy1PgpW2XhHWxQ1yw/wPGAfT2M3CGRJ3II7uT5Orqn1a0bWlo/lEV0WiqP+pPcQdajQ4a2YGJvpfzT1uBA==",) + . qq (headers="(created)",created="1664187470"); + note 'Starting fake API server'; my $server_instance = process sub { my $mock = Mojolicious->new; $mock->mode('test'); + $mock->routes->get( + '/public/build/ProjWithAuth/_result' => sub { + my $c = shift; + + if ($c->req->headers->authorization) { + return $c->render(status => 200, text => $fake_response_by_project{Proj1}); + } + + $c->res->headers->www_authenticate(qq(Signature realm="Use your developer account",headers="(created)")); + return $c->render(status => 401, text => 'login'); + }); + + $mock->routes->get( + '/public/build/ProjTestingSignature/_result' => sub { + my $c = shift; + + if ($c->req->headers->authorization && $auth_header_exact eq $c->req->headers->authorization) { + return $c->render(status => 200, text => $fake_response_by_project{Proj1}); + } + + $c->res->headers->www_authenticate(qq(Signature realm="Use your developer account",headers="(created)")); + return $c->render(status => 401, text => 'login'); + }); + for my $project (sort keys %fake_response_by_project) { $mock->routes->get( "/public/build/$project/_result" => sub { @@ -92,7 +131,27 @@ my $server_instance = process sub { $server_instance->set_pipes(0)->start; wait_for_or_bail_out { IO::Socket::INET->new(PeerAddr => '127.0.0.1', PeerPort => $port) } 'API'; -my ($t, $tempdir, $home, $params) = setup_obs_rsync_test(url => $url); +use Mojo::File qw(path tempfile); + +my $ssh_keyfile = tempfile(); +# using the key from [0] to have a reproduceable output. +$ssh_keyfile->spew(< $url, + config => { + username => 'dummy-username', + ssh_key_file => path($ssh_keyfile), + }); my $app = $t->app; my $helper = $app->obs_rsync; @@ -153,5 +212,21 @@ $t->get_ok('/admin/obs_rsync/queue')->status_is(200, 'jobs list')->content_like( $t->get_ok('/admin/obs_rsync/')->status_is(200, 'project list')->content_like(qr/published/)->content_like(qr/dirty/) ->content_like(qr/publishing/); +subtest 'test build service ssh authentication' => sub { + is($helper->is_status_dirty('ProjWithAuth'), 1, 're-authenticate with ssh auth'); +}; + +subtest 'test build service authentication signature generation' => sub { + $mocked_time = 1664187470; + note "time right now: " . time(); + + is(time(), $mocked_time, 'Time is not frozen!'); + + is($helper->is_status_dirty('ProjTestingSignature'), 1, 'signature matches fixture'); + $mocked_time = undef; +}; + $server_instance->stop; done_testing(); + +# [0]: https://www.suse.com/c/multi-factor-authentication-on-suses-build-service/