commit 4e770b10739cbad0e988f6ecb93b4c1ee7ba2af0 Author: Nick Date: Thu Dec 15 22:23:41 2016 -0500 Initial commit diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..81e6ebd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +FROM ubuntu:xenial + +# Add repos +RUN echo 'deb http://us.archive.ubuntu.com/ubuntu/ xenial multiverse' >> /etc/apt/sources.list.d/multiverse.list && \ + echo 'deb-src http://us.archive.ubuntu.com/ubuntu/ xenial multiverse' >> /etc/apt/sources.list.d/multiverse.list && \ + echo 'deb http://us.archive.ubuntu.com/ubuntu/ xenial-updates multiverse' >> /etc/apt/sources.list.d/multiverse.list && \ + echo 'deb-src http://us.archive.ubuntu.com/ubuntu/ xenial-updates multiverse' >> /etc/apt/sources.list.d/multiverse.list && \ + echo 'deb http://archive.ubuntu.com/ubuntu/ xenial-security multiverse' >> /etc/apt/sources.list.d/multiverse.list && \ + echo 'deb-src http://archive.ubuntu.com/ubuntu/ xenial-security multiverse' >> /etc/apt/sources.list.d/multiverse.list + +# Install the packages we need. Avahi will be included +RUN apt-get update && apt-get install -y \ + brother-lpr-drivers-extra brother-cups-wrapper-extra \ + cups \ + cups-pdf \ + inotify-tools \ + python-cups \ +&& rm -rf /var/lib/apt/lists/* + +# This will use port 631 +EXPOSE 631 + +# We want a mount for these +VOLUME /config +VOLUME /services + +# Add scripts +ADD root / +RUN chmod +x /root/* +CMD ["/root/run_cups.sh"] + +# Baked-in config file changes +RUN sed -i 's/Listen localhost:631/Listen 0.0.0.0:631/' /etc/cups/cupsd.conf && \ + sed -i 's/Browsing Off/Browsing On/' /etc/cups/cupsd.conf && \ + sed -i 's//\n Allow All/' /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 + diff --git a/README.md b/README.md new file mode 100644 index 0000000..ab0d690 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# QuadPortNick/cups-airprint +This Ubuntu-based image runs a CUPS instance that is meant as an AirPrint relay for printers that are already on the network but not AirPrint capable. I'm using it on a Synology NAS because for whatever reason the built in functionality is broken. So here we are... + +The Synology's CUPS is turned off and the local Avahi will be utilized for advertising the printers on the network. + +## Setting it up +~~~ +docker build -t QuadPortNick/cups-airprint . + +mkdir -p /volume1/docker/cups-airprint +docker create --name cups-airprint -e CUPSADMIN=cups -e CUPSPASSWORD=cupZZZ! -v /volume1/docker/cups-airprint:/config -v /etc/avahi/services:/services -p 631:631 QuadPortNick/cups-airprint +~~~ + +Now set auto-start in the Synology DSM interface and start the container. CUPS will be configurable at http://[diskstation]:631 using the CUPSADMIN/CUPSPASSWORD when needed. + +## Notes +* CUPS doesn't like printers.conf being mounted directly as it appears to delete/recreate it with changes, so we copy it in on start and then watch for it to change to make a backup of it. +* Watching for the printers.conf file changing also triggers generating the Avahi services. Thanks to @thfontaine for the script! + +  \ No newline at end of file diff --git a/root/root/airprint-generate.py b/root/root/airprint-generate.py new file mode 100644 index 0000000..989f63b --- /dev/null +++ b/root/root/airprint-generate.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python + +""" +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, urlparse +import os.path +from StringIO 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: + raise 'Failed to find python libxml or elementtree, please install one of those or use python >= 2.5' + +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 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/root/root/printer-update.sh b/root/root/printer-update.sh new file mode 100644 index 0000000..4bbf1e2 --- /dev/null +++ b/root/root/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 /services/AirPrint-*.service + /root/airprint-generate.py -d /services + cp /etc/cups/printers.conf /config/printers.conf + fi +done diff --git a/root/root/run_cups.sh b/root/root/run_cups.sh new file mode 100644 index 0000000..950c57e --- /dev/null +++ b/root/root/run_cups.sh @@ -0,0 +1,22 @@ +#!/bin/sh +set -e +set -x + +if [ $(grep -ci $CUPSADMIN /etc/shadow) -eq 0 ]; then + useradd -r -G lpadmin -M $CUPSADMIN +fi +echo $CUPSADMIN:$CUPSPASSWORD | chpasswd + +mkdir -p /config/ppd +mkdir -p /services +rm -rf /etc/cups/ppd +ln -s /config/ppd /etc/cups +if [ ! -f /config/printers.conf ]; then + touch /config/printers.conf +fi +cp /config/printers.conf /etc/cups/printers.conf + +/etc/init.d/dbus start +/etc/init.d/avahi-daemon start +/root/printer-update.sh & +exec /usr/sbin/cupsd -f