#! /usr/bin/env python # -*- coding: ISO-8859-15 -*- # CUPSPyKota accounting backend # # PyKota - Print Quotas for CUPS and LPRng # # (c) 2003 Jerome Alet # 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. # # $Id$ # # $Log$ # Revision 1.8 2003/11/15 14:26:44 jalet # General improvements to the documentation. # Email address changed in sample configuration file, because # I receive low quota messages almost every day... # # Revision 1.7 2003/11/14 22:05:12 jalet # New CUPS backend fully functionnal. # Old CUPS configuration method is now officially deprecated. # # Revision 1.6 2003/11/14 20:13:11 jalet # We exit the loop too soon. # # Revision 1.5 2003/11/14 18:31:27 jalet # Not perfect, but seems to work with the poll() loop. # # Revision 1.4 2003/11/14 17:04:15 jalet # More (untested) work on the CUPS backend. # # Revision 1.3 2003/11/12 23:27:44 jalet # More work on new backend. This commit may be unstable. # # Revision 1.2 2003/11/12 09:33:34 jalet # New CUPS backend supports device enumeration # # Revision 1.1 2003/11/08 16:05:31 jalet # CUPS backend added for people to experiment. # # # import sys import os import popen2 import time import cStringIO import shlex import select import signal from pykota.tool import PyKotaTool, PyKotaToolError from pykota.config import PyKotaConfigError from pykota.storage import PyKotaStorageError from pykota.accounter import openAccounter, PyKotaAccounterError from pykota.requester import openRequester, PyKotaRequesterError class PyKotaPopen3(popen2.Popen3) : """Our own class to execute real backends. Their first argument is different from their path so using native popen2.Popen3 would not be feasible. """ def __init__(self, cmd, capturestderr=False, bufsize=-1, arg0=None) : self.arg0 = arg0 popen2.Popen3.__init__(self, cmd, capturestderr, bufsize) def _run_child(self, cmd): for i in range(3, 256): # TODO : MAXFD in original popen2 module try: os.close(i) except OSError: pass try: os.execvpe(cmd[0], [self.arg0 or cmd[0]] + cmd[1:], os.environ) finally: os._exit(1) class PyKotaBackend(PyKotaTool) : """Class for the PyKota backend.""" def __init__(self) : PyKotaTool.__init__(self) (self.printingsystem, \ self.printerhostname, \ self.printername, \ self.username, \ self.jobid, \ self.inputfile, \ self.copies, \ self.title, \ self.options, \ self.originalbackend) = self.extractCUPSInfo() self.accounter = openAccounter(self) def extractCUPSInfo(self) : """Returns a tuple (printingsystem, printerhostname, printername, username, jobid, filename, title, options, backend). Returns (None, None, None, None, None, None, None, None, None, None) if no printing system is recognized. """ # Try to detect CUPS if os.environ.has_key("CUPS_SERVERROOT") and os.path.isdir(os.environ.get("CUPS_SERVERROOT", "")) : if len(sys.argv) == 7 : inputfile = sys.argv[6] else : inputfile = None # the DEVICE_URI environment variable's value is # prefixed with "cupspykota:" otherwise we wouldn't # be called. We have to remove this from the environment # before launching the real backend. fulldevice_uri = os.environ.get("DEVICE_URI", "") device_uri = fulldevice_uri[len("cupspykota:"):] os.environ["DEVICE_URI"] = device_uri # TODO : check this for more complex urls than ipp://myprinter.dot.com:631/printers/lp try : (backend, destination) = device_uri.split(":", 1) except ValueError : raise PyKotaToolError, "Invalid DEVICE_URI : %s\n" % device_uri while destination.startswith("/") : destination = destination[1:] printerhostname = destination.split("/")[0].split(":")[0] return ("CUPS", \ printerhostname, \ os.environ.get("PRINTER"), \ sys.argv[2].strip(), \ sys.argv[1].strip(), \ inputfile, \ int(sys.argv[4].strip()), \ sys.argv[3], \ sys.argv[5], \ backend) else : self.logger.log_message(_("Printing system unknown, args=%s") % " ".join(sys.argv), "warn") return (None, None, None, None, None, None, None, None, None, None) # Unknown printing system def format_commandline(prt, usr, cmdline) : """Passes printer and user names on the command line.""" printer = prt.Name user = usr.Name # we don't want the external command's standard # output to break the print job's data, but we # want to keep its standard error return "%s >/dev/null" % (cmdline % locals()) def main(thebackend) : """Do it, and do it right !""" # first deal with signals # CUPS backends ignore SIGPIPE and exit(1) on SIGTERM # Fortunately SIGPIPE is already ignored by Python # It's there just in case this changes in the future. # But here we will IGNORE SIGTERM for now, and see # in a future release if we shouldn't pass it to the # original backend instead. signal.signal(signal.SIGPIPE, signal.SIG_IGN) signal.signal(signal.SIGTERM, signal.SIG_IGN) # # retrieve some informations on the current printer and user # from the Quota Storage. printer = thebackend.storage.getPrinter(thebackend.printername) if not printer.Exists : # The printer is unknown from the Quota Storage perspective # we let the job pass through, but log a warning message thebackend.logger.log_message(_("Printer %s not registered in the PyKota system") % thebackend.printername, "warn") action = "ALLOW" else : for dummy in range(2) : user = thebackend.storage.getUser(thebackend.username) if user.Exists : break else : # The user is unknown from the Quota Storage perspective # Depending on the default policy for this printer, we # either let the job pass through or reject it, but we # log a message in any case. (policy, args) = thebackend.config.getPrinterPolicy(thebackend.printername) if policy == "ALLOW" : action = "POLICY_ALLOW" elif policy == "EXTERNAL" : commandline = format_commandline(printer, user, args) thebackend.logger.log_message(_("User %s not registered in the PyKota system, applying external policy (%s) for printer %s") % (thebackend.username, commandline, thebackend.printername), "info") if os.system(commandline) : # if an error occured, we die without error, # so that the job doesn't stop the print queue. thebackend.logger.log_message(_("External policy %s for printer %s produced an error. Job rejected. Please check PyKota's configuration files.") % (commandline, thebackend.printername), "error") return 0 else : # here we try a second time, because the goal # of the external action was to add the user # in the database. continue else : action = "POLICY_DENY" thebackend.logger.log_message(_("User %s not registered in the PyKota system, applying default policy (%s) for printer %s") % (thebackend.username, action, thebackend.printername), "warn") if action == "POLICY_DENY" : # if not allowed to print then die, else proceed. # we die without error, so that the job doesn't # stop the print queue. return 0 # when we get there, the printer policy allows the job to pass break if user.Exists : # Is the current user allowed to print at all ? action = thebackend.warnUserPQuota(thebackend.storage.getUserPQuota(user, printer)) elif policy == "EXTERNAL" : # if the extenal policy produced no error, but the # user still doesn't exist, we die without error, # so that the job doesn't stop the print queue. thebackend.logger.log_message(_("External policy %s for printer %s couldn't add user %s. Job rejected.") % (commandline, thebackend.printername, thebackend.username), "error") return 0 if action not in ["ALLOW", "WARN"] : # if not allowed to print then die, else proceed. # we die without error, so that the job doesn't # stop the print queue. retcode = 0 else : # pass the job untouched to the underlying layer # and starts accounting at the same time thebackend.accounter.beginJob(printer, user) # Now it becomes tricky... # First ensure that we have a file object as input mustclose = 0 if thebackend.inputfile is not None : if hasattr(thebackend.inputfile, "read") : infile = thebackend.inputfile else : infile = open(thebackend.inputfile, "rb") mustclose = 1 else : infile = sys.stdin # Find the real backend pathname realbackend = os.path.join(os.path.split(sys.argv[0])[0], thebackend.originalbackend) # And launch it subprocess = PyKotaPopen3([realbackend] + sys.argv[1:], capturestderr=1, bufsize=0, arg0=os.environ["DEVICE_URI"]) # Save file descriptors, we will need them later. infno = infile.fileno() stdoutfno = sys.stdout.fileno() stderrfno = sys.stderr.fileno() fromcfno = subprocess.fromchild.fileno() tocfno = subprocess.tochild.fileno() cerrfno = subprocess.childerr.fileno() # We will have to be careful when dealing with I/O # So we use a poll object to know when to read or write pollster = select.poll() pollster.register(infno, select.POLLIN | select.POLLPRI) pollster.register(fromcfno, select.POLLIN | select.POLLPRI) pollster.register(cerrfno, select.POLLIN | select.POLLPRI) pollster.register(stdoutfno, select.POLLOUT) pollster.register(stderrfno, select.POLLOUT) pollster.register(tocfno, select.POLLOUT) # Initialize our buffers indata = "" outdata = "" errdata = "" endinput = 0 status = -1 while status == -1 : # First check if original backend is still alive status = subprocess.poll() # In any case, deal with any remaining I/O availablefds = pollster.poll() for (fd, mask) in availablefds : if mask & select.POLLOUT : # We can write if fd == tocfno : if indata : os.write(fd, indata) indata = "" elif fd == stdoutfno : if outdata : os.write(fd, outdata) outdata = "" elif fd == stderrfno : if errdata : os.write(fd, errdata) errdata = "" if (mask & select.POLLIN) or (mask & select.POLLPRI) : # We have something to read data = os.read(fd, 256 * 1024) if fd == infno : indata += data if not data : # If yes, then no more input data endinput = 1 # this happens with real files. elif fd == fromcfno : outdata += data elif fd == cerrfno : errdata += data if mask & select.POLLHUP : # Some standard I/O stream has no more datas if fd == infno : # Here we are in the case where the input file is stdin. # which has no more data to be read. endinput = 1 elif fd == fromcfno : # This should never happen, since # CUPS backends don't send anything on their # standard output if outdata : try : os.write(stdoutfno, outdata) outdata = "" except : pass try : pollster.unregister(fromcfno) except KeyError : pass else : os.close(fromcfno) elif fd == cerrfno : # Original CUPS backend has finished # to write informations on its standard error if errdata : # Try to write remaining info (normally "Ready to print.") try : os.write(stderrfno, errdata) errdata = "" except : pass # We are no more interested in this file descriptor try : pollster.unregister(cerrfno) except KeyError : pass else : os.close(cerrfno) if endinput : # We deal with remaining input datas here # because EOF can happen in two different # situations and I don't want to duplicate # code, nor making functions. if indata : try : os.write(tocfno, indata) indata = "" except : pass # Again, we're not interested in this file descriptor # anymore. try : pollster.unregister(tocfno) except KeyError : pass else : os.close(tocfno) # Input file was a real file, we have to close it. if mustclose : infile.close() # Check exit code of original CUPS backend. if os.WIFEXITED(status) : retcode = os.WEXITSTATUS(status) else : thebackend.logger.log_message(_("CUPS backend %s died abnormally.") % realbackend, "error") retcode = -1 # stops accounting. thebackend.accounter.endJob(printer, user) # retrieve the job size jobsize = thebackend.accounter.getJobSize() # update the quota for the current user on this printer if printer.Exists : if jobsize : userquota = thebackend.storage.getUserPQuota(user, printer) if userquota.Exists : userquota.increasePagesUsage(jobsize) # adds the current job to history printer.addJobToHistory(thebackend.jobid, user, thebackend.accounter.getLastPageCounter(), action, jobsize) return retcode if __name__ == "__main__" : # This is a CUPS backend, we should act and die like a CUPS backend if len(sys.argv) == 1 : # we will execute each existing backend in device enumeration mode # and generate their PyKota accounting counterpart (directory, myname) = os.path.split(sys.argv[0]) for backend in [os.path.join(directory, b) for b in os.listdir(directory) if os.path.isfile(os.path.join(directory, b)) and (b != myname)] : answer = os.popen(backend, "r") try : devices = [line.strip() for line in answer.readlines()] except : devices = [] status = answer.close() if status is None : for d in devices : # each line is of the form : 'xxxx xxxx "xxxx xxx" "xxxx xxx"' # so we have to decompose it carefully fdevice = cStringIO.StringIO("%s" % d) tokenizer = shlex.shlex(fdevice) tokenizer.wordchars = tokenizer.wordchars + r".:,?!~/\_$*-+={}[]()#" arguments = [] while 1 : token = tokenizer.get_token() if token : arguments.append(token) else : break fdevice.close() try : (devicetype, device, name, fullname) = arguments except ValueError : pass # ignore this 'bizarre' device else : if name.startswith('"') and name.endswith('"') : name = name[1:-1] if fullname.startswith('"') and fullname.endswith('"') : fullname = fullname[1:-1] print '%s cupspykota:%s "PyKota+%s" "PyKota managed %s"' % (devicetype, device, name, fullname) retcode = 0 elif len(sys.argv) not in (6, 7) : sys.stderr.write("ERROR: %s job-id user title copies options [file]\n" % sys.argv[0]) retcode = 1 else : try : # Initializes the backend kotabackend = PyKotaBackend() retcode = main(kotabackend) except (PyKotaToolError, PyKotaConfigError, PyKotaStorageError, PyKotaAccounterError, AttributeError, KeyError, IndexError, ValueError, TypeError, IOError), msg : sys.stderr.write("ERROR : cupspykota backend failed (%s)\n" % msg) sys.stderr.flush() retcode = 1 try : kotabackend.storage.close() except (TypeError, NameError, AttributeError) : pass sys.exit(retcode)