├── tests ├── __init__.py └── test_request.py ├── ippserver ├── __init__.py ├── data │ ├── 404.txt │ ├── error.txt │ └── homepage.txt ├── parsers.py ├── ppd.py ├── constants.py ├── pc2paper.py ├── request.py ├── __main__.py ├── server.py └── behaviour.py ├── setup.py ├── LICENSE ├── .gitignore └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ippserver/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ippserver/data/404.txt: -------------------------------------------------------------------------------- 1 | Page does not exist 2 | -------------------------------------------------------------------------------- /ippserver/data/error.txt: -------------------------------------------------------------------------------- 1 | There was an error. 2 | -------------------------------------------------------------------------------- /ippserver/data/homepage.txt: -------------------------------------------------------------------------------- 1 | This is h2g2bob's ipp-server.py 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/python 2 | 3 | from setuptools import setup, find_packages 4 | 5 | REQUIREMENTS = [ 6 | "requests" 7 | ] 8 | 9 | setup( 10 | name='ippserver', 11 | author="David Batley", 12 | author_email="git@dbatley.com", 13 | license="BSD2", 14 | description='An IPP server which acts like a printer', 15 | long_description='A module which implements enough of IPP to fool CUPS into thinking it is a real printer.', 16 | version='0.2', 17 | url='http://github.com/h2g2bob/ipp-server', 18 | packages=find_packages(exclude=["tests"]), 19 | test_suite="tests.test_request", 20 | package_data={ 21 | 'ippserver': ['data/*'], 22 | }, 23 | classifiers=[ 24 | 'Intended Audience :: Developers', 25 | 'License :: OSI Approved :: BSD License', 26 | 'Programming Language :: Python', 27 | 'Topic :: Software Development :: Libraries :: Python Modules'], 28 | install_requires=REQUIREMENTS, 29 | entry_points={ 30 | 'console_scripts': [ 31 | 'ippserver = ippserver.__main__:main', 32 | ] 33 | } 34 | ) 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017, 2018: David Batley (h2g2bob), Alexander (devkral) 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 2. Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 15 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 16 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 18 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 19 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 20 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 21 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 22 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 23 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /ippserver/parsers.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | from __future__ import absolute_import 3 | from __future__ import print_function 4 | from __future__ import unicode_literals 5 | 6 | import struct 7 | 8 | 9 | def read_struct(f, fmt): 10 | sz = struct.calcsize(fmt) 11 | string = f.read(sz) 12 | return struct.unpack(fmt, string) 13 | 14 | 15 | def write_struct(f, fmt, *args): 16 | data = struct.pack(fmt, *args) 17 | f.write(data) 18 | 19 | 20 | class Value(object): 21 | @classmethod 22 | def from_bytes(cls, _data): 23 | raise NotImplementedError() 24 | 25 | def bytes(self): 26 | raise NotImplementedError() 27 | 28 | def __bytes__(self): 29 | return self.bytes() 30 | 31 | 32 | class Boolean(Value): 33 | def __init__(self, value): 34 | assert isinstance(value, bool) 35 | self.boolean = value 36 | Value.__init__(self) 37 | 38 | @classmethod 39 | def from_bytes(cls, data): 40 | val, = struct.unpack(b'>b', data) 41 | return cls([False, True][val]) 42 | 43 | def bytes(self): 44 | return struct.pack(b'>b', 1 if self.boolean else 0) 45 | 46 | 47 | class Integer(Value): 48 | def __init__(self, value): 49 | assert isinstance(value, int) 50 | self.integer = value 51 | Value.__init__(self) 52 | 53 | @classmethod 54 | def from_bytes(cls, data): 55 | val, = struct.unpack(b'>i', data) 56 | return cls(val) 57 | 58 | def bytes(self): 59 | return struct.pack(b'>i', self.integer) 60 | 61 | 62 | class Enum(Integer): 63 | pass 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | #*.mo 52 | #*.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /ippserver/ppd.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | from __future__ import absolute_import 3 | from __future__ import print_function 4 | 5 | 6 | class PPD(object): 7 | def text(self): 8 | raise NotImplementedError() 9 | 10 | 11 | class BasicPostscriptPPD(PPD): 12 | product = 'ipp-server' 13 | manufacturer = 'h2g2bob' 14 | model = 'ipp-server-postscript' 15 | 16 | def text(self): 17 | return b'''*PPD-Adobe: "4.3" 18 | 19 | *%% This is a minimal config file 20 | *%% and is almost certainly missing lots of features 21 | 22 | *%% ___________ 23 | *%% | | 24 | *%% | PPD File. | 25 | *%% | | 26 | *%% (============(@| 27 | *%% | | | 28 | *%% | [ ] | | 29 | *%% |____________|/ 30 | *%% 31 | 32 | *%% About this PPD file 33 | *LanguageLevel: "2" 34 | *LanguageEncoding: ISOLatin1 35 | *LanguageVersion: English 36 | *PCFileName: "%(ppdfilename)s" 37 | 38 | *%% Basic capabilities of the device 39 | *FileSystem: False 40 | 41 | *%% Printer name 42 | *Product: "%(product)s" 43 | *Manufacturer: "%(manufacturer)s" 44 | *ModelName: "%(model)s" 45 | 46 | *%% Color 47 | *ColorDevice: True 48 | *DefaultColorSpace: CMYK 49 | *Throughput: "1" 50 | *Password: "0" 51 | ''' % \ 52 | { 53 | b"product": self.product.encode("ascii"), 54 | b"manufacturer": self.manufacturer.encode("ascii"), 55 | b"model": self.model.encode("ascii"), 56 | b"ppdfilename": b"%s%s" % (self.model.encode("ascii"), b'.ppd') 57 | } 58 | 59 | 60 | class BasicPdfPPD(BasicPostscriptPPD): 61 | model = 'ipp-server-pdf' 62 | 63 | def text(self): 64 | return super(BasicPdfPPD, self).text() + b''' 65 | *% The printer can only handle PDF files, so get CUPS to send that 66 | *% https://en.wikipedia.org/wiki/CUPS#Filter_system 67 | *% https://www.cups.org/doc/spec-ppd.html 68 | *cupsFilter2: "application/pdf application/vnd.cups-pdf 0 pdftopdf" 69 | *cupsFilter2: "application/postscript application/vnd.cups-pdf 50 pstopdf" 70 | ''' 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A minimal IPP server 2 | ==================== 3 | 4 | 5 | This is a small python script which __pretends to be a printer__. 6 | 7 | It works well enough to print documents on my Linux box with CUPS 1.7.5, but it doesn't implement the entire IPP specification. 8 | 9 | 10 | Add ipp-server as a printer 11 | --------------------------- 12 | 13 | Start running the server: 14 | ``` 15 | python -m ippserver --port 1234 save /tmp/ 16 | ``` 17 | 18 | The server listens to `localhost` by default. The well-known port of 631 is likely to already be in use by the web interface of CUPS, so I'm using port 1234 for this example. 19 | 20 | Next, add the printer as you would normally on your computer (ie: the Gnome or KDE add printer dialogs). The printer location is `ipp://localhost:1234/`. 21 | 22 | 23 | Doing things with print jobs 24 | ---------------------------- 25 | 26 | You can save print jobs as randomly named `.ps` files in a given directory: 27 | ``` 28 | python -m ippserver --port 1234 save /tmp/ 29 | ``` 30 | 31 | Alternatively, you can send the postscript files to a command. The following command will run [hexdump(1)] for every print job received. hexdump reads the .ps file from stdin. 32 | ``` 33 | python -m ippserver --port 1234 run hexdump 34 | ``` 35 | 36 | Or why not email print jobs to yourself using [mail(1)]: 37 | ``` 38 | python -m ippserver --port 1234 run \ 39 | mail -E \ 40 | -a "MIME-Version 1.0" -a "Content-Type: application/postscript" \ 41 | -s 'A printed document' some.person@example.com 42 | ``` 43 | 44 | 45 | PDF files 46 | --------- 47 | 48 | The printer normally advertises itself as a postscript printer. Alternatively, the printer can advertise itself as a PDF printer. This changes the printer description (PPD), so you will need to re-add the printer (eg: with a different port). 49 | 50 | Run the printer with `save --pdf`, and add a new printer: 51 | ``` 52 | python -m ippserver --port 7777 save --pdf /tmp/ 53 | ``` 54 | 55 | 56 | [hexdump(1)]: https://linux.die.net/man/1/hexdump 57 | [mail(1)]: https://linux.die.net/man/1/mail 58 | -------------------------------------------------------------------------------- /ippserver/constants.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | from __future__ import absolute_import 3 | from __future__ import print_function 4 | from __future__ import unicode_literals 5 | 6 | try: 7 | from enum import IntEnum 8 | except ImportError: 9 | IntEnum = object 10 | 11 | 12 | class SectionEnum(IntEnum): 13 | # delimiters (sections) 14 | SECTIONS = 0x00 15 | SECTIONS_MASK = 0xf0 16 | operation = 0x01 17 | job = 0x02 18 | END = 0x03 19 | printer = 0x04 20 | unsupported = 0x05 21 | 22 | @classmethod 23 | def is_section_tag(cls, tag): 24 | return (tag & cls.SECTIONS_MASK) == cls.SECTIONS 25 | 26 | 27 | class TagEnum(IntEnum): 28 | unsupported_value = 0x10 29 | unknown_value = 0x12 30 | no_value = 0x13 31 | 32 | # int types 33 | integer = 0x21 34 | boolean = 0x22 35 | enum = 0x23 36 | 37 | # string types 38 | octet_str = 0x30 39 | datetime_str = 0x31 40 | resolution = 0x32 41 | range_of_integer = 0x33 42 | text_with_language = 0x35 43 | name_with_language = 0x36 44 | 45 | text_without_language = 0x41 46 | name_without_language = 0x42 47 | keyword = 0x44 48 | uri = 0x45 49 | uri_scheme = 0x46 50 | charset = 0x47 51 | natural_language = 0x48 52 | mime_media_type = 0x49 53 | 54 | 55 | class StatusCodeEnum(IntEnum): 56 | # https://tools.ietf.org/html/rfc2911#section-13.1 57 | ok = 0x0000 58 | server_error_internal_error = 0x0500 59 | server_error_operation_not_supported = 0x0501 60 | server_error_job_canceled = 0x508 61 | 62 | 63 | class OperationEnum(IntEnum): 64 | # https://tools.ietf.org/html/rfc2911#section-4.4.15 65 | print_job = 0x0002 66 | validate_job = 0x0004 67 | cancel_job = 0x0008 68 | get_job_attributes = 0x0009 69 | get_jobs = 0x000a 70 | get_printer_attributes = 0x000b 71 | 72 | # 0x4000 - 0xFFFF is for extensions 73 | # CUPS extensions listed here: 74 | # https://web.archive.org/web/20061024184939/http://uw714doc.sco.com/en/cups/ipp.html 75 | cups_get_default = 0x4001 76 | cups_list_all_printers = 0x4002 77 | 78 | 79 | class JobStateEnum(IntEnum): 80 | # https://tools.ietf.org/html/rfc2911#section-4.3.7 81 | pending = 3 82 | pending_held = 4 83 | processing = 5 84 | processing_stopped = 6 85 | canceled = 7 86 | aborted = 8 87 | completed = 9 88 | -------------------------------------------------------------------------------- /ippserver/pc2paper.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | from __future__ import absolute_import 3 | from __future__ import print_function 4 | from __future__ import unicode_literals 5 | 6 | import json 7 | import logging 8 | import requests 9 | from collections import namedtuple 10 | 11 | 12 | class Pc2Paper(namedtuple('Pc2Paper', 13 | ('username', 'password', 'name', 'address1', 'address2', 'address3', 'address4', 'postcode', 'country', 'postage', 'paper', 'envelope', 'extras'))): 14 | # From https://www.pc2paper.co.uk/downloads/country.csv 15 | NUMERIC_COUNTRY_CODES = { 16 | 'UK': 1, 17 | } 18 | 19 | # From http://www.pc2paper.co.uk/datagetpostage.asp?method=getZonesLetterCanBeSentFrom&str=1 20 | POSTAGE_TYPES = { 21 | 'UK 1st': 3, 22 | 'UK 2nd': 31, 23 | } 24 | 25 | # From http://www.pc2paper.co.uk/datagetpostage.asp?method=getPaperBasedOnZoneAndPrintType&str=3,Colour%20Laser 26 | PAPER_TYPES = { 27 | '80gsm': 4, 28 | '100gsm': 17, 29 | 'Conqueror': 5, 30 | '80gsm double sided': 14, 31 | } 32 | 33 | ENVELOPE_TYPES = { 34 | 'DL': 1, 35 | 'C5': 10, 36 | 'A4': 11, 37 | } 38 | 39 | @classmethod 40 | def from_config_file(cls, filename): 41 | with open(filename) as f: 42 | data = json.load(f) 43 | 44 | conversions = [ 45 | ('country', cls.NUMERIC_COUNTRY_CODES), 46 | ('postage', cls.POSTAGE_TYPES), 47 | ('paper', cls.PAPER_TYPES), 48 | ('envelope', cls.ENVELOPE_TYPES), 49 | ] 50 | for key, lookup in conversions: 51 | if not isinstance(data[key], int): 52 | data[key] = lookup[data[key]] 53 | 54 | return cls(**data) 55 | 56 | def post_pdf_letter(self, filename, pdffile): 57 | pdf_guid = self._upload_pdf(filename, pdffile) 58 | self._post_letter(pdf_guid) 59 | 60 | def _upload_pdf(self, filename, pdffile): 61 | post_data = { 62 | 'username': self.username, 63 | 'password': self.password, 64 | 'filename': filename, 65 | 'fileContent': [ord(byte) for byte in pdffile], 66 | } 67 | response = requests.post( 68 | 'https://www.pc2paper.co.uk/lettercustomerapi.svc/json/UploadDocument', 69 | headers={'Content-type': 'application/json'}, 70 | data=json.dumps(post_data)) 71 | response_data = response.json() 72 | logging.debug('Response to uploading %r is %r', filename, response_data) 73 | error_messages = response_data['d']['ErrorMessages'] 74 | if error_messages: 75 | raise ValueError(error_messages) 76 | return response_data['d']['FileCreatedGUID'] 77 | 78 | def _post_letter(self, pdf_guid): 79 | post_data = { 80 | 'username': self.username, 81 | 'password': self.password, 82 | 'letterForPosting': { 83 | 'SourceClient' : 'h2g2bob ipp-server', 84 | 'Addresses': [{ 85 | 'ReceiverName': self.name, 86 | 'ReceiverAddressLine1': self.address1, 87 | 'ReceiverAddressLine2': self.address2, 88 | 'ReceiverAddressTownCityOrLine3': self.address3, 89 | 'ReceiverAddressCountyStateOrLine4': self.address4, 90 | 'ReceiverAddressPostCode': self.postcode, 91 | }], 92 | 'ReceiverCountryCode': self.country, 93 | 'Postage': self.postage, 94 | 'Paper': self.paper, 95 | 'Envelope': self.envelope, 96 | 'Extras': self.extras, 97 | # 'LetterBody' : '', 98 | 'FileAttachementGUIDs': [pdf_guid], 99 | }, 100 | } 101 | response = requests.post( 102 | 'https://www.pc2paper.co.uk/lettercustomerapi.svc/json/SendSubmitLetterForPosting', 103 | headers={'Content-type': 'application/json'}, 104 | data=json.dumps(post_data)) 105 | response_data = response.json() 106 | 107 | logging.debug('Response to posting %r is %r', pdf_guid, response_data) 108 | error_messages = response_data['d']['ErrorMessages'] 109 | if error_messages: 110 | raise ValueError(error_messages) 111 | -------------------------------------------------------------------------------- /ippserver/request.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | from __future__ import absolute_import 3 | from __future__ import print_function 4 | from __future__ import unicode_literals 5 | 6 | from io import BytesIO 7 | import operator 8 | import itertools 9 | 10 | from .parsers import read_struct, write_struct 11 | from .constants import SectionEnum, TagEnum 12 | 13 | 14 | class IppRequest(object): 15 | def __init__(self, version, opid_or_status, request_id, attributes): 16 | self.version = version # (major, minor) 17 | self.opid_or_status = opid_or_status 18 | self.request_id = request_id 19 | self._attributes = attributes 20 | 21 | def __cmp__(self, other): 22 | return self.__eq__(other) 23 | 24 | def __eq__(self, other): 25 | return type(self) == type(other) or self._attributes == other._attributes 26 | 27 | def __repr__(self): 28 | return 'IppRequest(%r, 0x%04x, 0x%02x, %r)' % ( 29 | self.version, 30 | self.opid_or_status, 31 | self.request_id, 32 | self._attributes,) 33 | 34 | @classmethod 35 | def from_string(cls, string): 36 | return cls.from_file(BytesIO(string)) 37 | 38 | @classmethod 39 | def from_file(cls, f): 40 | version = read_struct(f, b'>bb') # (major, minor) 41 | operation_id_or_status_code, request_id = read_struct(f, b'>hi') 42 | 43 | attributes = {} 44 | current_section = None 45 | current_name = None 46 | while True: 47 | tag, = read_struct(f, b'>B') 48 | 49 | if tag == SectionEnum.END: 50 | break 51 | elif SectionEnum.is_section_tag(tag): 52 | current_section = tag 53 | current_name = None 54 | else: 55 | if current_section is None: 56 | raise Exception('No section delimiter') 57 | 58 | name_len, = read_struct(f, b'>h') 59 | if name_len == 0: 60 | if current_name is None: 61 | raise Exception('Additional attribute needs a name to follow') 62 | else: 63 | # additional attribute, under the same name 64 | pass 65 | else: 66 | current_name = f.read(name_len) 67 | 68 | value_len, = read_struct(f, b'>h') 69 | value_str = f.read(value_len) 70 | attributes.setdefault((current_section, current_name, tag), []).append(value_str) 71 | 72 | return cls(version, operation_id_or_status_code, request_id, attributes) 73 | 74 | def to_string(self): 75 | sio = BytesIO() 76 | self.to_file(sio) 77 | return sio.getvalue() 78 | 79 | def to_file(self, f): 80 | version_major, version_minor = 1, 1 81 | write_struct(f, b'>bb', version_major, version_minor) 82 | write_struct(f, b'>hi', self.opid_or_status, self.request_id) 83 | 84 | for section, attrs_in_section in itertools.groupby( 85 | sorted(self._attributes.keys()), operator.itemgetter(0) 86 | ): 87 | write_struct(f, b'>B', section) 88 | for key in attrs_in_section: 89 | _section, name, tag = key 90 | for i, value in enumerate(self._attributes[key]): 91 | write_struct(f, b'>B', tag) 92 | if i == 0: 93 | write_struct(f, b'>h', len(name)) 94 | f.write(name) 95 | else: 96 | write_struct(f, b'>h', 0) 97 | # Integer must be 4 bytes 98 | assert (tag != TagEnum.integer or len(value) == 4) 99 | write_struct(f, b'>h', len(value)) 100 | f.write(value) 101 | write_struct(f, b'>B', SectionEnum.END) 102 | 103 | def attributes_to_multilevel(self, section=None): 104 | ret = {} 105 | for key in self._attributes.keys(): 106 | if section and section != key[0]: 107 | continue 108 | ret.setdefault(key[0], {}) 109 | ret[key[0]].setdefault(key[1], {}) 110 | ret[key[0]][key[1]][key[2]] = self._attributes[key] 111 | return ret 112 | 113 | def lookup(self, section, name, tag): 114 | return self._attributes[section, name, tag] 115 | 116 | def only(self, section, name, tag): 117 | items = self.lookup(section, name, tag) 118 | if len(items) == 1: 119 | return items[0] 120 | elif len(items) == 0: 121 | raise RuntimeError('self._attributes[%r, %r, %r] is empty list' % (section, name, tag,)) 122 | else: 123 | raise ValueError('self._attributes[%r, %r, %r] has more than one value' % (section, name, tag,)) 124 | -------------------------------------------------------------------------------- /ippserver/__main__.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | from __future__ import absolute_import 3 | from __future__ import print_function 4 | 5 | import argparse 6 | import logging 7 | import importlib 8 | import sys, os.path 9 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 10 | 11 | __package__ = "ippserver" 12 | 13 | 14 | from . import behaviour 15 | from .pc2paper import Pc2Paper 16 | from .server import run_server, IPPServer, IPPRequestHandler 17 | 18 | 19 | def parse_args(args=None): 20 | pdf_help = 'Request that CUPs sends the document as a PDF file, instead of a PS file. CUPs detects this setting when ADDING a printer: you may need to re-add the printer on a different port' 21 | 22 | parser = argparse.ArgumentParser(description='An IPP server') 23 | parser.add_argument('-v', '--verbose', action='count', help='Add debugging') 24 | parser.add_argument('-H', '--host', type=str, default='localhost', metavar='HOST', help='Address to listen on') 25 | parser.add_argument('-p', '--port', type=int, required=True, metavar='PORT', help='Port to listen on') 26 | 27 | parser_action = parser.add_subparsers(help='Actions', dest='action') 28 | 29 | parser_save = parser_action.add_parser('save', help='Write any print jobs to disk') 30 | parser_save.add_argument('--pdf', action='store_true', default=False, help=pdf_help) 31 | parser_save.add_argument('directory', metavar='DIRECTORY', help='Directory to save files into') 32 | 33 | parser_command = parser_action.add_parser('run', help='Run a command when recieving a print job') 34 | parser_command.add_argument('command', nargs=argparse.REMAINDER, metavar='COMMAND', help='Command to run') 35 | parser_command.add_argument('--pdf', action='store_true', default=False, help=pdf_help) 36 | parser_command.add_argument('--env', action='store_true', default=False, help="Store Job attributes in environment (IPP_JOB_ATTRIBUTES)") 37 | 38 | parser_saverun = parser_action.add_parser('saveandrun', help='Write any print jobs to disk and the run a command on them') 39 | parser_saverun.add_argument('--pdf', action='store_true', default=False, help=pdf_help) 40 | parser_saverun.add_argument('--env', action='store_true', default=False, help="Store Job attributes in environment (IPP_JOB_ATTRIBUTES)") 41 | parser_saverun.add_argument('directory', metavar='DIRECTORY', help='Directory to save files into') 42 | parser_saverun.add_argument('command', nargs=argparse.REMAINDER, metavar='COMMAND', help='Command to run (the filename will be added at the end)') 43 | 44 | parser_command = parser_action.add_parser('reject', help='Respond to all print jobs with job-canceled-at-device') 45 | 46 | parser_command = parser_action.add_parser('pc2paper', help='Post print jobs using http://www.pc2paper.org/') 47 | parser_command.add_argument('--pdf', action='store_true', default=False, help=pdf_help) 48 | parser_command.add_argument('--config', metavar='CONFIG', help='File containing an address to send to, in json format') 49 | parser_loader = parser_action.add_parser('load', help='Load own behaviour') 50 | parser_loader.add_argument('path', nargs=1, metavar=['PATH'], help='Module implementing behaviour') 51 | parser_loader.add_argument('command', nargs=argparse.REMAINDER, metavar='COMMAND', help='Arguments for the module') 52 | 53 | return parser.parse_args(args) 54 | 55 | 56 | def behaviour_from_parsed_args(args): 57 | if args.action == 'save': 58 | return behaviour.SaveFilePrinter( 59 | directory=args.directory, 60 | filename_ext='pdf' if args.pdf else 'ps') 61 | if args.action == 'run': 62 | return behaviour.RunCommandPrinter( 63 | command=args.command, 64 | use_env=args.env, 65 | filename_ext='pdf' if args.pdf else 'ps') 66 | if args.action == 'saveandrun': 67 | return behaviour.SaveAndRunPrinter( 68 | command=args.command, 69 | use_env=args.env, 70 | directory=args.directory, 71 | filename_ext='pdf' if args.pdf else 'ps') 72 | if args.action == 'pc2paper': 73 | pc2paper_config = Pc2Paper.from_config_file(args.config) 74 | return behaviour.PostageServicePrinter( 75 | service_api=pc2paper_config, 76 | filename_ext='pdf' if args.pdf else 'ps') 77 | if args.action == 'load': 78 | module, name = args.path[0].rsplit(".", 1) 79 | return getattr(importlib.import_module(module), name)(*args.command) 80 | if args.action == 'reject': 81 | return behaviour.RejectAllPrinter() 82 | raise RuntimeError(args) 83 | 84 | 85 | def main(args=None): 86 | parsed_args = parse_args(args) 87 | logging.basicConfig(level=logging.DEBUG if parsed_args.verbose else logging.INFO) 88 | 89 | server = IPPServer( 90 | (parsed_args.host, parsed_args.port), 91 | IPPRequestHandler, 92 | behaviour_from_parsed_args(parsed_args)) 93 | run_server(server) 94 | 95 | if __name__ == "__main__": 96 | main() 97 | -------------------------------------------------------------------------------- /ippserver/server.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | from __future__ import absolute_import 3 | from __future__ import print_function 4 | 5 | from io import BytesIO 6 | import threading 7 | try: 8 | import socketserver 9 | except ImportError: 10 | import SocketServer as socketserver 11 | try: 12 | from http.server import BaseHTTPRequestHandler 13 | except ImportError: 14 | from BaseHTTPServer import BaseHTTPRequestHandler 15 | import time 16 | import logging 17 | import os.path 18 | 19 | from . import request 20 | 21 | 22 | def local_file_location(filename): 23 | return os.path.join(os.path.dirname(__file__), 'data', filename) 24 | 25 | 26 | def _get_next_chunk(rfile): 27 | while True: 28 | chunk_size_s = rfile.readline() 29 | logging.debug('chunksz=%r', chunk_size_s) 30 | if not chunk_size_s: 31 | raise RuntimeError( 32 | 'Socket closed in the middle of a chunked request' 33 | ) 34 | if chunk_size_s.strip() != b'': 35 | break 36 | 37 | chunk_size = int(chunk_size_s, 16) 38 | if chunk_size == 0: 39 | return b'' 40 | chunk = rfile.read(chunk_size) 41 | logging.debug('chunk=0x%x', len(chunk)) 42 | return chunk 43 | 44 | 45 | def read_chunked(rfile): 46 | while True: 47 | chunk = _get_next_chunk(rfile) 48 | if chunk == b'': 49 | rfile.close() 50 | break 51 | else: 52 | yield chunk 53 | 54 | 55 | class IPPRequestHandler(BaseHTTPRequestHandler): 56 | default_request_version = "HTTP/1.1" 57 | protocol_version = "HTTP/1.1" 58 | 59 | def parse_request(self): 60 | ret = BaseHTTPRequestHandler.parse_request(self) 61 | if 'chunked' in self.headers.get('transfer-encoding', ''): 62 | self.rfile = BytesIO(b"".join(read_chunked(self.rfile))) 63 | self.close_connection = True 64 | return ret 65 | 66 | if not hasattr(BaseHTTPRequestHandler, "send_response_only"): 67 | def send_response_only(self, code, message=None): 68 | """Send the response header only.""" 69 | if message is None: 70 | if code in self.responses: 71 | message = self.responses[code][0] 72 | else: 73 | message = '' 74 | if not hasattr(self, '_headers_buffer'): 75 | self._headers_buffer = [] 76 | self._headers_buffer.append( 77 | ( 78 | "%s %d %s\r\n" % (self.protocol_version, code, message) 79 | ).encode('latin-1', 'strict') 80 | ) 81 | 82 | def log_error(self, format, *args): 83 | logging.error(format, *args) 84 | 85 | def log_message(self, format, *args): 86 | logging.debug(format, *args) 87 | 88 | def send_headers(self, status=200, content_type='text/plain', 89 | content_length=None): 90 | self.log_request(status) 91 | self.send_response_only(status, None) 92 | self.send_header('Server', 'ipp-server') 93 | self.send_header('Date', self.date_time_string()) 94 | self.send_header('Content-Type', content_type) 95 | if content_length: 96 | self.send_header('Content-Length', '%u' % content_length) 97 | self.send_header('Connection', 'close') 98 | self.end_headers() 99 | 100 | def do_POST(self): 101 | self.handle_ipp() 102 | 103 | def do_GET(self): 104 | self.handle_www() 105 | 106 | def handle_www(self): 107 | if self.path == '/': 108 | self.send_headers( 109 | status=200, content_type='text/plain' 110 | ) 111 | with open(local_file_location('homepage.txt'), 'rb') as wwwfile: 112 | self.wfile.write(wwwfile.read()) 113 | elif self.path.endswith('.ppd'): 114 | self.send_headers( 115 | status=200, content_type='text/plain' 116 | ) 117 | self.wfile.write(self.server.behaviour.ppd.text()) 118 | else: 119 | self.send_headers( 120 | status=404, content_type='text/plain' 121 | ) 122 | with open(local_file_location('404.txt'), 'rb') as wwwfile: 123 | self.wfile.write(wwwfile.read()) 124 | 125 | def handle_expect_100(self): 126 | """ Disable """ 127 | return True 128 | 129 | def handle_ipp(self): 130 | self.ipp_request = request.IppRequest.from_file(self.rfile) 131 | 132 | if self.server.behaviour.expect_page_data_follows(self.ipp_request): 133 | self.send_headers( 134 | status=100, content_type='application/ipp' 135 | ) 136 | postscript_file = self.rfile 137 | else: 138 | postscript_file = None 139 | 140 | ipp_response = self.server.behaviour.handle_ipp( 141 | self.ipp_request, postscript_file 142 | ).to_string() 143 | self.send_headers( 144 | status=200, content_type='application/ipp', 145 | content_length=len(ipp_response) 146 | ) 147 | self.wfile.write(ipp_response) 148 | 149 | 150 | class IPPServer(socketserver.ThreadingTCPServer): 151 | allow_reuse_address = True 152 | 153 | def __init__(self, address, request_handler, behaviour): 154 | self.behaviour = behaviour 155 | socketserver.ThreadingTCPServer.__init__(self, address, request_handler) # old style class! 156 | 157 | 158 | def wait_until_ctrl_c(): 159 | try: 160 | while True: 161 | time.sleep(300) 162 | except KeyboardInterrupt: 163 | return 164 | 165 | 166 | def run_server(server): 167 | logging.info('Listening on %r', server.server_address) 168 | server_thread = threading.Thread(target=server.serve_forever) 169 | server_thread.daemon = True 170 | server_thread.start() 171 | wait_until_ctrl_c() 172 | logging.info('Ready to shut down') 173 | server.shutdown() 174 | -------------------------------------------------------------------------------- /tests/test_request.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/python 2 | from __future__ import division 3 | from __future__ import absolute_import 4 | from __future__ import print_function 5 | from __future__ import unicode_literals 6 | 7 | import sys, os.path 8 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 9 | 10 | from ippserver.server import IPPRequestHandler 11 | from ippserver.constants import OperationEnum, TagEnum, SectionEnum 12 | from ippserver.request import IppRequest 13 | from ippserver.behaviour import RejectAllPrinter 14 | 15 | from io import BytesIO 16 | import logging 17 | import unittest 18 | 19 | class TestIppRequest(unittest.TestCase): 20 | printer_discovery_http_prefix = b'POST / HTTP/1.1\r\nContent-Length: 635\r\nContent-Type: application/ipp\r\nHost: localhost\r\nUser-Agent: CUPS/1.5.3\r\nExpect: 100-continue\r\n\r\n' 21 | printer_discovery = b'\x01\x01@\x02\x00\x00\x00\x01\x01G\x00\x12attributes-charset\x00\x05utf-8H\x00\x1battributes-natural-language\x00\x05en-gbD\x00\x14requested-attributes\x00\x12auth-info-requiredD\x00\x00\x00\ndevice-uriD\x00\x00\x00\x12job-sheets-defaultD\x00\x00\x00\x12marker-change-timeD\x00\x00\x00\rmarker-colorsD\x00\x00\x00\x12marker-high-levelsD\x00\x00\x00\rmarker-levelsD\x00\x00\x00\x11marker-low-levelsD\x00\x00\x00\x0emarker-messageD\x00\x00\x00\x0cmarker-namesD\x00\x00\x00\x0cmarker-typesD\x00\x00\x00\x10printer-commandsD\x00\x00\x00\x10printer-defaultsD\x00\x00\x00\x0cprinter-infoD\x00\x00\x00\x19printer-is-accepting-jobsD\x00\x00\x00\x11printer-is-sharedD\x00\x00\x00\x10printer-locationD\x00\x00\x00\x16printer-make-and-modelD\x00\x00\x00\x0cprinter-nameD\x00\x00\x00\rprinter-stateD\x00\x00\x00\x19printer-state-change-timeD\x00\x00\x00\x15printer-state-reasonsD\x00\x00\x00\x0cprinter-typeD\x00\x00\x00\x15printer-uri-supportedB\x00\x14requesting-user-name\x00\x04user\x03' 22 | def test_consistency(self): 23 | msg = IppRequest.from_string(self.printer_discovery) 24 | self.assertEqual(msg, IppRequest.from_string(msg.to_string())) 25 | 26 | def test_attr_only(self): 27 | msg = IppRequest.from_string(self.printer_discovery) 28 | self.assertEqual(msg.only(SectionEnum.operation, b'attributes-charset', TagEnum.charset,), b'utf-8') 29 | 30 | def test_attr_lookup(self): 31 | msg = IppRequest.from_string(self.printer_discovery) 32 | self.assertEqual(msg.lookup(SectionEnum.operation, b'attributes-charset', TagEnum.charset,), [b'utf-8']) 33 | 34 | def test_attr_only_noexist(self): 35 | msg = IppRequest.from_string(self.printer_discovery) 36 | self.assertRaises(KeyError, msg.only, SectionEnum.operation, b'no-exist', TagEnum.charset) 37 | 38 | def test_attr_lookup_noexist(self): 39 | msg = IppRequest.from_string(self.printer_discovery) 40 | self.assertRaises(KeyError, msg.lookup, SectionEnum.operation, b'no-exist', TagEnum.charset) 41 | 42 | def test_parse(self): 43 | msg = IppRequest.from_string(self.printer_discovery) 44 | self.assertEqual(msg._attributes, { 45 | (SectionEnum.operation, b'requested-attributes', TagEnum.keyword): [ 46 | b'auth-info-required', 47 | b'device-uri', 48 | b'job-sheets-default', 49 | b'marker-change-time', 50 | b'marker-colors', 51 | b'marker-high-levels', 52 | b'marker-levels', 53 | b'marker-low-levels', 54 | b'marker-message', 55 | b'marker-names', 56 | b'marker-types', 57 | b'printer-commands', 58 | b'printer-defaults', 59 | b'printer-info', 60 | b'printer-is-accepting-jobs', 61 | b'printer-is-shared', 62 | b'printer-location', 63 | b'printer-make-and-model', 64 | b'printer-name', 65 | b'printer-state', 66 | b'printer-state-change-time', 67 | b'printer-state-reasons', 68 | b'printer-type', 69 | b'printer-uri-supported'], 70 | (SectionEnum.operation, b'attributes-charset', TagEnum.charset): [ 71 | b'utf-8'], 72 | (SectionEnum.operation, b'attributes-natural-language', TagEnum.natural_language): [ 73 | b'en-gb'], 74 | (SectionEnum.operation, b'requesting-user-name', TagEnum.name_without_language): [ 75 | b'user']}) 76 | 77 | 78 | class MockRequest(object): 79 | rfile = None 80 | wfile = None 81 | 82 | def __init__(self, input): 83 | self.rfile = BytesIO(input) 84 | self.wfile = BytesIO() 85 | 86 | def settimeout(self, _timeout): 87 | pass 88 | 89 | def setsockopt(self, *_args, **_kwargs): 90 | pass 91 | 92 | def sendall(self, *_args): 93 | pass 94 | 95 | def makefile(self, mode, _size): 96 | if mode == "wb": 97 | return self.wfile 98 | elif mode == "rb": 99 | return self.rfile 100 | else: 101 | raise ValueError() 102 | 103 | 104 | class MockServer(object): 105 | behaviour = None 106 | def __init__(self, behaviour): 107 | self.behaviour = behaviour 108 | 109 | 110 | class TestPrintTestPage(unittest.TestCase): 111 | def test_strange_request(self): 112 | data = b'POST /printers/ipp-printer.py HTTP/1.1\r\nContent-Type: application/ipp\r\nHost: localhost:1234\r\nTransfer-Encoding: chunked\r\nUser-Agent: CUPS/1.7.5 (Linux 3.16.0-4-amd64; x86_64) IPP/2.0\r\nExpect: 100-continue\r\n\r\nbf\r\n\x02\x00\x00\x02\x00\x00\x00\x04\x01G\x00\x12attributes-charset\x00\x05utf-8H\x00\x1battributes-natural-language\x00\x05en-gbE\x00\x0bprinter-uri\x00,ipp://localhost:1234/printers/ipp-printer.pyB\x00\x14requesting-user-name\x00\x04userB\x00\x08job-name\x00\x0e12 - Test page\x03\r\n0\r\n\r\n' 113 | 114 | request = IPPRequestHandler( 115 | MockRequest(data), "127.0.0.1", MockServer(RejectAllPrinter()) 116 | ) 117 | 118 | self.assertEqual(request.ipp_request.opid_or_status, OperationEnum.print_job) 119 | 120 | 121 | if __name__ == '__main__': 122 | logging.getLogger().setLevel(logging.DEBUG) 123 | unittest.main() 124 | -------------------------------------------------------------------------------- /ippserver/behaviour.py: -------------------------------------------------------------------------------- 1 | 2 | from __future__ import division 3 | from __future__ import absolute_import 4 | from __future__ import print_function 5 | from __future__ import unicode_literals 6 | 7 | import logging 8 | import os 9 | import os.path 10 | import random 11 | import json 12 | import subprocess 13 | import time 14 | import uuid 15 | 16 | from .parsers import Integer, Enum, Boolean 17 | from .constants import ( 18 | JobStateEnum, OperationEnum, StatusCodeEnum, SectionEnum, TagEnum 19 | ) 20 | from .ppd import BasicPostscriptPPD, BasicPdfPPD 21 | from .request import IppRequest 22 | 23 | 24 | def get_job_id(req): 25 | return Integer.from_bytes( 26 | req.only( 27 | SectionEnum.operation, 28 | b'job-id', 29 | TagEnum.integer 30 | ) 31 | ).integer 32 | 33 | 34 | def read_in_blocks(postscript_file): 35 | while True: 36 | block = postscript_file.read(1024) 37 | if block == b'': 38 | break 39 | else: 40 | yield block 41 | 42 | 43 | def prepare_environment(ipp_request): 44 | env = os.environ.copy() 45 | env["IPP_JOB_ATTRIBUTES"] = json.dumps( 46 | ipp_request.attributes_to_multilevel(SectionEnum.job) 47 | ) 48 | return env 49 | 50 | 51 | class Behaviour(object): 52 | """Do anything in response to IPP requests""" 53 | version = (1, 1) 54 | base_uri = b'ipp://localhost:1234/' 55 | printer_uri = b'ipp://localhost:1234/printer' 56 | 57 | def __init__(self, ppd=BasicPostscriptPPD()): 58 | self.ppd = ppd 59 | 60 | def expect_page_data_follows(self, ipp_request): 61 | return ipp_request.opid_or_status == OperationEnum.print_job 62 | 63 | def handle_ipp(self, ipp_request, postscript_file): 64 | command_function = self.get_handle_command_function( 65 | ipp_request.opid_or_status 66 | ) 67 | logging.debug( 68 | 'IPP %r -> %s.%s', ipp_request.opid_or_status, type(self).__name__, 69 | command_function.__name__ 70 | ) 71 | return command_function(ipp_request, postscript_file) 72 | 73 | def get_handle_command_function(self, opid_or_status): 74 | raise NotImplementedError() 75 | 76 | 77 | class AllCommandsReturnNotImplemented(Behaviour): 78 | """A printer which responds to all commands with a not implemented error. 79 | 80 | There's no real use for this, it's just an example. 81 | """ 82 | def get_handle_command_function(self, _opid_or_status): 83 | return self.operation_not_implemented_response 84 | 85 | def operation_not_implemented_response(self, req, _psfile): 86 | attributes = self.minimal_attributes() 87 | return IppRequest( 88 | self.version, 89 | StatusCodeEnum.server_error_operation_not_supported, 90 | req.request_id, 91 | attributes) 92 | 93 | 94 | class StatelessPrinter(Behaviour): 95 | """A minimal printer which implements all the things a printer needs to work. 96 | 97 | The printer calls handle_postscript() for each print job. 98 | It says all print jobs succeed immediately: there are some stub functions like create_job() which subclasses could use to keep track of jobs, eg: if operation_get_jobs_response wants to return something sensible. 99 | """ 100 | 101 | def get_handle_command_function(self, opid_or_status): 102 | commands = { 103 | OperationEnum.get_printer_attributes: self.operation_printer_list_response, 104 | OperationEnum.cups_list_all_printers: self.operation_printer_list_response, 105 | OperationEnum.cups_get_default: self.operation_printer_list_response, 106 | OperationEnum.validate_job: self.operation_validate_job_response, 107 | OperationEnum.get_jobs: self.operation_get_jobs_response, 108 | OperationEnum.get_job_attributes: self.operation_get_job_attributes_response, 109 | OperationEnum.print_job: self.operation_print_job_response, 110 | 0x0d0a: self.operation_misidentified_as_http, 111 | } 112 | 113 | try: 114 | command_function = commands[opid_or_status] 115 | except KeyError: 116 | logging.warn('Operation not supported 0x%04x', opid_or_status) 117 | command_function = self.operation_not_implemented_response 118 | return command_function 119 | 120 | def operation_not_implemented_response(self, req, _psfile): 121 | attributes = self.minimal_attributes() 122 | return IppRequest( 123 | self.version, 124 | # StatusCodeEnum.server_error_operation_not_supported, 125 | StatusCodeEnum.server_error_internal_error, 126 | req.request_id, 127 | attributes) 128 | 129 | def operation_printer_list_response(self, req, _psfile): 130 | attributes = self.printer_list_attributes() 131 | return IppRequest( 132 | self.version, 133 | StatusCodeEnum.ok, 134 | req.request_id, 135 | attributes) 136 | 137 | def operation_validate_job_response(self, req, _psfile): 138 | # TODO this just pretends it's ok! 139 | attributes = self.minimal_attributes() 140 | return IppRequest( 141 | self.version, 142 | StatusCodeEnum.ok, 143 | req.request_id, 144 | attributes) 145 | 146 | def operation_get_jobs_response(self, req, _psfile): 147 | # an empty list of jobs, which probably breaks the rfc 148 | # if the client asked for completed jobs 149 | # https://tools.ietf.org/html/rfc2911#section-3.2.6.2 150 | attributes = self.minimal_attributes() 151 | return IppRequest( 152 | self.version, 153 | StatusCodeEnum.ok, 154 | req.request_id, 155 | attributes) 156 | 157 | def operation_print_job_response(self, req, psfile): 158 | job_id = self.create_job(req) 159 | attributes = self.print_job_attributes( 160 | job_id, JobStateEnum.pending, 161 | [b'job-incoming', b'job-data-insufficient'] 162 | ) 163 | self.handle_postscript(req, psfile) 164 | return IppRequest( 165 | self.version, 166 | StatusCodeEnum.ok, 167 | req.request_id, 168 | attributes) 169 | 170 | def operation_get_job_attributes_response(self, req, _psfile): 171 | # Should have all these attributes: 172 | # https://tools.ietf.org/html/rfc2911#section-4.3 173 | 174 | job_id = get_job_id(req) 175 | attributes = self.print_job_attributes( 176 | job_id, 177 | JobStateEnum.completed, 178 | [b'none'] 179 | ) 180 | return IppRequest( 181 | self.version, 182 | StatusCodeEnum.ok, 183 | req.request_id, 184 | attributes) 185 | 186 | def operation_misidentified_as_http(self, _req, _psfile): 187 | raise Exception("The opid for this operation is \\r\\n, which suggests the request was actually a http request.") 188 | 189 | def minimal_attributes(self): 190 | return { 191 | # This list comes from 192 | # https://tools.ietf.org/html/rfc2911 193 | # Section 3.1.4.2 Response Operation Attributes 194 | ( 195 | SectionEnum.operation, 196 | b'attributes-charset', 197 | TagEnum.charset 198 | ): [b'utf-8'], 199 | ( 200 | SectionEnum.operation, 201 | b'attributes-natural-language', 202 | TagEnum.natural_language 203 | ): [b'en'], 204 | } 205 | 206 | def printer_list_attributes(self): 207 | attr = { 208 | # rfc2911 section 4.4 209 | ( 210 | SectionEnum.printer, 211 | b'printer-uri-supported', 212 | TagEnum.uri 213 | ): [self.printer_uri], 214 | ( 215 | SectionEnum.printer, 216 | b'uri-authentication-supported', 217 | TagEnum.keyword 218 | ): [b'none'], 219 | ( 220 | SectionEnum.printer, 221 | b'uri-security-supported', 222 | TagEnum.keyword 223 | ): [b'none'], 224 | ( 225 | SectionEnum.printer, 226 | b'printer-name', 227 | TagEnum.name_without_language 228 | ): [b'ipp-printer.py'], 229 | ( 230 | SectionEnum.printer, 231 | b'printer-info', 232 | TagEnum.text_without_language 233 | ): [b'Printer using ipp-printer.py'], 234 | ( 235 | SectionEnum.printer, 236 | b'printer-make-and-model', 237 | TagEnum.text_without_language 238 | ): [b'h2g2bob\'s ipp-printer.py 0.00'], 239 | ( 240 | SectionEnum.printer, 241 | b'printer-state', 242 | TagEnum.enum 243 | ): [Enum(3).bytes()], # XXX 3 is idle 244 | ( 245 | SectionEnum.printer, 246 | b'printer-state-reasons', 247 | TagEnum.keyword 248 | ): [b'none'], 249 | ( 250 | SectionEnum.printer, 251 | b'ipp-versions-supported', 252 | TagEnum.keyword 253 | ): [b'1.1'], 254 | ( 255 | SectionEnum.printer, 256 | b'operations-supported', 257 | TagEnum.enum 258 | ): [ 259 | Enum(x).bytes() 260 | for x in ( 261 | OperationEnum.print_job, # (required by cups) 262 | OperationEnum.validate_job, # (required by cups) 263 | OperationEnum.cancel_job, # (required by cups) 264 | OperationEnum.get_job_attributes, # (required by cups) 265 | OperationEnum.get_printer_attributes, 266 | )], 267 | ( 268 | SectionEnum.printer, 269 | b'multiple-document-jobs-supported', 270 | TagEnum.boolean 271 | ): [Boolean(False).bytes()], 272 | ( 273 | SectionEnum.printer, 274 | b'charset-configured', 275 | TagEnum.charset 276 | ): [b'utf-8'], 277 | ( 278 | SectionEnum.printer, 279 | b'charset-supported', 280 | TagEnum.charset 281 | ): [b'utf-8'], 282 | ( 283 | SectionEnum.printer, 284 | b'natural-language-configured', 285 | TagEnum.natural_language 286 | ): [b'en'], 287 | ( 288 | SectionEnum.printer, 289 | b'generated-natural-language-supported', 290 | TagEnum.natural_language 291 | ): [b'en'], 292 | ( 293 | SectionEnum.printer, 294 | b'document-format-default', 295 | TagEnum.mime_media_type 296 | ): [b'application/pdf'], 297 | ( 298 | SectionEnum.printer, 299 | b'document-format-supported', 300 | TagEnum.mime_media_type 301 | ): [b'application/pdf'], 302 | ( 303 | SectionEnum.printer, 304 | b'printer-is-accepting-jobs', 305 | TagEnum.boolean 306 | ): [Boolean(True).bytes()], 307 | ( 308 | SectionEnum.printer, 309 | b'queued-job-count', 310 | TagEnum.integer 311 | ): [b'\x00\x00\x00\x00'], 312 | ( 313 | SectionEnum.printer, 314 | b'pdl-override-supported', 315 | TagEnum.keyword 316 | ): [b'not-attempted'], 317 | ( 318 | SectionEnum.printer, 319 | b'printer-up-time', 320 | TagEnum.integer 321 | ): [Integer(self.printer_uptime()).bytes()], 322 | ( 323 | SectionEnum.printer, 324 | b'compression-supported', 325 | TagEnum.keyword 326 | ): [b'none'], 327 | } 328 | attr.update(self.minimal_attributes()) 329 | return attr 330 | 331 | def print_job_attributes(self, job_id, state, state_reasons): 332 | # state reasons come from rfc2911 section 4.3.8 333 | job_uri = b'%sjob/%d' % (self.base_uri, job_id,) 334 | attr = { 335 | # Required for print-job: 336 | ( 337 | SectionEnum.operation, 338 | b'job-uri', 339 | TagEnum.uri 340 | ): [job_uri], 341 | ( 342 | SectionEnum.operation, 343 | b'job-id', 344 | TagEnum.integer 345 | ): [Integer(job_id).bytes()], 346 | ( 347 | SectionEnum.operation, 348 | b'job-state', 349 | TagEnum.enum 350 | ): [Enum(state).bytes()], 351 | ( 352 | SectionEnum.operation, 353 | b'job-state-reasons', 354 | TagEnum.keyword 355 | ): state_reasons, 356 | 357 | # Required for get-job-attributes: 358 | 359 | ( 360 | SectionEnum.operation, 361 | b'job-printer-uri', 362 | TagEnum.uri 363 | ): [self.printer_uri], 364 | ( 365 | SectionEnum.operation, 366 | b'job-name', 367 | TagEnum.name_without_language 368 | ): [b'Print job %s' % Integer(job_id).bytes()], 369 | ( 370 | SectionEnum.operation, 371 | b'job-originating-user-name', 372 | TagEnum.name_without_language 373 | ): [b'job-originating-user-name'], 374 | ( 375 | SectionEnum.operation, 376 | b'time-at-creation', 377 | TagEnum.integer 378 | ): [b'\x00\x00\x00\x00'], 379 | ( 380 | SectionEnum.operation, 381 | b'time-at-processing', 382 | TagEnum.integer 383 | ): [b'\x00\x00\x00\x00'], 384 | ( 385 | SectionEnum.operation, 386 | b'time-at-completed', 387 | TagEnum.integer 388 | ): [b'\x00\x00\x00\x00'], 389 | ( 390 | SectionEnum.operation, 391 | b'job-printer-up-time', 392 | TagEnum.integer 393 | ): [Integer(self.printer_uptime()).bytes()] 394 | } 395 | attr.update(self.minimal_attributes()) 396 | return attr 397 | 398 | def printer_uptime(self): 399 | return int(time.time()) 400 | 401 | def create_job(self, req): 402 | """Return a job id. 403 | 404 | The StatelessPrinter does not care about the id, but perhaps 405 | it can be subclassed into something that keeps track of jobs. 406 | """ 407 | return random.randint(1,9999) 408 | 409 | def handle_postscript(self, ipp_request, postscript_file): 410 | raise NotImplementedError 411 | 412 | 413 | class RejectAllPrinter(StatelessPrinter): 414 | """A printer that rejects all the print jobs it recieves. 415 | 416 | Cups ignores the rejection notice. I suspect this is because the 417 | communication is: 418 | recv http post headers 419 | recv ipp print_job 420 | send http continue headers 421 | recv data 422 | send ipp aborted 423 | 424 | But to be effective, I suspect the errors need to be sent before the 425 | http continue: 426 | recv http post headers 427 | recv ipp print_job 428 | send http headers 429 | send ipp aborted 430 | """ 431 | 432 | def operation_print_job_response(self, req, _psfile): 433 | job_id = self.create_job(req) 434 | attributes = self.print_job_attributes( 435 | job_id, JobStateEnum.aborted, [b'job-canceled-at-device'] 436 | ) 437 | return IppRequest( 438 | self.version, 439 | StatusCodeEnum.server_error_job_canceled, 440 | req.request_id, 441 | attributes) 442 | 443 | def operation_get_job_attributes_response(self, req, _psfile): 444 | job_id = get_job_id(req) 445 | attributes = self.print_job_attributes( 446 | job_id, JobStateEnum.aborted, [b'job-canceled-at-device'] 447 | ) 448 | return IppRequest( 449 | self.version, 450 | StatusCodeEnum.server_error_job_canceled, 451 | req.request_id, 452 | attributes) 453 | 454 | 455 | class SaveFilePrinter(StatelessPrinter): 456 | def __init__(self, directory, filename_ext): 457 | self.directory = directory 458 | self.filename_ext = filename_ext 459 | 460 | ppd = { 461 | 'ps': BasicPostscriptPPD(), 462 | 'pdf': BasicPdfPPD(), 463 | }[filename_ext] 464 | 465 | super(SaveFilePrinter, self).__init__(ppd=ppd) 466 | 467 | def handle_postscript(self, ipp_request, postscript_file): 468 | filename = self.filename(ipp_request) 469 | logging.info('Saving print job as %r', filename) 470 | with open(filename, 'wb') as diskfile: 471 | for block in read_in_blocks(postscript_file): 472 | diskfile.write(block) 473 | self.run_after_saving(filename, ipp_request) 474 | 475 | def run_after_saving(self, filename, ipp_request): 476 | pass 477 | 478 | def filename(self, ipp_request): 479 | leaf = self.leaf_filename(ipp_request) 480 | return os.path.join(self.directory, leaf) 481 | 482 | def leaf_filename(self, _ipp_request): 483 | # Possibly use the job name from the ipp_request? 484 | return 'ipp-server-print-job-%s.%s' % (uuid.uuid1(), self.filename_ext) 485 | 486 | 487 | class SaveAndRunPrinter(SaveFilePrinter): 488 | def __init__(self, directory, use_env, filename_ext, command): 489 | self.command = command 490 | self.use_env = use_env 491 | super(SaveAndRunPrinter, self).__init__( 492 | directory=directory, filename_ext=filename_ext 493 | ) 494 | 495 | def run_after_saving(self, filename, ipp_request): 496 | proc = subprocess.Popen(self.command + [filename], 497 | env=prepare_environment(ipp_request) if self.use_env else None 498 | ) 499 | proc.communicate() 500 | if proc.returncode: 501 | raise RuntimeError( 502 | 'The command %r exited with code %r', 503 | self.command, 504 | proc.returncode 505 | ) 506 | 507 | 508 | class RunCommandPrinter(StatelessPrinter): 509 | def __init__(self, command, use_env, filename_ext): 510 | self.command = command 511 | self.use_env = use_env 512 | 513 | ppd = { 514 | 'ps': BasicPostscriptPPD(), 515 | 'pdf': BasicPdfPPD(), 516 | }[filename_ext] 517 | 518 | super(RunCommandPrinter, self).__init__(ppd=ppd) 519 | 520 | def handle_postscript(self, ipp_request, postscript_file): 521 | logging.info('Running command for job') 522 | proc = subprocess.Popen( 523 | self.command, 524 | env=prepare_environment(ipp_request) if self.use_env else None, 525 | stdin=subprocess.PIPE) 526 | data = b''.join(read_in_blocks(postscript_file)) 527 | proc.communicate(data) 528 | if proc.returncode: 529 | raise RuntimeError( 530 | 'The command %r exited with code %r', 531 | self.command, 532 | proc.returncode 533 | ) 534 | 535 | 536 | class PostageServicePrinter(StatelessPrinter): 537 | def __init__(self, service_api, filename_ext): 538 | self.service_api = service_api 539 | self.filename_ext = filename_ext 540 | 541 | ppd = { 542 | 'ps': BasicPostscriptPPD(), 543 | 'pdf': BasicPdfPPD(), 544 | }[filename_ext] 545 | 546 | super(PostageServicePrinter, self).__init__(ppd=ppd) 547 | 548 | def handle_postscript(self, _ipp_request, postscript_file): 549 | filename = b'ipp-server-{}.{}'.format( 550 | int(time.time()), 551 | self.filename_ext) 552 | data = b''.join(read_in_blocks(postscript_file)) 553 | self.service_api.post_pdf_letter(filename, data) 554 | --------------------------------------------------------------------------------