Skip to content

Commit

Permalink
Add support for SSH Authentication to ObsRsync Plugin
Browse files Browse the repository at this point in the history
ObsRsync Plugin now supports the new authentication mechanism of Build
Service.

Heavily inspired by:
- https://github.com/openSUSE/obs-build/blob/master/PBuild/SigAuth.pm
- openSUSE/cavil@583009d
- https://www.suse.com/c/multi-factor-authentication-on-suses-build-service/

Addresses poo#139073
  • Loading branch information
josegomezr committed Nov 14, 2023
1 parent e6799a9 commit 6be5a9d
Show file tree
Hide file tree
Showing 4 changed files with 135 additions and 4 deletions.
11 changes: 11 additions & 0 deletions etc/openqa/openqa.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions lib/OpenQA/Setup.pm
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ sub read_config ($app) {
queue_limit => 200,
concurrency => 2,
project_status_url => '',
username => '',
ssh_key_file => ''
},
cleanup => {
concurrent => 0,
Expand Down
49 changes: 46 additions & 3 deletions lib/OpenQA/WebAPI/Plugin/ObsRsync.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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} });
Expand All @@ -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))
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
77 changes: 76 additions & 1 deletion t/ui/27-plugin_obs_rsync_obs_status.t
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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 {
Expand All @@ -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(<<EOF);
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBIqlwQI+bxWOj1TOdlL4z9AuOZdeYvOgHGQhtnxn+g+QAAAJiRS1EekUtR
HgAAAAtzc2gtZWQyNTUxOQAAACBIqlwQI+bxWOj1TOdlL4z9AuOZdeYvOgHGQhtnxn+g+Q
AAAECrZDKH46WiRLiazilOn4+BlnESdV8CNReMvlm2Pr6Yr0iqXBAj5vFY6PVM52UvjP0C
45l15i86AcZCG2fGf6D5AAAAE3NhbXBsZS1tZmEtZmxvd0BpYnMBAg==
-----END OPENSSH PRIVATE KEY-----
EOF


my ($t, $tempdir, $home, $params) = setup_obs_rsync_test(
url => $url,
config => {
username => 'dummy-username',
ssh_key_file => path($ssh_keyfile),
});
my $app = $t->app;
my $helper = $app->obs_rsync;

Expand Down Expand Up @@ -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/

0 comments on commit 6be5a9d

Please sign in to comment.