You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
ngcpcfg/sbin/ngcp-sync-constants

775 lines
21 KiB

#!/usr/bin/perl -w
#----------------------------------------------------------------------
# Syncronizes passwords from constants.yml with MySQL
#----------------------------------------------------------------------
use strict;
use DBI qw(:sql_types);
use YAML::Tiny;
use Getopt::Long;
use Sys::Hostname;
use Data::Dumper;
use Socket;
use List::MoreUtils qw(any);
use Data::Validate::IP qw(is_ipv4 is_ipv6);
use IPC::System::Simple qw(system capturex);
#----------------------------------------------------------------------
use constant CONSTANTS_YML => "/etc/ngcp-config/constants.yml";
use constant NETWORK_YML => "/etc/ngcp-config/network.yml";
use constant MYSQL_CREDENTIALS => "/etc/mysql/sipwise.cnf";
use constant MYSQL_DATA => {
voisniff => { dbuser => 'dbpassword' },
cleanuptools => { dbuser => 'dbpassword' },
rsyslog => { dbuser => 'dbpassword' },
sems => { dbuser => 'dbpassword',
prepaid_dbuser => 'prepaid_dbpassword' },
rateomat => { accountingdb => { user => 'pass' } },
faxserver => { hylafax => { db_user => 'db_pass' } },
cdrexport => { dbuser => 'dbpassword' },
checktools => { dbuser => 'dbpassword' },
mysql => { repuser => 'reppassword' },
kamailio => { proxy => { dbrwuser => 'dbrwpw',
dbrouser => 'dbropw' } },
mediator => { dbuser => 'dbpassword' },
asterisk => { odbc => { dbuser => 'dbpassword' } },
ossbss => { provisioning => {
billingdb => { user => 'pass' } } },
prosody => { dbuser => 'dbpassword' },
database => { debian => { dbuser => 'dbpassword' } },
};
use constant COPY_PASSWORDS => [ # pairs of from/to
{ rateomat => { accountingdb => { user => 'pass' }}},
{ rateomat => { billingdb => { user => 'pass' }}},
{ kamailio => { proxy => { dbrwuser => 'dbrwpw' }}},
{ kamailio => { lb => { dbrwuser => 'dbrwpw' }}},
{ kamailio => { proxy => { dbrouser => 'dbropw' }}},
{ kamailio => { lb => { dbrouser => 'dbropw' }}},
{ ossbss => { provisioning => { billingdb => { user => 'pass' }}}},
{ ossbss => { provisioning => { database => { user => 'pass' }}}},
{ ossbss => { provisioning => { billingdb => { user => 'pass' }}}},
{ ossbss => { provisioning => { openserdb => { user => 'pass' }}}},
{ ossbss => { provisioning => { billingdb => { user => 'pass' }}}},
{ reminder => { dbuser => 'dbpassword' }}
];
use constant NGCP_NODENAME => "/etc/ngcp_ha_node";
sub Usage {
print <<USAGE;
==
Syncronizes passwords from constants.yml with MySQL
==
$0 [options]
Options:
-help|-h|-? -- this help
-root|-r -- use mysql root user without password as DB credentials
-init-passwords|-i -- generate new passwords (constants.yml is updated)
-slave|-s -- sync repuser and replication on slave cluster instance
-no-grant-nodes -- skip copy grants for external nodes
-force-grants -- force grants (remove and create user if exists)
-test|-t -- test mode (no updates)
-verbose|-v -- verbose mode
USAGE
exit 0;
}
my $mysql_root = 0;
my $db_users = 'mysql';
my $skip_grant_nodes = 0;
my $slave = 0;
my $yml = {};
my $password_length = 20;
my $init_passwords = 0;
my $debug = 0;
my $test_mode = 0;
my $error = 0;
my $force_grants = 0;
GetOptions("h|?|help" => \&Usage,
"i|init-passwords" => \$init_passwords,
"r|root" => \$mysql_root,
"s|slave" => \$slave,
"no-grant-nodes" => \$skip_grant_nodes,
"t|test" => \$test_mode,
"force-grants" => \$force_grants,
"v|verbose" => \$debug);
#----------------------------------------------------------------------
sub pwgen {
my @list = ("a".."z",0..9,"A".."Z");
my @randoms;
for (1..$password_length) {
push @randoms, $list[int(rand($#list))];
}
return join "", @randoms;
}
sub get_mysql_credentials {
my $force_root = shift || 0;
my $mysql_user = 'sipwise';
my $mysql_pass;
if($force_root) {
$mysql_user='root';
return ($mysql_user,);
}
open(my $fh, "<", MYSQL_CREDENTIALS)
or die "Can't open ".MYSQL_CREDENTIALS.": ".$!;
($mysql_pass = <$fh>) =~ s/^.+='(.+?)'\s*$/$1/;
close $fh;
return ($mysql_user, $mysql_pass);
}
sub get_nodename {
my @lines = capturex( [ 0 ], "/usr/sbin/ngcp-nodename");
my $l = shift @lines;
chomp $l;
print "nodename=>$l\n" if $debug;
return $l;
}
sub connect_db {
my ($dbhost, $dbport, $force_root) = @_;
my ($mysql_user, $mysql_pass) = get_mysql_credentials($force_root);
my $dbh = DBI->connect("DBI:mysql:database=$db_users;host=$dbhost;port=$dbport",
$mysql_user, $mysql_pass,
{ PrintError => 1 })
or die "Can't connect to MySQL database $db_users: ". $DBI::errstr;
print "--> connected to $dbhost:$dbport as $mysql_user\n" if $debug;
return $dbh;
}
sub set_master {
my %args = @_;
my $dbh = $args{dbh};
my $mhost = $args{host};
my $mport = $args{port} || 3306;
my $user = $args{user};
my $pass = $args{pass};
my $sth_master = $dbh->prepare(<<SQL);
CHANGE MASTER TO
MASTER_HOST=?,
MASTER_PORT=?,
MASTER_USER=?,
MASTER_PASSWORD=?,
MASTER_CONNECT_RETRY=10;
SQL
print " ---> update master info to master_host='$mhost:$mport', master_user='$user', master_password='$pass'\n" if $debug;
$dbh->do('SLAVE STOP') unless $test_mode;
unless($test_mode) {
$sth_master->bind_param(1, $mhost);
$sth_master->bind_param(2, $mport, { TYPE => SQL_INTEGER });
$sth_master->bind_param(3, $user);
$sth_master->bind_param(4, $pass);
$sth_master->execute();
};
$dbh->do('SLAVE START') unless $test_mode;
$sth_master->finish;
}
sub set_master_pass {
my %args = @_;
my $dbh = $args{dbh};
my $user = $args{user};
my $pass = $args{pass};
my $sth_master = $dbh->prepare(<<SQL);
CHANGE MASTER TO
MASTER_USER=?,
MASTER_PASSWORD=?;
SQL
print " ---> update master info to master_user='$user', master_password='$pass'\n" if $debug;
$dbh->do('SLAVE STOP') unless $test_mode;
unless($test_mode) {
$sth_master->bind_param(1, $user);
$sth_master->bind_param(2, $pass);
$sth_master->execute();
};
$dbh->do('SLAVE START') unless $test_mode;
$sth_master->finish;
}
sub get_master_node {
my $mhost = get_nodename();
if($mhost eq 'sp1') {
$mhost = 'sp2';
} elsif($mhost eq 'sp2') {
$mhost = 'sp1';
}
else {
return;
}
return $mhost;
}
sub sync_repuser {
my %args = @_;
my $dbh = $args{dbh};
my $user = $args{user};
my $pass = $args{pass};
my $dbid = $args{dbid} || '';
my $minfo = "/var/lib/mysql$dbid/master.info";
my $mhost = $args{mhost};
my $mport = $args{mport} || 3306;
my $mhost_ip = $args{mhost};
unless ( is_ipv4($mhost) or is_ipv6($mhost) ) {
$mhost_ip = inet_ntoa(scalar(gethostbyname($mhost)));
print " --> $mhost => $mhost_ip\n" if $debug;
}
print " --> syncing replication user '$user' for master host '$mhost'\n" if $debug;
if(-f $minfo) {
my $mfh;
unless(open $mfh, '<', $minfo) {
print STDERR "Failed to open $minfo for syncing, replication not synced!\n";
$error = 1;
return;
}
my @minfo_content = <$mfh>;
close $mfh;
unless(any {/^(${mhost}|${mhost_ip})\s*$/} @minfo_content) {
print STDERR "${mhost} or ${mhost_ip} were not found at $minfo. Fix replication manually";
$error = 2;
return;
}
unless(any {/^${pass}\s*$/} @minfo_content) {
set_master_pass(
dbh => $dbh,
user => $user, pass => $pass
);
} else {
print " --> replication password in master.info already in sync for '$user' at master '$mhost'\n" if $debug;
}
} else {
set_master(
dbh => $dbh,
host => $mhost,
user => $user, pass => $pass
);
}
}
sub do_flush {
my $dbh = shift;
print "--> flush priveleges\n" if $debug;
$dbh->do('FLUSH PRIVILEGES')
or die "Can't flush MySQL privileges: ". $DBI::errstr;
}
sub grant_user {
my %args = @_;
my $dbh = $args{dbh};
my $user = $args{user};
my @hosts = @{ $args{hosts} };
my $repuser = $args{repuser} || 0;
my $reppwd = $args{reppwd};
my @grant;
if ($repuser) {
if (not $reppwd) {
die "No reppwd parameter";
}
push @grant, "SUPER,REPLICATION CLIENT,REPLICATION SLAVE,RELOAD";
push @grant, "IDENTIFIED BY '$reppwd'";
}
else {
push @grant, "ALL";
push @grant, "WITH GRANT OPTION"
}
#print "--> hosts:".Dumper(\@hosts)."\n" if $debug;
foreach my $host (@hosts) {
print "--> grant user $user ${grant[0]} from $host\n" if $debug;
$dbh->do("GRANT ${grant[0]} ON *.* TO '$user'\@'$host' ${grant[1]};") unless $test_mode;
}
}
sub copy_grants_user
{
my %args = @_;
my $dbh = $args{dbh};
my $user = $args{user};
my $pass = $args{pass};
my @hosts = @{ $args{hosts} };
my $host_orig = $args{host_orig} || 'localhost';
my $force = $args{force} || 0;
my $count;
# Select user
my $sth_sel = $dbh->prepare(<<SQL);
SELECT count(*) from user
WHERE User = ? and Host = ?
SQL
# Create user
my $sth_cu = $dbh->prepare(<<SQL);
CREATE USER ?@? IDENTIFIED BY ?;
SQL
# Copy db's privileges
my $sth_cd = $dbh->prepare(<<SQL);
INSERT INTO mysql.db
SELECT ?, Db, User, Select_priv, Insert_priv, Update_priv, Delete_priv, Create_priv,
Drop_priv, Grant_priv, References_priv, Index_priv, Alter_priv,
Create_tmp_table_priv, Lock_tables_priv, Create_view_priv,
Show_view_priv, Create_routine_priv, Alter_routine_priv, Execute_priv,
Event_priv, Trigger_priv
FROM mysql.db
WHERE User = ? AND Host = ?;
SQL
# Copy table's privileges
my $sth_ct = $dbh->prepare(<<SQL);
INSERT INTO mysql.tables_priv
SELECT ?, db, user, table_name, grantor, timestamp, table_priv, column_priv
FROM mysql.tables_priv
WHERE user = ? AND host = ?;
SQL
# Copy routine's privileges
my $sth_cr = $dbh->prepare(<<SQL);
INSERT INTO mysql.procs_priv
SELECT ?, db, user, routine_name, routine_type, grantor, proc_priv, timestamp
FROM mysql.procs_priv
WHERE user = ? AND host = ?;
SQL
foreach my $host (@hosts) {
if ($force) {
print "--> revoke and delete '${user}'\@'${host}'\n" if $debug;
$dbh->do("REVOKE ALL PRIVILEGES, GRANT OPTION FROM '${user}'\@'${host}';") unless $test_mode;
$dbh->do("DROP USER '${user}'\@'${host}';") unless $test_mode;
}
$sth_sel->execute($user, $host);
$count = $sth_sel->fetchrow_array();
if ($count == 0) {
print "--> create user '${user}'\@'${host}'\n" if $debug;
$sth_cu->execute($user, $host, $pass) unless $test_mode;
print "--> copy grants of user '${user}'\@'${host_orig}' to ${host}\n" if $debug;
$sth_cd->execute($host, $user, $host_orig) unless $test_mode;
$sth_ct->execute($host, $user, $host_orig) unless $test_mode;
$sth_cr->execute($host, $user, $host_orig) unless $test_mode;
}
else {
print "--> user ${user} already created. Set password again\n" if $debug;
$dbh->do("SET PASSWORD FOR '${user}'\@'${host}' = PASSWORD('${pass}');") unless $test_mode;
}
}
$sth_sel->finish;
$sth_cu->finish;
$sth_cd->finish;
$sth_ct->finish;
$sth_cr->finish;
}
sub copy_grants {
my %args = @_;
my $dbh = $args{dbh};
my @hosts = @{ $args{hosts} };
my $host_orig = $args{host_orig} || 'localhost';
my $force = $args{force} || 0;
my $yml_ref;
foreach my $key (keys %{MYSQL_DATA()}) {
# skip repuser
next if($key eq "mysql");
print $key." => " if $debug;
$yml_ref = $yml->[0]->{$key};
my $opts = { init_passwords => 0 };
my $data = get_user_pass(MYSQL_DATA->{$key}, $opts, $yml_ref);
#print "**data:".Dumper($data)."\n" if $debug;
foreach my $pair (@$data) {
my $user = $pair->{'user'};
my $pass = $pair->{'pass'};
next unless($user && $pass);
print "user:$user\n" if $debug;
copy_grants_user(
dbh => $dbh,
user => $user, pass => $pass,
hosts => \@hosts,
host_orig => $host_orig,
force => $force
);
}
}
return if $test_mode;
do_flush($dbh);
}
sub sync_user {
my %args = @_;
my $dbh = $args{dbh};
my $user = $args{user};
my $pass = $args{pass};
my $sth_sel = $dbh->prepare(<<SQL);
SELECT count(*) from user
WHERE User = ?
SQL
my $sth_upd = $dbh->prepare(<<SQL);
UPDATE user
SET Password=PASSWORD(?)
WHERE User = ?
SQL
$sth_sel->execute($user);
(my $count) = $sth_sel->fetchrow_array();
if ($count) {
print " ---> updating $user => $pass\n" if $debug;
$sth_upd->execute($pass, $user) unless $test_mode;
}
$sth_sel->finish;
$sth_upd->finish;
return if $test_mode;
do_flush($dbh);
}
sub sync_mysql_data {
my $dbh = shift;
my $yml_ref;
my $mhost = get_master_node();
my @hosts = ( $mhost, );
foreach my $key (keys %{MYSQL_DATA()}) {
$yml_ref = $yml->[0]->{$key};
print $key." => " if $debug;
my $opts = { init_passwords => $key eq "mysql" ? 0 : $init_passwords };
my $data = get_user_pass(MYSQL_DATA->{$key}, $opts, $yml_ref);
foreach my $pair (@$data) {
my $user = $pair->{'user'};
my $pass = $pair->{'pass'};
next unless ($user && $pass);
# special handling for getting replication set up or in sync
if($user eq 'replicator') {
if ($mhost) {
sync_repuser(
dbh => $dbh,
mhost => $mhost,
user => $user, pass => $pass
);
grant_user(
dbh => $dbh,
user => $user,
hosts => \@hosts,
repuser => 1,
reppwd => $pass
);
}
else {
print " --> skipping '$user' for unknown hostname '$mhost'\n" if $debug;
next;
}
}
sync_user( dbh => $dbh, user => $user, pass => $pass );
}
}
}
sub get_user_pass {
my $h_ref = shift || "No data passed to fetch_mysql_data";
my $opts = shift;
my $yml_ref = shift;
my @data;
foreach my $ref (keys %$h_ref) {
if (ref($ref) eq 'HASH') {
return get_user_pass($ref, $opts);
} elsif (ref($h_ref->{$ref}) eq 'HASH') {
print $ref." => " if $debug;
$yml_ref = $yml_ref->{$ref};
return get_user_pass($h_ref->{$ref}, $opts, $yml_ref);
} else {
print " ".$ref." -- ".$h_ref->{$ref} if $debug;
$opts->{'init_passwords'} and $yml_ref->{$h_ref->{$ref}} = pwgen();
my %pair;
$pair{'user'} = $yml_ref->{$ref};
$pair{'pass'} = $yml_ref->{$h_ref->{$ref}};
$pair{'user_key'} = $ref;
$pair{'pass_key'} = $h_ref->{$ref};
push @data, \%pair;
}
}
print "\n" if $debug;
return \@data;
}
sub copy_passwords {
my $yml_ref;
print "Copying internal passwords\n" if $debug;
my $saved_init_passwords = $init_passwords;
$init_passwords = 0;
my $pairs_count = $#{+COPY_PASSWORDS};
for (my $idx=0;$idx<$pairs_count+1;$idx++) {
next if $idx % 2;
die "Incorrect from/to pair" if $idx+1 >= $pairs_count+1;
$yml_ref = $yml->[0];
print "from => " if $debug;
my $from_data = get_user_pass(COPY_PASSWORDS->[$idx]);
die "No 'from' user/pass data available" if $#$from_data == -1;
$yml_ref = $yml->[0];
print "to => " if $debug;
my $to_data = get_user_pass(COPY_PASSWORDS->[$idx+1]);
die "No 'from' user/pass data available" if $#$to_data == -1;
my $user = $from_data->[0]{'user'};
my $pass = $from_data->[0]{'pass'};
my $user_key = $to_data->[0]{'user_key'};
my $pass_key = $to_data->[0]{'pass_key'};
if ($user && $pass && $user_key && $pass_key) {
print " ---> updating $user => $pass\n" if $debug;
$yml_ref->{$user_key} = $user;
$yml_ref->{$pass_key} = $pass;
}
}
$init_passwords = $saved_init_passwords;
}
sub do_pair_sync {
my $dbhost = $yml->[0]->{database}->{pair}->{dbhost} || "localhost";
my $dbport = $yml->[0]->{database}->{pair}->{dbport} || 3306;
system("/usr/share/ngcp-ngcpcfg/helper/check-for-mysql");
my $dbh = connect_db($dbhost, $dbport, $mysql_root);
eval {
$dbh->begin_work;
print "Syncing ".CONSTANTS_YML." -> MySQL ... ";
print "\n" if $debug;
sync_mysql_data($dbh);
if ($mysql_root) {
# mysql sipwise user
my ($mysql_user, $mysql_pass) = get_mysql_credentials();
my @hosts = ('localhost');
grant_user(dbh => $dbh, user => $mysql_user, hosts => \@hosts, repuser => 0 );
sync_user( dbh => $dbh, user => $mysql_user, pass => $mysql_pass );
}
};
if ($@) {
$dbh->rollback;
die "\nError during syncronization: " . $@;
} else {
$test_mode ? $dbh->rollback : $dbh->commit;
}
$dbh->disconnect if $dbh;
print "Done.\n";
}
sub get_slave_hosts {
my $self = shift;
my $network = new YAML::Tiny;
$network = YAML::Tiny->read(NETWORK_YML) || do {
print "Can't read network file: $!\n";
return;
};
my @hosts;
foreach my $host (keys %{$network->[0]->{hosts}})
{
push @hosts, $host unless($host =~ '^db\d+[ab]$');
}
return @hosts;
}
sub do_grant_nodes
{
my $dbhost = $yml->[0]->{database}->{pair}->{dbhost} || "localhost";
my $dbport = $yml->[0]->{database}->{pair}->{dbport} || 3306;
my $user = $yml->[0]->{mysql}->{repuser};
my $pwd = $yml->[0]->{mysql}->{reppassword};
my $hostname = hostname();
my @hosts;
return unless($hostname =~ '^db\d+[ab]$');
@hosts = get_slave_hosts($hostname);
if (not @hosts) {
print "skip do_grant_nodes\n";
return;
}
push @hosts, 'sp1';
push @hosts, 'sp2';
print "--> db cluster node $hostname detected. grant repo user\n" if $debug;
my $dbh = connect_db($dbhost, $dbport, $mysql_root);
eval {
$dbh->begin_work;
grant_user(dbh => $dbh, user => $user, hosts => \@hosts, repuser => 1, reppwd => $pwd );
# mysql sipwise user
my ($mysql_user, $mysql_pass) = get_mysql_credentials();
grant_user(dbh => $dbh, user => $mysql_user, hosts => \@hosts, repuser => 0 );
sync_user( dbh => $dbh, user => $mysql_user, pass => $mysql_pass );
# all mysql users except repuser
copy_grants(dbh => $dbh, hosts => \@hosts, force => $force_grants);
};
if ($@) {
$dbh->rollback;
die "\nError during syncronization: " . $@;
} else {
$test_mode ? $dbh->rollback : $dbh->commit;
}
$dbh->disconnect if $dbh;
}
sub do_slave_sync
{
my $dbhost = $yml->[0]->{database}->{local}->{dbhost};
my $dbport = $yml->[0]->{database}->{local}->{dbport};
my $mhost = $yml->[0]->{database}->{central}->{dbmaster};
my $mport = $yml->[0]->{database}->{central}->{dbport};
my $user = $yml->[0]->{mysql}->{repuser};
my $pass = $yml->[0]->{mysql}->{reppassword};
my $hostname = hostname();
if ( $yml->[0]->{database}->{central}->{dbhost} eq "localhost" ) {
print "database.central.dbhost is localhost. skipping.\n";
return
}
my $dbh = connect_db($dbhost, $dbport, 1);
eval {
$dbh->begin_work;
if($hostname =~ '^db\d+[ab]$') {
print "--> db cluster node $hostname detected. skipping slave config\n" if $debug;
}
else {
sync_repuser(
dbh => $dbh,
mhost => $mhost, mport => $mport,
user => $user, pass => $pass,
dbid => "2"
);
# mysql sipwise user
my ($mysql_user, $mysql_pass) = get_mysql_credentials();
my @hosts = ('localhost', 'sp1', 'sp2');
grant_user(dbh => $dbh, user => $mysql_user, hosts => \@hosts, repuser => 0 );
sync_user( dbh => $dbh, user => $mysql_user, pass => $mysql_pass );
# create localhost users if we need to recreate grants after dump excluding mysql table
# copy_grants(dbh => $dbh, hosts => \@hosts, host_orig => 'web1a', force => 1);
}
sync_user( dbh => $dbh, user => $user, pass => $pass );
};
if ($@) {
$dbh->rollback;
die "\nError during syncronization: " . $@;
} else {
$test_mode ? $dbh->rollback : $dbh->commit;
}
$dbh->disconnect if $dbh;
print "Done.\n";
}
sub main {
$yml = new YAML::Tiny;
$yml = YAML::Tiny->read(CONSTANTS_YML)
or die "Can't read constants file: $!\n";
if ($init_passwords and not $test_mode and not -w CONSTANTS_YML) {
die CONSTANTS_YML . " is not writable";
}
print "[TEST MODE]\n" if $test_mode;
if($slave) {
do_slave_sync();
print "Slave node Done\n";
return;
}
do_pair_sync();
do_grant_nodes() unless $skip_grant_nodes;
return unless $init_passwords;
copy_passwords();
return if $test_mode;
print "Writing new passwords into ".CONSTANTS_YML." ... ";
$yml->write(CONSTANTS_YML);
print "Done\n";
}
#----------------------------------------------------------------------
main();
exit $error;
__END__
=pod
=head1 NAME
ngcp-sync-constants - syncronizes passwords from constants.yml with MySQL
=head1 SYNOPSIS
ngcp-sync-constants [ options ... ]
=over 8
=item B<--root>
Use mysql root user without password as DB credentials
=item B<--init-passwords>
New passwords are generated (passwords for "mysql" is not generated to avoid replication problems)
=item B<--slave>
Sync repuser and check replication of the read-only dbcluster instance (mysqld2)
=item B<--no-grant-nodes>
Skip creation of grants for external nodes. This is useful on installation phase at sp2
=item B<--force-grants>
Remove and recreate users for slave hosts
=item B<--test>
No real updates, only for checks
=item B<--verbose>
Verbose mode where all changes are written to STDOUT
=back
=head1 DESCRIPTION
B<This program> reads constants.yml file, parses it and syncronizes all required passwords with MySQL
=head1 EXIT STATUS
=over 8
=item B<exit code 0>
Everything is ok
=item B<exit code != 0>
Something is wrong, an error message raises
=back
=head1 INCOMPATIBILITIES
No known at this time.
=head1 BUGS AND LIMITATIONS
Please report problems you notice to the Sipwise Development Team <support@sipwise.com>.
=head1 AUTHOR
Kirill Solomko <ksolomko@sipwise.com>
=head1 LICENSE AND COPYRIGHT
GPL-3+, Sipwise GmbH, Austria
=cut