diff --git a/lib/App/Netdisco/JobQueue/PostgreSQL.pm b/lib/App/Netdisco/JobQueue/PostgreSQL.pm index c8faf5b8..4f256ba4 100644 --- a/lib/App/Netdisco/JobQueue/PostgreSQL.pm +++ b/lib/App/Netdisco/JobQueue/PostgreSQL.pm @@ -270,6 +270,7 @@ sub jq_complete { log => $job->log, started => $job->started, finished => $job->finished, + (($job->action eq 'hook') ? (subaction => undef) : ()), }); }); $happy = true; @@ -284,6 +285,7 @@ sub jq_complete { sub jq_log { return schema('netdisco')->resultset('Admin')->search({ + { 'me.action' => { '-not_like' => 'hook::%' } }, -or => [ { 'me.log' => undef }, { 'me.log' => { '-not_like' => 'duplicate of %' } }, diff --git a/lib/App/Netdisco/Util/Permission.pm b/lib/App/Netdisco/Util/Permission.pm index 39a3d357..74504f10 100644 --- a/lib/App/Netdisco/Util/Permission.pm +++ b/lib/App/Netdisco/Util/Permission.pm @@ -29,8 +29,8 @@ subroutines. =head2 check_acl_no( $ip | $instance, $setting_name | $acl_entry | \@acl ) Given an IP address or object instance, returns true if the configuration -setting C<$setting_name> matches, else returns false. If the setting is -undefined or empty, then C also returns false. +setting C<$setting_name> matches, else returns false. If the content of the +setting is undefined or empty, then C also returns false. If C<$setting_name> is a valid setting, then it will be resolved to the access control list, else we assume you passed an ACL entry or ACL. @@ -51,8 +51,8 @@ sub check_acl_no { =head2 check_acl_only( $ip | $instance, $setting_name | $acl_entry | \@acl ) Given an IP address or object instance, returns true if the configuration -setting C<$setting_name> matches, else returns false. If the setting is -undefined or empty, then C also returns true. +setting C<$setting_name> matches, else returns false. If the content of the +setting is undefined or empty, then C also returns true. If C<$setting_name> is a valid setting, then it will be resolved to the access control list, else we assume you passed an ACL entry or ACL. diff --git a/lib/App/Netdisco/Util/Worker.pm b/lib/App/Netdisco/Util/Worker.pm new file mode 100644 index 00000000..51213b04 --- /dev/null +++ b/lib/App/Netdisco/Util/Worker.pm @@ -0,0 +1,34 @@ +package App::Netdisco::Util::Worker; + +use Dancer ':syntax'; +use App::Netdisco::JobQueue 'jq_insert'; + +use Encode 'encode'; +use MIME::Base64 'encode_base64'; + +use Storable 'dclone'; +use Data::Visitor::Tiny; + +use base 'Exporter'; +our @EXPORT = ('queue_hook'); + +sub queue_hook { + my ($hook, $conf) = @_; + my $extra = { action_conf => dclone ($conf->{'with'} || {}), + event_data => dclone (vars->{'hook_data'} || {}) }; + + # remove scalar references which to_json cannot handle + visit( $extra->{'event_data'}, sub { + my ($key, $valueref) = @_; + $$valueref = '' if ref $$valueref eq 'SCALAR'; + }); + + jq_insert({ + action => ('hook::'. lc($conf->{'type'})), + extra => encode_base64( encode('UTF-8', to_json( $extra )) ), + }); + + return 1; +} + +true; diff --git a/lib/App/Netdisco/Web/AdminTask.pm b/lib/App/Netdisco/Web/AdminTask.pm index 479a1a31..1eb7ee83 100644 --- a/lib/App/Netdisco/Web/AdminTask.pm +++ b/lib/App/Netdisco/Web/AdminTask.pm @@ -32,6 +32,8 @@ sub add_job { foreach my $action (@{ setting('job_prio')->{high} }, @{ setting('job_prio')->{normal} }) { + next if $action and $action =~ m/^hook::/; # skip hooks + ajax "/ajax/control/admin/$action" => require_role admin => sub { add_job($action, param('device'), param('extra')) or send_error('Bad device', 400); diff --git a/lib/App/Netdisco/Worker/Plugin.pm b/lib/App/Netdisco/Worker/Plugin.pm index 6eca1d72..40a6a208 100644 --- a/lib/App/Netdisco/Worker/Plugin.pm +++ b/lib/App/Netdisco/Worker/Plugin.pm @@ -46,7 +46,7 @@ register 'register_worker' => sub { # support part-actions via action::namespace if ($job->only_namespace and $workerconf->{phase} ne 'check') { return unless $workerconf->{namespace} eq lc( $job->only_namespace ) - or (($workerconf->{phase} eq 'early') + or (($job->only_namespace ne 'hooks') and ($workerconf->{phase} eq 'early') and ($job->device and not $job->device->in_storage)); } diff --git a/lib/App/Netdisco/Worker/Plugin/Discover/Hooks.pm b/lib/App/Netdisco/Worker/Plugin/Discover/Hooks.pm new file mode 100644 index 00000000..0404a759 --- /dev/null +++ b/lib/App/Netdisco/Worker/Plugin/Discover/Hooks.pm @@ -0,0 +1,32 @@ +package App::Netdisco::Worker::Plugin::Discover::Hooks; + +use Dancer ':syntax'; +use App::Netdisco::Worker::Plugin; +use aliased 'App::Netdisco::Worker::Status'; + +use App::Netdisco::Util::Worker; +use App::Netdisco::Util::Permission qw/check_acl_no check_acl_only/; + +register_worker({ phase => 'late' }, sub { + my ($job, $workerconf) = @_; + my $count = 0; + + foreach my $conf (@{ setting('hooks') }) { + my $no = ($conf->{'filter'}->{'no'} || []); + my $only = ($conf->{'filter'}->{'only'} || []); + + next if check_acl_no( $job->device, $no ); + next unless check_acl_only( $job->device, $only); + + $count += queue_hook('new_device', $conf) + if vars->{'new_device'} and $conf->{'event'} eq 'new_device'; + + $count += queue_hook('discover', $conf) + if $conf->{'event'} eq 'discover'; + } + + return Status + ->info(sprintf ' [%s] hooks - %d queued', $job->device, $count); +}); + +true; diff --git a/lib/App/Netdisco/Worker/Plugin/Discover/Properties.pm b/lib/App/Netdisco/Worker/Plugin/Discover/Properties.pm index 1e80e7f1..ff90da55 100644 --- a/lib/App/Netdisco/Worker/Plugin/Discover/Properties.pm +++ b/lib/App/Netdisco/Worker/Plugin/Discover/Properties.pm @@ -71,6 +71,13 @@ register_worker({ phase => 'early', driver => 'snmp' }, sub { } } + # support for Hooks + vars->{'hook_data'} = { $device->get_columns }; + delete vars->{'hook_data'}->{'snmp_comm'}; # for privacy + + # support for new_device Hook + vars->{'new_device'} = 1 if not $device->in_storage; + schema('netdisco')->txn_do(sub { $device->update_or_insert(undef, {for => 'update'}); return Status->done("Ended discover for $device"); @@ -149,6 +156,9 @@ register_worker({ phase => 'early', driver => 'snmp' }, sub { push @$resolved_aliases, { alias => $device->ip, dns => $device->dns } if 0 == scalar grep {$_->{alias} eq $device->ip} @aliases; + # support for Hooks + vars->{'hook_data'}->{'device_ips'} = $resolved_aliases; + schema('netdisco')->txn_do(sub { my $gone = $device->device_ips->delete; debug sprintf ' [%s] device - removed %d aliases', @@ -228,7 +238,7 @@ register_worker({ phase => 'early', driver => 'snmp' }, sub { if (exists $i_ignore->{$entry}) { debug sprintf ' [%s] interfaces - ignoring %s (%s) (%s)', - $device->ip, $entry, $port, $i_type->{$entry}; + $device->ip, $entry, $port, ($i_type->{$entry} || ''); next; } @@ -298,6 +308,9 @@ register_worker({ phase => 'early', driver => 'snmp' }, sub { $interfaces{$master}->{is_master} = 'true'; } + # support for Hooks + vars->{'hook_data'}->{'ports'} = [values %interfaces]; + schema('netdisco')->resultset('DevicePort')->txn_do_locked(sub { my $gone = $device->ports->delete({keep_nodes => 1}); debug sprintf ' [%s] interfaces - removed %d interfaces', diff --git a/lib/App/Netdisco/Worker/Plugin/Discover/VLANs.pm b/lib/App/Netdisco/Worker/Plugin/Discover/VLANs.pm index 2bf99039..d718cc2e 100644 --- a/lib/App/Netdisco/Worker/Plugin/Discover/VLANs.pm +++ b/lib/App/Netdisco/Worker/Plugin/Discover/VLANs.pm @@ -124,6 +124,9 @@ register_worker({ phase => 'main', driver => 'snmp' }, sub { }; } + # support for Hooks + vars->{'hook_data'}->{'vlans'} = \@devicevlans; + schema('netdisco')->txn_do(sub { my $gone = $device->vlans->delete; debug sprintf ' [%s] vlans - removed %d device VLANs', diff --git a/lib/App/Netdisco/Worker/Plugin/Hook.pm b/lib/App/Netdisco/Worker/Plugin/Hook.pm new file mode 100644 index 00000000..f9f2dd76 --- /dev/null +++ b/lib/App/Netdisco/Worker/Plugin/Hook.pm @@ -0,0 +1,16 @@ +package App::Netdisco::Worker::Plugin::Hook; + +use Dancer ':syntax'; +use App::Netdisco::Worker::Plugin; +use aliased 'App::Netdisco::Worker::Status'; + +register_worker({ phase => 'check' }, sub { + my ($job, $workerconf) = @_; + + return Status->error('can only run a specific hook') + unless $job->action eq 'hook' and defined $job->only_namespace; + + return Status->done('Hook is able to run.'); +}); + +true; diff --git a/lib/App/Netdisco/Worker/Plugin/Hook/HTTP.pm b/lib/App/Netdisco/Worker/Plugin/Hook/HTTP.pm new file mode 100644 index 00000000..1bd5234f --- /dev/null +++ b/lib/App/Netdisco/Worker/Plugin/Hook/HTTP.pm @@ -0,0 +1,63 @@ +package App::Netdisco::Worker::Plugin::Hook::HTTP; + +use Dancer ':syntax'; +use App::Netdisco::Worker::Plugin; +use aliased 'App::Netdisco::Worker::Status'; + +use MIME::Base64 'decode_base64'; +use HTTP::Tiny; +use Template; + +register_worker({ phase => 'main' }, sub { + my ($job, $workerconf) = @_; + my $extra = from_json( decode_base64( $job->extra || '' ) ); + + my $event_data = $extra->{'event_data'}; + my $action_conf = $extra->{'action_conf'}; + $action_conf->{'body'} ||= to_json($event_data); + + return Status->error('missing url parameter to http Hook') + if !defined $action_conf->{'url'}; + + my $tt = Template->new({ ENCODING => 'utf8' }); + my $http = HTTP::Tiny + ->new( timeout => (($action_conf->{'timeout'} || 5000) / 1000) ); + + $action_conf->{'custom_headers'} ||= {}; + $action_conf->{'custom_headers'}->{'Content-Type'} + ||= 'application/json; charset=UTF-8'; + $action_conf->{'custom_headers'}->{'Authorization'} + = ('Bearer '. $action_conf->{'bearer_token'}) + if $action_conf->{'bearer_token'}; + + my ($orig_url, $url) = ($action_conf->{'url'}, undef); + $action_conf->{'url_is_template'} ||= 1 + if !exists $action_conf->{'url_is_template'}; + $tt->process(\$orig_url, $event_data, \$url) + if $action_conf->{'url_is_template'}; + $url ||= $orig_url; + + my ($orig_body, $body) = ($action_conf->{'body'} , undef); + $action_conf->{'body_is_template'} ||= 1 + if !exists $action_conf->{'body_is_template'}; + $tt->process(\$orig_body, $event_data, \$body) + if $action_conf->{'body_is_template'}; + $body ||= $orig_body; + + my $response = $http->request( + ($action_conf->{'method'} || 'POST'), $url, + { headers => $action_conf->{'custom_headers'}, + content => $body }, + ); + + if ($action_conf->{'ignore_failure'} or $response->{'success'}) { + return Status->done(sprintf 'HTTP Hook: %s %s', + $response->{'status'}, $response->{'reason'}); + } + else { + return Status->error(sprintf 'HTTP Hook: %s %s', + $response->{'status'}, $response->{'reason'}); + } +}); + +true; diff --git a/share/config.yml b/share/config.yml index 59a78c63..b2af71f3 100644 --- a/share/config.yml +++ b/share/config.yml @@ -347,6 +347,8 @@ dns: hosts_file: '/etc/hosts' no: ['group:__LOCAL_ADDRESSES__','169.254.0.0/16'] +hooks: [] + schedule: discoverall: when: '5 7 * * *' @@ -364,6 +366,7 @@ schedule: job_prio: high: + - hook::http - location - contact - portcontrol @@ -401,11 +404,14 @@ worker_plugins: - 'Discover::VLANs' - 'Discover::Wireless' - 'Discover::WithNodes' + - 'Discover::Hooks' - 'DiscoverAll' - 'DumpConfig' - 'Expire' - 'ExpireNodes' - 'Graph' + - 'Hook' + - 'Hook::HTTP' - 'Location' - 'Macsuck' - 'Macsuck::Nodes'