diff --git a/Dockerfile b/Dockerfile index 73303f9..cd64f91 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,8 @@ RUN apt-get update -qq && apt-get upgrade -qqy \ hp-ppd \ hplip \ avahi-daemon \ + inotify-tools \ + python3-cups \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* @@ -43,13 +45,15 @@ RUN sed -i 's/Listen localhost:631/Listen 0.0.0.0:631/' /etc/cups/cupsd.conf && sed -i 's//\n Allow All\n Require user @SYSTEM/' /etc/cups/cupsd.conf && \ sed -i 's//\n Allow All/' /etc/cups/cupsd.conf && \ echo "ServerAlias *" >> /etc/cups/cupsd.conf && \ - echo "DefaultEncryption Never" >> /etc/cups/cupsd.conf + echo "DefaultEncryption Never" >> /etc/cups/cupsd.conf && \ + sed -i 's/#enable-dbus=yes/enable-dbus=no/' /etc/avahi/avahi-daemon.conf # back up cups configs in case used does not add their own RUN cp -rp /etc/cups /etc/cups-bak VOLUME [ "/etc/cups" ] +VOLUME [ "/etc/avahi/services" ] -COPY entrypoint.sh / -RUN chmod +x /entrypoint.sh +COPY entrypoint.sh airprint-generate.py printer-update.sh / +RUN chmod +x /entrypoint.sh /airprint-generate.py /printer-update.sh CMD ["/entrypoint.sh"] diff --git a/airprint-generate.py b/airprint-generate.py new file mode 100644 index 0000000..9fb5895 --- /dev/null +++ b/airprint-generate.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 + +""" +Copyright (c) 2010 Timothy J Fontaine + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + +import cups, os, optparse, re +import urllib.parse as urlparse +import os.path +from io import StringIO + +from xml.dom.minidom import parseString +from xml.dom import minidom + +import sys + +try: + import lxml.etree as etree + from lxml.etree import Element, ElementTree, tostring +except: + try: + from xml.etree.ElementTree import Element, ElementTree, tostring + etree = None + except: + try: + from elementtree import Element, ElementTree, tostring + etree = None + except: + print('Failed to find python libxml or elementtree, please install one of those or use python >= 2.5') + raise + +XML_TEMPLATE = """ + + + + _ipp._tcp + _universal._sub._ipp._tcp + 631 + txtvers=1 + qtotal=1 + Transparent=T + URF=none + +""" + +#TODO XXX FIXME +#ty=AirPrint Ricoh Aficio MP 6000 +#Binary=T +#Duplex=T +#Copies=T + + +DOCUMENT_TYPES = { + # These content-types will be at the front of the list + 'application/pdf': True, + 'application/postscript': True, + 'application/vnd.cups-raster': True, + 'application/octet-stream': True, + 'image/urf': True, + 'image/png': True, + 'image/tiff': True, + 'image/png': True, + 'image/jpeg': True, + 'image/gif': True, + 'text/plain': True, + 'text/html': True, + + # These content-types will never be reported + 'image/x-xwindowdump': False, + 'image/x-xpixmap': False, + 'image/x-xbitmap': False, + 'image/x-sun-raster': False, + 'image/x-sgi-rgb': False, + 'image/x-portable-pixmap': False, + 'image/x-portable-graymap': False, + 'image/x-portable-bitmap': False, + 'image/x-portable-anymap': False, + 'application/x-shell': False, + 'application/x-perl': False, + 'application/x-csource': False, + 'application/x-cshell': False, +} + +class AirPrintGenerate(object): + def __init__(self, host=None, user=None, port=None, verbose=False, + directory=None, prefix='AirPrint-', adminurl=False): + self.host = host + self.user = user + self.port = port + self.verbose = verbose + self.directory = directory + self.prefix = prefix + self.adminurl = adminurl + + if self.user: + cups.setUser(self.user) + + def generate(self): + if not self.host: + conn = cups.Connection() + else: + if not self.port: + self.port = 631 + conn = cups.Connection(self.host, self.port) + + printers = conn.getPrinters() + + for p, v in list(printers.items()): + if v['printer-is-shared']: + attrs = conn.getPrinterAttributes(p) + uri = urlparse.urlparse(v['printer-uri-supported']) + + tree = ElementTree() + tree.parse(StringIO(XML_TEMPLATE.replace('\n', '').replace('\r', '').replace('\t', ''))) + + name = tree.find('name') + name.text = 'AirPrint %s @ %%h' % (p) + + service = tree.find('service') + + port = service.find('port') + port_no = None + if hasattr(uri, 'port'): + port_no = uri.port + if not port_no: + port_no = self.port + if not port_no: + port_no = cups.getPort() + port.text = '%d' % port_no + + if hasattr(uri, 'path'): + rp = uri.path + else: + rp = uri[2] + + re_match = re.match(r'^//(.*):(\d+)(/.*)', rp) + if re_match: + rp = re_match.group(3) + + #Remove leading slashes from path + #TODO XXX FIXME I'm worried this will match broken urlparse + #results as well (for instance if they don't include a port) + #the xml would be malform'd either way + rp = re.sub(r'^/+', '', rp) + + path = Element('txt-record') + path.text = 'rp=%s' % (rp) + service.append(path) + + desc = Element('txt-record') + desc.text = 'note=%s' % (v['printer-info']) + service.append(desc) + + product = Element('txt-record') + product.text = 'product=(GPL Ghostscript)' + service.append(product) + + state = Element('txt-record') + state.text = 'printer-state=%s' % (v['printer-state']) + service.append(state) + + ptype = Element('txt-record') + ptype.text = 'printer-type=%s' % (hex(v['printer-type'])) + service.append(ptype) + + pdl = Element('txt-record') + fmts = [] + defer = [] + + for a in attrs['document-format-supported']: + if a in DOCUMENT_TYPES: + if DOCUMENT_TYPES[a]: + fmts.append(a) + else: + defer.append(a) + + if 'image/urf' not in fmts: + sys.stderr.write('image/urf is not in mime types, %s may not be available on ios6 (see https://github.com/tjfontaine/airprint-generate/issues/5)%s' % (p, os.linesep)) + + fmts = ','.join(fmts+defer) + + dropped = [] + + # TODO XXX FIXME all fields should be checked for 255 limit + while len('pdl=%s' % (fmts)) >= 255: + (fmts, drop) = fmts.rsplit(',', 1) + dropped.append(drop) + + if len(dropped) and self.verbose: + sys.stderr.write('%s Losing support for: %s%s' % (p, ','.join(dropped), os.linesep)) + + pdl.text = 'pdl=%s' % (fmts) + service.append(pdl) + + if self.adminurl: + admin = Element('txt-record') + admin.text = 'adminurl=%s' % (v['printer-uri-supported']) + service.append(admin) + + fname = '%s%s.service' % (self.prefix, p) + + if self.directory: + fname = os.path.join(self.directory, fname) + + f = open(fname, 'w') + + if etree: + tree.write(f, pretty_print=True, xml_declaration=True, encoding="UTF-8") + else: + xmlstr = tostring(tree.getroot()) + doc = parseString(xmlstr) + dt= minidom.getDOMImplementation('').createDocumentType('service-group', None, 'avahi-service.dtd') + doc.insertBefore(dt, doc.documentElement) + doc.writexml(f) + f.close() + + if self.verbose: + sys.stderr.write('Created: %s%s' % (fname, os.linesep)) + +if __name__ == '__main__': + parser = optparse.OptionParser() + parser.add_option('-H', '--host', action="store", type="string", + dest='hostname', help='Hostname of CUPS server (optional)', metavar='HOSTNAME') + parser.add_option('-P', '--port', action="store", type="int", + dest='port', help='Port number of CUPS server', metavar='PORT') + parser.add_option('-u', '--user', action="store", type="string", + dest='username', help='Username to authenticate with against CUPS', + metavar='USER') + parser.add_option('-d', '--directory', action="store", type="string", + dest='directory', help='Directory to create service files', + metavar='DIRECTORY') + parser.add_option('-v', '--verbose', action="store_true", dest="verbose", + help="Print debugging information to STDERR") + parser.add_option('-p', '--prefix', action="store", type="string", + dest='prefix', help='Prefix all files with this string', metavar='PREFIX', + default='AirPrint-') + parser.add_option('-a', '--admin', action="store_true", dest="adminurl", + help="Include the printer specified uri as the adminurl") + + (options, args) = parser.parse_args() + + # TODO XXX FIXME -- if cups login required, need to add + # air=username,password + from getpass import getpass + cups.setPasswordCB(getpass) + + if options.directory: + if not os.path.exists(options.directory): + os.mkdir(options.directory) + + apg = AirPrintGenerate( + user=options.username, + host=options.hostname, + port=options.port, + verbose=options.verbose, + directory=options.directory, + prefix=options.prefix, + adminurl=options.adminurl, + ) + + apg.generate() diff --git a/entrypoint.sh b/entrypoint.sh index 8e0d36b..d083d4d 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -16,4 +16,7 @@ if [ ! -f /etc/cups/cupsd.conf ]; then cp -rpn /etc/cups-bak/* /etc/cups/ fi +/printer-update.sh & +avahi-daemon & exec /usr/sbin/cupsd -f + diff --git a/printer-update.sh b/printer-update.sh new file mode 100644 index 0000000..dea5e4a --- /dev/null +++ b/printer-update.sh @@ -0,0 +1,9 @@ +#!/bin/bash +inotifywait -m -e close_write,moved_to,create /etc/cups | +while read -r directory events filename; do + if [ "$filename" = "printers.conf" ]; then + rm -rf /etc/avahi/services/AirPrint-*.service + /airprint-generate.py -d /etc/avahi/services + cp /etc/cups/printers.conf /config/printers.conf + fi +done