diff --git a/.github/workflows/centreon-perl-common-lib.yml b/.github/workflows/centreon-perl-common-lib.yml new file mode 100644 index 0000000000..6c684035c0 --- /dev/null +++ b/.github/workflows/centreon-perl-common-lib.yml @@ -0,0 +1,134 @@ +name: centreon-perl-common-lib + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +on: + workflow_dispatch: + pull_request: + paths: + - 'connectors/centreonPerlLibs/**' + push: + branches: + - develop + - master + paths: + - 'connectors/centreonPerlLibs/**' + +jobs: + get-environment: + uses: ./.github/workflows/get-environment.yml + with: + version_file: connectors/centreonPerlLibs/version.yaml + + package: + needs: [get-environment] + if: ${{ needs.get-environment.outputs.stability != 'stable' }} + runs-on: ubuntu-22.04 + strategy: + matrix: + include: + - package_extension: rpm + image: packaging-plugins-alma8 + distrib: el8 + - package_extension: rpm + image: packaging-plugins-alma9 + distrib: el9 + - package_extension: deb + image: packaging-plugins-bookworm + distrib: bookworm + - package_extension: deb + image: packaging-plugins-jammy + distrib: jammy + + container: + image: ${{ vars.DOCKER_INTERNAL_REGISTRY_URL }}/${{ matrix.image }} + credentials: + username: ${{ secrets.DOCKER_REGISTRY_ID }} + password: ${{ secrets.DOCKER_REGISTRY_PASSWD }} + + name: package ${{ matrix.distrib }} + + steps: + - name: Checkout sources + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Package + uses: ./.github/actions/package-nfpm + with: + nfpm_file_pattern: "connectors/centreonPerlLibs/packaging/centreon-vault-common-lib.yaml" + distrib: ${{ matrix.distrib }} + package_extension: ${{ matrix.package_extension }} + version: ${{ needs.get-environment.outputs.version }} + release: 1 + commit_hash: ${{ github.sha }} + cache_key: ${{ github.sha }}-${{ github.run_id }}-${{ matrix.package_extension }}-${{ matrix.distrib }} + rpm_gpg_key: ${{ secrets.RPM_GPG_SIGNING_KEY }} + rpm_gpg_signing_key_id: ${{ secrets.RPM_GPG_SIGNING_KEY_ID }} + rpm_gpg_signing_passphrase: ${{ secrets.RPM_GPG_SIGNING_PASSPHRASE }} + stability: ${{ needs.get-environment.outputs.stability }} + + deliver-rpm: + needs: [get-environment, package] + if: ${{ contains(fromJson('["testing", "unstable"]'), needs.get-environment.outputs.stability) }} + runs-on: [self-hosted, common] + + strategy: + matrix: + distrib: [el8, el9] + + steps: + - name: Checkout sources + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Delivery + uses: ./.github/actions/rpm-delivery + with: + module_name: centreon-perl-common-lib + distrib: ${{ matrix.distrib }} + cache_key: ${{ github.sha }}-${{ github.run_id }}-rpm-${{ matrix.distrib }} + stability: ${{ needs.get-environment.outputs.stability }} + artifactory_token: ${{ secrets.ARTIFACTORY_ACCESS_TOKEN }} + + deliver-deb: + needs: [get-environment, package] + if: ${{ contains(fromJson('["testing", "unstable"]'), needs.get-environment.outputs.stability) }} + runs-on: [self-hosted, common] + + strategy: + matrix: + distrib: [bullseye, bookworm, jammy] + + steps: + - name: Checkout sources + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Delivery + uses: ./.github/actions/deb-delivery + with: + module_name: centreon-perl-common-lib + distrib: ${{ matrix.distrib }} + cache_key: ${{ github.sha }}-${{ github.run_id }}-deb-${{ matrix.distrib }} + stability: ${{ needs.get-environment.outputs.stability }} + artifactory_token: ${{ secrets.ARTIFACTORY_ACCESS_TOKEN }} + + promote: + needs: [get-environment] + if: ${{ contains(fromJson('["stable"]'), needs.get-environment.outputs.stability) }} + runs-on: [self-hosted, common] + strategy: + matrix: + distrib: [el8, el9, bullseye, bookworm] + + steps: + - name: Checkout sources + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Promote ${{ matrix.distrib }} to stable + uses: ./.github/actions/promote-to-stable + with: + artifactory_token: ${{ secrets.ARTIFACTORY_ACCESS_TOKEN }} + module: centreon-perl-common + distrib: ${{ matrix.distrib }} + stability: ${{ needs.get-environment.outputs.stability }} diff --git a/connectors/centreonPerlLibs/packaging/centreon-perl-common-lib.yaml b/connectors/centreonPerlLibs/packaging/centreon-perl-common-lib.yaml new file mode 100644 index 0000000000..ca6ddac3c7 --- /dev/null +++ b/connectors/centreonPerlLibs/packaging/centreon-perl-common-lib.yaml @@ -0,0 +1,44 @@ +name: "centreon-perl-common-lib" +arch: "all" +platform: "linux" +version_schema: "none" +version: "${VERSION}" +release: "${RELEASE}${DIST}" +section: "default" +priority: "optional" +maintainer: "Centreon " +description: | + Perl library to communicate with an hashicorp vault API + Commit: @COMMIT_HASH@ +vendor: "Centreon" +homepage: "https://centreon.com" +license: "Apache-2.0" + +provides: + - centreon-perl-common-lib + +contents: + - src: "../src/" + dst: "/usr/share/perl5/vendor_perl" + packager: rpm + - src: "../src/" + dst: "/usr/share/perl5" + packager: deb + +overrides: + rpm: + depends: + - perl(Crypt::OpenSSL::AES) + - perl(JSON::XS) + - perl-Net-Curl + deb: + depends: + - libcrypt-openssl-aes-perl + - libio-socket-inet6-perl + - libjson-xs-perl + - libnet-curl-perl + +rpm: + signature: + key_file: ${RPM_SIGNING_KEY_FILE} + key_id: ${RPM_SIGNING_KEY_ID} diff --git a/connectors/centreonPerlLibs/src/centreon/common/centreonvault.pm b/connectors/centreonPerlLibs/src/centreon/common/centreonvault.pm new file mode 100644 index 0000000000..0a79b68240 --- /dev/null +++ b/connectors/centreonPerlLibs/src/centreon/common/centreonvault.pm @@ -0,0 +1,412 @@ +# +# Copyright 2024 Centreon (http://www.centreon.com/) +# +# Centreon is a full-fledged industry-strength solution that meets +# the needs in IT infrastructure and application monitoring for +# service performance. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +package centreon::common::centreonvault; + +use strict; +use warnings; + +use MIME::Base64; +use Crypt::OpenSSL::AES; +use Net::Curl::Easy qw(:constants); +use JSON::XS; + +my $VAULT_PATH_REGEX = qr/^secret::hashicorp_vault::([^:]+)::(.+)$/; + +sub new { + my ($class, %options) = @_; + my $self = bless \%options, $class; + # mandatory options: + # - logger: logger object + # - config_file: either path of a JSON vault config file or the configuration as a perl hash. + + $self->{enabled} = 1; + $self->{crypted_credentials} = 1; + + if ( !$self->init() ) { + $self->{enabled} = 0; + $self->{logger}->writeLogError("An error occurred in init() method. Centreonvault cannot be used."); + } + return $self; +} + + +sub init { + my ($self, %options) = @_; + + $self->check_options() or return undef; + + # for unit test purpose, if the config is given as an hash, we don't try to read the config file. + if (ref $self->{config_file} eq ref {}) { + $self->{vault_config} = $self->{config_file}; + } else { + # check if the following information is available + $self->{logger}->writeLogDebug("Reading Vault configuration from file " . $self->{config_file} . "."); + $self->{vault_config} = parse_json_file('json_file' => $self->{config_file}); + if (defined($self->{vault_config}->{error_message})) { + $self->{logger}->writeLogError("Error while parsing " . $self->{config_file} . ": " + . $self->{vault_config}->{error_message}); + return undef; + } + } + $self->check_configuration() or return undef; + + $self->{logger}->writeLogDebug("Vault configuration read. Name: " . $self->{vault_config}->{name} + . ". Url: " . $self->{vault_config}->{url} . "."); + + # Create the Curl object, it will be used several times + $self->{curl_easy} = Net::Curl::Easy->new(); + $self->{curl_easy}->setopt( CURLOPT_USERAGENT, "Centreon VMware daemon's centreonvault.pm"); + + return 1; +} + +sub check_options { + my ($self, %options) = @_; + + if ( !defined($self->{logger}) ) { + die "FATAL: No logger given to the constructor. Centreonvault cannot be used."; + } + if ( !defined($self->{config_file})) { + $self->{logger}->writeLogError("No config file given to the constructor. Centreonvault cannot be used."); + return undef; + } + if ( ! -f $self->{config_file} and ref $self->{config_file} ne ref {}) { + $self->{logger}->writeLogError("The given configuration file " . $self->{config_file} + . " does not exist. Centreonvault cannot be used."); + return undef; + } + + return 1; +} + +sub check_configuration { + my ($self, %options) = @_; + + if ( !defined($self->{vault_config}->{url}) || $self->{vault_config}->{url} eq '') { + $self->{logger}->writeLogInfo("Vault url is missing from configuration."); + $self->{vault_config}->{url} = '127.0.0.1'; + } + if ( !defined($self->{vault_config}->{port}) || $self->{vault_config}->{port} eq '') { + $self->{logger}->writeLogInfo("Vault port is missing from configuration."); + $self->{vault_config}->{port} = '443'; + } + + # Normally, the role_id and secret_id data are encrypted using AES wit the following information: + # firstKey = APP_SECRET (environment variable) + # secondKey = 'salt' (hashing) key given by vault.json configuration file + # both are base64 encoded + if ( !defined($self->{vault_config}->{salt}) || $self->{vault_config}->{salt} eq '') { + $self->{logger}->writeLogError("Vault environment does not seem complete: 'salt' attribute missing from " + . $self->{config_file} + . ". 'role_id' and 'secret_id' won't be decrypted, so they'll be used as they're stored in the vault config file."); + $self->{crypted_credentials} = 0; + $self->{hash_key} = ''; + } else { + $self->{hash_key} = $self->{vault_config}->{salt}; # key for sha3-512 hmac + } + + if ( !defined($ENV{'APP_SECRET'}) || $ENV{'APP_SECRET'} eq '' ) { + $self->{logger}->writeLogError("Vault environment does not seem complete. 'APP_SECRET' environment variable missing." + . " 'role_id' and 'secret_id' won't be decrypted, so they'll be used as they're stored in the vault config file."); + $self->{crypted_credentials} = 0; + $self->{encryption_key} = ''; + } else { + $self->{encryption_key} = $ENV{'APP_SECRET'}; # key for aes-256-cbc + } + + return 1; +} + +sub extract_and_decrypt { + my ($self, %options) = @_; + + my $input = decode_base64($options{data}); + $self->{logger}->writeLogDebug("data to extract and decrypt: '" . $options{data} . "'"); + + # with AES-256, the IV length must 16 bytes + my $iv_length = 16; + # extract the IV, the hashed data, the encrypted data + my $iv = substr($input, 0, $iv_length); # initialization vector + my $hashed_data = substr($input, $iv_length, 64); # hmac of the original data, for integrity control + my $encrypted_data = substr($input, $iv_length + 64); # data to decrypt + + # create the AES object + $self->{logger}->writeLogDebug( + "Creating the AES decryption object for initialization vector (IV) of length " + . length($iv) . "B, key of length " . length($self->{encryption_key}) . "B." + ); + my $cipher; + eval { + $cipher = Crypt::OpenSSL::AES->new( + decode_base64( $self->{encryption_key} ), + { + 'cipher' => 'AES-256-CBC', + 'iv' => $iv, + 'padding' => 1 + } + ); + }; + if ($@) { + $self->{logger}->writeLogError("There was an error while creating the AES object: " . $@); + return undef; + } + + # decrypt + $self->{logger}->writeLogDebug("Decrypting the data of length " . length($encrypted_data) . "B."); + my $decrypted_data; + eval {$decrypted_data = $cipher->decrypt($encrypted_data);}; + if ($@) { + $self->{logger}->writeLogError("There was an error while decrypting one of the AES-encrypted data: " . $@); + return undef; + } + + return $decrypted_data; +} + +sub authenticate { + my ($self) = @_; + + # initial value: assuming the role and secret id might not be encrypted + my $role_id = $self->{vault_config}->{role_id}; + my $secret_id = $self->{vault_config}->{secret_id}; + + + if ($self->{crypted_credentials}) { + # Then decrypt using https://github.com/perl-openssl/perl-Crypt-OpenSSL-AES + # keep the decrypted data in local variables so that they stay in memory for as little time as possible + $self->{logger}->writeLogDebug("Decrypting the credentials needed to authenticate to the vault."); + $role_id = $self->extract_and_decrypt( ('data' => $role_id )); + $secret_id = $self->extract_and_decrypt( ('data' => $secret_id )); + $self->{logger}->writeLogDebug("role_id and secret_id have been decrypted."); + } else { + $self->{logger}->writeLogDebug("role_id and secret_id are not crypted"); + } + + + # Authenticate to get the token + my $url = "https://" . $self->{vault_config}->{url} . ":" . $self->{vault_config}->{port} . "/v1/auth/approle/login"; + $self->{logger}->writeLogDebug("Authenticating to the vault server at URL: $url"); + $self->{curl_easy}->setopt( CURLOPT_URL, $url ); + + my $post_data = "role_id=$role_id&secret_id=$secret_id"; + my $auth_result_json; + # to get more details (in STDERR) + #$self->{curl_easy}->setopt(CURLOPT_VERBOSE, 1); + $self->{curl_easy}->setopt(CURLOPT_POST, 1); + $self->{curl_easy}->setopt(CURLOPT_POSTFIELDS, $post_data); + $self->{curl_easy}->setopt(CURLOPT_POSTFIELDSIZE, length($post_data)); + $self->{curl_easy}->setopt(CURLOPT_WRITEDATA(), \$auth_result_json); + + eval { + $self->{curl_easy}->perform(); + }; + if ($@) { + $self->{logger}->writeLogError("Error while authenticating to the vault: " . $@); + return undef; + } + + $self->{logger}->writeLogInfo("Authentication to the vault passed." ); + + my $auth_result_obj = transform_json_to_object($auth_result_json); + if (defined($auth_result_obj->{error_message})) { + $self->{logger}->writeLogError("Error while decoding JSON '$auth_result_json'. Message: " + . $auth_result_obj->{error_message}); + return undef; + } + + # store the token (.auth.client_token) and its expiration date (current date + .lease_duration) + my $expiration_epoch = -1; + my $lease_duration = $auth_result_obj->{auth}->{lease_duration}; + if ( defined($lease_duration) + && $lease_duration =~ /\d+/ + && $lease_duration > 0 ) { + $expiration_epoch = time() + $lease_duration; + } + $self->{auth} = { + 'token' => $auth_result_obj->{auth}->{client_token}, + 'expiration_epoch' => $expiration_epoch + }; + $self->{logger}->writeLogInfo("Authenticating worked. Token valid until " + . localtime($self->{auth}->{expiration_epoch})); + + return 1; +} + +sub is_token_still_valid { + my ($self) = @_; + if ( + !defined($self->{auth}) + || !defined($self->{auth}->{token}) + || $self->{auth}->{token} eq '' + || $self->{auth}->{expiration_epoch} <= time() + ) { + $self->{logger}->writeLogInfo("The token has expired or is invalid."); + return undef; + } + $self->{logger}->writeLogDebug("The token is still valid."); + return 1; +} + +sub get_secret { + my ($self, $secret) = @_; + + # if vault not enabled, return the secret unchanged + return $secret if ( ! $self->{enabled}); + + my ($secret_path, $secret_name) = $secret =~ $VAULT_PATH_REGEX; + if (!defined($secret_path) || !defined($secret_name)) { + $self->{logger}->writeLogInfo("A string given to get_secret does not look like a secret. Using it as a plain text credential?"); + return $secret; + } + $self->{logger}->writeLogDebug("Secret path: $secret_path - Secret name: $secret_name"); + + if (!defined($self->{auth}) || !$self->is_token_still_valid() ) { + $self->authenticate() or return $secret; + } + + # prepare the GET statement + my $get_result_json; + my $url = "https://" . $self->{vault_config}->{url} . ":" . $self->{vault_config}->{port} . "/v1/" . $secret_path; + $self->{logger}->writeLogDebug("Requesting URL: $url"); + + #$self->{curl_easy}->setopt( CURLOPT_VERBOSE, 1 ); + $self->{curl_easy}->setopt( CURLOPT_URL, $url ); + $self->{curl_easy}->setopt( CURLOPT_POST, 0 ); + $self->{curl_easy}->setopt( CURLOPT_WRITEDATA(), \$get_result_json ); + $self->{curl_easy}->setopt( CURLOPT_HTTPHEADER(), ["X-Vault-Token: " . $self->{auth}->{token}]); + + eval { + $self->{curl_easy}->perform(); + }; + if ($@) { + $self->{logger}->writeLogError("Error while getting a secret from the vault: " . $@); + return $secret; + } + + $self->{logger}->writeLogDebug("Request passed."); + # request_id + + # the result is a json string, convert it into an object + my $get_result_obj = transform_json_to_object($get_result_json); + if (defined($get_result_obj->{error_message})) { + $self->{logger}->writeLogError("Error while decoding JSON '$get_result_json'. Message: " + . $get_result_obj->{error_message}); + return $secret; + } + $self->{logger}->writeLogDebug("Request id is " . $get_result_obj->{request_id}); + + # .data.data will contain the stored macros + if ( !defined($get_result_obj->{data}) + || !defined($get_result_obj->{data}->{data}) + || !defined($get_result_obj->{data}->{data}->{$secret_name}) ) { + $self->{logger}->writeLogError("Could not get secret '$secret_name' from path '$secret_path' from the vault. Enable debug for more details."); + $self->{logger}->writeLogDebug("Response: " . $get_result_json); + return $secret; + } + $self->{logger}->writeLogInfo("Secret '$secret_name' from path '$secret_path' retrieved from the vault."); + return $get_result_obj->{data}->{data}->{$secret_name}; +} + +sub transform_json_to_object { + my ($json_data) = @_; + + my $json_as_object; + eval { + $json_as_object = decode_json($json_data); + }; + if ($@) { + return ({'error_message' => "Could not decode JSON from '$json_data'. Reason: " . $@}); + }; + return($json_as_object); +} + +sub parse_json_file { + my (%options) = @_; + + my $fh; + my $json_data = ''; + + my $json_file = $options{json_file}; + + open($fh, '<', $json_file) or return ('error_message' => "parse_json_file: Cannot open " . $json_file); + for my $line (<$fh>) { + chomp $line; + $json_data .= $line; + } + close($fh); + return transform_json_to_object($json_data); +} + +1; + +__END__ + +=head1 NAME + +Centreon Vault password manager + +=head1 SYNOPSIS + +Allows to retrieve secrets (usually username and password) from a Hashicorp vault compatible api given a config file as constructor. + + use centreon::common::logger; + use centreon::script::centreonvault; + my $vault = centreon::script::centreonvault->new( + ( + 'logger' => centreon::common::logger->new(), + 'config_file' => '/var/lib/centreon/vault/vault.json' + ) + ); + my $password = $vault->get_secret('secret::hashicorp_vault::mypath/to/mysecrets::password'); + +=head1 METHODS + +=head2 new(\%options) + +Constructor of the vault object. + +%options must provide: + +- logger: an object of the centreon::common::logger class. + +- config_file: full path and file name of the Centreon Vault JSON config file. + +The default config_file path should be '/var/lib/centreon/vault/vault.json'. +The expected file format for Centreon Vault is: + + { + "name": "hashicorp_vault", + "url": "vault-server.mydomain.com", + "salt": "", + "port": 443, + "root_path": "vmware_daemon", + "role_id": ")", + "secret_id": ")" + } + +=head2 get_secret($secret) + +Returns the secret stored in the Centreon Vault at the given path. +If the format of the secret does not match the regular expression +C +or in case of any failure in the process, the method will return the secret unchanged. + +=cut diff --git a/connectors/centreonPerlLibs/src/centreon/common/logger.pm b/connectors/centreonPerlLibs/src/centreon/common/logger.pm new file mode 100644 index 0000000000..58224f7112 --- /dev/null +++ b/connectors/centreonPerlLibs/src/centreon/common/logger.pm @@ -0,0 +1,266 @@ +# Copyright 2024 Centreon (http://www.centreon.com/) +# +# Centreon is a full-fledged industry-strength solution that meets +# the needs in IT infrastructure and application monitoring for +# service performance. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +package centreon::common::logger; + +=head1 NOM + +centreon::common::logger - Simple logging module + +=head1 SYNOPSIS + + #!/usr/bin/perl -w + + use strict; + use warnings; + + my $logger = new centreon::common::logger(); + + $logger->writeLogInfo("information"); + +=head1 DESCRIPTION + +This module offers a simple interface to write log messages to various output: + +* standard output +* file +* syslog + +=cut + +use strict; +use warnings; +use Sys::Syslog qw(:standard :macros); +use IO::Handle; + +my %syslog_severities = ( + 2 => LOG_CRIT, + 3 => LOG_ERR, + 4 => LOG_WARNING, + 5 => LOG_NOTICE, + 6 => LOG_INFO, + 7 => LOG_DEBUG +); +my %human_severities = ( + 2 => 'fatal', + 3 => 'error', + 4 => 'warning', + 5 => 'notice', + 6 => 'info', + 7 => 'debug' +); + +sub new { + my $class = shift; + + my $self = bless + { + file => 0, + filehandler => undef, + # warning by default, see %human_severities for the available possibilty + severity => 4, + old_severity => 4, + # 0 = stdout, 1 = file, 2 = syslog + log_mode => 0, + # Output pid of current process + withpid => 0, + # syslog + log_facility => undef, + log_option => LOG_PID, + }, $class; + return $self; +} + +sub file_mode($$) { + my ($self, $file) = @_; + + if (defined($self->{filehandler})) { + $self->{filehandler}->close(); + } + if (open($self->{filehandler}, ">>", $file)){ + $self->{log_mode} = 1; + $self->{filehandler}->autoflush(1); + $self->{file_name} = $file; + return 1; + } + $self->{filehandler} = undef; + print STDERR "Cannot open file $file: $!\n"; + return 0; +} + +sub is_file_mode { + my $self = shift; + + if ($self->{log_mode} == 1) { + return 1; + } + return 0; +} + +sub is_debug { + my $self = shift; + + if ($self->{severity} < 6) { + return 0; + } + return 1; +} + +sub syslog_mode($$$) { + my ($self, $logopt, $facility) = @_; + + $self->{log_mode} = 2; + openlog($0, $logopt, $facility); + return 1; +} + +# For daemons +sub redirect_output { + my $self = shift; + + if ($self->is_file_mode()) { + open my $lfh, '>>', $self->{file_name}; + open STDOUT, '>&', $lfh; + open STDERR, '>&', $lfh; + } +} +# Bypass the write cache set up by the kernel/file system and always write the log +# as soon as it is sent. +sub flush_output { + my ($self, %options) = @_; + + $| = 1 if (defined($options{enabled})); +} + +sub force_default_severity { + my ($self, %options) = @_; + + $self->{old_severity} = defined($options{severity}) ? $options{severity} : $self->{severity}; +} + +sub set_default_severity { + my $self = shift; + + $self->{severity} = $self->{old_severity}; +} + +# Getter/Setter Log severity +sub severity { + my $self = shift; + + if (@_) { + my $save_severity = $self->{severity}; + if ($_[0] =~ /^[0124567]$/) { + $self->{severity} = $_[0]; + } elsif ($_[0] eq "none") { + $self->{severity} = 0; + } elsif ($_[0] eq "fatal") { + $self->{severity} = 2; + } elsif ($_[0] eq "error") { + $self->{severity} = 3; + } elsif ($_[0] eq "warning") { + $self->{severity} = 4; + } elsif ($_[0] eq "notice") { + $self->{severity} = 5; + } elsif ($_[0] eq "info") { + $self->{severity} = 6; + } elsif ($_[0] eq "debug") { + $self->{severity} = 7; + } else { + $self->writeLogError("Wrong severity value given: " . $_[0] . ". Keeping default value: " . $self->{severity}); + return $self->{severity}; + } + $self->{old_severity} = $save_severity; + } + return $human_severities{$self->{severity}}; +} + +sub withpid { + my $self = shift; + if (@_) { + $self->{withpid} = $_[0]; + } + return $self->{withpid}; +} + +sub get_date { + my $self = shift; + my ($sec,$min,$hour,$mday,$mon,$year,$wday,$yday,$isdst) = localtime(time()); + return sprintf("%04d-%02d-%02d %02d:%02d:%02d", + $year+1900, $mon+1, $mday, $hour, $min, $sec); +} + +sub writeLog($$$%) { + my ($self, $severity, $msg, %options) = @_; + + # do nothing if the configured severity does not imply logging this message + return if ($self->{severity} < $severity); + + my $withdate = (defined $options{withdate}) ? $options{withdate} : 1; + $msg = ($self->{withpid} == 1) ? "[$$] $msg " : $msg; + + my $newmsg = ($withdate) + ? "[" . $self->get_date . "] " : ''; + $newmsg .= "[" . $human_severities{$severity} . "] " . $msg; + # Bit mask: if AND gives 0 it means the log level does not require this message to be logged + + if ($self->{log_mode} == 0) { + print "$newmsg\n"; + } elsif ($self->{log_mode} == 1) { + if (defined $self->{filehandler}) { + print { $self->{filehandler} } "$newmsg\n"; + } + } elsif ($self->{log_mode} == 2) { + syslog($syslog_severities{$severity}, $msg); + } +} + +sub writeLogDebug { + shift->writeLog(7, @_); +} + +sub writeLogInfo { + shift->writeLog(6, @_); +} + +sub writeLogNotice { + shift->writeLog(5, @_); +} + +sub writeLogWarn { + shift->writeLog(4, @_); +} + +sub writeLogError { + shift->writeLog(2, @_); +} + +sub writeLogFatal { + shift->writeLog(1, @_); + die("FATAL: " . $_[0] . "\n"); +} + +sub DESTROY { + my $self = shift; + + if (defined $self->{filehandler}) { + $self->{filehandler}->close(); + } +} + +1; diff --git a/connectors/centreonPerlLibs/t/centreonvault.t b/connectors/centreonPerlLibs/t/centreonvault.t new file mode 100644 index 0000000000..ee1a52349f --- /dev/null +++ b/connectors/centreonPerlLibs/t/centreonvault.t @@ -0,0 +1,266 @@ +#!/usr/bin/perl +use strict; +use warnings; +use Test2::V0; +use Test2::Plugin::NoWarnings echo => 1; +use Test2::Tools::Compare qw{is like match}; +use Net::Curl::Easy qw(:constants); +use Data::Dumper qw(Dumper); +use Storable qw(dclone); +use FindBin; +use lib "$FindBin::RealBin/../src"; + +use centreon::common::centreonvault; +use centreon::common::logger; +use JSON::XS; + + +# this sub make an hash with all generic data used in the tests, and send back a hashref. +sub create_data_set { + my $set = {}; + $set->{vault} = undef; + $set->{logger} = centreon::common::logger->new(); + $set->{logger}->file_mode("/dev/null"); + + # this is an exemple of configuration for vault. + # I encrypted the string "String-to-encrypt" from the C++ implementation, and set it to secret_id and role_id + # the key to decrypt should be set as an environment variable. + # the salt can be used to encrypt again the data, so the script can be sure the decryption worked correctly, but this function is not implemented yet. + $set->{default_app_secret} = 'SGVsbG8gd29ybGQsIGRvZywgY2F0LCBwdXBwaWVzLgo='; + $set->{decryted_string} = 'String-to-encrypt'; + $set->{vault_config_hash} = { + "name" => "default", + "url" => "localhost", + "port" => 443, + "root_path" => "path", + "role_id" => "4vOkzIaIJ7yxGWmysGVYY9sYHDyDM1nEv1++jSx9eAHpj83J6aIjE5SPvvpF6kBu3JeFga7o6DDS2yC7jVPAwXsWiur+KUOQncPq0JtjiFojr9YkrO8x1w1dmQFq/RqYV/S/kUare8z6r6+RnAxwsA==", + "secret_id" => "4vOkzIaIJ7yxGWmysGVYY9sYHDyDM1nEv1++jSx9eAHpj83J6aIjE5SPvvpF6kBu3JeFga7o6DDS2yC7jVPAwXsWiur+KUOQncPq0JtjiFojr9YkrO8x1w1dmQFq/RqYV/S/kUare8z6r6+RnAxwsA==", + "salt" => "U2FsdA==" }; # for now the salt is not used, it will be used to check if the data where correctly decrypted. + + $set->{wrong_vault_config_hash} = { + "name" => "default", + "url" => "localhost", + "port" => 443, + "root_path" => "path", + "role_id" => "WrongCryptedDataThatAESWontBeAbleToDecrypt==", + "secret_id" => "WrongCryptedDataThatAESWontBeAbleToDecrypt==", + "salt" => "U2FsdA==" }; + + # We will make multiples tests about authentication. + # this is all the fields that should be set everytime. + $set->{http}->{generic_auth_fields} = { + CURLOPT_POST() => { result => 1, detail => 'the http request should be POST' }, + CURLOPT_POSTFIELDS() => { result => 'role_id=String-to-encrypt&secret_id=String-to-encrypt', detail => 'postfields are correct' }, + CURLOPT_POSTFIELDSIZE() => { result => 53, detail => 'post field size is set' }, + CURLOPT_URL() => { result => 'https://localhost:443/v1/auth/approle/login', detail => 'target url was set' }, }; + # this is the token given by the API when the authentication work. + $set->{http}->{"Vault_Token"} = "RandomAuthTokenGivenByVault"; + $set->{http}->{"vault_token_expiration"} = 13455; + + $set->{http}->{working_auth} = { + (CURLOPT_WRITEDATA() => { + result => '{"auth":{"lease_duration": "' . $set->{http}->{"vault_token_expiration"} . '", + "client_token": "' . $set->{http}->{"Vault_Token"} . '"}}' }), + %{$set->{http}->{generic_auth_fields}} }; + + $set->{http}->{wrong_auth} = { (CURLOPT_WRITEDATA() => { result => '{' }), %{$set->{http}->{generic_auth_fields}} }; + + return $set; + +} + +sub test_new { + my $set = shift; + my $vault = ''; + my @test_data = ( + { 'logger' => undef, 'config_file' => undef, 'test' => '$error_message =~ /FATAL: No logger given to the constructor/' }, + { 'logger' => $set->{logger}, 'config_file' => undef, 'test' => '$vault->{enabled} == 0' }, + { 'logger' => $set->{logger}, 'config_file' => 'does_not_exist.json', 'test' => '$vault->{enabled} == 0' } + ); + + for my $i (0 .. $#test_data) { + my $logger = $test_data[$i]->{logger}; + my $config_file = $test_data[$i]->{config_file}; + my $test = $test_data[$i]->{test}; + + eval { + $vault = centreon::common::centreonvault->new( + ( + 'logger' => $logger, + 'config_file' => $config_file + ) + ); + }; + my $error_message = defined($@) ? $@ : ''; + ok(eval($test), "'$test' should be true"); + } +} + +sub test_decrypt { + my $set = shift; + my $vault = centreon::common::centreonvault->new( + ( + 'logger' => $set->{logger}, + 'config_file' => $set->{vault_config_hash} + ) + ); + + is($vault->extract_and_decrypt(('data' => $set->{vault_config_hash}->{secret_id})), 'String-to-encrypt', 'extract_and_decrypt() worked'); + +} + +sub test_transform_json_to_object { + my $tests_cases = [ + { + json => '{"int": 12, "string": "A String with space", "array" : ["array-key", "string"]}', + result => { "int" => 12, "string" => "A String with space", "array" => [ "array-key", "string" ] }, + detail => "simple json can be decoded as a perl object" + }, + { + json => '"int": 12, "string": "A String with space", "array" : ["array-key", "string"]}', + result => { "error_message" => match(qr/^Could not decode JSON from/) }, + detail => "invalid json should generate an error" + }, + { + json => '', + result => { "error_message" => match(qr/^Could not decode JSON from.*'. Reason:/) }, + detail => "empty json" + }, + { + json => 'abcdef', + result => { "error_message" => match(qr/^Could not decode JSON from/) }, + detail => "simple string json" + }, + { + json => '{}', + result => {}, + detail => "empty json brace should make an empty object" + }, + ]; + + for my $test (@$tests_cases) { + is(centreon::common::centreonvault::transform_json_to_object($test->{json}), $test->{result}, $test->{detail}); + } + +} + +sub test_authenticate { + my $set = shift; + my $vault = centreon::common::centreonvault->new( + ( + 'logger' => $set->{logger}, + 'config_file' => $set->{vault_config_hash} + ) + ); + + my $mock_http_authenticate = mock_http($set->{http}->{working_auth}); + $vault->authenticate(); + is($vault->{auth}->{token}, $set->{http}->{"Vault_Token"}, "the token was correctly retrieved by authenticate()"); + is($vault->{auth}->{expiration_epoch}, time() + $set->{http}->{"vault_token_expiration"}, 'the expiration date is correct'); +} + +sub test_get_secret { + my $set = shift; + + print(" When vault don't work we should send back the input token\n"); + my $vault = centreon::common::centreonvault->new( + ( + 'logger' => $set->{logger}, + 'config_file' => $set->{wrong_vault_config_hash} + ) + ); + is($vault->get_secret("token"), "token", "role_id and secret_id can't be decrypted"); + + $vault = centreon::common::centreonvault->new( + ( + 'logger' => $set->{logger}, + 'config_file' => $set->{vault_config_hash} + ) + ); + my $clear_password = $vault->get_secret("token"); + is($vault->get_secret("token"), "token", "no authentication done because secret don't look like an hashicorp path"); + + my $http_wrong_authentication = { (CURLOPT_WRITEDATA() => { result => '{' }), %{$set->{http}->{generic_auth_fields}} }; + my $mock_http_authenticate = mock_http($http_wrong_authentication); + $clear_password = $vault->get_secret("secret::hashicorp_vault::SecretPathArg::secretNameFromApiResponse"); + is($vault->get_secret("token"), "token", "authentication didn't work because api send back an invalid authentication response"); + + print(" When vault work we should send back the token retrieved by the API\n"); + my $http_get_secret_work = { + CURLOPT_WRITEDATA() => { result => '{"request_id": "ARandomString", "data": {"data" : {"secretNameFromApiResponse": "tokenGotFromApi"}}}' }, + CURLOPT_POST() => { result => '0', detail => 'the http request should not be POST.' }, + CURLOPT_HTTPHEADER() => { result => [ "X-Vault-Token: " . $set->{http}->{"Vault_Token"} ], detail => 'the authentication header should be set.' }, + CURLOPT_URL() => { result => 'https://localhost:443/v1/SecretPathArg', detail => 'target url was set' } + }; + $mock_http_authenticate = mock_http( $set->{http}->{working_auth}, $http_get_secret_work); + $clear_password = $vault->get_secret("secret::hashicorp_vault::SecretPathArg::secretNameFromApiResponse"); + is($clear_password, "tokenGotFromApi", "authentication worked, the token was correctly retrieved by get_secret() from the API"); + +} + +sub main { + my $set = create_data_set(); + + my $old_app_secret = $ENV{'APP_SECRET'}; + $ENV{'APP_SECRET'} = $set->{default_app_secret}; + print " Validate function not reaching external ressources\n"; + test_new($set); + test_decrypt($set); + test_transform_json_to_object($set); + print " Validate authentication and secret retrieving\n"; + test_authenticate($set); + test_get_secret($set); + $ENV{'APP_SECRET'} = $old_app_secret; + + done_testing(); +} + +# this sub is used to mock the Net::Curl::Easy object, to simulate the http request. +# the returned object should be stored in a local variable for the time of your test, +# as the mock will be enabled until the variable is deleted. +sub mock_http { + # without dclone, the hash is modified by the test, and the second test using it will fail. + my @mock_list = @{dclone(\@_)}; + my $required_option = shift(@mock_list); + + my $mock = mock 'Net::Curl::Easy'; # is from Test2::Tools::Mock, included by Test2::V0 + $mock->override('perform' => sub($) { + # Normally this sub perform the actual http request and set the result to the variable given to setopt(). + # For test purpose, we set the mocked data in the setopt(), and only use perform() to check every parameter have correctly been set. + # once we are sure all parameter where correctly set, we prepare the next request if there is any. + # this is not what is done in reality, but it's easier for mocking purpose. + if (keys %{$required_option}) { + fail "[mock-curl] Some curl parameter where not correctly set : " . join(', ', keys(%{$required_option})) . "\n"; + } + $required_option = shift(@mock_list); + }, + # this sub is called for each parameter set to curl, we will check if the parameter is correctly set. + 'setopt' => sub($$$) { + my $self = shift; # we don't need this one + my $opt_name = shift; # the option name, see Net::Curl::Easy (:constant) for the list of possible value. + my $opt_value = shift; + # the real workhorse of the lib, we must have an hashref $required_option = {} already declared. + # this sub check in the hash if the option is correctly set, and delete it from the hash if it's correct. + # when doing perform, all options should have been set. So if there is still element in the hash, + # it is an error, as some parameter where not correctly set. + # writedata is processed differently to send back the data to the caller. + if ($opt_name == CURLOPT_WRITEDATA) { + $$opt_value = $required_option->{$opt_name}->{result}; + delete($required_option->{$opt_name}); + return; + } + if ($required_option->{$opt_name}) { + is($opt_value, $required_option->{$opt_name}->{result}, "[mock-curl] " . $required_option->{$opt_name}->{detail}); + delete($required_option->{$opt_name}); + } else { + print(Dumper($required_option)); + fail("$opt_name is not present in the required_option hash."); + + } + } + ); + # we need to return the mocked object and to keep it, or the mock will be deleted and reverted. + return $mock; + +} +&main; \ No newline at end of file diff --git a/connectors/centreonPerlLibs/t/exemple-file.json b/connectors/centreonPerlLibs/t/exemple-file.json new file mode 100644 index 0000000000..13174fd07c --- /dev/null +++ b/connectors/centreonPerlLibs/t/exemple-file.json @@ -0,0 +1,7 @@ +{ + "int": 12, + "string": "A String with space", + "array" : [ + "array-key", "string" + ] +} \ No newline at end of file diff --git a/connectors/centreonPerlLibs/t/logger.t b/connectors/centreonPerlLibs/t/logger.t new file mode 100644 index 0000000000..9229257c91 --- /dev/null +++ b/connectors/centreonPerlLibs/t/logger.t @@ -0,0 +1,60 @@ +#!/usr/bin/perl +use strict; +use warnings; + +use Test2::V0; +use FindBin; +use lib "$FindBin::Bin/../src"; +use centreon::common::logger; + +# Each function test a different aspect of the library (roughtly one public function each). +# To be sure there is no side effect each test create it's own test object +sub test_severity { + my $logger = centreon::common::logger->new(); + is($logger->severity(), "warning", "default severity should be warning."); + + for my $sev ('fatal', 'error', 'warning', 'notice', 'info', 'debug') { + $logger->severity($sev); + is($logger->severity(), $sev, "severity $sev was correctly set."); + } +} +# By default the logger should write to stdout, so we capture stdout to a variable and check what the object have written. +sub test_writeLogInfo { + my $logger = centreon::common::logger->new(); + $logger->flush_output(enabled => 1); + $logger->severity("debug"); + my $out; + my $logExemple = "this is an info log."; + do { + local *STDOUT; + open STDOUT, ">", \$out; + $logger->writeLogInfo($logExemple); + }; + my $log = check_log_format_with_date_no_pid($out, "info"); + + is($log, $logExemple, "log is the same as what we sent."); + + print "written to original STDOUT : $out\n"; +} + +sub main { + test_severity(); + test_writeLogInfo(); + #test_file_mode(); + #test_is_file_mode(); + + + done_testing; +} +# Helper function +sub check_log_format_with_date_no_pid { + # the best way to check the date would be to mock the time() function, and to run get_date(). + # as it is a builtin function, it is possible but hard to setup and prone various side effect. + my $log = shift; + my $severity = shift; + ok(($log =~ /^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\] \[$severity\] (.*$)/), "log format is respected."); + ok((defined $1), "the log is not empty"); + return $1; +} + +&main; \ No newline at end of file diff --git a/connectors/centreonPerlLibs/version.yaml b/connectors/centreonPerlLibs/version.yaml new file mode 100644 index 0000000000..fabb12895a --- /dev/null +++ b/connectors/centreonPerlLibs/version.yaml @@ -0,0 +1,2 @@ +--- + version: 1.0.0 \ No newline at end of file