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