diff --git a/Netdisco/Changes b/Netdisco/Changes index 3807d514..a28f01ca 100644 --- a/Netdisco/Changes +++ b/Netdisco/Changes @@ -5,6 +5,7 @@ * [#86] Use Vendor abbrevs to enhance node display in device port view * [#74] Device Name / DNS mismatches report * [#71] Node search by date (but not time) + * [#73] NetBIOS Poller - nbtstat and nbtwalk [ENHANCEMENTS] diff --git a/Netdisco/bin/netdisco-do b/Netdisco/bin/netdisco-do index 242c84bd..0629acba 100755 --- a/Netdisco/bin/netdisco-do +++ b/Netdisco/bin/netdisco-do @@ -77,6 +77,7 @@ if (!length $action) { with 'App::Netdisco::Daemon::Worker::Poller::Device'; with 'App::Netdisco::Daemon::Worker::Poller::Arpnip'; with 'App::Netdisco::Daemon::Worker::Poller::Macsuck'; + with 'App::Netdisco::Daemon::Worker::Poller::Nbtstat'; with 'App::Netdisco::Daemon::Worker::Poller::Expiry'; with 'App::Netdisco::Daemon::Worker::Interactive::DeviceActions'; with 'App::Netdisco::Daemon::Worker::Interactive::PortActions'; @@ -129,9 +130,9 @@ C<-D> flag is given. This program allows you to run any Netdisco poller job from the command-line. -Note that some jobs (C, C, C) simply add -entries to the Netdisco job queue for other jobs, so won't seem to do much -when you trigger them. +Note that some jobs (C, C, C), C) +simply add entries to the Netdisco job queue for other jobs, so won't seem +to do much when you trigger them. =head1 ACTIONS @@ -147,6 +148,10 @@ Run a macsuck on the device (specified with C<-d>). Run an arpnip on the device (specified with C<-d>). +=head2 nbtstat + +Run an nbtstat on the node (specified with C<-d>). + =head2 set_location Set the SNMP location field on the device (specified with C<-d>). Pass the diff --git a/Netdisco/lib/App/Netdisco/Core/Nbtstat.pm b/Netdisco/lib/App/Netdisco/Core/Nbtstat.pm new file mode 100644 index 00000000..ec0ff795 --- /dev/null +++ b/Netdisco/lib/App/Netdisco/Core/Nbtstat.pm @@ -0,0 +1,159 @@ +package App::Netdisco::Core::Nbtstat; + +use Dancer qw/:syntax :script/; +use Dancer::Plugin::DBIC 'schema'; + +use App::Netdisco::Util::SanityCheck 'check_mac'; +use NetAddr::IP::Lite ':lower'; +use Time::HiRes 'gettimeofday'; +use Net::NBName; + +use base 'Exporter'; +our @EXPORT = (); +our @EXPORT_OK = qw/ do_nbtstat store_nbt /; +our %EXPORT_TAGS = (all => \@EXPORT_OK); + +=head1 NAME + +App::Netdisco::Core::Nbtstat + +=head1 DESCRIPTION + +Helper subroutines to support parts of the Netdisco application. + +There are no default exports, however the C<:all> tag will export all +subroutines. + +=head1 EXPORT_OK + +=head2 do_nbtstat( $node ) + +Connects to node and gets NetBIOS information. Then adds entries to +node_nbt table. + +Returns whether a node is answering netbios calls or not. + +=cut + +sub do_nbtstat { + my $host = shift; + + my $ip = NetAddr::IP::Lite->new($host) or return; + + unless ( $ip->version() == 4 ) { + debug ' nbtstat only supports IPv4, invalid ip %s', $ip->addr; + return; + } + + my $nb = Net::NBName->new; + + my $ns = $nb->node_status( $ip->addr ); + + # Check for NetBIOS Info + return unless $ns; + + my $nbname = _filter_nbname( $ip->addr, $ns ); + + if ($nbname) { + store_nbt($nbname); + } + + return 1; +} + +# filter nbt names / information +sub _filter_nbname { + my $ip = shift; + my $node_status = shift; + + my $server = 0; + my $nbname = ''; + my $domain = ''; + my $nbuser = ''; + + for my $rr ( $node_status->names ) { + my $suffix = defined $rr->suffix ? $rr->suffix : -1; + my $G = defined $rr->G ? $rr->G : ''; + my $name = defined $rr->name ? $rr->name : ''; + + if ( $suffix == 0 and $G eq "GROUP" ) { + $domain = $name; + } + if ( $suffix == 3 and $G eq "UNIQUE" ) { + $nbuser = $name; + } + if ( $suffix == 0 and $G eq "UNIQUE" ) { + $nbname = $name unless $name =~ /^IS~/; + } + if ( $suffix == 32 and $G eq "UNIQUE" ) { + $server = 1; + } + } + + unless ($nbname) { + debug ' nbtstat no computer name found for %s', $ip; + return; + } + + my $mac = $node_status->mac_address || ''; + + unless ( check_mac( $ip, $mac ) ) { + + # Just assume it's the last MAC we saw this IP at. + my $node_ip = schema('netdisco')->resultset('NodeIp') + ->single( { ip => $ip, active => \'true' } ); + + if ( !defined $node_ip ) { + debug ' no MAC for %s returned by nbtstat or in DB', $ip; + return; + } + $mac = $node_ip->mac; + } + + return { + ip => $ip, + mac => $mac, + nbname => $nbname, + domain => $domain, + server => $server, + nbuser => $nbuser + }; +} + +=item store_nbt($nb_hash_ref, $now?) + +Stores entries in C table from the provided hash reference; MAC +C, IP C, Unique NetBIOS Node Name C, NetBIOS Domain or +Workgroup C, whether the Server Service is running C, +and the current NetBIOS user C. + +Adds new entry or time stamps matching one. + +Optionally a literal string can be passed in the second argument for the +C timestamp, otherwise the current timestamp (C) is used. + +=cut + +sub store_nbt { + my ( $hash_ref, $now ) = @_; + $now ||= 'now()'; + + schema('netdisco')->resultset('NodeNbt')->update_or_create( + { mac => $hash_ref->{'mac'}, + ip => $hash_ref->{'ip'}, + nbname => $hash_ref->{'nbname'}, + domain => $hash_ref->{'domain'}, + server => $hash_ref->{'server'}, + nbuser => $hash_ref->{'nbuser'}, + active => \'true', + time_last => \$now, + }, + { key => 'primary', + for => 'update', + } + ); + + return; +} + +1; diff --git a/Netdisco/lib/App/Netdisco/DB/Result/Virtual/PollerPerformance.pm b/Netdisco/lib/App/Netdisco/DB/Result/Virtual/PollerPerformance.pm index 1bba2c0e..1f023bbe 100644 --- a/Netdisco/lib/App/Netdisco/DB/Result/Virtual/PollerPerformance.pm +++ b/Netdisco/lib/App/Netdisco/DB/Result/Virtual/PollerPerformance.pm @@ -14,7 +14,7 @@ __PACKAGE__->result_source_instance->view_definition(< 1 ORDER BY entered DESC, elapsed DESC diff --git a/Netdisco/lib/App/Netdisco/DB/ResultSet/NodeIp.pm b/Netdisco/lib/App/Netdisco/DB/ResultSet/NodeIp.pm index c36f80f1..dfadd8f7 100644 --- a/Netdisco/lib/App/Netdisco/DB/ResultSet/NodeIp.pm +++ b/Netdisco/lib/App/Netdisco/DB/ResultSet/NodeIp.pm @@ -191,4 +191,28 @@ sub search_by_mac { ->search($cond, $attrs); } +=head2 ip_version( $version ) + + my $rset = $rs->ip_version(4); + +This predefined C returns a ResultSet of matching rows from the +NodeIp table of nodes with addresses of the supplied IP version. + +=over 4 + +=item * + +The C parameter must be an integer either 4 or 6. + +=cut + +sub ip_version { + my ( $rs, $version ) = @_; + + die "ip_version input must be either 4 or 6\n" + unless $version && ( $version == 4 || $version == 6 ); + + return $rs->search_rs( \[ 'family(ip) = ?', $version ] ); +} + 1; diff --git a/Netdisco/lib/App/Netdisco/Daemon/Queue.pm b/Netdisco/lib/App/Netdisco/Daemon/Queue.pm index 811a8f2a..71e8f563 100644 --- a/Netdisco/lib/App/Netdisco/Daemon/Queue.pm +++ b/Netdisco/lib/App/Netdisco/Daemon/Queue.pm @@ -22,8 +22,10 @@ sub capacity_for { debug "checking local capacity for action $action"; my $action_map = { - Poller => [qw/discoverall discover arpwalk arpnip macwalk macsuck expiry/], - Interactive => [qw/location contact portcontrol portname vlan power/], + Poller => [ + qw/discoverall discover arpwalk arpnip macwalk macsuck nbtstat nbtwalk expiry/ + ], + Interactive => [qw/location contact portcontrol portname vlan power/], }; my $role_map = { diff --git a/Netdisco/lib/App/Netdisco/Daemon/Worker/Manager.pm b/Netdisco/lib/App/Netdisco/Daemon/Worker/Manager.pm index e5dc5b63..2834f6ad 100644 --- a/Netdisco/lib/App/Netdisco/Daemon/Worker/Manager.pm +++ b/Netdisco/lib/App/Netdisco/Daemon/Worker/Manager.pm @@ -13,7 +13,7 @@ my $fqdn = hostfqdn || 'localhost'; my $role_map = { (map {$_ => 'Poller'} - qw/discoverall discover arpwalk arpnip macwalk macsuck expiry/), + qw/discoverall discover arpwalk arpnip macwalk macsuck nbtstat nbtwalk expiry/), (map {$_ => 'Interactive'} qw/location contact portcontrol portname vlan power/) }; diff --git a/Netdisco/lib/App/Netdisco/Daemon/Worker/Poller.pm b/Netdisco/lib/App/Netdisco/Daemon/Worker/Poller.pm index 499de226..34559016 100644 --- a/Netdisco/lib/App/Netdisco/Daemon/Worker/Poller.pm +++ b/Netdisco/lib/App/Netdisco/Daemon/Worker/Poller.pm @@ -10,6 +10,7 @@ with 'App::Netdisco::Daemon::Worker::Common'; with 'App::Netdisco::Daemon::Worker::Poller::Device', 'App::Netdisco::Daemon::Worker::Poller::Arpnip', 'App::Netdisco::Daemon::Worker::Poller::Macsuck', + 'App::Netdisco::Daemon::Worker::Poller::Nbtstat', 'App::Netdisco::Daemon::Worker::Poller::Expiry'; sub worker_type { 'pol' } diff --git a/Netdisco/lib/App/Netdisco/Daemon/Worker/Poller/Common.pm b/Netdisco/lib/App/Netdisco/Daemon/Worker/Poller/Common.pm index ae2c2338..b14b0ab6 100644 --- a/Netdisco/lib/App/Netdisco/Daemon/Worker/Poller/Common.pm +++ b/Netdisco/lib/App/Netdisco/Daemon/Worker/Poller/Common.pm @@ -88,4 +88,80 @@ sub _single_body { return job_done("Ended $job_type for ". $host->addr); } +# _walk_nodes_body +# Queue a job for all active nodes that have been seen in the last +# configured days. +# +sub _walk_nodes_body { + my ($self, $job_type) = @_; + + my $action_method = $job_type .'_action'; + my $job_action = $self->$action_method; + + my $jobqueue = schema('netdisco')->resultset('Admin'); + my $rs = schema('netdisco')->resultset('NodeIp') + ->search({ip => { -not_in => + $jobqueue->search({ + device => { '!=' => undef}, + action => $job_type, + status => { -like => 'queued%' }, + })->get_column('device')->as_query + }, -bool => 'active'}); + + my $ip_version = $job_type .'_ip_version'; + my $job_ip_ver = $self->$ip_version; + + if ($job_ip_ver) { + $rs = $rs->ip_version($job_ip_ver) + } + + my $config_max_age = $job_type . '_max_age'; + my $max_age = setting($config_max_age); + + if ($max_age) { + my $interval = "$max_age day"; + $rs = $rs->search( + { time_last => \[ '>= now() - ?::interval', $interval ] } ); + } + + my @nodes = $rs->get_column('ip')->all; + + my $filter_method = $job_type .'_filter'; + my $job_filter = $self->$filter_method; + + my @filtered_nodes = grep {$job_filter->($_)} @nodes; + + schema('netdisco')->resultset('Admin')->txn_do_locked(sub { + $jobqueue->populate([ + map {{ + device => $_, + action => $job_type, + status => 'queued', + }} (@filtered_nodes) + ]); + }); + + return job_done("Queued $job_type job for all nodes"); +} + +sub _single_node_body { + my ($self, $job_type, $job) = @_; + + my $action_method = $job_type .'_action'; + my $job_action = $self->$action_method; + + my $host = NetAddr::IP::Lite->new($job->device); + + my $filter_method = $job_type .'_filter'; + my $job_filter = $self->$filter_method; + + unless ($job_filter->($host->addr)) { + return job_defer("$job_type deferred: $host is not ${job_type}able"); + } + + $job_action->($host->addr); + + return job_done("Ended $job_type for ". $host->addr); +} + 1; diff --git a/Netdisco/lib/App/Netdisco/Daemon/Worker/Poller/Nbtstat.pm b/Netdisco/lib/App/Netdisco/Daemon/Worker/Poller/Nbtstat.pm new file mode 100644 index 00000000..53af8dcc --- /dev/null +++ b/Netdisco/lib/App/Netdisco/Daemon/Worker/Poller/Nbtstat.pm @@ -0,0 +1,18 @@ +package App::Netdisco::Daemon::Worker::Poller::Nbtstat; + +use App::Netdisco::Core::Nbtstat 'do_nbtstat'; +use App::Netdisco::Util::Node 'is_nbtstatable'; + +use Role::Tiny; +use namespace::clean; + +with 'App::Netdisco::Daemon::Worker::Poller::Common'; + +sub nbtstat_action { \&do_nbtstat } +sub nbtstat_filter { \&is_nbtstatable } +sub nbtstat_ip_version { 4 } + +sub nbtwalk { (shift)->_walk_nodes_body('nbtstat', @_) } +sub nbtstat { (shift)->_single_node_body('nbtstat', @_) } + +1; diff --git a/Netdisco/lib/App/Netdisco/Daemon/Worker/Scheduler.pm b/Netdisco/lib/App/Netdisco/Daemon/Worker/Scheduler.pm index 63255ee4..27250a7b 100644 --- a/Netdisco/lib/App/Netdisco/Daemon/Worker/Scheduler.pm +++ b/Netdisco/lib/App/Netdisco/Daemon/Worker/Scheduler.pm @@ -14,10 +14,10 @@ my $jobactions = { discoverall arpwalk macwalk + nbtwalk expiry / # saveconfigs -# nbtwalk # backup }; diff --git a/Netdisco/lib/App/Netdisco/Manual/Configuration.pod b/Netdisco/lib/App/Netdisco/Manual/Configuration.pod index 634a8324..5c3d82af 100644 --- a/Netdisco/lib/App/Netdisco/Manual/Configuration.pod +++ b/Netdisco/lib/App/Netdisco/Manual/Configuration.pod @@ -586,6 +586,31 @@ Value: Number. Default: 0. Sets the minimum amount of time in seconds which must elapse between any two arpnip jobs for a device. +=head3 C + +Value: List of Network Identifiers. Default: Empty List. + +IP addresses in the list will not be visited for nbtstat. You can include +hostnames, IP addresses, subnets (nbtstat only supports IPv4), YAML Regexp +to match the DNS name, and address ranges (using a hyphen and no whitespace) +in the list. + +=head3 C + +Value: List of Network Identifiers. Default: Empty List. + +If present, nbtstat will be limited to IP addresses matching entries in this +list. You can include hostnames, IP addresses, subnets +(nbtstat only supports IPv4), YAML Regexp to match the DNS name, and address +ranges (using a hyphen and no whitespace). + +=head3 C + +Value: Number. Default: 7. + +The maximum age of a node in days for it to be checked for NetBIOS +information. + =head3 C Value: Number of Days. @@ -800,6 +825,8 @@ hour fields (which accept same types as C notation). For example: min: 15 hour: '*/2' wday: 'mon-fri' + nbtwalk: + when: '0 8,13,21 * * *' expiry: when: '20 23 * * *' diff --git a/Netdisco/lib/App/Netdisco/Manual/Developing.pod b/Netdisco/lib/App/Netdisco/Manual/Developing.pod index 4e4cefe0..e40eace7 100644 --- a/Netdisco/lib/App/Netdisco/Manual/Developing.pod +++ b/Netdisco/lib/App/Netdisco/Manual/Developing.pod @@ -438,9 +438,10 @@ Interactive and Poller workers in their C file (zero or more of each). The fourth kind of worker is called the Scheduler and takes care of adding -discover, macsuck, and arpnip jobs to the queue (which are in turn handled by -the Poller worker). This worker is automatically started only if the user has -enabled the "C" section of their C site config. +discover, macsuck, arpnip, and nbtstat jobs to the queue (which are in turn +handled by the Poller worker). This worker is automatically started only if +the user has enabled the "C" section of their +C site config. =head2 SNMP::Info diff --git a/Netdisco/lib/App/Netdisco/Util/Node.pm b/Netdisco/lib/App/Netdisco/Util/Node.pm new file mode 100644 index 00000000..d64c0fb5 --- /dev/null +++ b/Netdisco/lib/App/Netdisco/Util/Node.pm @@ -0,0 +1,206 @@ +package App::Netdisco::Util::Node; + +use Dancer qw/:syntax :script/; +use Dancer::Plugin::DBIC 'schema'; + +use NetAddr::IP::Lite ':lower'; +use App::Netdisco::Util::DNS 'hostname_from_ip'; + +use base 'Exporter'; +our @EXPORT = (); +our @EXPORT_OK = qw/ + check_node_acl + check_node_no + check_node_only + is_nbtstatable +/; +our %EXPORT_TAGS = (all => \@EXPORT_OK); + +=head1 NAME + +App::Netdisco::Util::Node + +=head1 DESCRIPTION + +A set of helper subroutines to support parts of the Netdisco application. + +There are no default exports, however the C<:all> tag will export all +subroutines. + +=head1 EXPORT_OK + +=head2 check_node_acl( $ip, \@config ) + +Given the IP address of a node, returns true if any of the items in C<< +\@config >> matches that node, otherwise returns false. + +Normally you use C and C, passing the name of the +configuration setting to load. This helper instead requires not the name of +the setting, but its value. + +=cut + +sub check_node_acl { + my ($ip, $config) = @_; + my $device = get_device($ip) or return 0; + my $addr = NetAddr::IP::Lite->new($device->ip); + + foreach my $item (@$config) { + if (ref qr// eq ref $item) { + my $name = hostname_from_ip($addr->addr) or next; + return 1 if $name =~ $item; + next; + } + + if ($item =~ m/([a-f0-9]+)-([a-f0-9]+)$/i) { + my $first = $1; + my $last = $2; + + if ($item =~ m/:/) { + next unless $addr->bits == 128; + + $first = hex $first; + $last = hex $last; + + (my $header = $item) =~ s/:[^:]+$/:/; + foreach my $part ($first .. $last) { + my $ip = NetAddr::IP::Lite->new($header . sprintf('%x',$part) . '/128') + or next; + return 1 if $ip == $addr; + } + } + else { + next unless $addr->bits == 32; + + (my $header = $item) =~ s/\.[^.]+$/./; + foreach my $part ($first .. $last) { + my $ip = NetAddr::IP::Lite->new($header . $part . '/32') + or next; + return 1 if $ip == $addr; + } + } + + next; + } + + my $ip = NetAddr::IP::Lite->new($item) + or next; + next unless $ip->bits == $addr->bits; + + return 1 if $ip->contains($addr); + } + + return 0; +} + +=head2 check_node_no( $ip, $setting_name ) + +Given the IP address of a node, returns true if the configuration setting +C<$setting_name> matches that device, else returns false. If the setting +is undefined or empty, then C also returns false. + + print "rejected!" if check_node_no($ip, 'nbtstat_no'); + +There are several options for what C<$setting_name> can contain: + +=over 4 + +=item * + +Hostname, IP address, IP prefix + +=item * + +IP address range, using a hyphen and no whitespace + +=item * + +Regular Expression in YAML format which will match the node DNS name, e.g.: + + - !!perl/regexp ^sep0.*$ + +=back + +To simply match all nodes, use "C" or IP Prefix "C<0.0.0.0/0>". All +regular expressions are anchored (that is, they must match the whole string). +To match no nodes we recommend an entry of "C" in the setting. + +=cut + +sub check_node_no { + my ($ip, $setting_name) = @_; + + my $config = setting($setting_name) || []; + return 0 if not scalar @$config; + + return check_acl($ip, $config); +} + +=head2 check_node_only( $ip, $setting_name ) + +Given the IP address of a node, returns true if the configuration setting +C<$setting_name> matches that node, else returns false. If the setting +is undefined or empty, then C also returns true. + + print "rejected!" unless check_node_only($ip, 'nbtstat_only'); + +There are several options for what C<$setting_name> can contain: + +=over 4 + +=item * + +Hostname, IP address, IP prefix + +=item * + +IP address range, using a hyphen and no whitespace + +=item * + +Regular Expression in YAML format which will match the node DNS name, e.g.: + + - !!perl/regexp ^sep0.*$ + +=back + +To simply match all nodes, use "C" or IP Prefix "C<0.0.0.0/0>". All +regular expressions are anchored (that is, they must match the whole string). +To match no nodes we recommend an entry of "C" in the setting. + +=cut + +sub check_node_only { + my ($ip, $setting_name) = @_; + + my $config = setting($setting_name) || []; + return 1 if not scalar @$config; + + return check_acl($ip, $config); +} + +=head2 is_nbtstatable( $ip ) + +Given an IP address, returns C if Netdisco on this host is permitted by +the local configuration to nbtstat the node. + +The configuration items C and C are checked +against the given IP. + +Returns false if the host is not permitted to nbtstat the target node. + +=cut + +sub is_nbtstatable { + my $ip = shift; + + return _bail_msg("is_nbtstatable: node matched nbtstat_no") + if check_node_no($ip, 'nbtstat_no'); + + return _bail_msg("is_nbtstatable: node failed to match nbtstat_only") + unless check_node_only($ip, 'nbtstat_only'); + + return 1; +} + +1; diff --git a/Netdisco/lib/App/Netdisco/Web/AdminTask.pm b/Netdisco/lib/App/Netdisco/Web/AdminTask.pm index 3a600d2a..2c0efeca 100644 --- a/Netdisco/lib/App/Netdisco/Web/AdminTask.pm +++ b/Netdisco/lib/App/Netdisco/Web/AdminTask.pm @@ -40,6 +40,7 @@ my %jobs_all = map {$_ => 1} qw/ discoverall macwalk arpwalk + nbtwalk /; foreach my $jobtype (keys %jobs_all, keys %jobs) { diff --git a/Netdisco/share/config.yml b/Netdisco/share/config.yml index 863746a7..7570581f 100644 --- a/Netdisco/share/config.yml +++ b/Netdisco/share/config.yml @@ -108,6 +108,9 @@ snmpforce_v3: [] arpnip_no: [] arpnip_only: [] arpnip_min_age: 0 +nbtstat_no: [] +nbtstat_only: [] +nbtstat_max_age: 7 expire_devices: 0 expire_nodes: 0 expire_nodes_archive: 0 @@ -173,6 +176,8 @@ dns: # min: 15 # hour: '*/2' # wday: 'mon-fri' +# nbtwalk: +# when: '0 8,13,21 * * *' # --------------- # DANCER INTERNAL diff --git a/Netdisco/share/environments/deployment.yml b/Netdisco/share/environments/deployment.yml index d5b5ed3f..d360a39f 100644 --- a/Netdisco/share/environments/deployment.yml +++ b/Netdisco/share/environments/deployment.yml @@ -43,6 +43,8 @@ database: # arpwalk: # when: # min: 50 +# nbtwalk: +# when: '0 8,13,21 * * *' # expiry: # when: '20 23 * * *' diff --git a/Netdisco/share/views/ajax/admintask/performance.tt b/Netdisco/share/views/ajax/admintask/performance.tt index 75f84950..caa6f9bb 100644 --- a/Netdisco/share/views/ajax/admintask/performance.tt +++ b/Netdisco/share/views/ajax/admintask/performance.tt @@ -24,6 +24,9 @@ [% ELSIF NOT dis AND row.action == 'discover' %] class="info" [% SET dis = 1 %] + [% ELSIF NOT nbt AND row.action == 'nbtstat' %] + class="info" + [% SET nbt = 1 %] [% END %] > [% row.action.ucfirst | html_entity %] diff --git a/Netdisco/share/views/layouts/main.tt b/Netdisco/share/views/layouts/main.tt index e633f352..e58f43bf 100644 --- a/Netdisco/share/views/layouts/main.tt +++ b/Netdisco/share/views/layouts/main.tt @@ -105,6 +105,11 @@ +
  • +
    + +
    +
  • [% IF settings._admin_tasks.size %]
  • [% FOREACH ai IN settings._admin_order %]