#!/usr/bin/perl

# This is a program for manipulating Captrap's MAC address table.

# 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_mac - an interactive program for manipulating Captrap's MAC address
table

=head1 SYNOPSIS

=over

=item Interactive:

captrap_mac -inter

=item Command-line:

captrap_mac [OPTION] [OPTION-PARAMETERS]

=back

=head1 DESCRIPTION

Captrap uses a MySQL table to associate MAC addresses with various traffic
states (up, down, etc.). This program adds to, removes from, modifies, and
lists the MAC address table.

captrap_mac can be used as an interactive terminal or as a traditional
command-line program.

=head1 OPTIONS

=over

=item -help

Print brief usage text.

=item -inter

Start an interactive terminal session. All functions available in the
command-line interface are available in the terminal. For quick edits, the
terminal may be more convenient; all operations are guided.

=item -list-known

Print a table of known MAC addresses.

=item -list-unknown

Print a table of unknown MAC addresses.

=item -add-mac MAC STATE COMMENT

Add a MAC address. This action takes three parameters: (1) MAC address in the
usual colon-separated hexadecimal form, (2) traffic state, and (3) comment. The
MAC address may be upper- or lowercase; it will be translated to lowercase
automatically. The traffic state must be one of the states listed in the config
file. The comment is mandatory, but it may be empty; use "" to supply an empty
comment.

=item -del-mac MAC

Delete a MAC address. This action takes one parameter: a MAC address. See
"-add-mac" for details.

=item -upd-mac OLD_MAC NEW_MAC STATE COMMENT

Update a MAC address, state, and comment. This action takes four parameters: a
MAC address to change, and the new MAC address, state, and comment. See
"-add-mac" for details.

=item -auto-add

Attempt to determine the local and remote MAC addresses by examining the output
of 'ifconfig', 'route', and 'arp', and then add them to the database. This also
adds the broadcast address.

=item -auto-print

Like "-auto-add", but just print what would be added.

=back

=head1 EXIT STATUS

=over

=item 0Z<>

Everything is ok.

=item 1Z<>

No arguments given; usage information was shown.

=item 2Z<>

Invalid argument.

=item 3Z<>

There was a problem executing an action.

=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 Print known and unknown MAC addresses:

captrap_mac -list-known -list-unknown

=item List known addresses, add a test MAC, list again, delete the test, and
list once more:

captrap_mac -list-known -add-mac 00:de:ad:be:ef:01 bogey test -list-known
-del-mac 00:de:ad:be:ef:01 -list-known

=item Change the state and comment of a MAC address (comment is made blank):

captrap_mac -upd-mac 00:de:ad:be:ef:01 00:de:ad:be:ef:01 up ""

=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_mkconfig, captrap_mkdb

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 DBI;
use Term::ReadLine; # apt-get install libterm-readline-perl-perl ??

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


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

captrap_mac [ACTION] [[ACTION-PARAMETERS]] ...

ACTIONS

$actions_text
"
}


# wrapper for printing known MAC addresses
sub get_print_known_macs {
  my $common = shift;
  print_known_macs(get_known_macs($common));
  return 0; # ok
}


# wrapper for printing unknown MAC addresses
sub get_print_unknown_macs {
  my $common = shift;
  print_unknown_macs(get_unknown_macs($common));
  return 0; # ok
}


# print a labelled table of known MAC addresses
sub print_known_macs {
  my $macs = shift;
  my $labels = [ "MAC address", "state", "comment" ];
  print "[[[ known MAC addresses ]]]\n";
  print_table($macs, $labels);
}


# print a labelled table of unknown MAC addresses
sub print_unknown_macs {
  my $macs = shift;
  my $labels = [ "MAC address", "bytes", "first seen", "last seen" ];
  print "[[[ unknown MAC addresses ]]]\n";
  print_table($macs, $labels);
  return 0; # ok
}


# print a labelled table of states
sub print_valid_states {
  my $states = shift;
  my $labels = [ "state" ];
  print "[[[ valid states ]]]\n";
  print_table($states, $labels);
}


# print an indexed table
sub print_table {
  my $table = shift; # ref to array of array refs
  my $labels = shift; # array ref
  # first initialize column widths based on label widths
  my @widths;
  for (my $i = 0; $i < @$labels; ++$i) {
    $widths[$i] = length($labels->[$i]);
  }
  # now expand widths as necessary to fit data
  foreach my $line (@$table) {
    for (my $i = 0; $i < @$line; ++$i) {
      # if it's not defined, make room for '<NULL>'
      my $l = defined($line->[$i]) ? length($line->[$i]) : length('<NULL>');
      if (defined($widths[$i])) {
        $widths[$i] = $l if $l > $widths[$i];
      } else {
      # new entry
        $widths[$i] = $l;
      }
    }
  }
  # use widths to generate format string
  my @ph;
  my $len = 0; # length of a separator line (used later)
  foreach my $width (@widths) {
    push(@ph, "%-${width}s");
    $len += $width;
  }
  my $fmt = join("  ", @ph) . "\n";
  $len += 2 * $#widths; # to account for the double-spaces
  # now adjust to make room for indices
  my $digits = length($#{$table});
  my $ws = " " x ($digits + 2);
  printf("$ws$fmt", @$labels);
  $fmt = "%${digits}d  $fmt";
  # print a separator line
  print $ws, "-" x $len, "\n";
  # now finally print the lines
  for (my $i = 0; $i < @$table; ++$i) {
    # make undefs safe for printing
    my @row = map { defined($_) ? $_ : "<NULL>" } @{$table->[$i]};
    printf("$fmt", $i, @row);
  }
  print "\n";
}

# -----------------------------------------------------------------------------
# database
# -----------------------------------------------------------------------------

# get all known macs
sub get_known_macs {
  my $common = shift;
  my $macs = $common->{config}->{db_table_macs};
  my $sel = "
      SELECT MAC, STATE, COMMENT
      FROM $macs
      ORDER BY STATE, MAC
  ";
  my $sth = $common->{dbh}->prepare($sel);
  $sth->execute();
  my @macs;
  while (my @a = $sth->fetchrow_array()) {
    die if @a < 3; # shouldn't happen
    push(@macs, \@a);
  }
  return \@macs;
}


# add a mac address
sub add_mac {
  my $common = shift;
  my $mac = shift;
  my $state = shift;
  my $comment = shift;
  unless (tr_mac(\$mac)) {
    print STDERR "Error: supplied MAC address \"$mac\" is invalid\n";
    return 2;
  }
  if (mac_is_known($common, $mac)) {
    print STDERR "Error: supplied MAC address \"$mac\" is already in table\n";
    return 3;
  }
  unless (check_state($common, $state)) {
    print STDERR "Error: supplied state \"$state\" is invalid\n";
    return 2;
  }
  $comment = undef if safe_eq($comment, "");
  my $macs = $common->{config}->{db_table_macs};
  my $ins = "
      INSERT INTO $macs (mac, state, comment)
      VALUES (?, ?, ?)
  ";
  my $sth = $common->{dbh}->prepare($ins);
  $sth->execute($mac, $state, $comment);
  return 0;
}


# delete a mac address
sub del_mac {
  my $common = shift;
  my $mac = shift;
  unless (tr_mac(\$mac)) {
    print STDERR "Error: supplied MAC address \"$mac\" is invalid\n";
    return 2;
  }
  unless (mac_is_known($common, $mac)) {
    print STDERR "Error: supplied MAC address \"$mac\" is not in table\n";
    return 3;
  }
  my $macs = $common->{config}->{db_table_macs};
  my $del = "
      DELETE FROM $macs
      WHERE MAC = ? LIMIT 1
  ";
  my $sth = $common->{dbh}->prepare($del);
  $sth->execute($mac);
  return 0;
}


# update a mac address
sub upd_mac {
  my $common = shift;
  my $old_mac = shift;
  my $mac = shift;
  my $state = shift;
  my $comment = shift;
  unless (tr_mac(\$old_mac)) {
    print STDERR "Error: supplied MAC address \"$old_mac\" is invalid\n";
    return 2;
  }
  unless (mac_is_known($common, $old_mac)) {
    print STDERR "Error: supplied MAC address \"$old_mac\" is not in table\n";
    return 3;
  }
  unless (tr_mac(\$mac)) {
    print STDERR "Error: supplied MAC address \"$mac\" is invalid\n";
    return 2;
  }
  if ($mac ne $old_mac && mac_is_known($common, $mac)) {
    print STDERR "Error: supplied MAC address \"$mac\" is already in table\n";
    return 3;
  }
  unless (check_state($common, $state)) {
    print STDERR "Error: supplied state \"$state\" is invalid\n";
    return 2;
  }
  $comment = undef if safe_eq($comment, "");
  my $macs = $common->{config}->{db_table_macs};
  my $upd = "
      UPDATE $macs SET MAC = ?, STATE = ?, COMMENT = ?
      WHERE MAC = ? LIMIT 1
  ";
  my $sth = $common->{dbh}->prepare($upd);
  $sth->execute($mac, $state, $comment, $old_mac);
  return 0;
}

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

# translate a mac address to lowercase and check if it is valid
sub tr_mac {
  my $mac = shift; # scalar ref
  $$mac =~ tr/[A-Z]/[a-z]/;
  return $$mac =~ /^([0-9a-f]{2}:){5}[0-9a-f]{2}$/;
}


# check if a mac address is in the macs table
# mac should already be lowercase
sub mac_is_known {
  my $common = shift;
  my $mac = shift;
  my $known_macs = get_known_macs($common);
  foreach my $line (@$known_macs) {
    return 1 if $line->[0] eq $mac;
  }
  return 0; # uknown mac
}


# see if supplied state is valid
sub check_state {
  my $common = shift;
  my $state = shift;
  foreach my $valid (@{$common->{config}->{states}}) {
    return 1 if $state eq $valid;
  }
  return 0; # bad
}


# make a 1-column table out of the states list
sub get_valid_states {
  my $common = shift;
  my $states = $common->{config}->{states};
  return [ map { [ $_ ] } @$states ];
}


# strip leading and trailing whitespace
sub trim {
  my $line = shift;
  $line =~ /^\s*(.*\S)\s*$/;
  return $1;
}


# make a line out of MAC info
sub mac_line {
  my $mac = shift;
  my $state = shift;
  my $comment = shift;
  $comment = "<NULL>" unless defined($comment);
  return "$mac  $state  $comment\n";
}

# -----------------------------------------------------------------------------
# interactive terminal functions
# -----------------------------------------------------------------------------

# get a MAC address from the user, possibly using a table index
sub interact_get_mac {
  my $term = shift;
  my $macs = shift;
  my $lineref = shift; # may be undef
  my $enter = "Enter the index number from the table";
  my $prompt = "  index ";
  # print different stuff depending on whether the user can enter a MAC
  if (defined($lineref)) {
    $enter .= ".\n";
    $prompt .= "? ";
  } else {
    $enter .= ", or enter a valid MAC address.\n";
    $prompt .= "or MAC ? ";
  }
  my $to_quit = "To cancel and go back to the previous terminal, hit CTRL-D.\n";
  print $enter;
  print $to_quit;
  my $line;
  GET_MAC: while (defined($line = $term->readline($prompt))) {
    $line = trim($line);
    next GET_MAC unless defined($line);
    if ($line =~ '^\d+$') {
      # looks like an index number
      if ($line > $#{$macs}) {
        print "Error: index is too high. Highest index in table: $#{$macs}.\n";
        print "Look at the table of MACs and try again.\n";
        print $to_quit;
        next GET_MAC;
      }
      # ok, looks like a valid index
      $$lineref = $macs->[$line] if defined($lineref);
      return $macs->[$line]->[0];
    }
    # only accept a MAC if $lineref is undefined
    if (!defined($lineref) && tr_mac(\$line)) {
      # ok, looks like a valid MAC address
      return $line;
    }
    print "Error: unrecognized input: $line\n";
    print $enter;
    print $to_quit;
  }
  return undef; # user hit CTRL-D
}


# get a state from the user by using a table index
sub interact_get_state {
  my $term = shift;
  my $states = shift;
  print "What state is traffic received by this MAC?\n";
  print "Enter the index number from the table\n";
  my $to_quit = "To cancel and go back to the previous terminal, hit CTRL-D.\n";
  print $to_quit;
  my $prompt = "  state index ? ";
  my $line;
  GET_STATE: while (defined($line = $term->readline($prompt))) {
    $line = trim($line);
    next GET_STATE unless defined($line);
    if ($line =~ '^\d+$') {
      # looks like an index number
      if ($line > $#{$states}) {
        print "Error: index is too high. Highest index in table: $#{$states}.\n";
        print "Look at the table of states and try again.\n";
        print $to_quit;
        next GET_STATE;
      }
      # ok, looks like a valid index
      return $states->[$line]->[0];
    }
    print "Error: unrecognized input: $line\n";
    print "Enter an index number from the table.\n";
    print $to_quit;
  }
  return undef; # user hit CTRL-D
}


# get a comment from the user
sub interact_get_comment {
  my $term = shift;
  print "Would you like to enter a comment into the table?\n";
  print "If so, type the comment below. Otherwise, hit <ENTER>.\n";
  my $to_quit = "To cancel and go back to the previous terminal, hit CTRL-D.\n";
  print $to_quit;
  my $prompt = "  comment ? ";
  return $term->readline($prompt);
}


# print help information for how to change MAC info
sub interact_change_info_help {
  my $mac = shift;
  my $state = shift;
  my $comment = shift;
  $comment = "<NULL>" unless defined($comment);
  return "Here's the current set of information:\n" .
      mac_line($mac, $state, $comment) . "\n" .
      "Do you want to change anything?\n" .
      "Enter 'm' to change MAC, 's' for state, 'c' for comment.\n" .
      "Enter 'n' if you don't want to make any further changes.\n" .
      "Enter 'h' to print this help.\n" .
      "To quit back to the main interactive terminal, hit CTRL-D.\n";
}


# walk the user through changing mac/state/comment
# returns undef if the user cancelled, 1 if changes were made, or 0 otherwise
sub interact_change_info {
  my $common = shift;
  my $term = shift;
  my $unknown_macs = shift;
  my $states = shift;
  my $mac = shift;     # scalar ref
  my $state = shift;   # scalar ref
  my $comment = shift; # scalar ref
  print interact_change_info_help($$mac, $$state, $$comment);
  my $prompt = "  change info ? ";
  my $line;
  my ($old_mac, $old_state, $old_comment) = ($$mac, $$state, $$comment);
  CHANGE: while (defined($line = $term->readline($prompt))) {
    $line = trim($line);
    next CHANGE unless defined($line);
    my $cmd = (split(/\s/, $line))[0];
    my ($save, $ref); # in case the user cancels a change
    given ($cmd) {
      when (/^(m|mac)$/i) {
        ($save, $ref) = ($$mac, $mac);
        print_unknown_macs($unknown_macs);
        $$mac = interact_get_mac($term, $unknown_macs);
      }
      when (/^(s|state)$/i) {
        ($save, $ref) = ($$state, $state);
        print_valid_states($states);
        $$state = interact_get_state($term, $states);
      }
      when (/^(c|comment)$/i) {
        ($save, $ref) = ($$comment, $comment);
        $$comment = interact_get_comment($term);
        if (safe_eq($$comment, "")) {
          $$comment = undef if safe_eq($$comment, "");
          # we need to trick the undef check below
          $ref = \"tricked";
        }
      }
      when (/^(h|help)$/i) {
        # interact_change_info_help is printed anyway
      }
      when (/^(n|no)$/i) {
        print "Finished making changes.\n";
        my $changed = 0;
        $changed |= $old_mac     ne $$mac;
        $changed |= $old_state   ne $$state;
        $changed |= ! safe_eq($old_comment, $$comment);
        return $changed;
      }
      default {
        print "unrecognised command \"$cmd\"; try \"help\"\n";
        next;
      }
    }
    unless (defined($$ref)) {
      # user hit CTRL-D while making an individual change
      print "\nchange cancelled\n";
      $$ref = $save;
    }
    print interact_change_info_help($$mac, $$state, $$comment);
  }
  # user hit CTRL-D, so revert changes
  print "\nCancelling changes to this MAC...\n";
  $$mac     = $old_mac;
  $$state   = $old_state;
  $$comment = $old_comment;
  return undef;
}


# query for confirmation
sub interact_confirm {
  my $term = shift;
  my $prompt = shift;
  print "Enter 'y' or 'n'.\n";
  my $line;
  CONFIRM: while (defined($line = $term->readline($prompt))) {
    $line = trim($line);
    next CONFIRM unless defined($line);
    my $cmd = (split(/\s/, $line))[0];
    given ($cmd) {
      when (/^(y|yes)$/i) {
        return 1;
      }
      when (/^(n|no)$/i) {
        return 0;
      }
      default {
        print "Please enter 'y' or 'n'.\n";
      }
    }
  }
  # user hit CTRL-D
  print "\nchoice cancelled\n";
  return undef;
}


# interactive terminal for adding a MAC address
sub interact_add {
  my $common = shift;
  my $term = shift;
  my $unknown_macs = get_unknown_macs($common);
  print_unknown_macs($unknown_macs);
  print "What MAC address would you like to add?\n";
  my $mac = interact_get_mac($term, $unknown_macs);
  if (! defined($mac)) {
    print "\nCancelled\n";
    return 0;
  }
  print "You have chosen to add MAC address: $mac\n";
  my $states = get_valid_states($common);
  print_valid_states($states);
  my $state = interact_get_state($term, $states);
  if (! defined($state)) {
    print "\nCancelled\n";
    return 0;
  }
  print "You have chosen state: $state\n";
  my $comment = interact_get_comment($term);
  if (! defined($comment)) {
    print "\nCancelled\n";
    return 0;
  }
  # empty comment --> NULL
  if ($comment eq "") {
    print "You have chosen to leave comment NULL.\n";
    $comment = undef;
  } else {
    print "You have chosen comment: $comment\n";
  }
  my $changed = interact_change_info($common, $term, $unknown_macs, $states,
      \$mac, \$state, \$comment);
  if ($changed) {
    print "MAC info was changed to:\n";
  } else {
    print "MAC info remains set to:\n";
  }
  print mac_line($mac, $state, $comment);
  print "Do you want to add this MAC address to the database?\n";
  if (interact_confirm($term, "  confirm add ? ")) {
    if (! add_mac($common, $mac, $state, $comment)) {
      print "Successfully added $mac.\n";
    }
    print "Returning to main terminal.\n";
  } else {
    print "Add canceled. Returning to main terminal.\n";
  }
}


# interactive terminal for deleting a MAC address
sub interact_del {
  my $common = shift;
  my $term = shift;
  my $known_macs = get_known_macs($common);
  print_known_macs($known_macs);
  print "What MAC address would you like to delete?\n";
  my $mac = interact_get_mac($term, $known_macs);
  if (! defined($mac)) {
    print "\nCancelled\n";
    return 0;
  }
  print "You have chosen to delete MAC address: $mac\n";
  print "Do you want to delete this MAC address from the database?\n";
  if (interact_confirm($term, "  confirm delete ? ")) {
    if (! del_mac($common, $mac)) {
      print "Successfully deleted $mac.\n";
    }
    print "Returning to main terminal.\n";
  } else {
    print "Delete canceled. Returning to main terminal.\n";
  }
}


# interactive terminal for updating a MAC address
sub interact_upd {
  my $common = shift;
  my $term = shift;
  my $known_macs   = get_known_macs($common);
  my $unknown_macs = get_unknown_macs($common);
  print_known_macs($known_macs);
  print "What MAC address would you like to update?\n";
  my $lineref;
  my $old_mac = interact_get_mac($term, $known_macs, \$lineref);
  if (! defined($old_mac)) {
    print "\nCancelled\n";
    return 0;
  }
  print "You have chosen to update MAC address: $old_mac\n";
  my ($mac, $state, $comment) = @$lineref;
  my $states = get_valid_states($common);
  my $changed = interact_change_info($common, $term, $unknown_macs, $states,
      \$mac, \$state, \$comment);
  if (!$changed) {
    print "No changes made. Returning to main terminal.\n";
    return;
  }
  print "MAC info was changed to:\n";
  print mac_line($mac, $state, $comment);
  print "Do you want to update the MAC info in the database?\n";
  if (interact_confirm($term, "  confirm update ? ")) {
    if (! upd_mac($common, $old_mac, $mac, $state, $comment)) {
      print "Successfully updated $mac.\n";
    }
    print "Returning to main terminal.\n";
  } else {
    print "Update canceled. Returning to main terminal.\n";
  }
}


# main interactive terminal
sub interact {
  my $common = shift;
  my $term = Term::ReadLine->new("captrap_mac");
  my $prompt = "captrap_mac > ";
  print "\nWelcome! Enter 'h' for help or 'q' to quit.\n";
  my $line;
  TERM: while (defined($line = $term->readline($prompt))) {
    $line = trim($line);
    next TERM unless defined($line);
    my $cmd = (split(/\s/, $line))[0];
    given ($cmd) {
      when (/^(a|add)$/i) {
        interact_add($common, $term);
      }
      when (/^(d|delete)$/i) {
        interact_del($common, $term);
      }
      when (/^(u|update)$/i) {
        interact_upd($common, $term);
      }
      when (/^(l|list)$/i) {
        get_print_known_macs($common);
        get_print_unknown_macs($common);
      }
      when (/^(h|help|\?)$/i) {
        interact_help();
      }
      when (/^(q|quit)$/i) {
        print "quitting interactive mode\n";
        last TERM;
      }
      default {
        print "unrecognised command \"$cmd\"; try \"help\"\n";
      }
    }
  }
  print "\n";
}


# print help for main interactive terminal
sub interact_help {
  print "
This is an interactive terminal for manipulating information in Captrap's MAC
table. You can add, delete, and update information about MAC addresses by using
the following commands. All commands have a short form--you can enter just the
first letter. For example, 'a' is equivalent to 'add'.

add      Add a MAC address to the table.

delete   Delete a MAC address from the table.

update   Update a MAC address or any of its information.

list     Print lists of known and unknown MAC addresses.

help     Print this information

quit     Quit the terminal.

The 'add', 'delete', and 'update' commands ask several questions. To cancel an
in-progress add, update, or delete, use CTRL-D to go back to the main terminal.
CTRL-D also exits from the main terminal.

If you ever get lost, it's always safe to kill this program (by using CTRL-C,
for example); changes to the MAC address table are only committed after a
confirmation prompt.
";
}


# -----------------------------------------------------------------------------
# automatic address adding
# -----------------------------------------------------------------------------

# run a command, search the output, and set corresponding parameters
sub grep_cmd {
  my $cmd = shift; # array ref
  my $re = shift; # regular expression
  local (*C_OUT, *P_IN);
  pipe(P_IN, C_OUT); # child --> parent
  my $childpid = fork;
  die "couldn't fork" unless defined($childpid);
  if (! $childpid) {
    # I am the child
    close(P_IN);
    open(STDOUT, ">&=C_OUT") or die "child: could not reopen STDOUT";
    # "perldoc -f exec" and "perldoc perlobj" to see why
    exec { $cmd->[0] } @$cmd or die "Can't exec ", $cmd->[0];
  }
  # I am the parent
  close(C_OUT);
  my $matches;
  while (<P_IN>) {
    if (/$re/) {
      # we got it!
      $matches = [ $1, $2, $3, $4, $5, $6, $7, $8, $9 ];
      1 while (<P_IN>); # discard the rest
      last;
    }
  }
  close(P_IN) or die "close P_IN failed for some reason";
  # don't want zombies
  waitpid($childpid, 0);
  my ($status, $signal) = ($? >> 8, $? & 127);
  if ($status) {
    die "child '$cmd->[0]' command failed. exit status $status, signal $signal";
  }
  return $matches; # is undef if $re not found
}


# automatically determine address to add
sub auto_add {
  my $common = shift;
  my $dry_run = shift; # may be undef
  my $iface = $common->{config}->{interface};
  my $matches;
  # get local MAC address
  $matches = grep_cmd([ "ifconfig" ],
      qr/^$iface\s+.*\s+HWaddr\s+([0-9a-f:]*)\s*$/);
  my $local_mac = $matches->[0];
  unless (defined($local_mac)) {
    print STDERR "unable to determine local MAC address";
    return 1;
  }
  print "local MAC address: $local_mac\n";
  # get remote gateway IP address
  $matches = grep_cmd([ qw(route -n) ], qr/^0\.0\.0\.0\s+([0-9.]*)\s+/);
  my $gw_ip = $matches->[0];
  unless (defined($gw_ip)) {
    print STDERR "unable to determine remote gateway IP address";
    return 1;
  }
  print "remote gateway IP address: $gw_ip\n";
  # ping the gateway
  print "pinging gateway $gw_ip ... ";
  $matches = grep_cmd([ qw(ping -n -c 1 -W 10), $gw_ip ],
      qr/^(\d+) bytes from $gw_ip:/);
  my $pinged = $matches->[0];
  unless (defined($pinged)) {
    print STDERR "failed\nunable to ping remote gateway $gw_ip";
    return 1;
  }
  print "ok\n";
  # check the arp cache for the gateway MAC
  my $gw_ip_esc = $gw_ip;
  $gw_ip_esc =~ s/\./\\\./g; # have to escape '.'
  $matches = grep_cmd([ qw(arp -an), $gw_ip ],
      qr/^\? \($gw_ip_esc\) at (([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}) /);
  my $gw_mac = $matches->[0];
  unless (defined($gw_mac)) {
    print STDERR "unable to determine gateway MAC address from ARP cache\n";
    return 1;
  }
  $gw_mac = lc($gw_mac);
  print "remote gateway MAC address: $gw_mac\n";
  # broadcast is broadcast
  my $bcast_mac = "ff:ff:ff:ff:ff:ff";
  # ok, now we know all we need, so check the database for existing MACs
  my $macs_found = [
    [ $local_mac, "down",  "automatically determined local address" ],
    [ $gw_mac,    "up",    "automatically determined gateway address" ],
    [ $bcast_mac, "bcast", "broadcast address" ],
  ];
  my $macs_add = [];
  foreach my $mac (@$macs_found) {
    if (mac_is_known($common, $mac->[0])) {
      print "already in database: $mac->[0]\n";
    } else {
      push(@$macs_add, $mac);
    }
  }
  unless (@$macs_add) {
    print "no remaining MAC addresses to add\n";
    return 0;
  }
  my $labels = [ "MAC address", "state", "comment" ];
  print "\n[[[ MAC addresses to add ]]]\n";
  print_table($macs_add, $labels);
  return 0 if ($dry_run);
  # now add them
  foreach my $mac (@$macs_add) {
    print "adding: $mac->[0]...\n";
    add_mac($common, @$mac);
  }
  print "all done\n";
  return 0;
}


# wrapper for printing only
sub auto_print {
  my $common = shift;
  return auto_add($common, 1);
}

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

# return a hash of action info
sub mk_actions {
  my $actions = mk_ixhash();
  %$actions = (
    "-help" => {
      func => \&usage,
      args => [],
      desc => "
          Print this usage text.
      ",
    },
    "-inter" => {
      func => \&interact,
      args => [],
      desc => "
          Start an interactive terminal session.
      ",
    },
    "-list-known" => {
      func => \&get_print_known_macs,
      args => [],
      desc => "
          Print a table of known MAC addresses.
      ",
    },
    "-list-unknown" => {
      func => \&get_print_unknown_macs,
      args =>[],
      desc => "
          Print a table of unknown MAC addresses.
      ",
    },
    "-add-mac" => {
      func => \&add_mac,
      args => [ qw(MAC STATE COMMENT) ],
      desc => "
          Add a MAC address.
      ",
    },
    "-del-mac" => {
      func => \&del_mac,
      args => [ "MAC" ],
      desc => "
          Delete a MAC address.
      ",
    },
    "-upd-mac" => {
      func => \&upd_mac,
      args => [ qw(OLD_MAC NEW_MAC STATE COMMENT) ],
      desc => "
          Update a MAC address, state, and comment.
      ",
    },
    "-auto-add" => {
      func => \&auto_add,
      args => [],
      desc => "
          Attempt to automatically add some known addresses.
      ",
    },
    "-auto-print" => {
      func => \&auto_print,
      args => [],
      desc => "
          Like \"-auto-add\", but just print what would be added.
      ",
    },
  );
  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,
  dbh => mk_dbh($config, 1),
};

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

$common->{dbh}->disconnect();
exit(0);
