#!/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/>.


=head1 NAME

captrap_mkconfig - a script for generating and updating Captrap's configuration
files.

=head1 SYNOPSIS

captrap_mkconfig [OPTION] [OPTION-PARAMETERS]

=head1 DESCRIPTION

This program can be used to create fresh versions of Captrap's configuration
files. If the old files are present, captrap_mkconfig can parse them and write
old values to the new files. MySQL passwords will be automatically generated if
necessary.

captrap_mac can also generate a pmacct configuration file that will be
compatible with Captrap's configuration.

=head1 OPTIONS

=over

=item -help

Print brief usage text.

=item -print

Print the contents of each config file that would be written.

=item -rewrite

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.

=item -write INTERFACE

Write the config files. This action takes a single argument 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.

=item -print-pmacct

Print the contents of a pmacct configuration file that would be written with
"-write-pmacct".

=item -write-pmacct FILE

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.

=back

=head1 FILES

=over

=item /etc/captrap/captrap.conf

The main Captrap configuration file.

=item /etc/captrap/priv.conf

The privileged-user Captrap configuration file.

=back

=head1 EXAMPLES

=over

=item Generage fresh config files with interface set to eth0:

captrap_mkconfig -write eth0

=item Update config files (perhaps after installing a new Captrap version):

captrap_mkconfig -rewrite

=item Generate a config file for pmacctd:

captrap_mkconfig -write-pmacct /etc/pmacct/pmacct.eth0.conf

=back

=head1 AUTHOR

Corey Hickey <bugfood-c@fatooh.org>

This program is free software; you may redistribute and/or modify it under the
terms of the GNU General Public License, version 3. See the source file for the
usual GPL preamble and the COPYING file for a copy of the GPL.

=head1 SEE ALSO

captrap_mkdb, captrap_mac

The documentation included with the Captrap source code has more information on
setup and general usage.

=cut


use 5.010; # we need Perl >= 5.10 for given/when
use strict;
use warnings FATAL => 'all';
no warnings "experimental::smartmatch";
use feature 'switch';

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


# 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. For
full usage information, see the man page and/or documentation provided in the
Captrap source archive.

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

ACTIONS

$actions_text
"
}


# 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;
  given ($item->{var}) {
    when ('s') {
      $ret = $val;
    }
    when ('a') {
      $ret = a2l($val);
    }
    when ('h') {
      $ret = h2l($val);
    }
    when ('ha') {
      $ret = ha2l($val);
    }
    default {
      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";
  my $dir = dirname($file);
  if (-e $dir) {
    if (! -d $dir) {
      print STDERR "error: '$dir' exists but is not a directory.\n";
      return 0;
    }
  } else {
    unless (mkdir($dir)) {
      print STDERR "error: problem creating config directory '$dir': $!\n";
      return 0;
    }
  }
  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 '$temp': $!\n";
    return 0;
  }
  unless (defined(syswrite($fh, $data))) {
    print STDERR "error: problem writing to temporary file '$temp': $!\n";
    close($fh);
    return 0;
  }
  unless (fsync($fh)) {
    print STDERR "error: problem fsync-ing temporary file '$temp': $!\n";
    close($fh);
    return 0;
  }
  unless (close($fh)) {
    print STDERR "error: problem closing temporary file '$temp': $!\n";
    return 0;
  }
  my $dh;
  open($dh, $dir);
  unless (fsync($dh)) {
    print STDERR "error: problem fsync-ing directory '$dir': $!\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 '$dir': $!\n";
    close($dh);
    return 0;
  }
  unless (close($dh)) {
    print STDERR "error: problem closing directory '$dir': $!\n";
    return 0;
  }
  return 1;
}

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

# generate a random passowrd
sub random_password {
  # all printable characters...
  my $chars = join('', map { chr } (ord(' ') .. ord('~')));
  # ...except ' ' and '#' and '"' and '\'
  $chars =~ tr/ #"\\//d;
  $chars = [ split(//, $chars) ]; # make array ref
  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.
      ",
    },
    "-write" => {
      func => \&write_configs,
      args => [ "INTERFACE" ],
      desc => "
          Write the config files.
      ",
    },
    "-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.
      ",
    },
  );
  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);
