package Munin::Master::Node;

# This module is used by UpdateWorker to keep in touch with a node and
# parse some of the output.

use warnings;
use strict;

use Carp;
use Munin::Master::Config;
use Munin::Common::Timeout;
use Munin::Common::TLSClient;
use Data::Dumper;
use Log::Log4perl qw( :easy );
use Time::HiRes qw( gettimeofday tv_interval );
use IO::Socket::INET6;

# Used as a timestamp value, this declares none was found
use constant NO_TIMESTAMP => -1;

my $config = Munin::Master::Config->instance()->{config};

# Quick version, to enable "DEBUG ... if $debug" constructs
my $debug = $config->{debug};

# Note: This timeout governs both small commands and waiting for the total
# output of a plugin.  It is reset for each read.

sub new {
    my ($class, $address, $port, $host, $configref) = @_;

    my $self = {
        address => $address,
        port    => $port,
        host    => $host,
        tls     => undef,
        reader  => undef,
        pid     => undef,
        writer  => undef,
        master_capabilities => "multigraph dirtyconfig",
        io_timeout => 120,
	configref => $configref,
    };

    return bless $self, $class;
}


sub do_in_session {
    my ($self, $block) = @_;

    if ($self->_do_connect()) {
	$self->_run_starttls_if_required();
	my $exit_value = $block->();
	$self->_do_close();
	return { exit_value => $exit_value }; # If we're still here
    }
    return 0;  # _do_connect failed.
}


sub _do_connect {
    # Connect to a munin node.  Return false if not, true otherwise.
    my ($self) = @_;

    LOGCROAK("[FATAL] No address!  Did you forget to set 'update no' or to set 'address <IP>' ?")
	if !defined($self->{address});

    # Check if it's an URI or a plain host
    use URI;

    # Parameters are space-separated from the main address
    my ($url, $params) = split(/ +/, $self->{address}, 2);
    my $uri = new URI($url);

    # If address is only "ssh://host/" $params will not get set
    $params = "" unless defined $params;

    # If the scheme is not defined, it's a plain host. 
    # Prefix it with munin:// to be able to parse it like others
    $uri = new URI("munin://" . $url) unless $uri->scheme;
    LOGCROAK("[FATAL] '$url' is not a valid address!") unless $uri->scheme;

    if ($uri->scheme eq "munin") {
        $self->{reader} = $self->{writer} = IO::Socket::INET6->new(
		PeerAddr  => $uri->host,
		PeerPort  => $self->{port} || 4949,
		LocalAddr => $self->_get_node_or_global_setting("local_address"),
		Proto     => 'tcp', 
		MultiHomed => 1,
		Timeout   => $config->{timeout}
	);
	unless ($self->{reader} && defined $self->{reader}->connected()) {
		ERROR "Failed to connect to node $self->{address}:$self->{port}/tcp : $!";
		return 0;
	}
    } elsif ($uri->scheme eq "ssh") {
	    my $ssh_command = sprintf("%s %s", $config->{ssh_command}, $config->{ssh_options});
	    my $user_part = ($uri->user) ? ($uri->user . "@") : "";
	    my $remote_cmd = ($uri->path ne '/') ? $uri->path : "";

	    # Add any parameter to the cmd
	    my $remote_connection_cmd = $ssh_command . " -p " . $uri->port . " " . $user_part . $uri->host . " " . $remote_cmd . " " . $params;

	    # Open a triple pipe
   	    use IPC::Open3;

	    $self->{reader} = new IO::Handle();
	    $self->{writer} = new IO::Handle();
	    $self->{stderr} = new IO::Handle();

	    DEBUG "[DEBUG] open3($remote_connection_cmd)";
	    $self->{pid} = open3($self->{writer}, $self->{reader}, $self->{stderr}, $remote_connection_cmd);
            ERROR "Failed to connect to node $self->{address} : $!" unless $self->{pid};
    } elsif ($uri->scheme eq "cmd") {
        # local commands should ignore the username, url and host
        my $local_cmd = $uri->path;
        my $local_pipe_cmd = "$local_cmd $params";

	    # Open a triple pipe
   	    use IPC::Open3;

	    $self->{reader} = new IO::Handle();
	    $self->{writer} = new IO::Handle();
	    $self->{stderr} = new IO::Handle();

	    DEBUG "[DEBUG] open3($local_pipe_cmd)";
	    $self->{pid} = open3($self->{writer}, $self->{reader}, $self->{stderr}, $local_pipe_cmd);
            ERROR "Failed to execute local command: $!" unless $self->{pid};
    } else {
	    ERROR "Unknown scheme : " . $uri->scheme;
	    return 0;
    }

    # check all the lines until we find one that matches the expected
    # greeting; ignore anything that doesn't look like it as long as
    # there is output. This allows to accept SSH connections where
    # lastlog or motd is used.
    until(defined($self->{node_name})) {
	my $greeting = $self->_node_read_single();
	if (!$greeting) {
	    die "[ERROR] Got unknown reply from node ".$self->{host}."\n";
	}

	if ($greeting =~ /\#.*(?:lrrd|munin) (?:client|node) at (\S+)/i) {
	    $self->{node_name} = $1;
	}
    };

    INFO "[INFO] node $self->{host} advertised itself as $self->{node_name} instead." if $self->{node_name} && $self->{node_name} ne $self->{host};

    return 1;
}


sub _get_node_or_global_setting {
    my ($self, $key) = @_;
    return exists $self->{configref}->{$key} ? $self->{configref}->{$key} : $config->{$key};
}


sub _run_starttls_if_required {
    my ($self) = @_;

    # TLS should only be attempted if explicitly enabled. The default
    # value is therefore "disabled" (and not "auto" as before).
    my $tls_requirement = $self->_get_node_or_global_setting("tls");
    DEBUG "TLS set to \"$tls_requirement\".";
    return if $tls_requirement eq 'disabled';
    my $logger = Log::Log4perl->get_logger("Munin::Master");
    $self->{tls} = Munin::Common::TLSClient->new({
        DEBUG        => $config->{debug},
        logger       => sub { $logger->warn(@_) },
        read_fd      => fileno($self->{reader}),
        read_func    => sub { _node_read_single($self) },
        tls_ca_cert  => $self->_get_node_or_global_setting("tls_ca_certificate"),
        tls_cert     => $self->_get_node_or_global_setting("tls_certificate"),
        tls_paranoia => $tls_requirement,
        tls_priv     => $self->_get_node_or_global_setting("tls_private_key"),
        tls_vdepth   => $self->_get_node_or_global_setting("tls_verify_depth"),
        tls_verify   => $self->_get_node_or_global_setting("tls_verify_certificate"),
        tls_match    => $self->_get_node_or_global_setting("tls_match"),
        write_fd     => fileno($self->{writer}),
        write_func   => sub { _node_write_single($self, @_) },
    });

    if (!$self->{tls}->start_tls()) {
        $self->{tls} = undef;
        if ($tls_requirement eq "paranoid" or $tls_requirement eq "enabled") {
	    die "[ERROR] Could not establish TLS connection to '$self->{address}'. Skipping.\n";
        }
    }
}


sub _do_close {
    my ($self) = @_;

    close $self->{reader};
    close $self->{writer};
    $self->{reader} = undef;
    $self->{writer} = undef;

    # Close stderr if needed
    close $self->{stderr} if $self->{stderr};
    $self->{stderr} = undef if $self->{stderr};

    # Reap the underlying process
    waitpid($self->{pid}, 0) if (defined $self->{pid});
}


sub negotiate_capabilities {
    my ($self) = @_;
    # Please note: Sone of the capabilities are asymetrical.  Each
    # side simply announces which capabilities they have, and then the
    # other takes advantage of the capabilities it understands (or
    # dumbs itself down to the counterparts level of sophistication).

    DEBUG "[DEBUG] Negotiating capabilities\n";

    $self->_node_write_single("cap $self->{master_capabilities}\n");
    my $cap = $self->_node_read_single();

    if (index($cap, 'cap ') == -1) {
        return ('NA');
    }

    my @node_capabilities = split(/\s+/,$cap);
    shift @node_capabilities ; # Get rid of leading "cap".

    DEBUG "[DEBUG] Node says /$cap/\n";

    return @node_capabilities;
}


sub list_plugins {
    my ($self) = @_;

    # Check for one on this node- if not, use the global one
    my $use_node_name = $self->_get_node_or_global_setting("use_node_name");
    my $host = $use_node_name
        ? $self->{node_name}
        : $self->{host};

    my $use_default_node = $self->_get_node_or_global_setting("use_default_node");

    if (! $use_default_node && ! $host) {
	die "[ERROR] Couldn't find out which host to list on $host.\n";
    }

    my $list_host = $use_default_node ? "" : $host;
    $self->_node_write_single("list $list_host\n");
    my $list = $self->_node_read_single();

    if (not $list) {
        WARN "[WARNING] Config node $self->{host} listed no services for $host, (advertised as $self->{node_name}).  Please see http://munin-monitoring.org/wiki/FAQ_no_graphs for further information.";
    }

    return split / /, $list;
}


sub parse_service_config {
    my ($self, $service, $lines) = @_;

    my $errors;
    my $correct;

    my $plugin = $service;

    my $nodedesignation = $self->{host}."/".$self->{address}."/".$self->{port};

    my $global_config = {
	multigraph => [],
    };
    my $data_source_config = {};
    my @graph_order = ( );

    # Pascal style nested subroutine
    local *new_service = sub {
	push @{$global_config->{multigraph}}, $service;
	$global_config->{$service} = [];
	$data_source_config->{$service} = {};
    };


   local *push_graphorder = sub {
		my ($oldservice) = @_;

		# We always appends the field names in config order to any
		# graph_order given.
		# Note that this results in duplicates in the internal state
		# for @graph_order but munin_get_field_order() will eliminate
		# them before graphing.

		if (@graph_order) {
			foreach (@{$global_config->{$oldservice}}) {
				if ( $_->[0] eq 'graph_order' ) {
					# append to a given graph_order
					$_->[1] .= join(' ', '', @graph_order);
					@graph_order = ( );
					return;
				}
			}
			push @{$global_config->{$oldservice}}, ['graph_order', join(' ', @graph_order)];
		}
		@graph_order = ( );
	};


    DEBUG "[DEBUG] Now parsing config output from plugin $plugin on "
	.$self->{host};

    new_service($service);

    for my $line (@$lines) {

	DEBUG "[CONFIG from $plugin] $line" if $debug;

	if ($line =~ /\# timeout/) {
	    die "[ERROR] Timeout error on $nodedesignation during fetch of $plugin. \n";
	}

        next unless $line;
        next if $line =~ /^\#/;

	if ($line =~ m{\A multigraph \s+ (.+) }xms) {
	    push_graphorder($service);

	    $service = $1;

	    if ($service eq 'multigraph') {
		ERROR "[ERROR] SERVICE can't be named \"$service\" in plugin $plugin on ".$self->{host}."/".$self->{address}."/".$self->{port};
                $errors++;
                last;
	    }
            if ($service =~ /(^\.|\.$|\.\.)/) {
                ERROR "[ERROR] SERVICE \"$service\" contains dots in wrong places in plugin $plugin on ".$self->{host}."/".$self->{address}."/".$self->{port};
                $errors++;
                last;
            }
            if ($service !~ m/^[-\w.:.]+$/) {
                ERROR "[ERROR] SERVICE \"$service\" contains weird characters in plugin $plugin on ".$self->{host}."/".$self->{address}."/".$self->{port};
                $errors++;
                last;
            }
	    new_service($service) unless $global_config->{$service};
	    DEBUG "[CONFIG multigraph $plugin] Service is now $service";
	    $correct++;
	}
	elsif ($line =~ m{\A ([^\s\.]+) \s+ (.+?) \s* $}xms) {
	    $correct++;

	    my $label = $self->_sanitise_fieldname($1);

	    # add to config if not already here
	    push @{$global_config->{$service}}, [$label, $2]
	    	unless grep { $_->[0] eq $label }  @{$global_config->{$service}};
            DEBUG "[CONFIG graph global $plugin] $service->$label = $2" if $debug;
        } elsif ($line =~ m{\A ([^\.]+)\.value \s+ (.+?) \s* $}xms) {
	    $correct++;
	    # Special case for dirtyconfig
            my ($ds_name, $value, $when) = ($1, $2, NO_TIMESTAMP);
            
	    $ds_name = $self->_sanitise_fieldname($ds_name);
	    if ($value =~ /^(\d+):(.+)$/) {
		$when = $1;
		$value = $2;
	    }
            DEBUG "[CONFIG dirtyconfig $plugin] Storing $value from $when in $ds_name";

	    # Creating the datastructure if not created already
            $data_source_config->{$service}{$ds_name} ||= {};
            $data_source_config->{$service}{$ds_name}{when} ||= [];
            $data_source_config->{$service}{$ds_name}{value} ||= [];
	
	    # Saving the timed value in the datastructure
	    push @{$data_source_config->{$service}{$ds_name}{when}}, $when;
	    push @{$data_source_config->{$service}{$ds_name}{value}}, $value;
        }
	elsif ($line =~ m{\A ([^\.]+)\.([^\s]+) \s+ (.+?) \s* $}xms) {
	    $correct++;
	    
            my ($ds_name, $ds_var, $ds_val) = ($1, $2, $3);
            $ds_name = $self->_sanitise_fieldname($ds_name);
            $data_source_config->{$service}{$ds_name} ||= {};
            $data_source_config->{$service}{$ds_name}{$ds_var} = $ds_val;
            DEBUG "[CONFIG dataseries $plugin] $service->$ds_name.$ds_var = $ds_val" if $debug;
            push ( @graph_order, $ds_name ) if $ds_var eq 'label';
        }
        elsif ($line =~ m{\A ([^\.]+)\.([^\s]+) \s* $}xms) {
            # the field value is empty - ignore it
            # see https://github.com/munin-monitoring/contrib/issues/1156#issuecomment-746884950
            # For example the meminfo "slab_size" graph may contain empty fields.  These should not
            # end up as log noise, but can be safely ignored instead.
            $correct++;
            DEBUG "[DEBUG] Ignoring field without value ('$line') from $plugin on $nodedesignation.\n";
        }
	else {
	    $errors++;
	    DEBUG "[DEBUG] Protocol exception: unrecognized line '$line' from $plugin on $nodedesignation.\n";
        }
    }

    if ($errors) {
	WARN "[WARNING] $errors lines had errors while $correct lines were correct in data from 'config $plugin' on $nodedesignation";
    }

    $self->_validate_data_sources($data_source_config);

    push_graphorder($service);

    return (global => $global_config, data_source => $data_source_config);
}


sub fetch_service_config {
    my ($self, $service) = @_;

    my $t0 = [gettimeofday];

    DEBUG "[DEBUG] Fetching service configuration for '$service'";
    $self->_node_write_single("config $service\n");

    # The whole config in one fell swoop.
    my $lines = $self->_node_read();

    my $elapsed = tv_interval($t0);

    my $nodedesignation = $self->{host}."/".$self->{address}."/".$self->{port};
    DEBUG "[DEBUG] config: $elapsed sec for '$service' on $nodedesignation";

    $service = $self->_sanitise_plugin_name($service);

    return $self->parse_service_config($service, $lines);
}

sub spoolfetch {
    my ($self, $timestamp) = @_;

    DEBUG "[DEBUG] Fetching spooled services since $timestamp (" . localtime($timestamp) . ")";
    $self->_node_write_single("spoolfetch $timestamp\n");

    # The whole stuff in one fell swoop.
    my $lines = $self->_node_read();

    # using the multigraph parsing. 
    # Using "__root__" as a special plugin name. 
    return $self->parse_service_config("__root__", $lines);
}

sub _validate_data_sources {
    my ($self, $all_data_source_config) = @_;

    my $nodedesignation = $self->{host}."/".$self->{address}.":".$self->{port};

    for my $service (keys %$all_data_source_config) {
	my $data_source_config = $all_data_source_config->{$service};

	for my $ds (keys %$data_source_config) {
	    if (!defined $data_source_config->{$ds}{label}) {
		ERROR "Missing required attribute 'label' for data source '$ds' in service $service on $nodedesignation";
		$data_source_config->{$ds}{label} = 'No .label provided';
		$data_source_config->{$ds}{extinfo} = "NOTE: The plugin did not provide any label for the data source $ds.  It is in need of fixing.";
	    }
	}
    }
}


sub parse_service_data {
    my ($self, $service, $lines) = @_;

    my $plugin = $service;
    my $errors = 0;
    my $correct = 0;

    my $nodedesignation = $self->{host}."/".$self->{address}.":".$self->{port};

    my %values = (
	$service => {},
    );

    DEBUG "[DEBUG] Now parsing fetch output from plugin $plugin on ".
	$nodedesignation;

    # every 'N' has the same value. Should not take parsing time into the equation
    my $now = time;

    for my $line (@$lines) {

	DEBUG "[FETCH from $plugin] $line";

	if ($line =~ /\# timeout/) {
	    die "[WARNING] Timeout in fetch from '$plugin' on ".
		$nodedesignation;
	}

        next unless $line;
        next if $line =~ /^\#/;

	if ($line =~ m{\A multigraph \s+ (.+) }xms) {
	    $service = $1;
            if ($service =~ /(^\.|\.$|\.\.)/) {
                ERROR "[ERROR] SERVICE \"$service\" contains dots in wrong places in plugin $plugin on ".$self->{host}."/".$self->{address}."/".$self->{port};
                $errors++;
                last;
            }
            if ($service !~ m/^[-\w.:.]+$/) {
                ERROR "[ERROR] SERVICE \"$service\" contains weird characters in plugin $plugin on ".$self->{host}."/".$self->{address}."/".$self->{port};
                $errors++;
                last;
            }
	    $values{$service} = {};

	    if ($service eq 'multigraph') {
                $errors++;
		ERROR "[ERROR] SERVICE can't be named \"$service\" in plugin $plugin on ".
		    $nodedesignation;
                last;
	    }
	    $correct++;
	}
	elsif ($line =~ m{\A ([^\.]+)\.value \s+ ([\S:]+) }xms) {
            my ($data_source, $value, $when) = ($1, $2, $now);

	    $correct++;

            $data_source = $self->_sanitise_fieldname($data_source);

	    DEBUG "[FETCH from $plugin] Storing $value in $data_source";

	    if ($value =~ /^(\d+):(.+)$/) {
		$when = $1;
		$value = $2;
	    }

	    $values{$service}{$data_source} ||= { when => [], value => [], };

	    push @{$values{$service}{$data_source}{when}}, $when;
	    push @{$values{$service}{$data_source}{value}}, $value;
        }
	elsif ($line =~ m{\A ([^\.]+)\.extinfo \s+ (.+?) \s* $}xms) {
	    # Extinfo is used in munin-limits
            my ($data_source, $value) = ($1, $2);
	    
	    $correct++;

            $data_source = $self->_sanitise_fieldname($data_source);

	    $values{$service}{$data_source} ||= {};

	    $values{$service}{$data_source}{extinfo} = $value;

	}
        else {
	    $errors++;
            DEBUG "[DEBUG] Protocol exception while fetching '$service' from $plugin on $nodedesignation: unrecognized line '$line'";
	    next;
        }
    }
    if ($errors) {
	my $percent = ($errors / ($errors + $correct)) * 100; 
	$percent = sprintf("%.2f", $percent);
	WARN "[WARNING] $errors lines had errors while $correct lines were correct ($percent%) in data from 'fetch $plugin' on $nodedesignation";
    }

    return %values;
}


sub fetch_service_data {
    my ($self, $plugin) = @_;

    my $t0 = [gettimeofday];

    $self->_node_write_single("fetch $plugin\n");

    my $lines = $self->_node_read_fast();
    
    my $elapsed = tv_interval($t0);
    my $nodedesignation = $self->{host}."/".$self->{address}."/".$self->{port};
    DEBUG "[DEBUG] data: $elapsed sec for '$plugin' on $nodedesignation";

    $plugin = $self->_sanitise_plugin_name($plugin);

    return $self->parse_service_data($plugin, $lines);
}

sub quit {
    my ($self) = @_;

    my $t0 = [gettimeofday];
    $self->_node_write_single("quit \n");
    my $elapsed = tv_interval($t0);
    my $nodedesignation = $self->{host}."/".$self->{address}."/".$self->{port};
    DEBUG "[DEBUG] quit: $elapsed sec on $nodedesignation";

    return 1;
}


sub _sanitise_plugin_name {
    my ($self, $name) = @_;

    $name =~ s/[^_A-Za-z0-9]/_/g;
    
    return $name;
}


sub _sanitise_fieldname {
    # http://munin-monitoring.org/wiki/notes_on_datasource_names
    my ($self, $name) = @_;

    $name =~ s/^[^A-Za-z_]/_/;
    $name =~ s/[^A-Za-z0-9_]/_/g;

    return $name;
}


sub _node_write_single {
    my ($self, $text) = @_;

    DEBUG "[DEBUG] Writing to socket: \"$text\".";
    my $timed_out = !do_with_timeout($self->{io_timeout}, sub {
        if ($self->{tls} && $self->{tls}->session_started()) {
            $self->{tls}->write($text) or exit 9;
        } else {
            print { $self->{writer} } $text;
        }
	return 1;
    });
    if ($timed_out) {
        LOGCROAK "[FATAL] Socket write timed out to ".$self->{host}.
	    ".  Terminating process.";
    }
    return 1;
}


sub _node_read_single {
    my ($self) = @_;
    my $res = undef;

    my $timed_out = !do_with_timeout($self->{io_timeout}, sub {
      if ($self->{tls} && $self->{tls}->session_started()) {
          $res = $self->{tls}->read();
      }
      else {
          $res = readline $self->{reader};
      }
      # Remove \r *and* \n. Normally only one, since we read line per line.
      $res =~ tr/\x{d}\x{a}//d if defined $res;
      return 1;
    });
    if ($timed_out) {
        LOGCROAK "[FATAL] Socket read timed out to ".$self->{host}.
	    ".  Terminating process.";
    }
    if (!defined($res)) {
	# Probable socket not open.  Why are we here again then?
	# aren't we supposed to be in "do in session"?
	LOGCROAK "[FATAL] Socket read from ".$self->{host}." failed.  Terminating process.";
    }
    DEBUG "[DEBUG] Reading from socket to ".$self->{host}.": \"$res\"." if $debug;
    return $res;
}

sub _node_read_fast {
	my ($self) = @_;

	# We cannot bypass the IO if using TLS
	# so just reverting to normal mode.
	return _node_read(@_) if $self->{tls};

	# Disable Buffering here, to be able to use sysread()
	local $| = 1;

	my $io_src = $self->{reader};
        my $buf;
    	my $offset = 0;
        while (my $read_len = sysread($io_src, $buf, 4096, $offset)) {
		$offset += $read_len;

		# Stop when we read a \n.\n
		# ... No need to have a full regex : simple index()
		my $start_offset = $offset - $read_len - 3;
		$start_offset = 0 if $start_offset < 0;
		last if index($buf, "\n.\n", $start_offset) >= 0;

		# if empty, the client only sends a plain ".\n"
		last if $buf eq ".\n";
        }

	# Remove the last line that only contains ".\n"
	$buf =~ s/\.\n$//;

	return [ split(/\n/, $buf) ];
}

sub _node_read {
    my ($self) = @_;
    my @array = ();

    my $line = $self->_node_read_single();
    while($line ne ".") {
        push @array, $line;
        $line = $self->_node_read_single();
    }

    DEBUG "[DEBUG] Reading from socket: \"".(join ("\\n",@array))."\".";
    return \@array;
}

# Defines the URL::scheme for munin
package URI::munin;

# We are like a generic server
require URI::_server;
@URI::munin::ISA=qw(URI::_server);

# munin://HOST[:PORT]

sub default_port { return 4949; }

1;

__END__

=head1 NAME

Munin::Master::Node - Provides easy access to the munin node

=head1 SYNOPSIS

 use Munin::Master::Node;
 my $node = Munin::Master::Node->new('localhost', '4949', 'foo');
 $node->do_in_session(sub{
     ... # Call misc. methods on $node
 });

=head1 METHODS

=over

=item B<new>

FIX

=item B<do_in_session>

FIX

=item B<negotiate_capabilities>

FIX

=item B<list_services>

FIX

=item B<fetch_service_config>

FIX

=item B<fetch_service_data>

FIX

=back

