diff --git a/packaging/centreon-plugin-Cloud-Juniper-Mist-Restapi/deb.json b/packaging/centreon-plugin-Cloud-Juniper-Mist-Restapi/deb.json new file mode 100644 index 0000000000..9910ed4b82 --- /dev/null +++ b/packaging/centreon-plugin-Cloud-Juniper-Mist-Restapi/deb.json @@ -0,0 +1,5 @@ +{ + "dependencies": [ + "libcrypt-openssl-x509-perl" + ] +} diff --git a/packaging/centreon-plugin-Cloud-Juniper-Mist-Restapi/pkg.json b/packaging/centreon-plugin-Cloud-Juniper-Mist-Restapi/pkg.json new file mode 100644 index 0000000000..4281c1921e --- /dev/null +++ b/packaging/centreon-plugin-Cloud-Juniper-Mist-Restapi/pkg.json @@ -0,0 +1,9 @@ +{ + "pkg_name": "centreon-plugin-Cloud-Juniper-Mist-Restapi", + "pkg_summary": "Centreon Plugin to monitor Juniper Mist cloud-managed networks using the Mist REST API", + "plugin_name": "centreon_juniper_mist_restapi.pl", + "files": [ + "centreon/plugins/script_custom.pm", + "cloud/juniper/mist/restapi/" + ] +} diff --git a/packaging/centreon-plugin-Cloud-Juniper-Mist-Restapi/rpm.json b/packaging/centreon-plugin-Cloud-Juniper-Mist-Restapi/rpm.json new file mode 100644 index 0000000000..bf259a9140 --- /dev/null +++ b/packaging/centreon-plugin-Cloud-Juniper-Mist-Restapi/rpm.json @@ -0,0 +1,5 @@ +{ + "dependencies": [ + "perl(Crypt::OpenSSL::X509)" + ] +} diff --git a/src/cloud/juniper/mist/restapi/custom/api.pm b/src/cloud/juniper/mist/restapi/custom/api.pm new file mode 100644 index 0000000000..bf9ff52b13 --- /dev/null +++ b/src/cloud/juniper/mist/restapi/custom/api.pm @@ -0,0 +1,328 @@ +# +# Copyright 2026-Present 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 cloud::juniper::mist::restapi::custom::api; + +use strict; +use warnings; +use centreon::plugins::http; +use centreon::plugins::misc qw(json_decode); + +sub new { + my ($class, %options) = @_; + my $self = {}; + bless $self, $class; + + if (!defined($options{output})) { + print "Class Custom: Need to specify 'output' argument.\n"; + exit 3; + } + $options{output}->option_exit(short_msg => "Class Custom: Need to specify 'options' argument.") + if (!defined($options{options})); + + if (!defined($options{noptions})) { + $options{options}->add_options(arguments => { + 'hostname:s' => { name => 'hostname', default => 'api.eu.mist.com' }, + 'port:s' => { name => 'port', type => 'port', default => 443 }, + 'proto:s' => { name => 'proto', type => 'protocol_http', default => 'https' }, + 'api-token:s' => { name => 'api_token' }, + 'org-id:s' => { name => 'org_id' }, + 'timeout:s' => { name => 'timeout', type => 'numeric', default => 30 }, + 'max-pages:s' => { name => 'max_pages', type => 'numeric', default => 30 }, + 'unknown-http-status:s' => { name => 'unknown_http_status', default => '%{http_code} < 200 or %{http_code} >= 300' }, + 'warning-http-status:s' => { name => 'warning_http_status', default => '' }, + 'critical-http-status:s' => { name => 'critical_http_status', default => '' } + }); + } + $options{options}->add_help(package => __PACKAGE__, sections => 'REST API OPTIONS', once => 1); + + $self->{output} = $options{output}; + $self->{http} = centreon::plugins::http->new(%options); + + return $self; +} + +sub set_options { + my ($self, %options) = @_; + + $self->{option_results} = $options{option_results}; +} + +sub set_defaults {} + +sub check_options { + my ($self, %options) = @_; + + $self->{$_} = $self->{option_results}->{$_} + foreach (qw/hostname port proto api_token org_id timeout max_pages + unknown_http_status warning_http_status critical_http_status/); + + $self->{output}->option_exit(short_msg => "Need to specify --api-token option.") + if (!defined($self->{api_token}) || $self->{api_token} eq ''); + $self->{output}->option_exit(short_msg => "Need to specify --org-id option.") + if (!defined($self->{org_id}) || $self->{org_id} eq ''); + + return 0; +} + +sub get_hostname { + my ($self, %options) = @_; + + return $self->{hostname}; +} + +sub get_port { + my ($self, %options) = @_; + + return $self->{port}; +} + +sub get_org_id { + my ($self, %options) = @_; + + return $self->{org_id}; +} + +sub settings { + my ($self, %options) = @_; + + return if ($self->{settings_done}); + # The Mist organization token is a static, read-only API key sent as a + # 'Token' (not 'Bearer') Authorization header. There is no OAuth flow and + # therefore no token to cache or renew. + $self->{http}->add_header(key => 'Authorization', value => 'Token ' . $self->{api_token}); + $self->{http}->add_header(key => 'Accept', value => 'application/json'); + $self->{option_results}->{$_} = $self->{$_} + foreach (qw/hostname port proto timeout/); + $self->{http}->set_options(%{$self->{option_results}}); + $self->{settings_done} = 1; +} + +sub request_api { + my ($self, %options) = @_; + + $self->settings(); + + # The --unknown/warning/critical-http-status options are passed to the http + # layer for familiarity with the rest of the collection, but data modes + # deliberately exit UNKNOWN on any non-2xx via _http_error_exit below, with a + # friendly mapped message (401/403 token, 429 quota, 5xx). Deriving a + # WARNING/CRITICAL verdict from the HTTP code is the job of the api-test + # mode, which overrides these thresholds with empty ones and passes + # no_exit_on_error to build its own verdict. + my $content = $self->{http}->request( + url_path => $options{endpoint}, + get_param => $options{get_param}, + unknown_status => defined($options{unknown_status}) ? $options{unknown_status} : $self->{unknown_http_status}, + warning_status => defined($options{warning_status}) ? $options{warning_status} : $self->{warning_http_status}, + critical_status => defined($options{critical_status}) ? $options{critical_status} : $self->{critical_http_status} + ); + my $code = $self->{http}->get_code(); + + # api-test wants the raw outcome to build its own verdict. + return { code => $code, message => $self->{http}->get_message(), content => $content } + if ($options{no_exit_on_error}); + + $self->_http_error_exit(code => $code, endpoint => $options{endpoint}) + if ($code < 200 || $code >= 300); + + return json_decode($content, output => $self->{output}); +} + +# Pagination for endpoints returning a bare JSON array, driven by the 'page' +# and 'limit' parameters. Used by /stats/devices, which - unlike the search +# endpoints - returns an unwrapped array with no 'results' envelope. +sub request_api_paginated { + my ($self, %options) = @_; + + my $limit = $options{limit} // 1000; + my $max_pages = $options{max_pages} // $self->{max_pages}; + my @results; + + for (my $page = 1; $page <= $max_pages; $page++) { + my @get_param = ('limit=' . $limit, 'page=' . $page); + push @get_param, @{$options{get_param}} if (defined($options{get_param})); + + my $decoded = $self->request_api( + endpoint => $options{endpoint}, + get_param => \@get_param + ); + + last if (ref($decoded) ne 'ARRAY'); + push @results, @$decoded; + last if (scalar(@$decoded) < $limit); + } + + return \@results; +} + +# Pagination for Mist "search" endpoints. These wrap results in an object and +# expose a 'next' field holding a ready-made path with a 'search_after' cursor. +# The classic page/limit pagination does NOT work on these endpoints. +sub request_api_search { + my ($self, %options) = @_; + + my $limit = $options{limit} // 1000; + my $max_pages = $options{max_pages} // $self->{max_pages}; + my @results; + + my $endpoint = $options{endpoint}; + my @get_param = ('limit=' . $limit); + push @get_param, @{$options{get_param}} if (defined($options{get_param})); + + my $truncated = 0; + + for (my $page = 1; $page <= $max_pages; $page++) { + my $decoded = $self->request_api( + endpoint => $endpoint, + get_param => \@get_param + ); + + $self->{output}->option_exit(short_msg => "Unexpected response format from $options{endpoint}") + if (ref($decoded) ne 'HASH' || ref($decoded->{results}) ne 'ARRAY'); + + push @results, @{$decoded->{results}}; + + if (!defined($decoded->{next}) || $decoded->{next} eq '' + || scalar(@{$decoded->{results}}) < $limit) { + $truncated = 0; + last; + } + + # 'next' is a path plus an already-encoded query string; split it so the + # next call receives the same shape as the first one. + my ($next_path, $next_query) = split(/\?/, $decoded->{next}, 2); + $endpoint = $next_path; + @get_param = defined($next_query) ? split(/&/, $next_query) : (); + $truncated = 1; + } + + return (\@results, $truncated); +} + +sub _http_error_exit { + my ($self, %options) = @_; + + my $code = $options{code}; + my $message = $self->{http}->get_message(); + + if ($code == 401 || $code == 403) { + $self->{output}->option_exit(short_msg => "Authentication failed (HTTP $code) - check API token validity and privileges."); + } elsif ($code == 429) { + $self->{output}->option_exit(short_msg => "Mist API rate limit exceeded (HTTP 429) - reduce check frequency."); + } + + $self->{output}->option_exit(short_msg => "HTTP error [code: $code] [message: $message] on '$options{endpoint}'"); +} + +1; + +__END__ + +=head1 NAME + +Juniper Mist REST API + +=head1 SYNOPSIS + +Juniper Mist cloud REST API custom mode. + +=head1 REST API OPTIONS + +Juniper Mist REST API accessed with a read-only organization token. + +Generate an organization API token from the Mist dashboard +(Organization > Settings > API Token) and pass it with C<--api-token>. + +=over 8 + +=item B<--hostname> + +Mist API regional endpoint (default: 'api.eu.mist.com'). +Use 'api.mist.com' (Global 01), 'api.gc1.mist.com' (Global 03), etc. depending +on the region hosting your organization. + +=item B<--port> + +Port used (default: 443). + +=item B<--proto> + +Protocol to use: 'http' or 'https' (default: 'https'). + +=item B<--api-token> + +Mist organization API token. Sent as the 'Authorization: Token ' header. + +=item B<--org-id> + +Mist organization UUID. Used to build the '/api/v1/orgs//...' paths. + +=item B<--timeout> + +HTTP request timeout in seconds (default: 30). + +=item B<--max-pages> + +Maximum number of pages to retrieve when paginating list/search endpoints +(default: 30). Safety bound against unbounded result sets. + +=item B<--unknown-http-status> + +Unknown threshold for the HTTP response code +(default: '%{http_code} < 200 or %{http_code} >= 300'). + +=item B<--warning-http-status> + +Warning threshold for the HTTP response code. + +=item B<--critical-http-status> + +Critical threshold for the HTTP response code. + +=back + +=head1 MIST API CAVEATS + +Verified against 'api.eu.mist.com': + +=over 8 + +=item * The '/nac_clients/events/search' and '/nac_clients/events/count' +endpoints accept a 'site_id' query parameter but B. The +response is identical with or without the filter, and even a non-existent site +UUID returns organization-wide data. Per-site NAC figures must therefore be +computed client-side from the 'site_id' field carried by each event. + +=item * By contrast, '/alarms/search' and '/stats/devices' B honour +'site_id' server-side, so those modes pass it straight through. + +=item * Endpoint shapes are inconsistent: '/stats/devices' returns a bare JSON +array (page/limit pagination), while the '*/search' endpoints wrap results in a +'results' key and paginate through a 'next' field carrying a 'search_after' +cursor. + +=back + +=head1 DESCRIPTION + +B. + +=cut diff --git a/src/cloud/juniper/mist/restapi/mode/alarms.pm b/src/cloud/juniper/mist/restapi/mode/alarms.pm new file mode 100644 index 0000000000..4aba48f9c9 --- /dev/null +++ b/src/cloud/juniper/mist/restapi/mode/alarms.pm @@ -0,0 +1,237 @@ +# +# Copyright 2026-Present 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 cloud::juniper::mist::restapi::mode::alarms; + +use base qw(centreon::plugins::templates::counter); + +use strict; +use warnings; +use centreon::plugins::constants qw(:counters); +use centreon::plugins::templates::catalog_functions qw(catalog_status_threshold_ng); + +sub set_counters { + my ($self, %options) = @_; + + $self->{maps_counters_type} = [ + { name => 'global', type => COUNTER_TYPE_GLOBAL }, + { name => 'types', type => COUNTER_TYPE_INSTANCE, prefix_output => "Alarm type '%{type}' ", + message_multiple => 'All alarm types are OK', skipped_code => { -10 => 1 } } + ]; + + $self->{maps_counters}->{global} = [ + { + label => 'alarms-critical', nlabel => 'mist.alarms.critical.count', + set => { + key_values => [ { name => 'critical' } ], + output_template => 'critical: %d', + perfdatas => [ + { template => '%d', min => 0 } + ] + } + }, + { + label => 'alarms-warning', nlabel => 'mist.alarms.warning.count', + set => { + key_values => [ { name => 'warning' } ], + output_template => 'warning: %d', + perfdatas => [ + { template => '%d', min => 0 } + ] + } + }, + { + label => 'alarms-info', nlabel => 'mist.alarms.info.count', threshold => 0, + set => { + key_values => [ { name => 'info' } ], + output_template => 'info: %d', + perfdatas => [ + { template => '%d', min => 0 } + ] + } + } + ]; + + $self->{maps_counters}->{types} = [ + { + label => 'status', + type => COUNTER_KIND_TEXT, + # Evaluated once per alarm type (catalog_status_threshold_ng runs per + # instance), so a negative-match rule silences only the type it names + # and never neutralises the alert for the other types: + # --critical-status='%{critical} > 0 && %{type} !~ /some_type/' + # Silenced types stay counted in the perfdata. + critical_default => '%{critical} > 0', + display_ok => 0, + set => { + key_values => [ + { name => 'critical' }, { name => 'warning' }, { name => 'info' }, + { name => 'count' }, { name => 'total' }, { name => 'type' } + ], + output_template => 'critical: %{critical}, warning: %{warning}, info: %{info}', + closure_custom_threshold_check => \&catalog_status_threshold_ng + } + }, + { + label => 'alarm-count', nlabel => 'mist.alarms.count', threshold => 0, + set => { + key_values => [ { name => 'count' }, { name => 'type' } ], + output_template => 'count: %d', + perfdatas => [ + { template => '%d', min => 0, label_extra_instance => 1, instance_use => 'type' } + ] + } + } + ]; +} + +sub new { + my ($class, %options) = @_; + my $self = $class->SUPER::new(package => __PACKAGE__, %options, force_new_perfdata => 1); + bless $self, $class; + + $options{options}->add_options(arguments => { + 'site-id:s' => { name => 'site_id' }, + 'timeframe:s' => { name => 'timeframe', type => 'numeric', default => 3600 }, + 'filter-alarm-type:s' => { name => 'filter_alarm_type' }, + 'exclude-alarm-type:s' => { name => 'exclude_alarm_type' } + }); + + return $self; +} + +# /alarms/search honours site_id server-side, so the filter is passed straight +# through. It is a search endpoint: results are wrapped in a 'results' key and +# paginated through a 'search_after' cursor (handled by request_api_search). +sub manage_selection { + my ($self, %options) = @_; + + my $org_id = $options{custom}->get_org_id(); + my $timeframe = $self->{option_results}->{timeframe}; + + my @get_param = ('duration=' . $timeframe . 's'); + push @get_param, 'site_id=' . $self->{option_results}->{site_id} + if (defined($self->{option_results}->{site_id}) && $self->{option_results}->{site_id} ne ''); + + my ($alarms, $truncated) = $options{custom}->request_api_search( + endpoint => "/api/v1/orgs/$org_id/alarms/search", + get_param => \@get_param + ); + + my ($critical, $warning, $info) = (0, 0, 0); + my %by_type; + + foreach my $alarm (@$alarms) { + my $type = $alarm->{type} // 'unknown'; + + next if (defined($self->{option_results}->{filter_alarm_type}) && $self->{option_results}->{filter_alarm_type} ne '' + && $type !~ /$self->{option_results}->{filter_alarm_type}/i); + next if (defined($self->{option_results}->{exclude_alarm_type}) && $self->{option_results}->{exclude_alarm_type} ne '' + && $type =~ /$self->{option_results}->{exclude_alarm_type}/i); + + my $severity = lc($alarm->{severity} // 'info'); + $severity = 'info' if ($severity !~ /^(?:critical|warning|info)$/); + + if ($severity eq 'critical') { $critical++; } + elsif ($severity eq 'warning') { $warning++; } + else { $info++; } + + $by_type{$type}->{$severity}++; + $by_type{$type}->{count}++; + } + + my $total = $critical + $warning + $info; + + $self->{global} = { critical => $critical, warning => $warning, info => $info }; + + $self->{types} = {}; + foreach my $type (keys %by_type) { + $self->{types}->{$type} = { + type => $type, + critical => $by_type{$type}->{critical} // 0, + warning => $by_type{$type}->{warning} // 0, + info => $by_type{$type}->{info} // 0, + count => $by_type{$type}->{count}, + total => $total + }; + } + + # A truncated result means the max-pages safety bound was hit: surface it so + # the operator knows the counts are a lower bound, not the full window. + $self->{output}->output_add(long_msg => 'Note: result truncated (--max-pages reached), counts are a lower bound.') + if ($truncated); +} + +1; + +__END__ + +=head1 MODE + +Check the Juniper Mist alarms over a time window from the C +endpoint, broken down by severity and by alarm type. + +By default, any critical alarm raises a CRITICAL. A given alarm type can be +silenced with a negative-match C<--critical-status> rule while remaining counted +in the perfdata. + +=over 8 + +=item B<--site-id> + +Restrict the check to a single site (honoured server-side by the Mist API). + +=item B<--timeframe> + +Time window in seconds to look back for alarms (default: 3600). + +=item B<--filter-alarm-type> + +Only keep alarms whose type matches this regular expression. + +=item B<--exclude-alarm-type> + +Exclude alarms whose type matches this regular expression. + +=item B<--warning-status> + +Define the conditions to match for the status to be WARNING (per alarm type). +You can use the following variables: %{critical}, %{warning}, %{info}, +%{count}, %{total}, %{type}. + +=item B<--critical-status> + +Define the conditions to match for the status to be CRITICAL (per alarm type) +(default: '%{critical} > 0'). + +Example - silence a noisy alarm type while still alerting on the rest: +--critical-status='%{critical} > 0 && %{type} !~ /infra_arp_poison/' + +=item B<--warning-alarms-critical> B<--critical-alarms-critical> + +Threshold on the total number of critical alarms (no default). + +=item B<--warning-alarms-warning> B<--critical-alarms-warning> + +Threshold on the total number of warning alarms (no default). + +=back + +=cut diff --git a/src/cloud/juniper/mist/restapi/mode/apitest.pm b/src/cloud/juniper/mist/restapi/mode/apitest.pm new file mode 100644 index 0000000000..dd8a083fc1 --- /dev/null +++ b/src/cloud/juniper/mist/restapi/mode/apitest.pm @@ -0,0 +1,183 @@ +# +# Copyright 2026-Present 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 cloud::juniper::mist::restapi::mode::apitest; + +use base qw(centreon::plugins::templates::counter); + +use strict; +use warnings; +use centreon::plugins::constants qw(:counters); +use centreon::plugins::templates::catalog_functions qw(catalog_status_threshold_ng); +use centreon::plugins::misc qw(json_decode); +use Time::HiRes; + +sub set_counters { + my ($self, %options) = @_; + + $self->{maps_counters_type} = [ + { name => 'api', type => COUNTER_TYPE_GLOBAL } + ]; + + $self->{maps_counters}->{api} = [ + { + label => 'status', + type => COUNTER_KIND_TEXT, + # Since Mist publishes no machine-readable status page, this mode + # carries the diagnostic burden: it separates "the cloud is + # unreachable" from "our token is invalid/revoked", which call for + # very different on-call responses. It also surfaces token expiry + # risk (unused Mist org tokens are revoked after 90 days). + critical_default => '%{status} =~ /auth_failed|unreachable|server_error|error/', + warning_default => '%{status} =~ /rate_limited/', + set => { + key_values => [ { name => 'status' }, { name => 'http_code' }, { name => 'message' } ], + output_template => '%{message}', + closure_custom_threshold_check => \&catalog_status_threshold_ng + } + }, + { + label => 'response-time', nlabel => 'mist.api.response.time.seconds', + set => { + key_values => [ { name => 'response_time' } ], + output_template => 'response time: %.3fs', + perfdatas => [ + { template => '%.3f', unit => 's', min => 0 } + ] + } + } + ]; +} + +sub new { + my ($class, %options) = @_; + my $self = $class->SUPER::new(package => __PACKAGE__, %options, force_new_perfdata => 1); + bless $self, $class; + + $options{options}->add_options(arguments => {}); + + return $self; +} + +sub manage_selection { + my ($self, %options) = @_; + + my $started = Time::HiRes::time(); + + # Ask the custom layer for the raw outcome (no_exit_on_error) with the HTTP + # status thresholds disabled, so we build the verdict ourselves. + my $response = $options{custom}->request_api( + endpoint => '/api/v1/self', + no_exit_on_error => 1, + unknown_status => '', warning_status => '', critical_status => '' + ); + + my $elapsed = sprintf('%.3f', Time::HiRes::time() - $started); + my $code = $response->{code} // 0; + my $http_message = $response->{message} // ''; + + my ($status, $message); + if ($code >= 200 && $code < 300) { + my $decoded = json_decode($response->{content}, output => $self->{output}); + my @privileges; + if (ref($decoded->{privileges}) eq 'ARRAY') { + foreach my $privilege (@{$decoded->{privileges}}) { + push @privileges, ($privilege->{scope} // 'unknown') . '/' . ($privilege->{role} // 'unknown'); + } + } + my $identity = $decoded->{email} // $decoded->{name} // 'token'; + my $detail = @privileges ? ' (' . join(', ', sort @privileges) . ')' : ''; + $status = 'ok'; + $message = "Mist API reachable, credentials valid for '$identity'$detail"; + } elsif ($code == 401 || $code == 403) { + $status = 'auth_failed'; + $message = "Mist API reachable but authentication rejected (HTTP $code) - token invalid, revoked or lacking privileges"; + } elsif ($code == 429) { + $status = 'rate_limited'; + $message = "Mist API rate limit reached (HTTP 429) - polling too frequent or quota shared with another consumer"; + } elsif ($code == 0 + || $http_message =~ /can't connect|connection (?:refused|reset|timed out)|timeout|could not connect|no route to host|network is unreachable|bad hostname|name or service not known/i) { + # The LWP backend maps connection-level failures to HTTP 500 with a + # "Can't connect ..." message, so we detect them by message rather than + # code to tell a genuine cloud outage from a Mist server-side 5xx. + $status = 'unreachable'; + $message = "Mist API unreachable: " . ($http_message ne '' ? $http_message : 'connection failed'); + } elsif ($code >= 500) { + $status = 'server_error'; + $message = "Mist API server error (HTTP $code) - likely a cloud-side incident"; + } else { + $status = 'error'; + $message = "Mist API error (HTTP $code)"; + } + + $self->{api} = { + status => $status, + http_code => $code, + message => $message, + response_time => $elapsed + }; +} + +1; + +__END__ + +=head1 MODE + +Test reachability of the Juniper Mist API and the validity of the organization +token, by calling the C endpoint. This mode is the availability sentinel +for the plugin: Mist does not publish a machine-readable status page, so it +distinguishes a cloud outage from an invalid/revoked token. + +Default verdicts: + +=over 4 + +=item * connection failure / timeout -> CRITICAL (probable cloud incident) + +=item * HTTP 401 / 403 -> CRITICAL (token invalid, revoked or lacking privileges) + +=item * HTTP 429 -> WARNING (quota exceeded) + +=item * HTTP 5xx -> CRITICAL (Mist server error) + +=item * HTTP 2xx -> OK (identity and privileges reported) + +=back + +=over 8 + +=item B<--warning-status> + +Define the conditions to match for the status to be WARNING (default: +'%{status} =~ /rate_limited/'). Variables: %{status}, %{http_code}. + +=item B<--critical-status> + +Define the conditions to match for the status to be CRITICAL (default: +'%{status} =~ /auth_failed|unreachable|server_error|error/'). + +=item B<--warning-response-time> B<--critical-response-time> + +Threshold on the API response time in seconds (no default). + +=back + +=cut diff --git a/src/cloud/juniper/mist/restapi/mode/certificates.pm b/src/cloud/juniper/mist/restapi/mode/certificates.pm new file mode 100644 index 0000000000..f953581fa7 --- /dev/null +++ b/src/cloud/juniper/mist/restapi/mode/certificates.pm @@ -0,0 +1,271 @@ +# +# Copyright 2026-Present 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 cloud::juniper::mist::restapi::mode::certificates; + +use base qw(centreon::plugins::templates::counter); + +use strict; +use warnings; +use centreon::plugins::constants qw(:counters); +use centreon::plugins::misc; +use POSIX qw(floor); +use Time::Piece; +use File::Temp qw(tempfile); + +sub set_counters { + my ($self, %options) = @_; + + $self->{maps_counters_type} = [ + # skipped_code -10 (no value) is suppressed: when the organization uses a + # Mist-managed server certificate there is no custom cert to measure, and + # we surface that with an explicit OK message instead of a noisy + # "skipped (no value(s))". + { name => 'server', type => COUNTER_TYPE_GLOBAL, skipped_code => { -10 => 1 } }, + { name => 'cas', type => COUNTER_TYPE_INSTANCE, prefix_output => "CA certificate '%{subject}' ", + message_multiple => 'All CA certificates are OK' } + ]; + + $self->{maps_counters}->{server} = [ + { + label => 'certificate-expiry', + nlabel => 'mist.nac.server.certificate.expiry.days', + # Centreon range '60:' fires when the value is below 60, so an already + # expired certificate (negative days) also matches - which is the + # whole point: the silent RADIUS certificate expiry this plugin was + # written after must raise an alert. + warning_default => '60:', + critical_default => '30:', + set => { + key_values => [ { name => 'expiration' }, { name => 'subject' }, { name => 'issuer' }, { name => 'date' } ], + output_template => "RADIUS server certificate '%{subject}' expires in %{expiration} days (%{date})", + perfdatas => [ + { template => '%d', unit => 'd' } + ] + } + } + ]; + + $self->{maps_counters}->{cas} = [ + { + label => 'ca-expiry', + nlabel => 'mist.nac.ca.certificate.expiry.days', + warning_default => '180:', + critical_default => '90:', + set => { + key_values => [ { name => 'expiration' }, { name => 'subject' }, { name => 'issuer' }, { name => 'date' } ], + output_template => "expires in %{expiration} days (%{date})", + perfdatas => [ + { template => '%d', unit => 'd', label_extra_instance => 1, instance_use => 'subject' } + ] + } + } + ]; +} + +sub new { + my ($class, %options) = @_; + my $self = $class->SUPER::new(package => __PACKAGE__, %options, force_new_perfdata => 1); + bless $self, $class; + + $options{options}->add_options(arguments => { + 'check-cas' => { name => 'check_cas' }, + 'filter-certificate-cn:s' => { name => 'filter_certificate_cn' } + }); + + return $self; +} + +# Watches the NAC (Access Assurance) RADIUS server certificate and, optionally, +# the imported CA certificates. The certificates live in the organization +# settings under the 'mist_nac' section: +# { "mist_nac": { "server_cert": { "cert": "" }, "cacerts": [ "", ... ] } } +sub manage_selection { + my ($self, %options) = @_; + + my $org_id = $options{custom}->get_org_id(); + my $settings = $options{custom}->request_api(endpoint => "/api/v1/orgs/$org_id/setting"); + + $self->{output}->option_exit(short_msg => "No 'mist_nac' section in organization settings (Access Assurance not configured?)") + if (ref($settings->{mist_nac}) ne 'HASH'); + my $nac = $settings->{mist_nac}; + + # --- RADIUS server certificate ------------------------------------------ + my $server_pem = (ref($nac->{server_cert}) eq 'HASH') ? $nac->{server_cert}->{cert} : undef; + + if (defined($server_pem) && $server_pem =~ /BEGIN CERTIFICATE/) { + my $info = $self->_parse_certificate($server_pem); + if (!defined($info)) { + $self->{output}->output_add(severity => 'CRITICAL', short_msg => 'RADIUS server certificate is unreadable or invalid'); + } else { + $self->{server} = { + expiration => $info->{days_left}, + subject => $info->{subject_cn}, + issuer => $info->{issuer_cn}, + date => $info->{not_after_str} + }; + } + } else { + # No custom certificate means Mist manages and auto-renews it: this is a + # healthy state, not a gap in monitoring. + $self->{output}->output_add(severity => 'OK', short_msg => 'no custom RADIUS server certificate (Mist-managed, auto-renewed)'); + } + + # --- imported CA certificates ------------------------------------------- + $self->{cas} = {}; + if (defined($self->{option_results}->{check_cas}) && ref($nac->{cacerts}) eq 'ARRAY') { + my $index = 0; + foreach my $pem (@{$nac->{cacerts}}) { + $index++; + my $info = $self->_parse_certificate($pem); + if (!defined($info)) { + $self->{output}->output_add(severity => 'WARNING', short_msg => "CA certificate #$index is unreadable or invalid"); + next; + } + + my $cn = $info->{subject_cn}; + next if (defined($self->{option_results}->{filter_certificate_cn}) + && $self->{option_results}->{filter_certificate_cn} ne '' + && $cn !~ /$self->{option_results}->{filter_certificate_cn}/i); + + # Key on the index (unique) rather than the CN, so two CAs sharing a + # Common Name are both kept and displayed. + $self->{cas}->{$index} = { + expiration => $info->{days_left}, + subject => $cn, + issuer => $info->{issuer_cn}, + date => $info->{not_after_str} + }; + } + } +} + +# X.509 decoding uses Crypt::OpenSSL::X509 when available, and falls back to the +# openssl binary: minimal pollers often lack the XS module. +sub _parse_certificate { + my ($self, $pem) = @_; + + return undef if (!defined($pem) || $pem !~ /BEGIN CERTIFICATE/); + + my ($subject, $issuer, $enddate); + + my $decoded = eval { + require Crypt::OpenSSL::X509; + my $x509 = Crypt::OpenSSL::X509->new_from_string($pem); + $subject = $x509->subject(); + $issuer = $x509->issuer(); + $enddate = $x509->notAfter(); + 1; + }; + + if (!$decoded) { + my ($fh, $tmp) = tempfile('mistcertXXXXXX', TMPDIR => 1, UNLINK => 1); + print $fh $pem; + close($fh); + + my ($error, $output) = centreon::plugins::misc::backtick( + command => 'openssl', + arguments => [ 'x509', '-in', $tmp, '-noout', '-subject', '-issuer', '-enddate' ], + timeout => 10 + ); + return undef if ($error != 0 || !defined($output)); + + ($subject) = $output =~ /^subject=\s*(.+)$/m; + ($issuer) = $output =~ /^issuer=\s*(.+)$/m; + ($enddate) = $output =~ /^notAfter=\s*(.+)$/m; + } + + return undef if (!defined($enddate)); + + (my $normalized = $enddate) =~ s/\s+GMT\s*$//; + $normalized =~ s/\s+/ /g; + + my $time_piece = eval { Time::Piece->strptime($normalized, '%b %d %H:%M:%S %Y') }; + return undef if ($@ || !defined($time_piece)); + + return { + subject_cn => _extract_cn($subject) // 'unknown', + issuer_cn => _extract_cn($issuer) // '', + not_after_str => $time_piece->strftime('%Y-%m-%d'), + days_left => floor(($time_piece->epoch - time()) / 86400) + }; +} + +sub _extract_cn { + my ($dn) = @_; + + return undef if (!defined($dn)); + if ($dn =~ /CN\s*=\s*([^,\/]+)/) { + my $cn = $1; + $cn =~ s/^\s+|\s+$//g; + return $cn; + } + return undef; +} + +1; + +__END__ + +=head1 MODE + +Check the Juniper Mist Access Assurance (NAC) RADIUS server certificate and, +optionally, the imported CA certificates. + +A silent expiry of the RADIUS server certificate breaks every 802.1X +authentication at once, which is exactly the failure this mode guards against. +When no custom server certificate is configured, Mist manages and auto-renews +it, and the mode reports OK. + +=over 8 + +=item B<--check-cas> + +Also check the imported CA certificates (C). Disabled by +default. + +=item B<--filter-certificate-cn> + +Only check CA certificates whose Common Name matches this regular expression. + +=item B<--warning-certificate-expiry> + +Warning threshold, in days, for the RADIUS server certificate expiry +(default: '60:', i.e. warn when fewer than 60 days remain). + +=item B<--critical-certificate-expiry> + +Critical threshold, in days, for the RADIUS server certificate expiry +(default: '30:', i.e. alert when fewer than 30 days remain, including an +already expired certificate). + +=item B<--warning-ca-expiry> + +Warning threshold, in days, for the imported CA certificates expiry +(default: '180:'). + +=item B<--critical-ca-expiry> + +Critical threshold, in days, for the imported CA certificates expiry +(default: '90:'). + +=back + +=cut diff --git a/src/cloud/juniper/mist/restapi/mode/devices.pm b/src/cloud/juniper/mist/restapi/mode/devices.pm new file mode 100644 index 0000000000..01170ce28b --- /dev/null +++ b/src/cloud/juniper/mist/restapi/mode/devices.pm @@ -0,0 +1,277 @@ +# +# Copyright 2026-Present 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 cloud::juniper::mist::restapi::mode::devices; + +use base qw(centreon::plugins::templates::counter); + +use strict; +use warnings; +use centreon::plugins::constants qw(:counters); +use centreon::plugins::templates::catalog_functions qw(catalog_status_threshold_ng); + +sub prefix_type_output { + my ($self, %options) = @_; + + return "Device type '" . $options{instance_value}->{type} . "' "; +} + +sub set_counters { + my ($self, %options) = @_; + + $self->{maps_counters_type} = [ + { name => 'global', type => COUNTER_TYPE_GLOBAL }, + { name => 'types', type => COUNTER_TYPE_INSTANCE, cb_prefix_output => 'prefix_type_output', + message_multiple => 'All device types are OK', skipped_code => { -10 => 1 } }, + { name => 'devices', type => COUNTER_TYPE_INSTANCE, prefix_output => "Device '%{name}' [%{type}] ", + message_multiple => 'All monitored devices are OK', skipped_code => { -10 => 1 } } + ]; + + $self->{maps_counters}->{global} = [ + { + label => 'total', nlabel => 'mist.devices.total.count', display_ok => 0, threshold => 0, + set => { + key_values => [ { name => 'total' } ], + output_template => 'total devices: %d', + perfdatas => [ + { template => '%d', min => 0 } + ] + } + }, + { + label => 'devices-disconnected', nlabel => 'mist.devices.disconnected.count', + set => { + key_values => [ { name => 'disconnected' }, { name => 'total' } ], + output_template => 'disconnected: %d', + perfdatas => [ + { template => '%d', min => 0, max => 'total' } + ] + } + }, + { + label => 'devices-disconnected-prct', nlabel => 'mist.devices.disconnected.percentage', display_ok => 0, + set => { + key_values => [ { name => 'disconnected_prct' } ], + output_template => 'disconnected: %.2f%%', + perfdatas => [ + { template => '%.2f', unit => '%', min => 0, max => 100 } + ] + } + } + ]; + + $self->{maps_counters}->{types} = [ + { + label => 'type-disconnected', nlabel => 'mist.devices.disconnected.count', threshold => 0, + set => { + key_values => [ { name => 'disconnected' }, { name => 'total' }, { name => 'type' } ], + output_template => 'disconnected: %d', + perfdatas => [ + { template => '%d', min => 0, max => 'total', label_extra_instance => 1, instance_use => 'type' } + ] + } + } + ]; + + $self->{maps_counters}->{devices} = [ + { + label => 'status', + type => COUNTER_KIND_TEXT, + # Evaluated once per disconnected device (catalog_status_threshold_ng + # runs per instance), so a maintenance window written as a negative + # match silences only the devices it names and never neutralises the + # alert for the others: + # --critical-status='%{status} ne "connected" && %{name} !~ /SW-MAINT/' + # The aggregate %{disconnected}, %{disconnected_prct} and %{total} + # are also exposed for count-based expressions. Silenced devices stay + # counted in the disconnected perfdata and are not degrading. + critical_default => '%{status} ne "connected"', + display_ok => 0, + set => { + key_values => [ + { name => 'status' }, { name => 'name' }, { name => 'type' }, + { name => 'disconnected' }, { name => 'disconnected_prct' }, { name => 'total' } + ], + output_template => "status: %{status}", + closure_custom_threshold_check => \&catalog_status_threshold_ng + } + } + ]; +} + +sub new { + my ($class, %options) = @_; + my $self = $class->SUPER::new(package => __PACKAGE__, %options, force_new_perfdata => 1); + bless $self, $class; + + $options{options}->add_options(arguments => { + 'site-id:s' => { name => 'site_id' }, + 'filter-device-type:s' => { name => 'filter_device_type' }, + 'filter-device-name:s' => { name => 'filter_device_name' }, + 'exclude-device-name:s' => { name => 'exclude_device_name' } + }); + + return $self; +} + +# /stats/devices honours site_id server-side, so the filter is passed straight +# through and only matching devices are downloaded. The endpoint returns a bare +# JSON array paginated with page/limit. +sub manage_selection { + my ($self, %options) = @_; + + my $org_id = $options{custom}->get_org_id(); + my @get_param = ('type=all'); + push @get_param, 'site_id=' . $self->{option_results}->{site_id} + if (defined($self->{option_results}->{site_id}) && $self->{option_results}->{site_id} ne ''); + + my $devices = $options{custom}->request_api_paginated( + endpoint => "/api/v1/orgs/$org_id/stats/devices", + get_param => \@get_param + ); + + $self->{output}->option_exit(short_msg => "Unexpected response for /stats/devices (not an array).") + if (ref($devices) ne 'ARRAY'); + + my ($total, $disconnected) = (0, 0); + my %by_type; + my @disconnected_devices; + + foreach my $device (@$devices) { + my $type = $device->{type} // 'unknown'; + my $name = (defined($device->{name}) && $device->{name} ne '') ? $device->{name} : ($device->{mac} // 'unknown'); + + next if (defined($self->{option_results}->{filter_device_type}) && $self->{option_results}->{filter_device_type} ne '' + && $type !~ /$self->{option_results}->{filter_device_type}/i); + next if (defined($self->{option_results}->{filter_device_name}) && $self->{option_results}->{filter_device_name} ne '' + && $name !~ /$self->{option_results}->{filter_device_name}/i); + next if (defined($self->{option_results}->{exclude_device_name}) && $self->{option_results}->{exclude_device_name} ne '' + && $name =~ /$self->{option_results}->{exclude_device_name}/i); + + $total++; + $by_type{$type}->{total}++; + $by_type{$type}->{disconnected} //= 0; + + my $status = $device->{status} // 'unknown'; + if ($status ne 'connected') { + $disconnected++; + $by_type{$type}->{disconnected}++; + # Key the instance on the MAC (always present, unique) rather than the + # display name, so two devices sharing a name are not collapsed. + push @disconnected_devices, { + instance => $device->{mac} // $device->{id} // $name, + name => $name, type => $type, status => $status + }; + } + } + + $self->{output}->option_exit(short_msg => "No device matched the filters.") + if ($total == 0); + + my $disconnected_prct = $total > 0 ? $disconnected * 100 / $total : 0; + + $self->{global} = { + total => $total, + disconnected => $disconnected, + disconnected_prct => $disconnected_prct + }; + + $self->{types} = {}; + foreach my $type (keys %by_type) { + $self->{types}->{$type} = { + type => $type, + total => $by_type{$type}->{total}, + disconnected => $by_type{$type}->{disconnected} + }; + } + + # Only disconnected devices become instances: a maintenance-silenced device + # is by definition disconnected, so it is still instantiated (counted and + # evaluated) but the operator's negative-match rule keeps it non-degrading. + $self->{devices} = {}; + foreach my $device (@disconnected_devices) { + $self->{devices}->{ $device->{instance} } = { + name => $device->{name}, + type => $device->{type}, + status => $device->{status}, + disconnected => $disconnected, + disconnected_prct => $disconnected_prct, + total => $total + }; + } +} + +1; + +__END__ + +=head1 MODE + +Check the connectivity of Juniper Mist devices (access points, switches, +gateways) from the C endpoint. + +By default, any disconnected device raises a CRITICAL. A device under +maintenance can be silenced with a negative-match C<--critical-status> rule +while remaining counted in the disconnected metric. + +=over 8 + +=item B<--site-id> + +Restrict the check to a single site (honoured server-side by the Mist API). + +=item B<--filter-device-type> + +Only keep devices whose type matches this regular expression (e.g. 'ap', +'switch', 'gateway'). + +=item B<--filter-device-name> + +Only keep devices whose name matches this regular expression. + +=item B<--exclude-device-name> + +Exclude devices whose name matches this regular expression. + +=item B<--warning-status> + +Define the conditions to match for the status to be WARNING (per device). +You can use the following variables: %{status}, %{name}, %{type}, +%{disconnected}, %{disconnected_prct}, %{total}. + +=item B<--critical-status> + +Define the conditions to match for the status to be CRITICAL (per device) +(default: '%{status} ne "connected"'). + +Example - silence a switch under maintenance while still alerting on the rest: +--critical-status='%{status} ne "connected" && %{name} !~ /SW-MAINT/' + +=item B<--warning-devices-disconnected> B<--critical-devices-disconnected> + +Threshold on the absolute number of disconnected devices (no default). + +=item B<--warning-devices-disconnected-prct> B<--critical-devices-disconnected-prct> + +Threshold on the percentage of disconnected devices (no default). + +=back + +=cut diff --git a/src/cloud/juniper/mist/restapi/mode/licenses.pm b/src/cloud/juniper/mist/restapi/mode/licenses.pm new file mode 100644 index 0000000000..78bf34f276 --- /dev/null +++ b/src/cloud/juniper/mist/restapi/mode/licenses.pm @@ -0,0 +1,135 @@ +# +# Copyright 2026-Present 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 cloud::juniper::mist::restapi::mode::licenses; + +use base qw(centreon::plugins::templates::counter); + +use strict; +use warnings; +use centreon::plugins::constants qw(:counters); +use POSIX qw(floor); + +sub set_counters { + my ($self, %options) = @_; + + $self->{maps_counters_type} = [ + { name => 'licenses', type => COUNTER_TYPE_INSTANCE, prefix_output => "License '%{type}' ", + message_multiple => 'All licenses are OK', skipped_code => { -10 => 1 } } + ]; + + $self->{maps_counters}->{licenses} = [ + { + label => 'license-expiry', + nlabel => 'mist.license.expiry.days', + # '90:'/'30:' fire when fewer than 90/30 days remain; a negative + # value (already expired) is below the range too, so it alerts. + warning_default => '90:', + critical_default => '30:', + set => { + key_values => [ { name => 'expiration' }, { name => 'type' } ], + output_template => 'expires in %{expiration} days', + perfdatas => [ + { template => '%d', unit => 'd', label_extra_instance => 1, instance_use => 'type' } + ] + } + } + ]; +} + +sub new { + my ($class, %options) = @_; + my $self = $class->SUPER::new(package => __PACKAGE__, %options, force_new_perfdata => 1); + bless $self, $class; + + $options{options}->add_options(arguments => { + 'filter-license-type:s' => { name => 'filter_license_type' } + }); + + return $self; +} + +sub manage_selection { + my ($self, %options) = @_; + + my $org_id = $options{custom}->get_org_id(); + my $data = $options{custom}->request_api(endpoint => "/api/v1/orgs/$org_id/licenses"); + + my $licenses = (ref($data) eq 'HASH' && ref($data->{licenses}) eq 'ARRAY') ? $data->{licenses} : []; + $self->{output}->option_exit(short_msg => "No license found for this organization.") + if (!@$licenses); + + # Keep the earliest expiry per subscription type: that is the one that will + # bite first, and reporting every individual entitlement would be noise. + my %closest; + foreach my $license (@$licenses) { + my $end = $license->{end_time} // 0; + next if ($end <= 0); + + my $type = $license->{type} // $license->{subscription_id} // 'unknown'; + + next if (defined($self->{option_results}->{filter_license_type}) && $self->{option_results}->{filter_license_type} ne '' + && $type !~ /$self->{option_results}->{filter_license_type}/i); + + my $days = floor(($end - time()) / 86400); + $closest{$type} = $days if (!exists($closest{$type}) || $days < $closest{$type}); + } + + $self->{output}->option_exit(short_msg => "No license with an expiry date found.") + if (!%closest); + + $self->{licenses} = {}; + foreach my $type (keys %closest) { + $self->{licenses}->{$type} = { + type => $type, + expiration => $closest{$type} + }; + } +} + +1; + +__END__ + +=head1 MODE + +Check the expiry of Juniper Mist organization licenses from the C +endpoint. For each subscription type, only the earliest expiry is reported: it is +the one that will bite first. + +=over 8 + +=item B<--filter-license-type> + +Only check licenses whose type matches this regular expression. + +=item B<--warning-license-expiry> + +Warning threshold, in days, for license expiry (default: '90:', i.e. warn when +fewer than 90 days remain). + +=item B<--critical-license-expiry> + +Critical threshold, in days, for license expiry (default: '30:', including an +already expired license). + +=back + +=cut diff --git a/src/cloud/juniper/mist/restapi/mode/listsites.pm b/src/cloud/juniper/mist/restapi/mode/listsites.pm new file mode 100644 index 0000000000..90b4595a06 --- /dev/null +++ b/src/cloud/juniper/mist/restapi/mode/listsites.pm @@ -0,0 +1,152 @@ +# +# Copyright 2026-Present 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 cloud::juniper::mist::restapi::mode::listsites; + +use base qw(centreon::plugins::templates::counter); + +use strict; +use warnings; + +sub new { + my ($class, %options) = @_; + my $self = $class->SUPER::new(package => __PACKAGE__, %options); + bless $self, $class; + + $options{options}->add_options(arguments => { + 'filter-name:s' => { name => 'filter_name' } + }); + + return $self; +} + +sub check_options { + my ($self, %options) = @_; + $self->SUPER::check_options(%options); +} + +sub manage_selection { + my ($self, %options) = @_; + + my $org_id = $options{custom}->get_org_id(); + my $sites = $options{custom}->request_api(endpoint => "/api/v1/orgs/$org_id/sites"); + + $self->{output}->option_exit(short_msg => "Unexpected response for /sites (not an array).") + if (ref($sites) ne 'ARRAY'); + + $self->{sites} = {}; + foreach my $site (@$sites) { + next if (!defined($site->{id})); + my $name = $site->{name} // ''; + next if (defined($self->{option_results}->{filter_name}) && $self->{option_results}->{filter_name} ne '' + && $name !~ /$self->{option_results}->{filter_name}/i); + + # UTF-8 is handled natively: json_decode returns character strings and + # the framework's XML::LibXML disco output encodes them correctly, so a + # site named "S├úo Paulo" comes out as valid XML without any manual + # binmode or escaping. + $self->{sites}->{ $site->{id} } = { + id => $site->{id}, + name => $name, + country_code => $site->{country_code} // '', + timezone => $site->{timezone} // '' + }; + } +} + +sub run { + my ($self, %options) = @_; + + # Site names carry UTF-8 (e.g. "S├úo Paulo"). centreon::plugins::misc::json_decode + # returns decoded character strings, but output.pm applies an encoding layer + # only on its JSON and XML paths - the plain-text output path prints as-is, + # so without an explicit layer these characters degrade to raw Latin-1 and + # the human listing is corrupted. We therefore set the UTF-8 layer locally + # for the plain-text case only. + # This is deliberately scoped: --disco-show/--disco-format serialise through + # XML::LibXML and --output-format json through JSON::XS, both of which emit + # correctly-encoded bytes on their own; adding the layer there would + # double-encode. Hence the guard against the byte-producing output formats. + binmode(STDOUT, ':encoding(UTF-8)') + if (!defined($self->{option_results}->{output_json}) + && !defined($self->{option_results}->{output_xml}) + && !defined($self->{option_results}->{output_openmetrics})); + + $self->manage_selection(%options); + foreach my $id (sort { $self->{sites}->{$a}->{name} cmp $self->{sites}->{$b}->{name} } keys %{$self->{sites}}) { + $self->{output}->output_add( + long_msg => sprintf( + "[id: %s] [name: %s] [country_code: %s] [timezone: %s]", + $self->{sites}->{$id}->{id}, + $self->{sites}->{$id}->{name}, + $self->{sites}->{$id}->{country_code}, + $self->{sites}->{$id}->{timezone} + ) + ); + } + + $self->{output}->output_add( + severity => 'OK', + short_msg => sprintf('List sites [total: %d]:', scalar(keys %{$self->{sites}})) + ); + $self->{output}->display(nolabel => 1, force_ignore_perfdata => 1, force_long_output => 1); + $self->{output}->exit(); +} + +sub disco_format { + my ($self, %options) = @_; + + $self->{output}->add_disco_format(elements => ['id', 'name', 'country_code', 'timezone']); +} + +sub disco_show { + my ($self, %options) = @_; + + $self->manage_selection(%options); + foreach my $id (sort keys %{$self->{sites}}) { + $self->{output}->add_disco_entry( + id => $self->{sites}->{$id}->{id}, + name => $self->{sites}->{$id}->{name}, + country_code => $self->{sites}->{$id}->{country_code}, + timezone => $self->{sites}->{$id}->{timezone} + ); + } +} + +1; + +__END__ + +=head1 MODE + +List the sites of a Juniper Mist organization. + +Used for service discovery: pair it with C<--disco-format> to list the available +attributes and C<--disco-show> to return the discovered sites as XML. + +=over 8 + +=item B<--filter-name> + +Filter sites by name (can be a regular expression). + +=back + +=cut diff --git a/src/cloud/juniper/mist/restapi/mode/nacauthentications.pm b/src/cloud/juniper/mist/restapi/mode/nacauthentications.pm new file mode 100644 index 0000000000..9f5f8dfefd --- /dev/null +++ b/src/cloud/juniper/mist/restapi/mode/nacauthentications.pm @@ -0,0 +1,398 @@ +# +# Copyright 2026-Present 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 cloud::juniper::mist::restapi::mode::nacauthentications; + +use base qw(centreon::plugins::templates::counter); + +use strict; +use warnings; +use centreon::plugins::constants qw(:counters); +use centreon::plugins::templates::catalog_functions qw(catalog_status_threshold_ng); +use centreon::plugins::misc qw(json_decode); + +# The composite (dual-condition) threshold is the whole point of this mode. A +# ratio alone is not comparable across sites of very different sizes: one denied +# client on a site seeing 3 authentications an hour is 33% (noise), while 7% on a +# site handling 800 verdicts is ~60 real failures. The status therefore degrades +# only when the ratio AND the absolute number of denials AND a minimum sample +# size are all reached at once. +my $warning_composite = '%{total} >= 10 && %{deny_prct} >= 30 && %{deny} >= 5'; +my $critical_composite = '%{total} >= 10 && %{deny_prct} >= 50 && %{deny} >= 15'; + +sub skip_aggregate { + my ($self, %options) = @_; + + # In per-site mode the per-site statuses drive the verdict, so the + # organization-wide aggregate status must not be evaluated. + return ($self->{option_results}->{per_site}) ? 1 : 0; +} + +sub skip_sites { + my ($self, %options) = @_; + + return ($self->{option_results}->{per_site}) ? 0 : 1; +} + +sub set_counters { + my ($self, %options) = @_; + + $self->{maps_counters_type} = [ + { name => 'global', type => COUNTER_TYPE_GLOBAL }, + { name => 'aggregate', type => COUNTER_TYPE_GLOBAL, cb_init => 'skip_aggregate' }, + { name => 'sites', type => COUNTER_TYPE_INSTANCE, prefix_output => "Site '%{site_name}' ", + message_multiple => 'All sites NAC verdicts are OK', cb_init => 'skip_sites', skipped_code => { -10 => 1 } } + ]; + + $self->{maps_counters}->{global} = [ + { + label => 'deny-count', nlabel => 'mist.nac.verdicts.deny.count', + set => { + key_values => [ { name => 'deny' } ], + output_template => 'deny: %d', + perfdatas => [ { template => '%d', min => 0 } ] + } + }, + { + label => 'deny-prct', nlabel => 'mist.nac.verdicts.deny.percentage', display_ok => 0, + set => { + key_values => [ { name => 'deny_prct' } ], + output_template => 'deny rate: %.2f%%', + perfdatas => [ { template => '%.2f', unit => '%', min => 0, max => 100 } ] + } + }, + { + label => 'verdicts-total', nlabel => 'mist.nac.verdicts.total.count', + set => { + key_values => [ { name => 'total' } ], + output_template => 'total: %d', + perfdatas => [ { template => '%d', min => 0 } ] + } + }, + { + label => 'verdicts-permit', nlabel => 'mist.nac.verdicts.permit.count', display_ok => 0, threshold => 0, + set => { + key_values => [ { name => 'permit' } ], + output_template => 'permit: %d', + perfdatas => [ { template => '%d', min => 0 } ] + } + }, + { + label => 'cert-validation-failure', nlabel => 'mist.nac.server.certificate.validation.failure.count', threshold => 0, + set => { + key_values => [ { name => 'server_cert_failure' } ], + output_template => 'server cert validation failures: %d', + perfdatas => [ { template => '%d', min => 0 } ] + } + } + ]; + + $self->{maps_counters}->{aggregate} = [ + { + label => 'status', + type => COUNTER_KIND_TEXT, + warning_default => $warning_composite, + critical_default => $critical_composite, + set => { + key_values => [ + { name => 'total' }, { name => 'deny_prct' }, { name => 'deny' }, + { name => 'permit' }, { name => 'server_cert_failure' }, + { name => 'site_name' }, { name => 'site_id' } + ], + output_template => '%{site_name}: deny rate %{deny_prct|%.2f}%% (%{deny} deny / %{total} verdicts)', + closure_custom_threshold_check => \&catalog_status_threshold_ng + } + } + ]; + + $self->{maps_counters}->{sites} = [ + { + label => 'status', + type => COUNTER_KIND_TEXT, + warning_default => $warning_composite, + critical_default => $critical_composite, + set => { + key_values => [ + { name => 'total' }, { name => 'deny_prct' }, { name => 'deny' }, + { name => 'permit' }, { name => 'server_cert_failure' }, + { name => 'site_name' }, { name => 'site_id' } + ], + output_template => 'deny rate %{deny_prct|%.2f}%% (%{deny} deny / %{total} verdicts)', + closure_custom_threshold_check => \&catalog_status_threshold_ng + } + }, + { + label => 'site-deny-count', nlabel => 'mist.nac.site.verdicts.deny.count', threshold => 0, + set => { + key_values => [ { name => 'deny' }, { name => 'site_name' } ], + output_template => 'deny: %d', + perfdatas => [ { template => '%d', min => 0, label_extra_instance => 1, instance_use => 'site_name' } ] + } + }, + { + label => 'site-deny-prct', nlabel => 'mist.nac.site.verdicts.deny.percentage', threshold => 0, + set => { + key_values => [ { name => 'deny_prct' }, { name => 'site_name' } ], + output_template => 'deny rate: %.2f%%', + perfdatas => [ { template => '%.2f', unit => '%', min => 0, max => 100, label_extra_instance => 1, instance_use => 'site_name' } ] + } + }, + { + label => 'site-verdicts-total', nlabel => 'mist.nac.site.verdicts.total.count', threshold => 0, + set => { + key_values => [ { name => 'total' }, { name => 'site_name' } ], + output_template => 'total: %d', + perfdatas => [ { template => '%d', min => 0, label_extra_instance => 1, instance_use => 'site_name' } ] + } + } + ]; +} + +sub new { + my ($class, %options) = @_; + my $self = $class->SUPER::new(package => __PACKAGE__, %options, force_new_perfdata => 1); + bless $self, $class; + + $options{options}->add_options(arguments => { + 'timeframe:s' => { name => 'timeframe', type => 'numeric', default => 3600 }, + 'site-id:s' => { name => 'site_id' }, + 'per-site' => { name => 'per_site' }, + 'filter-site-name:s' => { name => 'filter_site_name' }, + 'filter-ssid:s' => { name => 'filter_ssid' } + }); + + return $self; +} + +sub run { + my ($self, %options) = @_; + + # Per-site perfdata instances are site names, which carry UTF-8 (e.g. + # "S├úo Paulo"). centreon::plugins::misc::json_decode returns decoded + # character strings, but output.pm applies an encoding layer only on its + # JSON and XML paths - the plain-text/perfdata output path prints as-is, so + # without an explicit layer these characters degrade to raw Latin-1 and the + # perfdata instance labels are corrupted (and unstable across polls). We + # therefore set the UTF-8 layer locally for the plain-text case only. + # Guarded against the byte-producing output formats (JSON/XML/OpenMetrics), + # which serialise through their own encoders and would be double-encoded. + binmode(STDOUT, ':encoding(UTF-8)') + if (!defined($self->{option_results}->{output_json}) + && !defined($self->{option_results}->{output_xml}) + && !defined($self->{option_results}->{output_openmetrics})); + + $self->SUPER::run(%options); +} + +# Resolve site UUID -> human name. Fault-tolerant: on any error we fall back to +# the raw UUID, so a transient /sites failure never breaks the NAC check itself. +# We must use no_exit_on_error here: request_api's error path calls option_exit +# (which exits the process, not die), so an eval would not catch it. +sub get_site_names { + my ($self, %options) = @_; + + my %names; + my $response = $options{custom}->request_api( + endpoint => "/api/v1/orgs/" . $options{org_id} . "/sites", + no_exit_on_error => 1, + unknown_status => '', warning_status => '', critical_status => '' + ); + + return \%names if (!defined($response->{code}) || $response->{code} < 200 || $response->{code} >= 300); + + my $sites = json_decode($response->{content}, silence => 1); + if (ref($sites) eq 'ARRAY') { + foreach my $site (@$sites) { + $names{ $site->{id} } = $site->{name} + if (defined($site->{id}) && defined($site->{name})); + } + } + + return \%names; +} + +# The NAC search endpoint ignores the site_id query parameter (verified caveat, +# documented in custom/api.pm), so per-site figures are computed here from the +# site_id field carried by each event, and --site-id is enforced client-side. +sub manage_selection { + my ($self, %options) = @_; + + my $org_id = $options{custom}->get_org_id(); + my $timeframe = $self->{option_results}->{timeframe}; + + my ($events, $truncated) = $options{custom}->request_api_search( + endpoint => "/api/v1/orgs/$org_id/nac_clients/events/search", + get_param => [ 'duration=' . $timeframe . 's' ] + ); + + my $site_names = $self->get_site_names(custom => $options{custom}, org_id => $org_id); + + my %global = (permit => 0, deny => 0, total => 0, server_cert_failure => 0); + my %per_site; + + foreach my $event (@$events) { + my $type = $event->{type} // ''; + next if ($type ne 'NAC_CLIENT_PERMIT' && $type ne 'NAC_CLIENT_DENY' && $type ne 'NAC_SERVER_CERT_VALIDATION_FAILURE'); + + my $site_id = $event->{site_id} // 'unknown'; + + # --site-id: hard restriction, applied client-side (API ignores it). + next if (defined($self->{option_results}->{site_id}) && $self->{option_results}->{site_id} ne '' + && $site_id ne $self->{option_results}->{site_id}); + + next if (defined($self->{option_results}->{filter_ssid}) && $self->{option_results}->{filter_ssid} ne '' + && (!defined($event->{ssid}) || $event->{ssid} !~ /$self->{option_results}->{filter_ssid}/)); + + my $site_name = $site_names->{$site_id} // $site_id; + + next if (defined($self->{option_results}->{filter_site_name}) && $self->{option_results}->{filter_site_name} ne '' + && $site_name !~ /$self->{option_results}->{filter_site_name}/); + + $per_site{$site_id} //= { + site_id => $site_id, site_name => $site_name, + permit => 0, deny => 0, total => 0, server_cert_failure => 0 + }; + + if ($type eq 'NAC_CLIENT_PERMIT') { + $global{permit}++; $global{total}++; + $per_site{$site_id}->{permit}++; $per_site{$site_id}->{total}++; + } elsif ($type eq 'NAC_CLIENT_DENY') { + $global{deny}++; $global{total}++; + $per_site{$site_id}->{deny}++; $per_site{$site_id}->{total}++; + } else { + $global{server_cert_failure}++; + $per_site{$site_id}->{server_cert_failure}++; + } + } + + my $deny_prct = $global{total} > 0 ? $global{deny} * 100 / $global{total} : 0; + + $self->{global} = { + deny => $global{deny}, + deny_prct => $deny_prct, + permit => $global{permit}, + total => $global{total}, + server_cert_failure => $global{server_cert_failure} + }; + + # Aggregate status scope: the single site when --site-id is set, otherwise + # the whole organization. + my $scope_name = 'organization'; + $scope_name = ($site_names->{ $self->{option_results}->{site_id} } // $self->{option_results}->{site_id}) + if (defined($self->{option_results}->{site_id}) && $self->{option_results}->{site_id} ne ''); + + $self->{aggregate} = { + total => $global{total}, deny_prct => $deny_prct, deny => $global{deny}, + permit => $global{permit}, server_cert_failure => $global{server_cert_failure}, + site_name => $scope_name, site_id => $self->{option_results}->{site_id} // 'org' + }; + + if ($self->{option_results}->{per_site}) { + $self->{output}->option_exit(short_msg => "No NAC event found over the last ${timeframe}s.") + if (!%per_site); + + $self->{sites} = {}; + foreach my $site_id (keys %per_site) { + my $entry = $per_site{$site_id}; + $self->{sites}->{$site_id} = { + site_id => $site_id, + site_name => $entry->{site_name}, + deny => $entry->{deny}, + deny_prct => $entry->{total} > 0 ? $entry->{deny} * 100 / $entry->{total} : 0, + permit => $entry->{permit}, + total => $entry->{total}, + server_cert_failure => $entry->{server_cert_failure} + }; + } + } + + $self->{output}->output_add(long_msg => 'Note: result truncated (--max-pages reached), counts are a lower bound.') + if ($truncated); +} + +1; + +__END__ + +=head1 MODE + +Check the Juniper Mist Access Assurance (NAC) 802.1X authentication verdicts +over a time window, from the C endpoint. + +The default status rule is a composite (dual-condition) threshold: the status +degrades only when the deny ratio, the absolute number of denials and a minimum +sample size are all reached at once. This avoids alerting on statistical noise +on low-traffic sites while still catching real outages on busy ones. + +The Mist NAC search endpoint ignores the C query parameter, so per-site +figures are computed client-side from each event's C. + +=over 8 + +=item B<--timeframe> + +Time window in seconds to look back for NAC events (default: 3600). + +=item B<--site-id> + +Restrict the check to a single site UUID (enforced client-side). + +=item B<--per-site> + +Evaluate and report one set of verdicts per site in a single check, instead of a +single organization-wide aggregate. + +=item B<--filter-site-name> + +Only keep events whose (resolved) site name matches this regular expression. + +=item B<--filter-ssid> + +Only keep events whose SSID matches this regular expression. + +=item B<--warning-status> + +Define the conditions to match for the status to be WARNING (evaluated per scope +/ per site). +Default: '%{total} >= 10 && %{deny_prct} >= 30 && %{deny} >= 5'. +Variables: %{total}, %{deny}, %{deny_prct}, %{permit}, %{server_cert_failure}, +%{site_name}, %{site_id}. + +=item B<--critical-status> + +Define the conditions to match for the status to be CRITICAL (evaluated per +scope / per site). +Default: '%{total} >= 10 && %{deny_prct} >= 50 && %{deny} >= 15'. + +=item B<--warning-deny-count> B<--critical-deny-count> + +Threshold on the absolute number of denied verdicts (no default). + +=item B<--warning-deny-prct> B<--critical-deny-prct> + +Threshold on the deny ratio in percent (no default). + +=item B<--warning-verdicts-total> B<--critical-verdicts-total> + +Threshold on the total number of verdicts (no default). + +=back + +=cut diff --git a/src/cloud/juniper/mist/restapi/plugin.pm b/src/cloud/juniper/mist/restapi/plugin.pm new file mode 100644 index 0000000000..f7a0847c29 --- /dev/null +++ b/src/cloud/juniper/mist/restapi/plugin.pm @@ -0,0 +1,55 @@ +# +# Copyright 2026-Present 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 cloud::juniper::mist::restapi::plugin; + +use strict; +use warnings; +use base qw(centreon::plugins::script_custom); + +sub new { + my ($class, %options) = @_; + my $self = $class->SUPER::new(package => __PACKAGE__, %options); + bless $self, $class; + + $self->{version} = '1.0'; + $self->{modes} = { + 'alarms' => 'cloud::juniper::mist::restapi::mode::alarms', + 'api-test' => 'cloud::juniper::mist::restapi::mode::apitest', + 'certificates' => 'cloud::juniper::mist::restapi::mode::certificates', + 'devices' => 'cloud::juniper::mist::restapi::mode::devices', + 'licenses' => 'cloud::juniper::mist::restapi::mode::licenses', + 'list-sites' => 'cloud::juniper::mist::restapi::mode::listsites', + 'nac-authentications' => 'cloud::juniper::mist::restapi::mode::nacauthentications' + }; + + $self->{custom_modes}->{api} = 'cloud::juniper::mist::restapi::custom::api'; + return $self; +} + +1; + +__END__ + +=head1 PLUGIN DESCRIPTION + +Check Juniper Mist cloud-managed networks through the Mist REST API. + +=cut diff --git a/tests/resources/spellcheck/stopwords.txt b/tests/resources/spellcheck/stopwords.txt index e9375126d4..d7c5419379 100644 --- a/tests/resources/spellcheck/stopwords.txt +++ b/tests/resources/spellcheck/stopwords.txt @@ -173,6 +173,7 @@ jobqueues Jolokia journalctl JSONPath +Juniper Kairos kccevent keepass @@ -209,6 +210,7 @@ Microkernel Mikrotik milliwatt milliwatts +Mist MMC module-cellradio-csq module-cellradio-rscp @@ -233,6 +235,7 @@ MTU multiple mW MX +NAC nagios Nagios NagVis @@ -305,6 +308,7 @@ QoS Qtree queue-messages-inflighted quts +RADIUS raidvolume redis-cli ReplicaJobSession @@ -318,6 +322,8 @@ RSS rssi RTT Rubrik +SSID +UUID µs Sansymphony SAS