#!/usr/bin/perl -T # -*- cperl -*- =begin comment Copyright (C) 2004-2010 Jimmy Olsen, Steve Schnepp This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; version 2 dated June, 1991. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . =end comment =cut use strict; use warnings; use IO::Handle; use Date::Parse; use POSIX qw(strftime locale_h); use CGI::Fast qw(:cgi); use CGI::Carp qw(fatalsToBrowser); use Time::HiRes qw(gettimeofday tv_interval); use Munin::Master::GraphOld; use Munin::Master::Utils; use Munin::Master::Logger; use Log::Log4perl qw( :easy ); my $GRAPHER = "$Munin::Common::Defaults::MUNIN_LIBDIR/munin-graph"; my $conffile = "$Munin::Common::Defaults::MUNIN_CONFDIR/munin.conf"; my %period = ( "day" => 300, "week" => 1800, "month" => 7200, "year" => 86400, "week-sum" => 1800, "year-sum" => 86400 ); my $logfile; my $scale = "day"; my @params ; push @params, "--config", $ENV{'MUNIN_CONFIG'} if (defined $ENV{'MUNIN_CONFIG'}); push @params, "--no-fork"; # FastCgi forks for us push @params, "--skip-locking", "--skip-stats", "--nolazy"; push @params, "--log-file", $logfile; my $config = graph_startup(\@params); logger_open($config->{'logdir'}); logger_debug() if defined($ENV{CGI_DEBUG}); # BEGIN FAST-CGI LOOP: setlocale (LC_TIME, 'C'); my $nb_request = 0; my $nb_request_max = 0; while (new CGI::Fast) { # 1rst thing is to validate the URL. Only a subset of chars are allowed. # Return 404 if not compliant, w/o logging. # This fixes http://bugs.debian.org/668666 and closes a lots of other potential bugs. if ( has_offending_chars($ENV{PATH_INFO}) || has_offending_chars($ENV{QUERY_STRING}) ) { # If parameters are not valid, just pretend we didn't find anything. print "Status: 404 Not Found\r\n", "Content-Type: image/png\r\n", "X-Munin-Pid: $$\r\n", "X-Munin-Request: $nb_request/$nb_request_max\r\n", "\r\n"; next; } my $pinpoint = undef; my $path = $ENV{PATH_INFO} || ""; DEBUG "Request path is $path"; # The full URL looks like this: # Case 1: # http://localhost:8080/munin-cgi/munin-cgi-graph/client/\ # Backend/dafnes.client.example.com/diskstats_iops-week.png # $path should be # /client/Backend/dafnes.client.example.com/diskstats_iops-week.png # # Interesting bits about that url: Nested groups! # # Case 2: # http://localhost:8080/munin-cgi/munin-cgi-graph/client/\ # Backend/dafnes.client.example.com/diskstats_iops/sda-week.png # $path should be # /client/Backend/dafnes.client.example.com/diskstats_iops/\ # sda-week.png # # Interesting bit that url: Nested groups at the start and multigraph # nesting bits at the end. # # Case 3: # http://localhost:8080/munin-cgi/munin-cgi-graph/client/\ # dafnes.client.example.com/if_err_bond0-day.png # $path: # /client/dafnes.client.example.com/if_err_bond0-day.png # # Simplest (old munin 1.2): No nesting at any end, fixed number of /es # # Despite the slippery structure of the $path this expression works with # the rest of the code. To make a more scientific try we would need to # split on / and traverse the $config to determine what kind of part # (domain, nested domain, host, service/plugin, or nested service) # we're looking at. # # Scale will in any case work out since - is only used before the # day/week/month/year/pinpoint part, and the next part is always .png. # # Note: $serv *may* have some "-" inside (See #1218) my ($dom, $host, $serv, $scale) = $path =~ m#^/(.*)/([^/]+)/([\w-]+)-([\w=,]+)\.png#; ## avoid bug in vim DEBUG "asked for ($dom, $host, $serv, $scale)"; if ($scale =~ /pinpoint=(\d+),(\d+)/) { $pinpoint = [ $1, $2, ]; } if (! &verify_parameters ($dom, $host, $serv, $scale)) { # If parameters are not valid, just say we didn't find anything. print "Status: 404 Not Found\r\n", "Content-Type: image/png\r\n", "X-Munin-Pid: $$\r\n", "X-Munin-Request: $nb_request/$nb_request_max\r\n", "\r\n"; next; } # Environment variables are cleared each request # so we must set RRDCACHED_ADDRESS each time $ENV{RRDCACHED_ADDRESS} = $config->{rrdcached_socket} if $config->{rrdcached_socket}; my $filename = get_picture_filename ($config, $dom, $host, $serv, $scale, $ENV{QUERY_STRING}); my $time = time; # If a "Cache-Control: no-cache" header gets send, we regenerate the image in every case: # Removed $pinpoint from the $no_cache expression - janl 2010-09-29 my $no_cache = defined($ENV{HTTP_CACHE_CONTROL}) && $ENV{HTTP_CACHE_CONTROL} =~ /no-cache/i; # Be able to deactivate the cache with the url if (defined(CGI::param("no_cache")) && CGI::param("no_cache") eq "yes") { $no_cache = 1; } # Having some QUERY_STRING disables the cache. if (defined($ENV{QUERY_STRING}) && $ENV{QUERY_STRING} ne "") { $no_cache = 1; } # Compute the cache values # FIXME: Take the plugins update_rate into account here, at least for # the day graph. update_rate should be in $config # my $graph_ttl = $pinpoint ? 1 : $period{$scale}; my $graph_ttl = $period{$scale} || 1; my $graph_last_expires = $time - ($time % $graph_ttl); my $graph_epoch = (! $no_cache) && file_newer_than($filename, $graph_last_expires); if ($graph_epoch) { # The graph is fresh enough. Sending either IMS if asked, or # just skip generation # Check for If-Modified-Since and send 304 if not changed: if (defined $ENV{HTTP_IF_MODIFIED_SINCE} && ! rfctime_newer_than($ENV{HTTP_IF_MODIFIED_SINCE}, $graph_epoch)) { my $headers = get_headers_for_file($filename, $graph_ttl); print "Status: 304\r\n", "Content-Type: image/png\r\n", "X-Munin-Pid: $$\r\n", "X-Munin-Request: $nb_request/$nb_request_max\r\n", "Content-Length: 0\r\n", "Expires: $headers->{Expires}\r\n", "Last-Modified: ", $headers->{"Last-Modified"}, "\r\n". "\n"; # We replied, continue with the next request next; } } else { # Should generate it my $scale_options; if ($pinpoint) { $scale_options = "--pinpoint=" . $pinpoint->[0] . "," . $pinpoint->[1]; } else { $scale_options = "--$scale"; } # Try to generate the graph my $generated_file = eval { draw_graph_or_complain($dom, $host, $serv, $scale_options, $filename); }; # handle exceptions if ($@) { if ($@ =~ m/^Could not find FQN/) { # Unknown graph asked print "Status: 404 Not Found\r\n", "Content-Type: image/png\r\n", "X-Munin-Pid: $$\r\n", "X-Munin-Request: $nb_request/$nb_request_max\r\n", "\r\n"; # Next item next; } # Generic error # .. we DO NOT DIE, as spawn-fcgi doesn't like it. ERROR "[ERROR] $@"; print "Status: 500\r\n", "Content-Type: text/plain\r\n", "X-Munin-Pid: $$\r\n", "X-Munin-Request $nb_request/$nb_request_max\r\n", ""; next; } # draw_graph_or_complain return 0, but already displayed a message next unless ($generated_file); } # Now send it: headers print "Status: 200\r\n", "Content-Type: image/png\r\n", "X-Munin-Pid: $$\r\n", "X-Munin-Request: $nb_request/$nb_request_max\r\n", ""; my $headers = get_headers_for_file($filename, $graph_ttl); foreach my $header_name (keys %$headers) { print "$header_name: $headers->{$header_name}\r\n"; } print "\r\n"; # ... and graph data send_graph_data($filename); # If $no_cache, remove the file. No need to keep it anyway. # And it handles http://bugs.debian.org/668667 unlink($filename) if $no_cache; } continue { $nb_request++; if ($nb_request_max && $nb_request > $nb_request_max) { # Cycle last; } } # END FAST-CGI LOOP - Time to die. Nicely. exit 0; sub get_headers_for_file { my ($filename, $graph_ttl) = @_; # At this time the file exists and should be served my @stats = stat ($filename); my $mtime_epoch = $stats[9]; my $last_modified = get_w3c_date_from_epoch($mtime_epoch); # "Expires" has to use last modified time as base: my $graph_next_expires = $mtime_epoch - ($mtime_epoch % $graph_ttl) + $graph_ttl; my $expires = get_w3c_date_from_epoch($graph_next_expires); return { "Expires" => $expires, "Last-Modified" => $last_modified, "Content-Length" => $stats[7], }; } sub get_w3c_date_from_epoch { my($epoch) = @_; return strftime("%a, %d %b %Y %H:%M:%S GMT", gmtime($epoch)); } sub send_graph_data { # Serve the graph contents. my($filename) = @_; my $buffer; if (! open (GRAPH_PNG_FILE, '<', $filename) ) { ERROR "[FATAL] Could not open image file \"$filename\" for reading: $!\n"; # We don't send anything... # .. we DO NOT DIE, as spawn-fcgi doesn't like it. return; } # No buffering wanted when sending the file local $| = 1; while (sysread(GRAPH_PNG_FILE,$buffer,40960)) { print $buffer; } close (GRAPH_PNG_FILE); } sub get_picture_filename { my $config = shift; my $domain = shift; my $name = shift; my $service = shift; my $scale = shift; my $params = shift; # XXX - hack to fix cgitmpdir default $config->{cgitmpdir} ||= "$Munin::Common::Defaults::MUNIN_DBDIR/cgi-tmp"; my $cgi_tmp_dir = $config->{cgitmpdir} . "/munin-cgi-graph"; $params = $params ? "?$params" : ""; $params =~ tr/\//_/; # / are forbidden in a filename $params = $1 if $params =~ m/(.*)/; # XXX - Q&D untaint return "$cgi_tmp_dir/$domain/$name/$service-$scale.png" . $params; } sub has_offending_chars { my $url_part = shift; return 0 if ! defined $url_part; # "." and ":" are for ip_ in IPv4 & IPv6 return $url_part =~ m:[^a-zA-Z0-9_/.,=&\:-]:; } sub verify_parameters { my $dom = shift; my $host = shift; my $serv = shift; my $scale = shift; if (!$dom) { WARN '[WARNING] Request for graph without specifying domain. Bailing out.'; return 0; } if (!$host) { WARN '[WARNING] Request for graph without specifying host. Bailing out.'; return 0; } if (!$serv) { WARN '[WARNING] Request for graph without specifying service. Bailing out.'; return 0; } if (!$scale) { WARN '[WARNING] Request for graph without specifying scale. Bailing out.'; return 0; } else { if (!defined $period{$scale} && $scale !~ /pinpoint=\d+,\d+/) { WARN '[WARNING] Weird pinpoint setting "'.$scale.'". Bailing out.'; return 0; } } # Checks the image size requested. if (( CGI::param("size_x") || "") =~ m/^(\d+)/) { my $max_size_x = ( $config->{max_size_x} || 4000); if ($1 > $max_size_x) { WARN "[WARNING] Asked image size x too large : $1 > $max_size_x. Bailing out."; return 0; } } if (( CGI::param("size_y") || "") =~ m/^(\d+)/) { my $max_size_y = ($config->{max_size_y} || 4000); if ($1 > $max_size_y) { WARN "[WARNING] Asked image size y too large : $1 > $max_size_y. Bailing out."; return 0; } } return 1; } sub file_newer_than { my $filename = shift; my $time = shift; if (-f $filename) { my @stats = stat (_); # $stats[9] holds the "last update" time and this needs # to be in the last update period my $last_update = $stats[9]; if ($last_update > $time) { return $last_update; } else { return 0; } } # No file found return 0; } sub draw_graph { my $dom = shift; my $host = shift; my $serv = shift; my $scale = shift; my $filename = shift; # remove old file if present if (-f $filename and !unlink($filename)) { ERROR "[FATAL] Could not remove \"$filename\": $!"; } $serv =~ s{[^\w_\/"'\[\]\(\)+=-]}{_}g; $serv =~ /^(.+)$/; $serv = $1; #" # . needs to be legal in host names $host =~ s{[^\w_\/"'\[\]\(\)\.+=-]}{_}g; $host =~ /^(.+)$/; $host = $1; #" # FIXME: Make "root" implied! my @params = ( '--host', $host, '--only-fqn', "root/$dom/$host/$serv", $scale, '--output-file', $filename ); # Sets the correct size on a by_graph basis { use Scalar::Util qw(looks_like_number); # using a temporary variable to avoid expansion in list context and fix CVE-2017-6188 my $size_x = CGI::param("size_x"); push @params, "--size_x", $size_x if looks_like_number($size_x); my $size_y = CGI::param("size_y"); push @params, "--size_y", $size_y if looks_like_number($size_y); my $upper_limit = CGI::param("upper_limit"); push @params, "--upper_limit", $upper_limit if looks_like_number($upper_limit); my $lower_limit = CGI::param("lower_limit"); push @params, "--lower_limit", $lower_limit if looks_like_number($lower_limit); } # Sometimes we want to set the IMG size, and not the canvas. push @params, "--full_size_mode" if (CGI::param("full_size_mode")); # Sometimes we want only the graph. Nothing else. push @params, "--only_graph" if (CGI::param("only_graph")); # XXX - the debug param is sticky. It really should be per request. push @params, "--debug" if (CGI::param("debug")); graph_main(\@params); return $filename; } sub draw_graph_or_complain { my $t0 = [ gettimeofday ]; # Actual work done here. my $ret = draw_graph(@_); my $graph_duration = tv_interval($t0); if (! -f $ret) { my ($dom, $host, $serv, $scale, $filename ) = @_; WARN "[WARNING] Could not draw graph \"$filename\": $ret"; print "Status: 500\r\n", "Content-Type: text/plain\r\n", "\r\n", "Could not draw graph \"$filename\"\r\n"; return 0; } else { print "X-Graph-Duration: $graph_duration\r\n"; return $ret; } } sub rfctime_newer_than { # See if the file has been modified since "the last time". # Format of since_string If-Modified-Since: Wed, 23 Jun 2004 16:11:06 GMT my $since_string = shift; my $created = shift; my $ifmodsec = str2time($since_string); return 1 if ($ifmodsec < $created); return 0; } # vim: syntax=perl ts=8