#!/usr/bin/perl

# This is a program for generating and updating Captrap's configuration
# files.

# Copyright 2009 Corey Hickey


# This file is part of Captrap.
#
# Captrap 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, either version 3 of the License, or
# (at your option) any later version.
#
# Captrap 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 Captrap.  If not, see <http://www.gnu.org/licenses/>.


use strict;
use warnings FATAL => 'all';

use Crypt::GeneratePassword;
use Fcntl;
use File::Sync qw(fsync);
use File::Basename;
use Switch;


# for development using a different Captrap module
# use lib "lib";
use Captrap  qw(:misc :actions :config);


# -----------------------------------------------------------------------------
# printing
# -----------------------------------------------------------------------------

# print main help info
sub usage {
  my $common = shift; # unused
  my $actions = mk_actions();
  my $actions_text = describe_actions($actions);
  print "
This is a script for generating or refreshing Captrap configuration files.

captrap_mkconfig [ACTION] [[ACTION-PARAMETERS]] ...

ACTIONS

$actions_text

EXIT STATUS

0              Everything is ok.

1              No arguments given; usage information was shown.

2              Invalid argument.

3              There was a problem executing an action.


EXAMPLES

Generage fresh config files with interface set to eth0:
captrap_mkconfig -write eth0

Update config files (perhaps after installing a new Captrap version):
captrap_mkconfig -rewrite

Generate a config file for pmacctd:
captrap_mkconfig -write-pmacct /etc/pmacct/pmacct.eth0.conf

"
}


# print all config files
sub print_configs {
  my $common = shift;
  my $config_sets = mk_config_sets($common);
  while (my (undef, $set) = each %$config_sets) {
    print "-" x 80, "\n";
    print make_config($set);
  }
  return 0;
}


# print pmacct config file
sub print_config_pmacct {
  my $common = shift;
  my $config = $common->{config};
  unless (-e config_file_path() && -e $config->{priv_conf}) {
    print STDERR "One or both Captrap config files are missing;\n",
        "use \"-write\" to generate them first\n";
    return 3;
  }
  print make_config_pmacct($config);
  return 0;
}


# -----------------------------------------------------------------------------
# config parsing/altering
# -----------------------------------------------------------------------------

# make sets of information config files
sub mk_config_sets {
  my $common = shift;
  my $config = $common->{config};
  my $sets = mk_ixhash();
  %$sets = (
    main => {
      path => config_file_path(),
      perm => 0644,
      head => mk_config_header(),
      info => mk_config_info(),
      conf => $config,
    },
    priv => {
      path => $config->{priv_conf},
      perm => 0600,
      head => mk_config_header_priv(),
      info => mk_config_info_priv(),
      conf => parse_config_priv($config, 0),
    },
  );
  return $sets;
}


# make the text of a pmacct configuration file
sub make_config_pmacct {
  my $config = shift;
  my $file = shift; # may be undef
  my $pmacct_info = mk_config_info_pmacct($config);
  my $text;
  if (defined($file)) {
    $text .= "! $file\n";
  }
  $text .= mk_config_header_pmacct() . "\n\n";
  while (my ($name, $item) = each %$pmacct_info) {
    $text .= comment_lines($item->{txt}, "!");
    $text .= "$name: $item->{val}\n";
    $text .= "\n";
  }
  return $text;
}


# make a header for pmacct config
sub mk_config_header_pmacct {
  return "
! This is a pmacctd configuration file generated by Captrap. Comments in this
! file are usually brief and/or specific to Captrap's usage of the pmacct
! database. For more information, consult pmacct's documentation, especially
! the CONFIG-KEYS file.
";
}


# make configuration info for pmacct
sub mk_config_info_pmacct {
  my $config = shift;
  my $config_priv = parse_config_priv($config, 0);
  my $info = mk_ixhash();
  %$info = (
    daemonize => {
      val => "true",
      txt => "Daemonize the process.",
    },
    pidfile => {
      val => "/var/run/pmacctd.pid",
      txt => "Write the pmacctd PID to this file.",
    },
    aggregate => {
      val => "dst_mac",
      txt => "Captrap distinguishes types of traffic based on the\n" .
          "destination MAC address.",
    },
    interface => {
      val => $config->{interface},
      txt => "Statistics are collected from this interface. If you change\n" .
          "the interface here, consider changing the \"interface\"\n" .
          "parameter in Captrap's configuration as well.",
    },
    plugins => {
      val => "mysql",
      txt => "Captrap only supports MySQL, so leave this alone unless you\n" .
          "know what you're doing.",
    },
    sql_host => {
      val => "localhost",
      txt => "Connect to the local MySQL server.",
    },
    sql_user => {
      val => $config_priv->{db_user_priv},
      txt => "Username to use when connecting to the database.\n",
    },
    sql_passwd => {
      val => $config_priv->{db_password_priv},
      txt => "Password to use when connecting to the database.\n",
    },
    sql_db => {
      val => $config->{db_database},
      txt => "If you change the database here, be sure to change it in\n" .
          "Captrap's configuration file as well.",
    },
    sql_table => {
      val => $config->{db_table_acct},
      txt => "If you change the acct table here, be sure to change it in\n" .
          "Captrap's configuration file as well.",
    },
    sql_table_version => {
      val => 1,
      txt => "Later versions of pmacct's tables introduce features not\n" .
          "used by Captrap.",
    },
    sql_optimize_clauses => {
      val => "true",
      txt => "This must be set to \"true\" in order to use more efficient\n" .
          "stripped-down tables.",
    },
    sql_history => {
      val => "1h",
      txt => "Accumulate statistics into one-hour time slots. Captrap\n" .
          "doesn't currently support other values, though smaller values\n" .
          "(such as 30m) won't hurt other than using more space and CPU\n" .
          "power.",
    },
    sql_history_roundoff => {
      val => "m",
      txt => "Round timestamps down to the beginning of the time slot.",
    },
    sql_recovery_logfile => {
      val => "/var/lib/pmacct/recovery_log",
      txt => "Log data to this file if database connection fails.",
    },
  );
  return $info;
}


# make the text of a configuration file
sub make_config {
  my $config_set = shift;
  my $config = $config_set->{conf};
  my $ret = "# $config_set->{path}\n$config_set->{head}\n\n";
  while (my ($name, $item) = each %{$config_set->{info}}) {
    $ret .= "# $name:\n";
    $ret .= comment_lines($item->{txt}, '#');
    if (defined($item->{def})) {
      $ret .= "# default: $item->{def}\n";
    }
    my $val = $config->{$name};
    # generate passwords
    unless (defined($val)) {
      if ($name eq 'db_password' || $name eq 'db_password_priv') {
        $val = random_password();
      }
    }
    my $config_val = make_config_val($item, $val);
    my $line = "$name = " . (defined($config_val) ? $config_val : "");
    # comment-out line if value is default
    if (safe_eq($config_val, $item->{def})) {
      $line = "# $line";
    }
    $ret .= "$line\n\n";
  }
  return $ret;
}


# convert a config item back into text form
sub make_config_val {
  my $item = shift; # hash ref
  my $val = shift; # scalar, possibly array/hash ref
  my $ret;
  switch ($item->{var}) {
    case ('s') {
      $ret = $val;
    }
    case ('a') {
      $ret = a2l($val);
    }
    case ('h') {
      $ret = h2l($val);
    }
    case ('ha') {
      $ret = ha2l($val);
    }
    else {
      die "unknown variable type '$item->{var}'";
    }
  }
  return $ret eq "" ? undef : $ret;
}


# convert an array into a comma-separated list of values
sub a2l {
  my $ref = shift;
  return join(',', map { defined($_) ? $_ : "" } @$ref);
}


# convert a hash into a semicolon-colon separated list of values
sub h2l {
  my $ref = shift;
  my @list;
  while (my ($key, $val) = each(%$ref)) {
    push(@list, "$key:$val");
  }
  return join(';', @list);
}


# convert a hash of arrays into a semicolon-colon separated list of values
sub ha2l {
  my $ref = shift;
  my @list;
  while (my ($key, $val) = each(%$ref)) {
    # $val is an array ref
    $val = a2l($val);
    push(@list, "$key:$val");
  }
  return join(';', @list);
}

# -----------------------------------------------------------------------------
# config writing
# -----------------------------------------------------------------------------

# write new config files
sub write_configs {
  my $common = shift;
  my $iface = shift; # defined if we're writing new files, otherwise undef
  my $config_sets = mk_config_sets($common);
  # if iface is defined, use it to make table names and such
  if (defined($iface)) {
    my $config = $config_sets->{main}->{conf};
    $config->{interface} = $iface;
    $config->{db_table_acct} = "acct_$iface";
    $config->{db_table_macs} = "macs_$iface";
  }
  while (my (undef, $set) = each %$config_sets) {
    my $file = $set->{path};
    if (-e $file && defined($iface)) {
      print STDERR "config file \"$file\" already exists\n",
          "either remove the file or use -rewrite\n";
      return 3;
    } elsif (! -e $file && ! defined($iface)) {
      print STDERR "config file \"$file\" does not exist\n",
          "use -write to generate new config files\n";
      return 3;
    }
    my $text = make_config($set);
    write_file($file, $text, $set->{perm}) || return 3;
  }
  return 0;
}


# write pmacct config file
sub write_config_pmacct {
  my $common = shift;
  my $file = shift;
  my $config = $common->{config};
  if (-e $file) {
    print STDERR "pmacctd config file \"$file\" already exists\n",
        "if you're sure you want to replace the file, remove it first\n";
    return 3;
  }
  unless (-e config_file_path() && -e $config->{priv_conf}) {
    print STDERR "One or both Captrap config files are missing;\n",
        "use \"-write\" to generate them first\n";
    return 3;
  }
  my $text = make_config_pmacct($config, $file);
  write_file($file, $text, 0400) || return 3;
  return 0;
}


# wrapper that specifies overwriting
sub rewrite_configs {
  my $common = shift;
  return write_configs($common, undef);
}


# write some data to a file
# This is not intended to provide secure temporary file creation; only decent
# atomicity in case of interruption. If an untrusted user has write permissions
# to the target directory, then we have other problems already...
sub write_file {
  my $file = shift;
  my $data = shift;
  my $perm = shift;
  my $temp = "$file.captrap_temp";
  if (-e $temp) {
    print STDERR "error: temporary file \"$temp\" already exists\n",
        "remove the file first.\n";
    return 0;
  }
  my $fh;
  unless (sysopen($fh, $temp, O_WRONLY | O_TRUNC | O_CREAT, $perm)) {
    print STDERR "error: problem opening temporary file: $!\n";
    return 0;
  }
  unless (defined(syswrite($fh, $data))) {
    print STDERR "error: problem writing to temporary file: $!\n";
    close($fh);
    return 0;
  }
  unless (fsync($fh)) {
    print STDERR "error: problem fsync-ing temporary file: $!\n";
    close($fh);
    return 0;
  }
  unless (close($fh)) {
    print STDERR "error: problem closing temporary file: $!\n";
    return 0;
  }
  my $dir = dirname($file);
  my $dh;
  open($dh, $dir);
  unless (fsync($dh)) {
    print STDERR "error: problem fsync-ing directory: $!\n";
    close($dh);
    return 0;
  }
  unless (rename($temp, $file)) {
    print STDERR "error: problem renaming \"$temp\" to \"$file\": $!\n";
    close($dh);
    return 0;
  }
  unless (fsync($dh)) {
    print STDERR "error: problem fsync-ing directory: $!\n";
    close($dh);
    return 0;
  }
  unless (close($dh)) {
    print STDERR "error: problem closing directory: $!\n";
    return 0;
  }
  return 1;
}

# -----------------------------------------------------------------------------
# misc utility
# -----------------------------------------------------------------------------

# generate a random passowrd
sub random_password {
  # all printable characters except ' ' and '#'
  my $chars = [ '!', '"', map { chr } ord('$') .. ord('~') ];
  return Crypt::GeneratePassword::chars(16, 16, $chars);
}


# comment-out every line in text
sub comment_lines {
  my $text = shift;
  my $char = shift;
  my @lines = map { "$char $_\n" } split("\n", $text);
  return join("", @lines);
}

# -----------------------------------------------------------------------------
# actions info
# -----------------------------------------------------------------------------

# return a hash of action info
sub mk_actions {
  my $actions = mk_ixhash();
  %$actions = (
    "-help" => {
      func => \&usage,
      args => [],
      desc => "
          Print this usage information.
      ",
    },
    "-print" => {
      func => \&print_configs,
      args => [],
      desc => "
          Print the contents of each config file that would be written.
      ",
    },
    "-rewrite" => {
      func => \&rewrite_configs,
      args => [],
      desc => "
          Write new config files, using any non-default parameters in the old
          files to set values in the new files. Note that the files are
          regenerated, so any custom comments or unrecognized parameters will
          disappear. Use \"-print\" first if you are unsure.
      ",
    },
    "-write" => {
      func => \&write_configs,
      args => [ "INTERFACE" ],
      desc => "
          Write the config files. This action takes a single \"interface\"
          parameter used for writing the \"interface\" parameter and the MySQL
          table names. Otherwise, all parameters in the generated files will be
          commented (hence default) except for passwords, which will be
          generated. This action refuses to run if the files exist; use
          \"-rewrite\" to overwrite existing files, or remove the files first
          if you want to generate a fresh configuration.
      ",
    },
    "-print-pmacct" => {
      func => \&print_config_pmacct,
      args => [],
      desc => "
          Print the contents of a pmacct configuration file that would be
          written with \"-write-pmacct\".
      ",
    },
    "-write-pmacct" => {
      func => \&write_config_pmacct,
      args => [ "FILE" ],
      desc => "
          Write pmacctd configuration to the file specified. This action reads
          configuration information from Captrap's configuration files, so you
          will need to use \"-write\" first, and then make any changes if
          necessary.
      ",
    },
  );
  return $actions;
}


# -----------------------------------------------------------------------------


# parse the arguments and take actions
if (! @ARGV) {
  usage();
  exit(1);
}
my $actions = mk_actions();
check_args(\@ARGV, $actions);

my $config = parse_config();
my $common = {
  config => $config,
};

do_args($common, \@ARGV, $actions);
exit(0);
