575 lines
17 KiB
Perl
575 lines
17 KiB
Perl
#!/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://<original uri>\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(<FH>){
|
|
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(<STDIN>){
|
|
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(<FH>){
|
|
$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 (<FH>){
|
|
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(<FH>) {
|
|
$quota = $_;
|
|
}
|
|
close (FH);
|
|
|
|
chomp($quota); # When editing files by hand, many people toss a newline in there.
|
|
|
|
return ($quota);
|
|
}
|