#!/usr/local/groundwork/perl/bin/perl -w -- # cacti_graphs.cgi # Copyright (c) 2011 GroundWork Open Source, Inc. (GroundWork) # All rights reserved. Use is subject to GroundWork commercial license terms. # BE SURE TO KEEP THIS UP-TO-DATE! my $VERSION = '0.2.0 (July 5, 2011)'; # ======================== # Documentation # ======================== # This script maps simple CGI query parameters into a Cacti URL that yields # a full set of graphs for a given host. All the Cacti databases which are # configured in the cacti.properties file are searched until we find one which # contains both the specified host and a matching graph-tree setup for that # host. If the specified graph tree is not found in a database where the # specified host is found, we attempt to find a default graph tree setup for # that host instead. # Use of this script requires setup in both cacti.properties and # status-viewer.properties to operate as intended. # To do: # (*) Code to read the cacti.properties file is stolen and adapted from the # find_cacti_graphs script. Abstract portions of that script into a GW::Cacti # package, so we can more readily share the code between applications. # (*) The present version of this script accepts a graph_tree_name query parameter # and backs that up with a built-in, hardcoded default graph tree name. A # future version of this script could perhaps generalize the graph_tree_name # query parameter to allow specifying an ordered list of possible graph tree # names to look for, possibly with "*" allowed as a completely wildcarded last # entry. The value of the parameter could be set as either multiple separate # values in the query URL (if the CGI package is guaranteed to maintain that # same ordering when it fetches the values for use in the script), or via a # punctuation-separated list of graph tree names. A comma or semicolon, or # perhaps whatever punctuation is either disallowed by Cacti in a graph tree # name, or some character which is otherwise unlikely to appear in a name, # would be the appropriate name separator. # ======================== # Perl setup # ======================== use strict; use CGI; $CGI::POST_MAX = 1024 * 1024; # max 1M posts, for security $CGI::DISABLE_UPLOADS = 1; # no uploads, for security use DBI; use HTML::Entities; # ======================== # Configuration values # ======================== my $cacti_props = '/usr/local/groundwork/config/cacti.properties'; my $default_graph_tree_name = 'Default Tree'; # These values will perhaps go away in a future release, when we will depend instead # on values drawn from cacti.properties. my $default_cacti_protocol = 'http'; my $default_cacti_urlpath = 'nms-cacti'; # You can set this flag to 1 to display more detailed diagnostics, # if we ever run into problems in the field. my $debug = 0; # ======================== # Working global variables # ======================== my @debug_text = (); # ======================== # Program # ======================== my $query = new CGI; # Success has opposite polarity in the outside world. exit (main() ? 0 : 1); # ======================== # Supporting routines # ======================== sub main { my $cgi_error = $query->cgi_error(); if (defined $cgi_error) { print $query->header(-status => $cgi_error); if ($cgi_error eq '413 Request entity too large') { ## We exceeded POST_MAX. print_error(413, 'You have tried to upload a file which is too large.'); } elsif ($cgi_error eq '400 Bad request (malformed multipart POST)') { ## Heck, file uploads don't even make sense for this script. Are we being attacked? print_error(400, 'A file upload has failed.'); } elsif ($cgi_error =~ /\s*(\d+) (.*)/) { print_error($1, $2); } else { print_error(400, $cgi_error); } return 0; } return cacti_graph_redirect(); } # Security check: Make sure we have a valid hostname, # before we go blindly substituting it into strings. # Reference: http://en.wikipedia.org/wiki/Hostname#Restrictions_on_valid_host_names # * FQDN max length: 255 characters # * FQDN structure: a series of FQDN components, separated by single "." characters # * FQDN component length: 1 to 63 characters # * FQDN component character set: [-a-zA-Z0-9] # * FQDN component structure: cannot start or end with a hyphen sub is_valid_hostname { my $name = shift; return 0 if not defined $name; my $name_length = length $name; return 0 if $name_length < 1 || $name_length > 255; foreach my $part ( split(/\./, $name, -1) ) { my $part_length = length $part; return 0 if $part_length < 1 || $part_length > 63; return 0 if $part =~ /[^-a-zA-Z0-9]/; return 0 if $part =~ /^-/; return 0 if $part =~ /-$/; } return 1; } sub cacti_graph_redirect { my ( $Database_Host, $Database_Port, $Database_Name, $Database_User, $Database_Pass ); my $error = undef; my $host_id = undef; my $graph_tree_id = undef; my $graph_tree_item_id = undef; # The cacti.host.hostname field contains an IP address. What looks to the outside world # like a hostname is actually stored in the cacti.host.description field instead. If # use_address is true, we will look up the host name someplace to find its address, then # search for that in the cacti.host.hostname field. If use_address is false or missing # (the common case), we will search for the hostname in the cacti.host.description field. my $graph_tree_name = $query->param('graph_tree_name'); my $host_name = $query->param('host_name'); my $use_address = $query->param('use_address'); # Let's not allow any injection vulnerabilities here. $host_name = undef if not is_valid_hostname($host_name); if ( !defined($graph_tree_name) or !defined($host_name) ) { print_error( 400, $debug ? ( !defined($graph_tree_name) ? 'Invalid graph tree name.' : !defined($host_name) ? 'Invalid host name.' : 'Unknown failure.' ) : 'Invalid query parameters.' ); return 0; } my $host = $use_address ? ip_address($host_name) : $host_name; my $config = read_config($cacti_props); if (!defined $config) { print_error( 500, $debug ? "Cannot read the \"$cacti_props\" config file." : 'Cannot read internal configuration.' ); return 0; } # Here we work out how many Cacti instances are defined in the cacti.properties file # and then loop through each to find database content that matches the query parameters. my @instance_labels = (); foreach my $parameter (keys %$config) { if ($parameter =~ /^cacti\.(\w+)\.host$/) { push @instance_labels, $1; } } foreach my $instance (@instance_labels) { my $successful = 1; my $protocol = config_value( $config, "cacti.$instance.protocol" ); my $domain = config_value( $config, "cacti.$instance.host" ); my $port = config_value( $config, "cacti.$instance.port" ); my $urlpath = config_value( $config, "cacti.$instance.urlpath" ); my $webserver = ( defined $port ) ? "$domain:$port" : $domain; $protocol = $default_cacti_protocol if not defined $protocol; $urlpath = $default_cacti_urlpath if not defined $urlpath; my $cacti_base_url = "$protocol://$webserver/$urlpath"; $cacti_base_url =~ s{/+$}{}; $Database_Host = config_value($config, "cacti.$instance.dbhost"); $Database_Port = config_value($config, "cacti.$instance.dbport"); $Database_Name = config_value($config, "cacti.$instance.dbname"); $Database_User = config_value($config, "cacti.$instance.dbuser"); $Database_Pass = config_value($config, "cacti.$instance.dbpass"); if ( !defined($Database_Host) || !defined($Database_Port) || !defined($Database_Name) || !defined($Database_User) || !defined($Database_Pass) ) { print_error( 500, $debug ? "Cannot find Cacti database access parameters for instance '$instance'." : 'Internal misconfiguration.' ); return 0; } my $dbh = undef; my $sth = undef; my $sqlstmt = undef; # FIX MINOR: $Database_Port is specified here, but testing with a # bad value shows that it is apparently ignored by the DBI package. eval { $dbh = DBI->connect( "DBI:mysql:database=$Database_Name:host=$Database_Host:port=$Database_Port", $Database_User, $Database_Pass, { 'RaiseError' => 1, 'PrintError' => 0 } ); }; if ( !$dbh ) { ## Possibly we can move on and find what we're looking for in some other database, and only ## report this error if we are unable to find the data we seek in all other databases, so for ## now we don't immediately treat this as a fatal exception. The difference is mostly in what ## might happen if you have multiple Cacti databases, and say the first one is down but that ## shouldn't matter because it's the second one that happens to contain what we need anyway. my $errstr = $DBI::errstr; chomp $errstr; $error = "Cannot connect to the \"$Database_Name\" database on \"$Database_Host\" ($errstr)."; $successful = 0; } if ($successful) { $sqlstmt = 'select id from host where ' . ($use_address ? 'hostname' : 'description') . ' = ?'; eval { $sth = $dbh->prepare($sqlstmt); $sth->execute($host); my @values = $sth->fetchrow_array(); $sth->finish(); if (@values) { $host_id = $values[0]; } else { ## Cannot find a suitable host in this database. push @debug_text, "Cannot find host \"$host\" in the \"$Database_Name\" database on \"$Database_Host\"." if $debug; $successful = 0; } }; if ($@) { chomp $@; $error = "Searching for a host in the \"$Database_Name\" database on \"$Database_Host\" failed ($@)."; $successful = 0; } } my $found_graph_tree_name = undef; if ($successful) { $sqlstmt = 'select id from graph_tree where name = ?'; eval { $sth = $dbh->prepare($sqlstmt); $sth->execute($graph_tree_name); my @values = $sth->fetchrow_array(); $sth->finish(); if (@values) { $graph_tree_id = $values[0]; $found_graph_tree_name = $graph_tree_name; } elsif ($graph_tree_name ne $default_graph_tree_name) { $sth->execute($default_graph_tree_name); @values = $sth->fetchrow_array(); $sth->finish(); if (@values) { $graph_tree_id = $values[0]; $found_graph_tree_name = $default_graph_tree_name; } else { ## Cannot find a suitable graph tree in this database. push @debug_text, "Cannot find graph tree \"$graph_tree_name\" or \"$default_graph_tree_name\" in the \"$Database_Name\" database on \"$Database_Host\"." if $debug; $successful = 0; } } else { ## Cannot find a suitable graph tree in this database. push @debug_text, "Cannot find graph tree \"$graph_tree_name\" in the \"$Database_Name\" database on \"$Database_Host\"." if $debug; $successful = 0; } }; if ($@) { chomp $@; $error = "Searching for a graph tree in the \"$Database_Name\" database on \"$Database_Host\" failed ($@)."; $successful = 0; } } if ($successful) { $sqlstmt = 'select id from graph_tree_items where graph_tree_id = ? and host_id = ?'; eval { $sth = $dbh->prepare($sqlstmt); $sth->execute($graph_tree_id, $host_id); my @values = $sth->fetchrow_array(); $sth->finish(); if (@values) { $graph_tree_item_id = $values[0]; } elsif ($found_graph_tree_name ne $default_graph_tree_name) { my $sqlstmt2 = 'select id from graph_tree where name = ?'; eval { my $sth2 = $dbh->prepare($sqlstmt2); $sth2->execute($default_graph_tree_name); @values = $sth2->fetchrow_array(); $sth2->finish(); if (@values) { $graph_tree_id = $values[0]; $found_graph_tree_name = $default_graph_tree_name; } else { ## Cannot find a suitable graph tree in this database. push @debug_text, "Cannot find default graph tree \"$default_graph_tree_name\" in the \"$Database_Name\" database on \"$Database_Host\"." if $debug; $successful = 0; } }; if ($@) { chomp $@; $error = "Searching for a default graph tree in the \"$Database_Name\" database on \"$Database_Host\" failed ($@)."; $successful = 0; } if ($successful) { $sth->execute($graph_tree_id, $host_id); @values = $sth->fetchrow_array(); $sth->finish(); if (@values) { $graph_tree_item_id = $values[0]; } else { ## Cannot find a suitable graph tree item in this database. push @debug_text, "Cannot find graph tree item for graph tree \"$graph_tree_name\" or \"$default_graph_tree_name\" and host \"$host\" in the \"$Database_Name\" database on \"$Database_Host\"." if $debug; $successful = 0; } } } else { ## Cannot find a suitable graph tree item in this database. push @debug_text, "Cannot find graph tree item for graph tree \"$graph_tree_name\" and host \"$host\" in the \"$Database_Name\" database on \"$Database_Host\"." if $debug; $successful = 0; } }; if ($@) { chomp $@; $error = "Searching for a graph tree item for host \"$host\" in the \"$Database_Name\" database on \"$Database_Host\" failed ($@)."; $successful = 0; } } # We catch possible disconnect errors here, but they're not worth reporting. eval { $dbh->disconnect(); }; if ( defined($cacti_base_url) and defined($graph_tree_id) and defined($graph_tree_item_id) ) { print_redirect( $cacti_base_url, $graph_tree_id, $graph_tree_item_id ); return 1; } } print_error( $error ? 500 : 404, $debug && $error ? $error : "Cannot find graphs for host \"$host_name\" under graph tree \"$graph_tree_name\"" . ( $graph_tree_name ne $default_graph_tree_name ? " or \"$default_graph_tree_name\"" : '' ) . " in any configured Cacti database." ); return 0; } sub ip_address { ## FIX MINOR: Implement some means of translating a host name to a sensible host address ## that Cacti might know about, keeping in mind that we might have a multi-homed machine ## (one with multiple network interfaces, and thus multiple addresses, potentially only ## one of which is known to Cacti for graphing purposes). Possibilities include: ## (*) using Perl's built-in gethostbyname() function, either called in scalar context ## to return only a single address (presumptively assumed to be the right one), or ## called in list context to return a full set of alternate addresses and then ## returning the full set of alternatives and iterating over them in the caller; or ## (*) searching Monarch for this host and its configured address there (subject to GWMON-9076). ## FIX MINOR: Once we do support such translation, make sure we support IPv6 addresses, too. print_error(501, 'IP address lookup is not yet supported.'); exit 1; } sub print_redirect { my $cacti_base_url = shift; my $graph_tree_id = shift; my $graph_tree_item_id = shift; print $query->redirect("$cacti_base_url/graph_view.php?action=tree&tree_id=$graph_tree_id&leaf_id=$graph_tree_item_id"); } sub print_error { my $status = shift; my $text = shift; my %status_code = ( 400 => '400 Bad Request', 404 => '404 Not Found', 413 => '413 Request Entity Too Large', 500 => '500 Internal Server Error', 501 => '501 Not Implemented' ); my $http_status = $status_code{$status} || '500 Internal Server Error'; print $query->header( -type => 'text/html', -status => $http_status ); print $query->start_html( -title => 'Cacti Graphs Lookup Error' ); print $http_status; if (@debug_text) { print $query->hr; print HTML::Entities::encode($_), "
\n" for @debug_text; } if ( defined $text ) { print $query->hr; print HTML::Entities::encode($text); } print $query->end_html(); } sub read_config { my $config_file = shift; my %config = (); if ( !open(CONFIG, '<', $config_file) ) { print_error( 500, $debug ? "Unable to open configuration file $config_file ($!)." : 'Cannot open configuration file.' ); exit 1; # return undef; } while (my $line = ) { chomp $line; if ( $line =~ /^\s*([^#]\S*)\s*=\s*(\S+)\s*$/ ) { $config{$1} = $2; } } close CONFIG; return \%config; } # This routine allows indirection at each key component level. # Normally, application code does not call this with a subkey or recursion level; # that argument is only used for recursive calls. # FIX THIS: Compare the ability to support indirection in configuration-key # components to what TypedConfig and Config::General can do in that respect, and # perhaps generalize the capabilities of TypedConfig to match what we have here. sub config_value { my $config = shift; my $key = shift; my $subkey = shift; my $level = shift || 0; if (++$level > 100) { my $fullkey = (defined $subkey) ? "$key.$subkey" : $key; print_error( 500, $debug ? "Too many levels of indirection found in config file when searching for \"$fullkey\"." : 'Malformed configuration file.' ); exit 1; } if (!defined $subkey) { if (exists $config->{$key}) { return $config->{$key}; } if ($key =~ /(\S+)\.(\S+)/) { return config_value($config,$1,$2,$level); } return undef; } if (exists $config->{"$key.$subkey"}) { return $config->{"$key.$subkey"}; } if (exists $config->{$key}) { my $keyvalue = $config->{$key}; if (defined($keyvalue) && $keyvalue =~ /^\$./) { $keyvalue =~ s/^\$//; return config_value($config,"$keyvalue.$subkey",undef,$level); } return undef; } if ($key =~ /(\S+)\.(\S+)/) { return config_value($config,$1,"$2.$subkey",$level); } return undef; } __END__ # Implementation notes: # # It is unfortunately the case that the "cacti" database contains NO foreign key constraints # that would ensure consistency between tables enforced by the database itself. It is up to # the Cacti application to do so. Furthermore, there are a variety of unique key constraints # that are NOT present but ought to be, which make it possible for multiple rows to appear # where we would expect just one. We have to cope with those circumstances in this script, # by making somewhat arbitrary selections (generally just the first row found) if we do # encounter duplicate data.