#!/usr/bin/perl # quosnmp # Version 1.0.0, Last modified on 2007-06-22 # A CUPS backend for SNMP-based print accounting and quota enforcement. # # Released by Marcus Lauer (marcus.lauer at nyu dot edu) # # Copyright (C) 2007 by Marcus Lauer (marcus.lauer at nyu dot edu) except where # previous copyright is in effect. # # Based on accsnmp v1.02.20070124 by jeff hardy (hardyjm at potsdam dot edu) # ############ # accsnmp # v1.02.20070124 # jeff hardy (hardyjm at potsdam dot edu) # backend wrapper hardware accounting for cups # # ############ # Copyright 2007, Jeff Hardy (hardyjm at potsdam dot edu) # # 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, # USA. # # ########### # example URI - quosnmp://socket://192.168.xxx.xxx # # arguments according to the docs, as C sees them: # 0 1 2 3 4 5 6 # lpd jobid username jobtitle copies printoptions file ///or read from STDIN if no filename # # arguments as Perl sees them: # 0 1 2 3 4 5 # jobid username jobtitle copies printoptions file ///or read from STDIN if no filename use strict; use Net::SNMP; # --- Modify these settings to suit your needs. my $ENFORCE_QUOTA = 1; # Turn on/off [1/0] print quota enforcement my $SAVE_JOBS = 0; #turn on/off [1/0] saving printjobs my $JOB_BLACKLIST = 0; #turn on/off [1/0] jobs blacklist my @JobBlacklist = (""); # Names of jobs to be blacklisted. my $LOGGING = 1; # Turn on/off [1/0] print logging function call my $LOGGING_FILE = "/var/log/cups/acc_page_log"; # File for printer logging my $ERROR_LOGGING = 1; # Turn on/off [1/0] error logging function call my $ERROR_LOGGING_FILE = "/var/log/cups/acc_error_log"; # File for error logging my $headerDiscount = 0; # Credit accounts this many pages per print job for mandatory header (or footer) pages my $DEBUG = 0; #turn on/off [1/0] debugging function call my $DEBUG_FILE = "/tmp/quosnmp.debug"; #tmp file for debugging my $SNMP_COMMUNITY = "public"; #best to use read community obviously # --- These settings can be modified, but it is less likely that you will need to do so. my $accDir = "/var/log/cups/accounting"; #directory to keep accounting files my $quoDir = "/var/log/cups/quotas"; #directory to keep quota files my $accPrinterList = "/etc/cups/accounted-printers.txt";#list of special (usually color) printers -- pages printed to these count triple my $backendDir = "/usr/lib/cups/backend"; #directory which contains all cups backends my $SNMP_TIMEOUT = 15; # SNMP timeout in seconds, 0 to try forever, max 60 my $SNMP_RETRIES = 5; # Number of times to retry an SNMP request, max 20 my $BACKEND_ATTEMPTS = 10; #backend retry attempts, 0 to try forever my $STALL_TIMEOUT = 600; # Assume printer is stalled after this many seconds, 0 to disable # --- You almost certainly do not want to modify these variables. my $PAGECOUNT_OID = "1.3.6.1.2.1.43.10.2.1.4.1.1"; #printer lifetime pagecount my $PRINTERSTATUS_OID = "1.3.6.1.2.1.25.3.5.1.1.1"; #printer status # --- Only experts should modify the code below this line. # This is used by some of the logging features. my @startTime = localtime(time); ### MAIN { ### ARGS CHECK if (!$ARGV[0]){ # Device discovery mode print ("network quosnmp \"Unknown\" \"Accounted Printer (SNMP)\"\n"); exit 0; } if (scalar(@ARGV) < 5 || scalar(@ARGV) > 6){ # Error print STDERR ("ERROR: Usage: quosnmp job-id user title copies options [file]\n"); exit 1; } # Now that we got this far, let us name the arguments to keep our sanity my ($jobID,$userName,$jobTitle,$copies,$printOptions,$printFile) = @ARGV; ### ENV CHECK # URI parsing must fit syntax or die # Ex: quosnmp://lpd://192.168.xxx.xxx # Also supported: quosnmp://hp:/net/HP_LaserJet_xxxx_Series?ip=192.168.xxx.xxx $ENV{DEVICE_URI} =~ m#(\S+)://(\S+):/\S+?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\S?# or cleanExit($jobID,$ENV{PRINTER},"ERROR: URI must be in format: quosnmp://\n"); my $wrapBackend = $1; my $realBackend = $2; my $printerIP = $3; ### BLACKLIST/QUOTA CHECKS if ( length($userName) < 1 ){ # No username cleanExit($jobID,$ENV{PRINTER},"ERROR: No username for printjob: user must authenticate\n"); } if ($ENFORCE_QUOTA == 1) { my $printQuota = getPrintQuota($quoDir,$userName); if ( $printQuota != -1 ) { # -1 is a special value meaning unlimited printing is allowed. my $userPagesPrinted = getUserTotal("$accDir/$userName"); # If the user is over quota, or is disallowed to print, or no quota file was found at all, then exit. if ( $userPagesPrinted >= $printQuota || $printQuota == 0 || $printQuota == -2 ) { cleanExit($jobID,$ENV{PRINTER},"ERROR: Account ($userName) is at or over quota ($printQuota pages) and cannot print\n"); } } } if ($JOB_BLACKLIST){ my $jobStatus = chkJobBlacklist($jobTitle); if ($jobStatus == -1){ # Job is blacklisted cleanExit($jobID,$ENV{PRINTER},"ERROR: Jobname ($jobTitle) is blacklisted from printing\n"); } } ### SPOOL JOB TO TEMP FILE my $jid = $jobID; my $uid = $userName; $jid =~ s/\W//g; #sanity check $uid =~ s/\W//g; #sanity check my $tempFile = "$ENV{TMPDIR}/$jid-$uid-cupsjob$$"; if ($printFile){ # Only spool given print file if we want to save if ($SAVE_JOBS){ open (OUT, ">$tempFile") or cleanExit($jid,$ENV{PRINTER},"ERROR: Cannot write $tempFile: $!\n"); open (FH, "$printFile") or cleanExit($jid,$ENV{PRINTER},"ERROR: Cannot read $printFile: $!\n"); while(){ print OUT "$_"; } close FH; close OUT; } } else { # Have to spool to temp if STDIN open (OUT, ">$tempFile") or cleanExit($jid,$ENV{PRINTER},"ERROR: Cannot write $tempFile: $!\n"); while(){ print OUT "$_"; } close OUT; } ### PRINTING # SNMP pagecount: first snmp query my $prePageCount = snmpGet($printerIP,$PAGECOUNT_OID,$jid); # NOTE: the next several lines are the guts of wrapping a cups backend # 1) Pull the device URI from the environment and fix it by removing the wrapper # 2) Tack this onto the front of the arg array to pass to the real backend # Note: 1 and 2 are required as different backends find the device URI by # different means. LPD gets it from the arg, whereas IPP gets it from env. # 3) If the job arrived via STDIN, tack printFile onto the end of the arg array # This will happen on non-raw queues and needs to be checked, otherwise we # will dos the printer with a "zero byte" error. # 4) Call the real backend with this new argument array my $deviceURI = $ENV{DEVICE_URI}; $deviceURI =~ s#$wrapBackend://##; $ENV{DEVICE_URI} = $deviceURI; if (!($printFile)){ $copies = 1; $printFile = $tempFile; } my @argvNew = ($deviceURI,$jobID,$userName,$jobTitle,$copies,$printOptions,$printFile); # Loop until $attempts trying the backend my $attempts = $BACKEND_ATTEMPTS; my $exitval = 1; while ($exitval){ # Override arg0 $exitval = system {"$backendDir/$realBackend"} @argvNew; $exitval >>= 8; if ($attempts > 0){ $attempts--; if ($attempts == 0){ unlink $tempFile; cleanExit($jid,$ENV{PRINTER},"ERROR: Backend failed!\n"); } } } # Now the real backend is processing the job, and we loop to keep an eye # on the printer status until it is done. sleep 1; my $waitCount = 0; my $somethingPrinted = 0; $SIG{ALRM} = sub { cleanExit($jid,$ENV{PRINTER},"ERROR: Printer is stalled!\n") }; while (1){ alarm ($STALL_TIMEOUT); # 3 is idle, 4 is printing, 5 is warmup, 1 is other my $prStatus = snmpGet($printerIP,$PRINTERSTATUS_OID,$jid); if ($prStatus == 4){ print STDERR "INFO: Printer status: printing\n"; $somethingPrinted = 1; } elsif (($prStatus != 4) && ($somethingPrinted)){ $waitCount += 1; # Wait until status is idle for several counts if ($waitCount == 3){ print STDERR "INFO: Job printed successfully\n"; last; } } else{ print STDERR "INFO: Printer status: other\n"; } sleep 2; } # SNMP pagecount: last snmp query my $postPageCount = snmpGet($printerIP,$PAGECOUNT_OID,$jid); ### ACCOUNTING # Delta of our pagecounts my $totalPages = $postPageCount - $prePageCount; # Give a discount for header pages if ($headerDiscount > 0){ $totalPages = $totalPages - $headerDiscount; if ($totalPages < 1){ $totalPages = 0; } } # If no error from real backend (non-zero is error)... if (!$exitval){ accounting($userName,$totalPages); } ### CLEANUP/EXIT # If requested, write out a system log. if ( $LOGGING ) { open (LOG_FH, ">>$LOGGING_FILE") or $LOGGING = 0; flock LOG_FH, 2; seek LOG_FH, 0 , 2; printf LOG_FH "%04d-%02d-%02d\t%02d:%02d:%02d\t",$startTime[5]+1900,$startTime[4]+1,$startTime[3],$startTime[2],$startTime[1],$startTime[0]; print LOG_FH "$printerIP\t$jobID\t$userName\t$jobTitle\t$copies\t$prePageCount\t$postPageCount\t$exitval\t$totalPages\t$headerDiscount\n"; flock LOG_FH, 8; close (LOG_FH); } if ($DEBUG == 1){ #print STDERR ("ERROR: $postPageCount minus $prePageCount equals $totalPages\n"); debug($DEBUG_FILE); } if (!$SAVE_JOBS){ unlink $tempFile; } exit $exitval; } sub cleanExit{ # Args: jobid, queuename, error message to print # Returns: nothing, deletes job and re-enables queue my $jobid = shift; my $queue = shift; my $errorMsg = shift; if ( length($errorMsg) > 0 ) { print STDERR $errorMsg; if ( $ERROR_LOGGING == 1 ) { my @errorTime = localtime(time); open (ERR_LOG_FH, ">>$ERROR_LOGGING_FILE") or $ERROR_LOGGING = 0; flock ERR_LOG_FH, 2; seek ERR_LOG_FH, 0 , 2; printf LOG_FH "%04d-%02d-%02d\t%02d:%02d:%02d\t",$errorTime[5]+1900,$errorTime[4]+1,$errorTime[3],$errorTime[2],$errorTime[1],$errorTime[0]; print ERR_LOG_FH "$jobid\t$queue\t$errorMsg\n"; flock ERR_LOG_FH, 8; close (ERR_LOG_FH); } } sleep 1; system("/usr/bin/lprm","$jobid"); sleep 1; system("/usr/sbin/cupsenable","$queue"); sleep 1; exit 1; } sub snmpGet{ # Args: ip, oid, jid # Returns: value of SNMP query. JobID needed only for exiting script cleanly if this fails. my $ip = shift; my $oid = shift; my $jid = shift; # Creating the session should always succeed my ($session, $error) = Net::SNMP->session( -hostname => $ip, -version => 'snmpv1', #or 'snmpv2c' or 'snmpv3' as required -community => $SNMP_COMMUNITY, -timeout => $SNMP_TIMEOUT, #default, max: 60 -retries => $SNMP_RETRIES #default, max: 20 ); # Paranoid check if (!defined($session)) { cleanExit($jid,$ENV{PRINTER},"ERROR: SNMP session creation error: $error\n"); } # The get request will loop until the alarm is tripped my $result; my $err; eval{ while(!defined($result)){ $result = $session->get_request( -varbindlist => [$oid] ); $err = $session->error; } alarm (0); # cancel the alarm }; if ($@ =~ /SNMP ERROR/){ cleanExit($jid,$ENV{PRINTER},"ERROR: SNMP Error: $err\n"); } # Paranoid check. This is spurious because the eval loop won't let this happen. if (!defined($result)) { my $err = $session->error; $session->close; cleanExit($jid,$ENV{PRINTER},"ERROR: SNMP Error: $err\n"); } my $return = $result->{$oid}; $session->close; return $return; } sub chkJobBlacklist{ # Args: jobname # Returns: -1/bad | 1/good # This is not terribly safe, but can be terribly handy my $jobName = shift; foreach my $blackJob (@JobBlacklist) { if ($blackJob =~ $jobName) { return -1; } } return 1; } sub accounting{ # Args: user, totalpages, printername # Returns: nothing, logs to page_log and writes to user file in accounting directory my $user = shift; my $newPages = shift; #what was printed this time if ($user eq ""){ $user = "NO_USER"; } my $accFile = "$accDir/$user"; # If color printer from our printer accounting list, multiply pagecount by three # # Example accPrinterList: # Every queuename ends with colon, color queues add =COLOR= # # SAT104CL:=COLOR= # SAT325: # SIS217: # if (chkPrinterColor($ENV{PRINTER},$accPrinterList)){ $newPages = $newPages*3; } # Send nonstandard message to page_log (real wrapped backend will also send standard message) print STDERR ("PAGE: $user $newPages\n"); # And now handle their historical total # If accFile exists: get previous count, get total, write new count if (-e $accFile){ my $histPages = getUserTotal($accFile); my $totPages = $newPages + $histPages; setUserTotal($accFile,$totPages); } # Else accFile does not exist: write out new accFile else{ setUserTotal($accFile,$newPages); # Modify read permissions so accounting is secure chmod (0640,"$accFile"); system ("chown","lp","$accFile"); system ("chgrp",$user,"$accFile"); # Note: this doesn't work unless CUPS runs this backend as root! } } sub getUserTotal{ # Args: accounting file # Returns: user's historical pagecount my $accFile = shift; my $histPages; open (FH, "$accFile") or warn "ERROR: Cannot read $accFile: $!\n"; while(){ $histPages = $histPages+$_; #extra safety in case of badly formatted file } close (FH); return $histPages; } sub setUserTotal{ # Args: accounting file # Returns: nothing, writes to user's pagecount file my $accFile = shift; my $totPages = shift; open (OUT, ">$accFile") or warn "ERROR: Cannot write $accFile: $!\n"; flock OUT, 2; print (OUT "$totPages\n"); flock OUT, 8; close (OUT); } sub chkPrinterColor{ # Args: printer, printerlistfile # Returns: 1/color printer | 0/not color printer my $printer = shift; my $accPrinterList = shift; open (FH, "$accPrinterList") or warn "Cannot read $accPrinterList: $!\n"; while (){ if (m/^$printer:=COLOR=/i){ return 1; } } close FH; return 0; } sub debug{ # Args: debugging file # Returns: nothing, just write out some debug info my $debugFile = shift; my $date=`/bin/date +%T%t%D`; open (OUT, ">>$debugFile"); flock OUT, 2; seek OUT, 0, 2; print (OUT "\n=======================\n"); # print time print (OUT "\n$date\n"); # print args print (OUT "\narguments passed to backend:\n"); my $i=0; foreach (@ARGV){ print (OUT "$i:$_\t"); $i++; } print (OUT "\n"); # print env print (OUT "\nenvironment variables:\n"); for(keys(%ENV)) { print (OUT "$_ = $ENV{$_}\t"); } print (OUT "\n"); print (OUT "\n=======================\n"); flock OUT, 8; close (OUT); } sub getPrintQuota { # Args: accounting filedir, username # Returns: user's print quota # # * The user quota _always_ has first priority # * The group which gives the highest quota has second priority, e.g. will be used if # no user quota is found. # * The default quota _always_ has the lowest priority. It will only be used if no # other quota is found. # # Possible return values: # -2 No quota files found, printing not allowed. # -1 Unlimited printing allowed (no quota). # 0 Printing explictly not allowed. # n>0 Quota is n pages. my $quotadir = shift; my $user = shift; my $acctQuota = -2; # A "no quota file found" error condition is the default. # First, look for a per-user quota. This quota has top priority. my $quotaFile = ($quotadir,"/",$user,"_user_quota"); if ( -e $quotaFile ) { $acctQuota = read_quota_file($quotaFile); } # If there is no user quota, look for a per-group quota for each group the user is in. # The first one found is used. Otherwise, the default quota is still used. if ( ! -e $quotaFile ) { my ($name,$passwd,$gid,$members) = getgrent(); while ( length($name)>0 ) { # This next line will allow me to use a simple pattern match. my $testmembers = " " . $members . " "; if ( $testmembers =~ /\s$user\s/ ) { my $testquotafile = $quotadir . "/" . $name . "_group_quota"; my $testquota = read_quota_file($testquotafile); if ( $testquota == -1 ) { $acctQuota = $testquota; last; } elsif ( $testquota > $acctQuota ) { $acctQuota = $testquota; } } ($name,$passwd,$gid,$members) = getgrent(); } # If no group quota was found either, use the default quota. if ( length($name) == 0 ) { $quotaFile = "$quotadir/print_quota"; $acctQuota = read_quota_file($quotaFile); } } return $acctQuota; } sub read_quota_file { # Arguments: file to read # Returns: user's current quota my $quotafile = shift; my $quota = -2; # An error condition is the default open (FH, "$quotafile") or return ($quota); while() { $quota = $_; } close (FH); chomp($quota); # When editing files by hand, many people toss a newline in there. return ($quota); }