#!/usr/bin/perl -Tw
#
# Copyright (c) 2019 Clementine Computing LLC.
#
# This file is part of PopuFare.
#
# PopuFare is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PopuFare 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 Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with PopuFare. If not, see .
#
require 5.002;
use strict;
use Socket;
use Switch;
use Carp;
use FileHandle;
use Fcntl;
use Digest::MD5 qw(md5 md5_hex md5_base64);
use Getopt::Long qw(:config no_ignore_case);
use POSIX;
use Time::Local;
use Data::Dumper;
use RideLogic;
use RideLogicAPIQueryWrapper;
my $PROGNAME = "ridelogic_billingd_using_api";
my $HOST;
my $DB;
my $DBUSER;
my $DBPASS;
my $DSN;
my $ORG = "ORG";
my $bind_ip = '127.0.0.1';
my $bind_port = 2455;
my $billing_logfile;
my $debug_logfile;
my $REJECT_RULE = $ORG . "-REJECT";
sub unix_to_readable_time {
my $unix_time = shift;
my @a = localtime($unix_time);
return sprintf('%d-%02d-%02d %02d:%02d:%02d', (1900+$a[5]), (1+$a[4]), $a[3], $a[2], $a[1], $a[0]);
}
sub readable_time_cmp {
my $ldate = shift;
my $rdate = shift;
$ldate =~ m/^(\d+)-(\d+)-(\d+) (\d+):(\d+):(\d+)$/;
my $lunx = timelocal($6, $5, $4, $3, $2 - 1, $1);
$rdate =~ m/^(\d+)-(\d+)-(\d+) (\d+):(\d+):(\d+)$/;
my $runx = timelocal($6, $5, $4, $3, $2 - 1, $1);
return $lunx - $runx;
}
sub get_readable_expiration_date {
my $readable_date = shift;
my $ndays = shift;
$readable_date =~ m/^(\d+)-(\d+)-(\d+) (\d+):(\d+):(\d+)$/;
my $t_unix = timelocal($6, $5, $4, $3, $2 - 1, $1);
my $s = $ndays*60*60*24;
my ($sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst) = localtime($t_unix + $s);
return strftime('%Y-%m-%d %H:%M:%S', 0, 30, 2, $mday, $mon, $year);
}
my $DebugMode = 0;
# This function only executes the passed code reference if the global variable $DebugMode is non-zero.
# The reason for this is that any calculation (like a FooBar::ComplexObject->toString call) will not be
# performed if we are not in debug mode, sort of like a very limited form of lazy evaluation.
#
sub ifdebug(&@)
{
my ($cmd) = @_;
&$cmd() if($DebugMode);
}
sub ExpirePass {
my $rldbh = shift;
my $cardid = shift;
# get active pass
my %rhash;
# $rldbh->get_user_pass(\%rhash,
$rldbh->GetUserPass(\%rhash,
{
CardId => $cardid,
Active => 1
}
);
my $pass_id = $rhash{'PassId'};
my $uc_type = uc($rhash{'Type'});
if ($pass_id)
{
# if the pass exists and has an expired nday, no nrides left
# or is a preactivated card, expire it
my $now_str = strftime("%Y-%m-%d %H:%M:%S", localtime());
if ( ( ($uc_type eq 'NDAY') and ( readable_time_cmp($rhash{'NDayExpiration'}, $now_str) <= 0 ) )
or ( ($uc_type eq 'NRIDE') and ( $rhash{'NRideRemain'} <= 0) )
or ($uc_type eq 'PREACTIVE') )
{
$rldbh->deactivate_user_pass($pass_id);
$rldbh->activate_user_card_pass($cardid);
$rldbh->insert_active_rider_table( { logical_card_id => $cardid } );
}
}
}
sub use_nride {
my $rldbh = shift;
my $pass_entry = shift;
my $art_entry = shift;
my $billing_ride_time = shift;
my $cur_rides = (($pass_entry->{'NRideRemain'} > 0) ? ($pass_entry->{'NRideRemain'}-1) : 0 );
my %update_pass_param =
(
nrides_remain => $cur_rides,
lastused => $billing_ride_time
);
if ( !$pass_entry->{'FirstUsed'} or
(readable_time_cmp($billing_ride_time, $pass_entry->{'FirstUsed'}) < 0) )
{
$update_pass_param{'firstused'} = $billing_ride_time ;
}
$rldbh->update_user_pass($pass_entry->{'PassId'}, \%update_pass_param);
if ($cur_rides > 0)
{
$rldbh->insert_active_rider_table( { logical_card_id => $pass_entry->{'CardId'} } );
}
}
sub use_nday {
my $rldbh = shift;
my $pass_entry = shift;
my $art_entry = shift;
my $billing_ride_time = shift;
my %update_pass_param =
(
lastused => $billing_ride_time
);
if ( !$pass_entry->{'FirstUsed'} or
(readable_time_cmp($billing_ride_time, $pass_entry->{'FirstUsed'}) < 0) )
{
$update_pass_param{'firstused'} = $billing_ride_time;
}
if (!$pass_entry->{'NDayExpiration'})
{
my $nday_exp = get_readable_expiration_date($billing_ride_time, $pass_entry->{'NDayOrig'});
$update_pass_param{'nday_expiration'} = $nday_exp;
$rldbh->update_user_pass($pass_entry->{'PassId'}, \%update_pass_param);
$rldbh->insert_active_rider_table( { logical_card_id => $pass_entry->{'CardId'} } );
}
else
{
$rldbh->update_user_pass($pass_entry->{'PassId'}, \%update_pass_param);
}
}
sub update_domain_card {
my $rldbh = shift;
my $pass_entry = shift;
my $billing_ride_time = shift;
my %update_pass_param;
if ( !$pass_entry->{'LastUsed'} or
(readable_time_cmp($billing_ride_time, $pass_entry->{'LastUsed'}) > 0) )
{
$update_pass_param{'lastused'} = $billing_ride_time;
}
if ( !$pass_entry->{'FirstUsed'} or
(readable_time_cmp($billing_ride_time, $pass_entry->{'FirstUsed'}) < 0) )
{
$update_pass_param{'firstused'} = $billing_ride_time;
}
if (scalar(keys(%update_pass_param)) > 0)
{
$rldbh->update_user_pass($pass_entry->{'PassId'}, \%update_pass_param );
}
}
sub art_pass_mismatch {
my $rldbh = shift;
my $logical_card_id = shift;
my $billing_cksum = shift;
my $billing_ride_time = shift;
my $billing_action = shift;
my $billing_rule = shift;
my $pass_entry = shift;
my $art_entry = shift;
my $mismatch = 0;
my $reason;
# order matters
if ( !$pass_entry->{'PassId'} )
{
if ( uc($billing_rule) ne $REJECT_RULE )
{
$reason = "Billing entry has rule \"$billing_rule\" but no passes on card";
$mismatch = 1;
}
elsif ( uc($art_entry->{'rule_name'}) ne $REJECT_RULE )
{
$reason = "art rule \"" . $art_entry->{'rule_name'} . "\" with no passes on card";
$mismatch = 1;
}
}
elsif (uc($art_entry->{'rule_name'}) ne uc($pass_entry->{'Rule'}))
{
$mismatch = 1;
$reason = "art rule \"" . $art_entry->{'rule_name'} . "\"";
$reason .= " != pass rule \"" . $pass_entry->{'Rule'} . "\"";
}
elsif ( uc($billing_rule) ne uc($pass_entry->{'Rule'}) )
{
# unless its a passback reject, we have a mismatch
if ( (uc($billing_action) ne 'REJECT') or (uc($billing_rule) ne 'PASSBACK') )
{
# bus got out of sync with art? give user this pass to protect against
# decrementing an nride when an nday (or something else) was reported
$mismatch = 1;
$reason = "billing rule \"$billing_rule\" != pass rule \"". $pass_entry->{'Rule'} ."\"";
}
}
if ($mismatch)
{
$rldbh->diagnostic_log("warning",
"$PROGNAME: cardid $logical_card_id, " .
"cksum $billing_cksum, " .
"passid " . ($pass_entry->{'PassId'} || "n/a" ) . " " .
"seq_num " . ($art_entry->{'seq_num'} || "n/a") . ", " .
"mismatch ($reason): " .
"bill. rule \"$billing_rule\", " .
"pass rule \"" . ($pass_entry->{'Rule'} || "n/a") . "\", " .
"art rule \"" . ($art_entry->{'rule_name'} || "n/a") . "\""
);
}
return $mismatch;
}
sub AdvanceRiderPass {
my $rldbh = shift;
my $logical_card_id = shift;
my $billing_cksum = shift;
my $billing_ride_time = shift;
my $billing_action = shift;
my $billing_rule = shift;
return 1 if !$logical_card_id;
my %art_entry;
$rldbh->get_active_rider_table(\%art_entry,
{
logical_card_id => $logical_card_id
}
);
if ( !$art_entry{'seq_num'} )
{
$rldbh->diagnostic_log('warning', "No seq_num found in billing_log for $logical_card_id");
return 0;
}
my %pass_entry;
# $rldbh->get_user_pass(\%pass_entry,
$rldbh->GetUserPass(\%pass_entry,
{
CardId => $logical_card_id,
Active => 1
}
);
return 0 if (art_pass_mismatch($rldbh,
$logical_card_id,
$billing_cksum,
$billing_ride_time,
$billing_action,
$billing_rule,
\%pass_entry,
\%art_entry));
# we only allow a pass to be used when it's an accept and the database is consistent for this pass
if (uc($billing_action) eq 'ACCEPT')
{
my $uc_type = uc($pass_entry{'Type'});
if ( $uc_type eq 'NRIDE')
{
use_nride($rldbh, \%pass_entry, \%art_entry, $billing_ride_time);
}
elsif ( $uc_type eq 'NDAY')
{
use_nday($rldbh, \%pass_entry, \%art_entry, $billing_ride_time);
}
else # domain card
{
update_domain_card($rldbh, \%pass_entry, $billing_ride_time);
}
}
else
{
# update first used/last used?
}
ExpirePass( $rldbh, $logical_card_id );
return 1;
}
sub ServerReply
{
my $client_query = $_[0];
$/="\n";
chomp($client_query);
my $response = "";
my $client_query_md5 = md5_hex($client_query);
my $rldbh = RideLogicAPIQueryWrapper->connect($DSN, $DBUSER, $DBPASS) || die "ERROR: could not connect to DB";
$rldbh->raise_error( 1 );
my $sth ;
my $loglvl ;
my $message ;
my $logmsg ;
if ($client_query =~ m/^[\s\x00]*$/)
{
$logmsg .= "Ignoring spurious blank line.\n";
$response .= "IGN\t" . $client_query_md5 . "\n";
}
elsif ($client_query =~ m/^\!/) #error
{
$loglvl = "error";
$message = $client_query;
$message =~ s/^.//;
try {
$rldbh->diagnostic_log($loglvl, $message)
or die "Couldn't write to diagnostic log: " . $rldbh->errstr;
$response .= "ACK\t" . $client_query_md5 . "\n";
}
catch {
$logmsg .= $_ . "\n";
$response .= "IGN\t" . $client_query_md5 . "\n";
};
}
elsif ($client_query =~ m/^\*/) #warning
{
$loglvl = "warning";
$message = $client_query;
$message =~ s/^.//;
try {
$rldbh->diagnostic_log($loglvl, $message)
or die "Couldn't write to diagnostic log: " . $rldbh->errstr;
$response .= "ACK\t" . $client_query_md5 . "\n";
}
catch {
$logmsg .= $_ . "\n";
$response .= "IGN\t" . $client_query_md5 . "\n";
};
}
elsif ($client_query =~ m/^\#/) #debug
{
$loglvl = "debug";
$message = $client_query;
$message =~ s/^.//;
try {
$rldbh->diagnostic_log($loglvl, $message)
or die "Couldn't write to diagnostic log: " . $rldbh->errstr;
$response .= "ACK\t" . $client_query_md5 . "\n";
}
catch {
$logmsg .= $_ . "\n";
$response .= "IGN\t" . $client_query_md5 . "\n";
};
}
elsif ($client_query =~ m/^(?:[^\t]*\t)+[^\t]*/) #look for a list of optionally blank tab-delimited fields
{
my @client_values = split(/[\t]/, $client_query, -1); #the -1 keeps split from trimming trailing blank fields
#0. equip_num
#1. driver
#2. paddle
#3. route
#4. trip
#5. stop
#6. ride_time
#7. latitude
#8. longitude
#9. action
#10. rule
#11. ruleparam
#12. reason
#13. credential
#14. logical_card_id
#15. cash_value
#16. stop_name
#17. (unused by DB) usec
try {
my $duplicate_billing_entry =
$rldbh->check_dup_billing_log($client_values[6], $client_query_md5);
if (!$duplicate_billing_entry) {
$rldbh->insert_billing_log($client_query_md5, @client_values[0..16]);
# $rldbh->lock_common();
# $rldbh->begin_work();
$rldbh->begin_locked_transaction_common();
my $r =
AdvanceRiderPass($rldbh, # db handle
$client_values[14], # logical_card_id
$client_query_md5, # billing log md5
unix_to_readable_time($client_values[6]), # ride_time (readable)
$client_values[9], # action (e.g. ACCEPT/REJECT)
$client_values[10]); # rule
$response .= "ACK\t" . $client_query_md5 . "\n";
# $rldbh->commit();
# $rldbh->unlock();
$rldbh->unlock_commit();
}
else
{
$response .= "DUP\t" . $client_query_md5 . "\n";
}
}
catch {
# $rldbh->rollback();
# $rldbh->unlock();
$rldbh->unlock_rollback();
$logmsg .= $_ . "\n";
$response .= "IGN\t" . $client_query_md5 . "\n";
};
}
else
{
$logmsg .= "Malformed log entry \"$client_query\".\n";
$response .= "IGN\t" . $client_query_md5 . "\n";
}
print $logmsg if $logmsg;
return $response;
}
sub debug_print
{
my $line = shift;
if ($debug_logfile =~ /^([^\0]+)$/) {
my $untainted_debug_logfile = $1;
sysopen ( my $fh , $untainted_debug_logfile, O_WRONLY|O_APPEND|O_CREAT, S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH );
print $fh $line . "\n";
close $fh;
}
}
sub handle_client()
{
close SERVER;
CLIENT->autoflush(1);
my $linebuffer;
while($linebuffer = )
{
if ($billing_logfile =~ /^([^\0]+)$/) {
my $untainted_billing_logfile = $1;
sysopen ( my $fh , $untainted_billing_logfile, O_WRONLY|O_APPEND|O_CREAT, S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH );
print $fh $linebuffer;
close $fh;
}
print CLIENT ServerReply($linebuffer);
} #while data from client
close CLIENT;
}
my $waitedpid = 0;
my $sigreceived = 0;
sub REAPER
{
while (($waitedpid = waitpid(-1, WNOHANG))>0) { }
$SIG{CHLD} = \&REAPER; # loathe sysV
$sigreceived = 1;
}
sub spawn
{
my $coderef = shift; #grab the first parameter
unless (@_ == 0 && $coderef && ref($coderef) eq 'CODE') #verify that it consists of a non-null block of executable perl code
{
confess "usage: spawn CODEREF"; #complain if this is not the case
}
my $pid;
if (!defined($pid = fork)) #attempt a fork, remembering the returned PID value
{
close CLIENT;
return; #failed to fork, we'd better close the client
}
elsif ($pid) #If the returned process ID is non-zero, that indicates that we are the parent process
{
return; # i'm the parent
}
else #otherwise, if the returned process ID is 0, that means we're the child process
{
exit &$coderef(); #in which case, we want to execute the child handler that was passed in, and then
#exit this (child) process when we've finished our conversation(s) with the
#other (client) end of the socket.
}
}
sub show_help_and_exit {
print "usage:\n";
print " [-i] interactive, do not daemonize\n";
print " [-c cfg] use cfg as config file (default to " . $RideLogic::RIDELOGIC_DAEMON_CONF . ") \n";
print " [-h] show help (this screen)\n";
exit;
}
#----------------------------------------------------------------------
#
#----------------------------------------------------------------------
my $daemonize = 1;
my $interactive = 0;
my $show_help = 0;
my $cfg_file = $RideLogic::RIDELOGIC_DAEMON_CONF;
my $api_cfg_file = $RideLogic::RIDELOGIC_API_CONF;
GetOptions(
'i|interactive' => \$interactive,
'c|config=s' => \$cfg_file,
'h|help' => \$show_help );
show_help_and_exit() if ($show_help);
$daemonize=0 if ($interactive);
#----------------------------------------------------------------------
# Local network settings for Inter-Process communication.
#----------------------------------------------------------------------
my $proto = getprotobyname('tcp');
my $addr = sockaddr_in( $bind_port ,inet_aton($bind_ip));;
#----------------------------------------------------------------------
my $max_retries = 10; #Maximum number of address-binding retries before we give up.
my $retry_count = $max_retries; #number of retries left...
my $retry_delay = 3; #number of seconds to wait between retries at binding to our designated IPC address
my $got_network = 0; #flag to let us know that we can quit retrying once we have gotten a valid listening socket
my %CFG_VAR;
read_config($cfg_file, \%CFG_VAR) if ($cfg_file);
read_config($api_cfg_file, \%CFG_VAR) if ($api_cfg_file);
my $logfile = ($CFG_VAR{"RIDELOGIC_DAEMON_LOG_DIR"} || $RideLogic::RIDELOGIC_DAEMON_LOG_DIR) . "/ridelogic_billingd.log";
$billing_logfile = ($CFG_VAR{"RIDELOGIC_DAEMON_LOG_DIR"} || $RideLogic::RIDELOGIC_DAEMON_LOG_DIR) . "/billing_log";
my $pidfile = ($CFG_VAR{"RIDELOGIC_DAEMON_PID_DIR"} || $RideLogic::RIDELOGIC_DAEMON_PID_DIR) . "/ridelogic_billingd.pid";
$debug_logfile = ($CFG_VAR{"RIDELOGIC_DAEMON_LOG_DIR"} || $RideLogic::RIDELOGIC_DAEMON_LOG_DIR) . "/billing_debug_log";
$HOST = $CFG_VAR{'RIDELOGIC_DB_SERVER'};
$DB = $CFG_VAR{'RIDELOGIC_DB'};
$DBUSER = $CFG_VAR{'RIDELOGIC_DB_USERNAME'};
$DBPASS = $CFG_VAR{'RIDELOGIC_DB_PASSWORD'};
$DSN = "dbi:mysql:host=" . $HOST . ";database=" . $DB;
#my $RLDBH = RideLogicAPIQueryWrapper->connect($DSN, $DBUSER, $DBPASS) || die "ERROR: could not connect to DB";
#my ($query, $result, $row);
#$RLDBH->raise_error( 1 );
daemonize($logfile, $pidfile) if ($daemonize);
# set our pipes to be piping hot
$|=1;
while( ($retry_count > 0) && (!$got_network) )
{
try #Try and allocate a socket, bind it to our IPC address, and set it to listen for connections
{
socket(SERVER,PF_INET,SOCK_STREAM,$proto) || die "socket: $!";
setsockopt(SERVER, SOL_SOCKET, SO_REUSEADDR, 1);
bind (SERVER, $addr) || die "bind: $!";
listen(SERVER,5) || die "listen: $!";
$got_network = 1;
}
catch #If that didn't work for some reason, log the error, clean up, and prepair to retry
{
my $errmsg = $_; #Remember the error message
close(SERVER); #Clean up the server socket if it needs it
#Decrement our remaining retry counter
$retry_count = $retry_count - 1;
#Log the message to our debug log
print "Failed to allocate socket, will retry $retry_count times: $errmsg\n";
#Wait a reasonable period before trying again
sleep $retry_delay;
};
}
if($got_network) #If we met with success binding to the network, report it
{
my $logmsg = "Socket setup successful. Listening for clients at $bind_ip:$bind_port\n";
print $logmsg;
}
else #If we ran out of patience and gave up, report that as well and exit
{
my $errmsg = "Could not allocate and bind listening socket at $bind_ip:$bind_port after $max_retries attempts.\n";
die $errmsg;
}
# Set up our signal handler which will clean up defunct child processes and let the main
# accept() loop know that the reason accept returned was due to a signal, not a legit connection.
$SIG{CHLD} = \&REAPER;
#This for loop is efficient, but confusting, so I'll break it down by clause
#
# The first clause ($sigreceived = 0) clears the signal received flag that will be set if the
# accept() call was interrupted by a signal. This clause runs once before the first run of the loop
#
# The second clause is the test clause, it will process the contents of the loop if EITHER
# accept() has returned (presumably generating a valid file handle for the CLIENT end of the
# socket, OR the signal received flag is set (thus accept would have returned early without
# having actually accepted a connection.
#
# The third clause (the 'incrementer') is run after each time the body is executed, before the
# test clause is executed again (deciding whether to run the body or drop out... This test
# clause will close the parent process' copy of the CLIENT file handle since (see body below)
# after the body executes, all communication with the socket referred to by that file handle
# will be carried out by the spawned child process. This frees the parent's copy of the CLIENT
# file handle to be used again in the parent process for the next accepted incoming connection.
for ( $sigreceived = 0; accept(CLIENT,SERVER) || $sigreceived; $sigreceived = 0, close CLIENT)
{
next if $sigreceived; #If we were interrupted by a signal, there is no real client, just go back and try to accept a new one
print "connection received.\n"; #Print a diagnostic message confirming that we have made a connection
spawn sub {handle_client();}; #fork() off a child process that will handle communication with the socket pointed to by the CLIENT file handle
}