#!/usr/bin/perl -wT
# gsutil - GrandStream configuration dump/restore + reboot
# Copyright (C) 2005  Charles Howes <grandstream@ch.pkts.ca>
# 
# 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; either version 2
# of the License, or (at your option) any later version.
# 
# 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, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.

# Version 2.1  - 2005-08-18

# Usage: gsutil -d 10.10.13.5 > phone1.conf   - dump configuration
#        gsutil -do 10.10.13.5 > phone1.conf  - dump in GS compatible style
#        gsutil -r 10.10.13.5 < phone1.conf   - restore configuration
#        gsutil -b 10.10.13.5           - reboot phone and wait for it
#        gsutil -bn 10.10.13.5          - reboot phone and don't wait
#        gsutil -e 10.10.13.5           - show firmware version number
#
#        gsutil -deorbn 10.10.13.5 < new.conf > old.conf - everything
#
#        gsutil -d 10.10.13.2 10.10.13.3 10.10.13.4 > out.conf - dump phones
#        gsutil -r 10.10.13.2 10.10.13.3 10.10.13.4 < in.conf - restore phones
#        gsutil -bn 10.10.13.2 10.10.13.3 10.10.13.4  - boot phones
#
# More than one phone can be specified at once; the configurations are
# appended when dumping, and separated when restoring.
#
# Only the settings listed in the input file will be changed on the phone.
# This allows you to change a single setting on all of your phones at
# once without having to download, edit and upload each phone's
# settings.  Make a configuration file containing only the parameter
# to change, and use that as input (gsutil -r phone1 < input; gsutil
# -r phone2 < input; etc.)
#
# Note: if restoring multiple phones at once, the command line ip
# addresses should be in the same order as they were when they were
# dumped, or else the configurations will go to different phones.
# This may or may not be what you want.

use strict;

use Getopt::Std;
use HTML::Form;
use HTTP::Request;
#use HTTP::Cookies;  # They don't work for me, I had to kludge it.
use LWP::UserAgent;
use Socket;
use POSIX qw(strftime);
use Data::Dumper;

# The configuration variables get translated into english with this table:
my %translate = (
  'P2' => 'admin_password',
  'P3' => 'name',
  'P8' => 'static_ipaddress_true',
  'P9' => 'static_ipaddress_1',
  'P10' => 'static_ipaddress_2',
  'P11' => 'static_ipaddress_3',
  'P12' => 'static_ipaddress_4',
  'P13' => 'static_subnet_1',
  'P14' => 'static_subnet_2',
  'P15' => 'static_subnet_3',
  'P16' => 'static_subnet_4',
  'P17' => 'static_router_1',
  'P18' => 'static_router_2',
  'P19' => 'static_router_3',
  'P20' => 'static_router_4',
  'P21' => 'static_dns1_1',
  'P22' => 'static_dns1_2',
  'P23' => 'static_dns1_3',
  'P24' => 'static_dns1_4',
  'P25' => 'static_dns2_1',
  'P26' => 'static_dns2_2',
  'P27' => 'static_dns2_3',
  'P28' => 'static_dns2_4',
  'P29' => 'early_dial',
  'P30' => 'ntp_server',
  'P31' => 'sip_registration',
  'P32' => 'register_expiration',
  'P33' => 'voicemail_userid',
  'P34' => 'authenticate_password',
  'P35' => 'sip_userid',
  'P36' => 'authenticate_id',
  'P37' => 'voice_frames_per_tx',
  'P38' => 'layer3_QoS',
  'P39' => 'local_rtp_port',
  'P40' => 'local_sip_port',
  'P41' => 'tftp_server_1',
  'P42' => 'tftp_server_2',
  'P43' => 'tftp_server_3',
  'P44' => 'tftp_server_4',
  'P46' => 'vocoder_7',
  'P47' => 'sip_server',
  'P48' => 'outbound_proxy',
  'P49' => 'G723_rate',
  'P50' => 'silence_suppression',
  'P51' => 'layer2_QoS_vlan_tag',
  'P52' => 'nat_traversal_true',
  'P57' => 'vocoder_1',
  'P58' => 'vocoder_2',
  'P59' => 'vocoder_3',
  'P60' => 'vocoder_4',
  'P61' => 'vocoder_5',
  'P62' => 'vocoder_6',
  'P63' => 'userid_is_phone_number',
  'P64' => 'time_zone',
  'P65' => 'send_anonymous',
  'P66' => 'dial_plan_prefix',
  'P71' => 'offhook_autodial',
  'P72' => 'use_hash_as_dial_key',
  'P73' => 'send_dtmf_mode',
  'P74' => 'send_flash_event',
  'P75' => 'daylight_savings_time',
  'P76' => 'nat_traversal_stun_server',
  'P78' => 'use_random_port',
  'P79' => 'dtmf_payload_type',
  'P81' => 'unregister_on_reboot',
  'P82' => 'PPPoE_id',
  'P83' => 'PPPoE_password',
  'P84' => 'keepalive_interval',
  'P85' => 'no_key_entry_timeout',
  'P87' => 'layer2_QoS_priority_value',
  'P88' => 'lock_keypad_update',
  'P90' => 'auto_answer',
  'P91' => 'disable_call_waiting',
  'P92' => 'dhcp_dns_server_1',
  'P93' => 'dhcp_dns_server_2',
  'P94' => 'dhcp_dns_server_3',
  'P95' => 'dhcp_dns_server_4',
  'P96' => 'iLBC_payload_type',
  'P97' => 'iLBC_frame_size',
  'P98' => 'vocoder_8',
  'P99' => 'subscribe_for_mwi',
  'P101' => 'use_nat_ip',
  'P102' => 'date_display_format',
  'P103' => 'use_dns_srv',
  'P104' => 'default_ring_tone',
  'P105' => 'custom_ring_tone_1_caller_id',
  'P106' => 'custom_ring_tone_2_caller_id',
  'P107' => 'custom_ring_tone_3_caller_id',
  'P191' => 'enable_call_features',
  'P192' => 'http_upgrade_url',
  'P193' => 'automatic_http_upgrade_days',
  'P194' => 'automatic_http_upgrade',
  'P196' => 'end_user_password',
  'P197' => 'proxy_require',
  'P207' => 'syslog_server',
  'P208' => 'syslog_level',
  'P212' => 'firmware_upgrade',
  'P213' => 'firmware_upgrade_tftp_1',
  'P214' => 'firmware_upgrade_tftp_2',
  'P215' => 'firmware_upgrade_tftp_3',
  'P216' => 'firmware_upgrade_tftp_4',
);

# Backup the command line:
my $cmdline=join(" ",@ARGV);

# Stuff used by getopts:
$main::VERSION="2.1";
$Getopt::Std::STANDARD_HELP_VERSION=1;

# Get command line options:
my %opts;
my $r=getopts('bdehnop:r',\%opts);

# If the user is asking for help:
if (defined $opts{'h'}) {
  VERSION_MESSAGE(*STDOUT{IO});
  HELP_MESSAGE(*STDOUT{IO});
  exit 0;
}

# Version information, called by getopts if --version or --help given.
sub VERSION_MESSAGE {
  my $fh=$_[0];  # Passed a file handle.
  print $fh "Version 2.1 of $0, a Grandstream BudgeTone phone backup, restore and\n";
  print $fh "reboot utility.\n";
}

# Help information, called by getopts if --help given.
sub HELP_MESSAGE {
  my $fh=$_[0];  # Passed a file handle.
  print $fh "Usage: $0 -[b|d|e|h|r] [-n] [-o] [-p password] address..\n";
  print $fh "  -b : reboot\n";
  print $fh "  -d : dump to stdout\n";
  print $fh "  -e : show phone firmware versions\n";
  print $fh "  -h, --help : print this help\n";
  print $fh "  -r : restore from stdin\n";
  print $fh "  -n : don't wait for reboot to finish\n";
  print $fh "  -o : don't translate configuration values\n";
  print $fh "  -p : password (default: admin)\n";
  print $fh "  --version : print the version of gsutil\n";
  # getopts exits after calling HELP_MESSAGE()
}

if (!defined $opts{'b'} and !defined $opts{'d'} and !defined $opts{'e'} and !defined $opts{'r'}) {
  print STDERR "No command was given.\n\n";
  print STDERR "Choose one of -b, -d, -e, or -r to do something.\n";
  print STDERR "Try `$0 --help' for more information.\n";
  exit 0;
}

# Get the password
my $password=$opts{'p'};
if (!defined $password) {$password='admin'};

# The addresses are on the command line:
if (!defined $ARGV[0]) {
  print STDERR "No addresses of phones were given on the command line.\n";
  HELP_MESSAGE(*STDOUT{IO});
  exit(3);
}

my $linenumber=0;

# For each address on the command line:
foreach my $add (@ARGV) {

  # Validate the address:
  if ($add !~ m/^([a-z0-9.-]{1,80})$/) { die("$add isn't a valid hostname."); }
  my $address=$1;
  if ($address=~m/[a-z]/) {
    if (!defined gethostbyname($address)) {
      warn("$0: unknown host $address\n");
      next;
    }
  }

  # Make a url out of it:
  my $url="http://$address/";

  # Set up the user agent:
  my $ua=LWP::UserAgent->new;

# Cookie handling in LWP is messed up, so these parts are commented out:

#  $ua->cookie_jar( {} );  # Store cookies temporarily

#  my $cookie_jar = HTTP::Cookies->new(
#    file=>"/tmp/lwp_cookies.dat",
#    autosave=>1,
#  );
#  $ua->cookie_jar($cookie_jar);  # Store cookies permanently

  $ua->timeout(3);  # Set timeout

  # Get the login page:
  my $request=HTTP::Request->new(GET => $url);
  my $response=$ua->request($request);

  # Page not found:
  if ($response->code == 403) {
    warn("$address couldn't find the login page, is it a GS phone?\n");
    next;
  }

  # Error:
  if ($response->code == 500) {
    if ($response->content =~ m/Can't connect to \S+ \(connect: timeout\)/) {
      warn("$address was unreachable, was that the right address?\n");
      next;
    }

    # Some other fatal error:
    print "A 500 error was received, but not for connection timeout.\n";
    print "Here is a full dump of the response:\n";
    print Dumper($response),"\n";
    exit 0;
  }

  # Parse the form on the login page
  my $form=HTML::Form->parse($response->content,$url);
  if (!defined $form) {
    warn("$address didn't respond with a form; is it a GS phone?\n");
    next;
  }

  # See if this is the right form
  my @names=$form->param;
  if (grep(/^P2$/,@names)==0) {
    warn("The form didn't have the expected 'P2' (password) field in it!\n".
        "The fields that were found were: ".join(", ",@names)."\n");
    next;
  }

  # Stuff in the password
  $form->value('P2' => $password);

  # Submit it (log in)
  $response = $ua->request($form->click);

  # Check to see if we logged in successfully.
  if ($response->content =~ m/Your Login Password is not recognized/) {
    die("$address: Your login password was not recognized.\n".
        "Try using the '-p password' option:\n".
	"  $0 -p secret-password $cmdline\n");
  }

  # Parse the response (the main config form).
  # This is done rather early, in order to cache the info.
  $form=HTML::Form->parse($response->content,$form->action);

  # Figure out the version:
  my $r=$response->content;
  my $version="";
  if ($r=~m/Grandstream Device Configuration/) { $version="1.0.6.x";}
  if ($r=~m/Grandstream IP Phone Configuration/) { $version="1.0.5.x";}
  if ($version eq "") {warn("$0: $address: Unknown firmware\n");next;}

  #print Dumper($response->headers);
  my $cookie=$response->headers->header("set-cookie");
  #1.0.5.x:'set-cookie' => 'SessionId=3bf1ca965d26edb9"; Version=1; Path=/'
  #1.0.6.x:'set-cookie' => 'SessionId="965c22e1bd86fece"; Version=1; Path=/'
  if (!defined $cookie) {
    die("$address: No cookie was sent!\n");
  }
  if ($cookie!~m/SessionId="?([^"]*)"/) {
    die("$address: The cookie didn't contain 'SessionId='!\n".
        "  It said: $cookie\n");
  }
  #if ($cookie!~m/SessionId="?([^"]*)"/) { die("No cookie found!"); }
  my $sessionid=$1;

  # Show the firmware release information:
  if (defined $opts{'e'}) {
    if ($version eq "1.0.5.x") {
      $r=~s/.*Software Version: <\/b><\/td>\s+//s;
      $r=~s/<\/td>.*//s;
      $r=~s/<td>//s;
      $r=~s/(&nbsp;|\s)+/ /sg;
      $r=~s/--/=/sg;
      $r=~s/^\s+//m;
      $r=~s/\s+$//mg;
      print "# $address versions: $r\n";
    }
    if ($version eq "1.0.6.x") {
      # Get status page
      $request=HTTP::Request->new(GET => $url."index.htm");
      $request->header("Cookie" => "SessionId=\"$sessionid\"");
      $response=$ua->request($request);
      if (!defined $response) {warn("$address didn't respond to request 2");next;}
      my $r2=$response->content;
      $r2=~s/.*Program--/Program--/s;
      $r2=~s/System Up Time:.*//s;
      $r2=~s/<\/td>.*//s;
      $r2=~s/(&nbsp;|\s)+/ /sg;
      $r2=~s/-- /=/sg;
      print "# $address versions: $r2\n";
    }
  }

  # Dump the data:
  if (defined $opts{'d'}) {
    # Print the data
    my $stamp=strftime("%Y-%m-%d %H:%M:%S",localtime());
    print "#-- start of phone $address ($stamp)\n"; # Mark the start

    # Dump form 1:
    foreach my $n ($form->param) {
      if ($n =~ m/^(cancel|reboot|gnkey|update)$/) {next;}
      if ($version eq "1.0.6.x" && $n =~ m/^(P2|P34|P196)$/) {next;} # Skip
      if (defined $translate{$n} && !defined $opts{'o'}) {
	print $translate{$n}," = ",$form->param($n),"\n";
      } else {
        if (!defined $opts{'o'}) {die("$address: $n not found in translate table");}
	if (defined $translate{$n}) {
	  print "# $translate{$n}:\n";
	}
	print "$n = ",$form->param($n),"\n";
	print "\n";
      }
    }

    if ($version eq "1.0.6.x") {
      # Dump the Basic Settings page as well:
      $request=HTTP::Request->new(GET => $url."config2.htm");
      $request->header("Cookie" => "SessionId=\"$sessionid\"");
      $response=$ua->request($request);
      $form=HTML::Form->parse($response->content,$form->action);

      print "# Page 2:\n";
      # Dump form 2:
      foreach my $n ($form->param) {
	if ($n =~ m/^(cancel|reboot|gnkey|update)$/) {next;}
	if ($version eq "1.0.6.x" && $n =~ m/^(P2|P34|P196)$/) {next;} # Skip
	if (defined $translate{$n} && !defined $opts{'o'}) {
	  print $translate{$n}," = ",$form->param($n),"\n";
	} else {
	  if (!defined $opts{'o'}) {die("$n not found in translate table");}
	  if (defined $translate{$n}) {
	    print "# $translate{$n}:\n";
	  }
	  print "$n = ",$form->param($n),"\n";
	  print "\n";
	}
      }

    }

    print "#-- end of phone $address ($stamp)\n";
  }

  # Restore the data:
  if (defined $opts{'r'}) {
    my %val;

    # Error checking:
    my %formlines;
    my %filled;

    # Prepare conversion back to P-values:
    my %untranslate;
    foreach (keys %translate) {$untranslate{uc($translate{$_})}=$_;}
    

    # Read the file:
    while(<STDIN>) {
      chomp;
      $linenumber++;

      if (m/^#-- end of phone/) {last;} # Stop 
      if (m/^#/) {next;}  # Skip comment lines
      if (m/^$/) {next;}  # Skip blank lines
      s/\s+#.*//;  # Comment deletion (won't cut in the middle of a word)

      if (m/^([^=]+)=(.*?)$/) {   # name = value...
        my $ls=uc($1);
	my $rs=$2;
	# Trim leading and trailing whitespace:
	$ls=~s/^\s*//; $ls=~s/\s*$//;
	$rs=~s/^\s*//; $rs=~s/\s*$//;
	# Optional quote removal:
	if ($rs=~m/'(.*)'/) {$rs=$1;}
	if ($rs=~m/"(.*)"/) {$rs=$1;}
	# Uppercase the name:
	$ls=uc($ls);
	# Stick it into %val:
	if (defined($translate{$ls})) { $val{$ls}=$rs;next;}
	if (defined($untranslate{$ls})) { $val{$untranslate{$ls}}=$rs;next;}
	die("$0: Unknown variable: '$_', stdin line $linenumber");
      }
      die("$0: Unrecognized line: '$_', stdin line $linenumber");
    }

    # Fill in the form:
    foreach my $n ($form->param) {
      $formlines{$n}=1;
      if (defined $val{$n}) { $form->param($n,$val{$n}); $filled{$n}=1;}
    }

    # Add cookie:
    $request=$form->make_request;
    $request->header("Cookie" => "SessionId=\"$sessionid\"");

    # Send it:
    $response=$ua->request($request);

    # There's a second config page in the new firmware.
    # It may be the case that we could stick everything into the first
    # form and it'd work, but I didn't feel like figuring that out.
    if ($version eq "1.0.6.x") {
      $request=HTTP::Request->new(GET => $url."config2.htm");
      $request->header("Cookie" => "SessionId=\"$sessionid\"");  # Add cookie
      $response=$ua->request($request);
      $form=HTML::Form->parse($response->content,$form->action);

      # Fill in the form
      foreach my $n ($form->param) {
	$formlines{$n}=1;
	if (defined $val{$n}) {$form->param($n,$val{$n});$filled{$n}=1;}
      }

      # Add cookie:
      $request=$form->make_request;
      $request->header("Cookie" => "SessionId=\"$sessionid\"");

      # Send it:
      $response=$ua->request($request);
    }

    # Check for missed data:
    foreach (sort keys %val) {
      if (!defined $formlines{$_}) {
        print "The config file had $_ ($translate{$_}), but the phone did not.\n";
      }
    }

    # Check for missed form fields (undesirable):
    #foreach (sort keys %formlines) {
    #  if (!defined $val{$_}) {
    #    print "The form had $_ ($translate{$_}), but the config file did not.\n";
    #  }
    #}

  }

  # Reboot the phone:
  if (defined $opts{'b'}) {
    $request->uri($url."rs.htm");
    $request->header("Cookie" => "SessionId=\"$sessionid\"");
    $response=$ua->request($request);

    # Wait for reboot to finish (skip it with the -n option)
    if (!defined $opts{'n'}) {
      my $now=time();
      my $up=1;
      $ua->timeout(1);
      for (my $n=0;$n<30;$n++) { # Should be done in 21 seconds or so.
	$request=HTTP::Request->new(GET => $url);
	$response=$ua->request($request);
	#print time()-$now,": ",$response->code,"\n";
	if ($response->code==200 and $up==1) {sleep(1);next;}
	if ($response->code==500) {$up=0;next;}
	last;  # It's up again after being down (200 -> 500 -> 200)
      }
    }
  }

  # Done with this phone, on to the next.
}
