diff --git a/modules/printhost/cleanup.nix b/modules/printhost/cleanup.nix index 2663a15d..da8ee6ce 100644 --- a/modules/printhost/cleanup.nix +++ b/modules/printhost/cleanup.nix @@ -53,12 +53,6 @@ let in { config = lib.mkIf cfg.enable { - # ocflib hardcodes /usr/sbin/sendmail; on NixOS postfix provides it at - # /run/wrappers/bin/sendmail via security.wrappers. - systemd.tmpfiles.rules = [ - "L /usr/sbin/sendmail - - - - /run/wrappers/bin/sendmail" - ]; - # Hourly job to NULL out doc_name for jobs older than 14 days (privacy). systemd.services.enforcer-privacy-cleanup = { description = "Remove old print job document titles for privacy"; diff --git a/modules/printhost/cups.nix b/modules/printhost/cups.nix index 742d9dab..0f560b7c 100644 --- a/modules/printhost/cups.nix +++ b/modules/printhost/cups.nix @@ -8,43 +8,6 @@ let cfg = config.ocf.printhost; - - # Python environment for the enforcer quota script - pythonEnv = config.ocf.python.package.withPackages ( - ps: with ps; [ - ocflib - pycups - pymysql - requests - ] - ); - - enforcerScript = ./scripts/enforcer.py; - - # Wrapper that invokes enforcer.py with the right Python environment - enforcerBin = pkgs.writeShellScript "enforcer" '' - exec ${pythonEnv}/bin/python3 ${enforcerScript} "$@" - ''; - - # ocf-cups-backend with enforcer path and password file paths substituted. - # mysqlPasswordFile/wayoutPasswordFile/redisPasswordFile are paths to agenix secrets. - ocfBackendScript = pkgs.replaceVars ./scripts/ocf-cups-backend { - enforcer = enforcerBin; - mysqlPasswordFile = cfg.mysqlPasswordFile; - wayoutPasswordFile = cfg.wayoutPasswordFile; - }; - - # Shell wrapper so the backend runs under the Nix-store python3 rather than - # relying on python3 being in PATH (CUPS backends run in a restricted env). - ocfBackendBin = pkgs.writeShellScript "ocfbackend" '' - exec ${pythonEnv}/bin/python3 ${ocfBackendScript} "$@" - ''; - - # Package exposing the backend at $out/lib/cups/backend/ocfbackend (mode 0700 - # so CUPS runs it as root, which is required for raw socket access to printers) - ocfCupsBackend = pkgs.runCommand "ocf-cups-backend" { } '' - install -Dm0700 ${ocfBackendBin} $out/lib/cups/backend/ocfbackend - ''; in { # use nixos-unstable printers module to include nixpkgs pr #558981 and #524127 @@ -65,6 +28,7 @@ in browsing = false; stateless = true; webInterface = true; + defaultShared = true; listenAddresses = [ "*:631" "*:443" @@ -81,8 +45,6 @@ in ErrorPolicy retry-job - DefaultShared Yes - Order allow,deny Allow from 169.229.226.0/24 @@ -139,10 +101,6 @@ in SystemGroup ocfstaff opstaff ServerKeychain /etc/cups-certs ''; - drivers = [ - ocfCupsBackend - pkgs.ocf-hplip - ]; }; hardware.printers = @@ -157,36 +115,35 @@ in ensurePrinters = (map (printer: { name = printer; - model = "raw"; + model = "everywhere"; description = "HP LaserJet M806"; location = "OCF lab"; - deviceUri = "ocfbackend:socket://${printer}:9100"; - ppdOptions = { - printer-is-shared = "false"; - Duplex = "DuplexNoTumble"; - }; + deviceUri = "ipps://${printer}.ocf.berkeley.edu?waitjob=false&waitprinter=false"; + ppdOptions.printer-is-shared = "false"; # don't share the individual printers in the class }) bwPrinters) ++ [ { - name = "fishpaper"; - model = "raw"; + name = "OCF-Color"; + model = "everywhere"; description = "HP Color LaserJet M856"; location = "OCF lab"; - deviceUri = "ocfbackend:socket://fishpaper:9100"; - ppdOptions = { - printer-is-shared = "true"; - }; + deviceUri = "ipps://fishpaper.ocf.berkeley.edu?waitjob=false&waitprinter=false"; } ]; ensureClasses = { - OCF-BW-Group = { + OCF-BW = { location = "OCF lab"; printers = bwPrinters; }; }; }; + systemd.services.cups.postStart = lib.mkAfter '' + lpadmin -p OCF-BW -o job-hold-until-default=indefinite -o Duplex=DuplexNoTumble + lpadmin -p OCF-Color -o job-hold-until-default=indefinite + ''; + services.avahi.enable = lib.mkForce false; }; } diff --git a/modules/printhost/default.nix b/modules/printhost/default.nix index 08328895..bf2aa061 100644 --- a/modules/printhost/default.nix +++ b/modules/printhost/default.nix @@ -4,11 +4,6 @@ let cfg = config.ocf.printhost; in { - imports = [ - ./cups.nix - ./cleanup.nix - ]; - options.ocf.printhost = { enable = lib.mkEnableOption "OCF print server"; @@ -30,7 +25,7 @@ in }; config = lib.mkIf config.ocf.printhost.enable { - # cups user needs acme group to read /var/lib/acme certs in preStart + # cups user needs acme group to read /var/lib/acme certs users.users."cups".extraGroups = [ "acme" ]; # root needs lp group to run lpadmin in the printer setup service users.users."root".extraGroups = [ "lp" ]; @@ -58,6 +53,11 @@ in }; }; + # ocflib hardcodes /usr/sbin/sendmail + systemd.tmpfiles.rules = [ + "L /usr/sbin/sendmail - - - - /run/wrappers/bin/sendmail" + ]; + # add all CNAMEs to tule's cert ocf.acme.extraCerts = [ "${cfg.subdomain}.ocf.berkeley.edu" diff --git a/modules/printhost/enforcer.nix b/modules/printhost/enforcer.nix new file mode 100644 index 00000000..243b9137 --- /dev/null +++ b/modules/printhost/enforcer.nix @@ -0,0 +1,67 @@ +{ + lib, + config, + pkgs, + ... +}: + +let + cfg = config.ocf.printhost; + + pythonEnv = config.ocf.python.package.withPackages ( + ps: with ps; [ + ocflib + pycups + pymysql + requests + pypdf + ] + ); + + ocfEnforcer = pkgs.stdenv.mkDerivation rec { + pname = "ocf-enforcer"; + version = "1.0.0"; + src = ./enforcer; + + nativeBuildInputs = [ pkgs.makeWrapper ]; + buildInputs = [ pythonEnv ]; + + dontBuild = true; + + postPatch = '' + substituteInPlace utils.py \ + --replace-fail "@mysqlPasswordFile@" "${cfg.mysqlPasswordFile}" \ + --replace-fail "@wayoutPasswordFile@" "${cfg.wayoutPasswordFile}" + ''; + + installPhase = '' + mkdir -p $out/bin + mkdir -p $out/lib/${pname} + + cp * $out/lib/${pname}/ + + makeWrapper ${lib.getExe pythonEnv} $out/bin/${pname} \ + --add-flags "$out/lib/${pname}/main.py" \ + --prefix PYTHONPATH : "$out/lib/${pname}" + ''; + + meta.mainProcess = "${pname}"; + }; +in +{ + config = lib.mkIf cfg.enable { + systemd.services.ocf-enforcer = { + description = "OCF Print Enforcer"; + wantedBy = [ "multi-user.target" ]; + wants = [ "cups.service" ]; + after = [ "cups.service" ]; + serviceConfig = { + ExecStart = "${lib.getExe ocfEnforcer}"; + Restart = "on-failure"; + RestartSec = "5s"; + User = "root"; # TODO have a custom user with perms + #User = "cups"; # user is created in services.printing at services/printing/cupsd.nix + }; + }; + }; +} diff --git a/modules/printhost/enforcer/enforcer.py b/modules/printhost/enforcer/enforcer.py new file mode 100644 index 00000000..76c1470a --- /dev/null +++ b/modules/printhost/enforcer/enforcer.py @@ -0,0 +1,94 @@ +import os +import subprocess +import sys +import threading +import time +from pprint import pprint +from syslog import syslog +from urllib.parse import urlparse + +import cups +import ocflib.printing.quota as quota +from ocflib.printing.printers import COLOR_PRINTERS +from utils import * + +MAX_RETRIES = 3 +DELAY = 10 + + +# needs a retry mechanism as the job data file may not exist yet when the job is created +def enforce_print_init(job_data, job: Job): + attempt = 0 + success = False + + conn = cups.Connection() + with open(MYSQL_PASSWORD_FILE) as f: + mysql_pass = f.read().strip() + with open(WAYOUT_PASSWORD_FILE) as f: + wayout_pass = f.read().strip() + + + while attempt < MAX_RETRIES and not success: + try: + syslog(f"job {job.id}: attempt {attempt + 1}...") + + enforce_print(conn, mysql_pass, wayout_pass, job_data, job) + + syslog(f"job {job.id}: enforcer succeeded") + success = True + + except cups.IPPError as e: + attempt += 1 + syslog(f"job {job.id}: error = {e}. retrying in {DELAY} seconds...") + time.sleep(DELAY) + + if not success: + syslog(f"job {job.id}: max retries reached") + cancel_job(conn, wayout_pass, job, None, ENFORCER_ERROR) + + +def enforce_print(conn, mysql_pass, wayout_pass, job_data, job: Job): + # get file details + document = conn.getDocument(job.printer_uri, job.id, 1) + data_file = document.get("file") + type = document.get("document-format") + assert type == "application/pdf" # must be pdf + file_size = str(os.path.getsize(data_file)) + job = job._replace( + data_file=data_file, + job_size=file_size, + ) + + job = job._replace( + pages=page_count(job), + # page_size=page_size(job), + ) + + with quota.get_connection(user="ocfprinting", password=mysql_pass) as c: + quo = quota.get_quota(c, job.user) + + # check quota before sending to printer + # if job.page_size not in LETTER_SIZES: # TODO fix page size check + # cancel_job(conn, wayout_pass, job, quo, NON_LETTER_ERROR) + if job.pages > quo.daily: + cancel_job(conn, wayout_pass, job, quo, INSUFFICIENT_QUOTA) + elif job.printer_name == "OCF-Color" and job.pages > quo.color: + cancel_job(conn, wayout_pass, job, quo, INSUFFICIENT_COLOR_QUOTA) + + # release job + syslog(f"job {job.id}: released") + conn.setJobHoldUntil(job.id, "no-hold") + save_pending_job_data(job_data, job) + + +def post_print(job_data, job: Job): + with open(MYSQL_PASSWORD_FILE) as f: + mysql_pass = f.read().strip() + with open(WAYOUT_PASSWORD_FILE) as f: + wayout_pass = f.read().strip() + + job = get_pending_job_data(job_data, job) + + with quota.get_connection(user="ocfprinting", password=mysql_pass) as c: + add_db_job(c, job) + notify_user(wayout_pass, job, None, JOB_QUEUED) diff --git a/modules/printhost/enforcer/main.py b/modules/printhost/enforcer/main.py new file mode 100644 index 00000000..67864940 --- /dev/null +++ b/modules/printhost/enforcer/main.py @@ -0,0 +1,192 @@ +import threading +import time +from pathlib import PurePosixPath +from syslog import syslog +from urllib.parse import urlparse + +import cups +from enforcer import * +from utils import * + + +def process_new_jobs(conn, job_data, job_id_list): + # jobs = conn.getJobs( + # which_jobs="not-completed", + # requested_attributes=[ + # "job-id", + # "job-name", + # "job-originating-user-name", + # "job-originating-host-name", + # "print-color-mode", + # "job-printer-uri-actual", + # "job-printer-uri", + # "copies", + # ], + # ) + + for job_id in job_id_list: + try: + job_attributes = conn.getJobAttributes( + job_id, + requested_attributes=[ + "job-id", + "job-name", + "job-uuid", + "job-originating-user-name", + "job-originating-host-name", + "print-color-mode", + "job-printer-uri-actual", + "job-printer-uri", + "copies", + ], + ) + job_name = job_attributes.get("job-name", "Unknown") + job_uuid = job_attributes.get("job_uuid", "Unknown") + user = job_attributes.get("job-originating-user-name", "Unknown") + host = job_attributes.get("job-originating-host-name", "Unknown") + color = job_attributes.get("print-color-mode", "Unknown") + copies = job_attributes.get("copies") + if not copies: + copies = 1 + else: + copies = int(copies) + printer_uri = job_attributes.get("job-printer-uri-actual") + if not printer_uri: + printer_uri = job_attributes.get("job-printer-uri") + printer = PurePosixPath(urlparse(printer_uri).path).name + + syslog( + f"Job #{job_id} | Printer: {printer} | User: {user} | Title: {job_name} | Host: {host} | Color: {color}" + ) + + job = Job( + id=int(job_id), + user=user, + document_title=job_name, + printer_name=printer, + printer_uri=printer_uri, + hostname=host, + time=datetime.now(), + copies=copies, + color=(color == "color"), + uuid=job_uuid, + ) + + t = threading.Thread( + target=enforce_print_init, + args=( + job_data, + job, + ), + name=f"job-{job_id}", + ) + t.start() + except cups.IPPError: + syslog(f"Job #{job_id} | failed to get data") + + +def process_completed_jobs(conn, job_data, job_id_list): + for job_id in job_id_list: + try: + job_attributes = conn.getJobAttributes( + job_id, + requested_attributes=[ + "job-id", + "job-state", + "job-uuid", + ], + ) + + job_state = job_attributes.get("job-state", "Unknown") + job_uuid = job_attributes.get("job_uuid", "Unknown") + + job = Job( + id=int(job_id), + uuid=job_uuid, + ) + + if job_state == cups.IPP_JOB_COMPLETED: + # job completed successfully + t = threading.Thread( + target=post_print, + args=( + job_data, + job, + ), + name=f"job-complete-{job_id}", + ) + t.start() + else: + syslog(f"Job #{job_id} | failed to print") + # job failed + pass + + except cups.IPPError: + syslog(f"Job #{job_id} | failed to get data") + + +def get_initial_jobs(conn): + all_active_jobs = conn.getJobs( + which_jobs="not-completed", requested_attributes=["job-id", "job-state"] + ) + held_jobs = [] + for job_id, job_info in all_active_jobs.items(): + if job_info.get("job-state") == cups.IPP_JOB_HELD: + held_jobs.append(job_id) + return held_jobs + + +def monitor_cups_jobs(conn, job_data): + sub_id = conn.createSubscription( + uri="/", + events=["job-created", "job-completed"], + ) + + sequence_num = 1 + + try: + while True: + data = conn.getNotifications([sub_id], [sequence_num]) + notifications = data.get("events", []) + + new_job_ids = [] + completed_job_ids = [] + max_seq = sequence_num + + for notify in notifications: + job_id = notify.get("notify-job-id") + reason = notify.get("notify-subscribed-event") + + seq = notify.get("notify-sequence-number") + if seq and seq >= max_seq: + max_seq = seq + 1 + + if job_id: + if reason == "job-created": + new_job_ids.append(job_id) + elif reason == "job-completed": + completed_job_ids.append(job_id) + else: + syslog("failed to get job id") + + process_new_jobs(conn, job_data, new_job_ids) + process_completed_jobs(conn, job_data, completed_job_ids) + + sequence_num = max(sequence_num, max_seq) + + time.sleep(1) + + finally: + try: + conn.cancelSubscription(sub_id) + except cups.IPPError: + pass + + +if __name__ == "__main__": + conn = cups.Connection() + + job_data = {} # init shared job data dict + + process_new_jobs(conn, job_data, get_initial_jobs(conn)) + monitor_cups_jobs(conn, job_data) diff --git a/modules/printhost/enforcer/messages.py b/modules/printhost/enforcer/messages.py new file mode 100644 index 00000000..8a32b630 --- /dev/null +++ b/modules/printhost/enforcer/messages.py @@ -0,0 +1,209 @@ +from textwrap import dedent +from typing import NamedTuple + +from ocflib.misc.mail import MAIL_SIGNATURE + + +class Message(NamedTuple): + subject: str + body: str + + +class MessagePair(NamedTuple): + message: Message | None + notification: Message + + +USER_ERROR_INFO = dedent("""\ + Username: {user} + Time: {time} + File: {doc_name} + Total pages: {pages} + Pages left today: {daily_pages} + Pages left this semester: {semester_pages} + Color pages left: {color_pages}\ +""") + +INSUFFICIENT_QUOTA_MESSAGE = Message( + subject="[OCF] Your latest print job was rejected", + body=dedent("""\ + Greetings from the Open Computing Facility, + + This email is letting you know that your most recent print job was + rejected since it would exceed your daily quota. The daily quota is + {daily_quota} pages today and the semesterly quota is {semester_quota} pages. + + """) + + USER_ERROR_INFO + + dedent(""" + + Does something look wrong? Please reply to + + help@ocf.berkeley.edu + + + """) + + MAIL_SIGNATURE, +) + +INSUFFICIENT_COLOR_QUOTA_MESSAGE = Message( + subject="[OCF] Your latest print job was rejected", + body=dedent("""\ + Greetings from the Open Computing Facility, + + This email is letting you know that your most recent print job was + rejected since it would exceed your color printing quota. The color + printing quota is {color_quota} pages and the semesterly quota is + {semester_quota} pages. + + """) + + USER_ERROR_INFO + + dedent(""" + + Does something look wrong? Please reply to + + help@ocf.berkeley.edu + + + """) + + MAIL_SIGNATURE, +) + +PRINTER_ERROR_MESSAGE = Message( + subject="[OCF] Your latest print job failed", + body=dedent("""\ + Greetings from the Open Computing Facility, + + This email is from the OCF to let you know that your most recent print + job failed due to a printer error. If there's something wrong with the + printers, please alert the operations staff at the desk. + + """) + + USER_ERROR_INFO + + dedent(""" + + Still can't get it to print? Please reply to + + help@ocf.berkeley.edu + + + """) + + MAIL_SIGNATURE, +) + +ENFORCER_ERROR_MESSAGE = Message( + subject="[OCF] Your latest print job failed", + body=dedent("""\ + Greetings from the Open Computing Facility, + + This email is from the OCF to let you know that your most recent print + job failed due to a problem with the print accounting system. OCF staff + have been notified of the problem and should fix it shortly. If there + is a staff member in lab, you can ask them for help in the meantime. + + """) + + USER_ERROR_INFO + + dedent(""" + + Still can't get it to print? Please reply to + + help@ocf.berkeley.edu + + + """) + + MAIL_SIGNATURE, +) + +NON_LETTER_ERROR_MESSAGE = Message( + subject="[OCF] Your latest print job failed", + body=dedent("""\ + Greetings from the Open Computing Facility, + + This email is letting you know that your most recent print job was + rejected since it was not letter sized. Please ensure that you are + following all instructions on the computers. + + """) + + USER_ERROR_INFO + + dedent(""" + + Does something look wrong? Please reply to + + help@ocf.berkeley.edu + + """) + + MAIL_SIGNATURE, +) + + +NOTIFY_QUOTA = Message( + subject="Insufficient Quota", + body=dedent("""\ + Your print job failed due to insufficient pages. Your job was + {pages} pages, and you have {quota} pages remaining today.\ +"""), +) + +NOTIFY_COLOR_QUOTA = Message( + subject="Insufficient Color Quota", + body=dedent("""\ + Your print job failed due to insufficient color quota. Your job was + {pages} pages, and you have {quota} color pages remaining today.\ +"""), +) + +NOTIFY_JOB_QUEUED = Message( + subject="Job Queued", + body=dedent("""\ + Your print job '{document}' was accepted and queued on '{printer}'.\ +"""), +) +NOTIFY_JOB_ERROR = Message( + subject="Print Error", + body=dedent("""\ + Your print job '{document}' failed due to a printer error. + Please contact a staff member for assistance.\ +"""), +) + +NOTIFY_ENFORCER_ERROR = Message( + subject="Print Error", + body=dedent("""\ + Your print job '{document}' failed due to a printing system error. + Please contact a staff member for assistance.\ +"""), +) + +NOTIFY_NON_LETTER = Message( + subject="Non Letter Error", + body=dedent("""\ + Your print job '{document}' failed due to not being letter sized.\ +"""), +) + + +# Reasons +INSUFFICIENT_QUOTA = MessagePair( + message=INSUFFICIENT_QUOTA_MESSAGE, + notification=NOTIFY_QUOTA, +) +INSUFFICIENT_COLOR_QUOTA = MessagePair( + message=INSUFFICIENT_COLOR_QUOTA_MESSAGE, + notification=NOTIFY_COLOR_QUOTA, +) +PRINTER_ERROR = MessagePair( + message=PRINTER_ERROR_MESSAGE, + notification=NOTIFY_JOB_ERROR, +) +ENFORCER_ERROR = MessagePair( + message=ENFORCER_ERROR_MESSAGE, + notification=NOTIFY_ENFORCER_ERROR, +) +NON_LETTER_ERROR = MessagePair( + message=NON_LETTER_ERROR_MESSAGE, + notification=NOTIFY_NON_LETTER, +) +JOB_QUEUED = MessagePair( + message=None, + notification=NOTIFY_JOB_QUEUED, +) diff --git a/modules/printhost/enforcer/utils.py b/modules/printhost/enforcer/utils.py new file mode 100644 index 00000000..71ba1a7d --- /dev/null +++ b/modules/printhost/enforcer/utils.py @@ -0,0 +1,186 @@ +import pickle +import re +import sys +from datetime import datetime +from syslog import syslog +from typing import NamedTuple + +import cups +import ocflib.printing.quota as quota +import requests +from messages import * +from ocflib.misc.mail import send_mail_user +from pypdf import PdfReader + +MYSQL_PASSWORD_FILE = "@mysqlPasswordFile@" +WAYOUT_PASSWORD_FILE = "@wayoutPasswordFile@" + +# CUPS backend exit codes +CUPS_BACKEND_OK = 0 +CUPS_BACKEND_FAILED = 1 # retry job +CUPS_BACKEND_CANCEL = 5 # cancel job, don't retry + +WAYOUT_APP_NAME = "Printer" +WAYOUT_PORT = 6767 + +LETTER_SIZES = {"Letter", "279x215mm", "215x279mm", "279x216mm", "216x279mm"} + + +class Job(NamedTuple): + id: int = 0 + user: str = "" + document_title: str = "" + printer_name: str = "" + printer_uri: str = "" + data_file: str = "" + job_size: str = "" + time: datetime = datetime.min + pages: int = 0 + hostname: str = "" + page_size: str = "" + copies: int = 0 + color: bool = False + uuid: str = "" + + +def page_count(job: Job): + """Read the page count and copies from the pdf""" + + pages = len(PdfReader(job.data_file).pages) + copies = job.copies + + total_sides = pages * copies + if total_sides <= 0: + syslog(f"failed to get document sides (pages={pages}, copies={copies})") + sys.exit(CUPS_BACKEND_CANCEL) + return total_sides + + +def page_size(job: Job): # TODO rewrite + """Read the page size from PostScript %%PageMedia: or %%DocumentMedia: comments.""" + + path = job.data_file + with open(path, "rb") as f: + header_chunk = f.read(4096) + if b"%!" not in header_chunk: + return None + with open(path, "r", errors="ignore") as f: + lines = f.readlines() + candidates = lines[:20] + lines[-20:] + for line in candidates: + if line.startswith("%%PageMedia:"): + return line.split()[1] if len(line.split()) > 1 else None + if line.startswith("%%DocumentMedia:"): + return line.split()[1] if len(line.split()) > 1 else None + return None + + +def send_printer_mail(message: Message, job: Job, quo: quota.UserQuota | None): + body = message.body.format( + user=job.user, + time=job.time, + doc_name=job.document_title, + pages=job.pages, + daily_quota=quota.daily_quota(), + semester_quota=quota.SEMESTERLY_QUOTA, + color_quota=quota.COLOR_QUOTA, + ) + + if quo: + body = body.format( + daily_pages=quo.daily, + semester_pages=quo.semesterly, + color_pages=quo.color, + ) + + send_mail_user(job.user, message.subject, body) + + +def get_hostname(job: Job): + conn = cups.Connection() + job_attrs = conn.getJobAttributes( + job.id, requested_attributes=["job-originating-host-name"] + ) + return job_attrs.get("job-originating-host-name") + + +def send_notification( + wayout_pass, job: Job, quo: quota.UserQuota | None, message: Message +): + try: + if job.hostname == "": + syslog(f"ERROR: no hostname found for job id: {job.id}") + return + url = "http://" + job.hostname + ":" + str(WAYOUT_PORT) + "/notify" + data = { + "summary": message.subject, + "body": message.body, + "app_name": WAYOUT_APP_NAME, + } + headers = {"Authorization": wayout_pass, "Content-Type": "application/json"} + _ = requests.post(url=url, json=data, headers=headers, timeout=5) + except Exception as e: + syslog("Exception: " + str(e)) + + +def notify_user( + wayout_pass, job: Job, quo: quota.UserQuota | None, reason: MessagePair +): + if reason.message: + send_printer_mail(format_message(job, quo, reason.message), job, quo) + send_notification( + wayout_pass, job, quo, format_message(job, quo, reason.notification) + ) + + +def cancel_job( + conn, wayout_pass, job: Job, quo: quota.UserQuota | None, reason: MessagePair +): + notify_user(wayout_pass, job, quo, reason) + conn.cancelJob(job.id) + sys.exit() + + +def format_message(job: Job, quo: quota.UserQuota | None, message: Message): + body = message.body.format( + user=job.user, + time=job.time, + doc_name=job.document_title, + document=job.document_title, + printer=job.printer_name, + pages=job.pages, + daily_quota=quota.daily_quota(), + semester_quota=quota.SEMESTERLY_QUOTA, + color_quota=quota.COLOR_QUOTA, + ) + if quo: + body = body.format( + daily_pages=quo.daily, + semester_pages=quo.semesterly, + color_pages=quo.color, + ) + + return Message(subject=message.subject, body=body) + + +def add_db_job(c, job: Job): + quota.add_job( + c, + quota.Job( + user=job.user, + time=job.time, + pages=job.pages, + queue=job.printer_name, + printer=job.printer_name, + doc_name=job.document_title, + filesize=job.job_size, + ), + ) + + +def save_pending_job_data(job_data, job): + job_data[job.uuid] = job + + +def get_pending_job_data(job_data, job): + return job_data.get(job.uuid) diff --git a/modules/printhost/scripts/enforcer.py b/modules/printhost/scripts/enforcer.py deleted file mode 100644 index 09ba2851..00000000 --- a/modules/printhost/scripts/enforcer.py +++ /dev/null @@ -1,422 +0,0 @@ -#!/usr/bin/env python3 -"""Enforcer is a prehook and posthook for the ocf-cups-backend whose primary -purpose is to add and subtract from page quotas as user jobs are processed. - -When a user sends a job to the print server, the backend calls enforcer before -sending it to the printer. Enforcer reads the job-impressions CUPS attribute to -count pages and connects to mysql to check against the user's quota. If enforcer -returns non-zero, the job is rejected. Otherwise, enforcer gets called again when -the job is done and logs the job in mysql. - -Another function of enforcer is to send notifications to desktops to let users -know when a job has been sent to the printer, been rejected due to the quota, -finished printing, or failed to print. -""" -import argparse -import os -import sys -import re -import subprocess -from pathlib import Path -from collections import namedtuple -from datetime import datetime -from syslog import syslog -from textwrap import dedent -from traceback import format_exc - -import ocflib.printing.quota as quota -import requests -from ocflib.misc.mail import MAIL_SIGNATURE -from ocflib.misc.mail import send_mail_user -from ocflib.misc.mail import send_problem_report -import cups - - -COLOR_QUEUES = {'fishpaper'} - -APP_NAME = 'Printer' -PORT = 6767 - -Message = namedtuple('Message', ['subject', 'body']) - - -USER_ERROR_INFO = dedent("""\ - Username: {user} - Time: {time} - File: {doc_name} - Total pages: {pages} - Pages left today: {daily_pages} - Pages left this semester: {semester_pages} - Color pages left: {color_pages}\ -""") - -INSUFFICIENT_QUOTA_MESSAGE = Message( - subject='[OCF] Your latest print job was rejected', - body=dedent("""\ - Greetings from the Open Computing Facility, - - This email is letting you know that your most recent print job was - rejected since it would exceed your daily quota. The daily quota is - {daily_quota} pages today and the semesterly quota is {semester_quota} pages. - - """) + USER_ERROR_INFO + dedent(""" - - Does something look wrong? Please reply to - - help@ocf.berkeley.edu - - - """) + MAIL_SIGNATURE -) - -INSUFFICIENT_COLOR_QUOTA_MESSAGE = Message( - subject='[OCF] Your latest print job was rejected', - body=dedent("""\ - Greetings from the Open Computing Facility, - - This email is letting you know that your most recent print job was - rejected since it would exceed your color printing quota. The color - printing quota is {color_quota} pages and the semesterly quota is - {semester_quota} pages. - - """) + USER_ERROR_INFO + dedent(""" - - Does something look wrong? Please reply to - - help@ocf.berkeley.edu - - - """) + MAIL_SIGNATURE -) - -PRINTER_ERROR_MESSAGE = Message( - subject='[OCF] Your latest print job failed', - body=dedent("""\ - Greetings from the Open Computing Facility, - - This email is from the OCF to let you know that your most recent print - job failed due to a printer error. If there's something wrong with the - printers, please alert the operations staff at the desk. - - """) + USER_ERROR_INFO + dedent(""" - - Still can't get it to print? Please reply to - - help@ocf.berkeley.edu - - - """) + MAIL_SIGNATURE -) - -ENFORCER_ERROR_MESSAGE = Message( - subject='[OCF] Your latest print job failed', - body=dedent("""\ - Greetings from the Open Computing Facility, - - This email is from the OCF to let you know that your most recent print - job failed due to a problem with the print accounting system. OCF staff - have been notified of the problem and should fix it shortly. If there - is a staff member in lab, you can ask them for help in the meantime. - - """) + USER_ERROR_INFO + dedent(""" - - Still can't get it to print? Please reply to - - help@ocf.berkeley.edu - - - """) + MAIL_SIGNATURE -) - - -NOTIFY_QUOTA_MESSAGE = dedent("""\ - Your print job failed due to insufficient pages. Your job was - {pages} pages, and you have {quota} pages remaining today.\ -""") - -NOTIFY_COLOR_QUOTA_MESSAGE = dedent("""\ - Your print job failed due to insufficient color quota. Your job was - {pages} pages, and you have {quota} color pages remaining today.\ -""") - -NOTIFY_JOB_QUEUED = dedent("""\ - Your print job '{document}' was accepted and queued on '{printer}'.\ -""") - -NOTIFY_JOB_ERROR = dedent("""\ - Your print job '{document}' failed due to a printer error. - Please contact a staff member for assistance.\ -""") - -NON_LETTER_ERROR_MESSAGE = Message( - subject='[OCF] Your latest print job failed', - body=dedent("""\ - Greetings from the Open Computing Facility, - - This email is letting you know that your most recent print job was - rejected since it was not letter sized. Please ensure that you are - following all instructions on the computers. - - """) + USER_ERROR_INFO + dedent(""" - - Does something look wrong? Please reply to - - help@ocf.berkeley.edu - - """) + MAIL_SIGNATURE -) - -NOTIFY_NON_LETTER = dedent("""\ - Your print job '{document}' failed due to not being letter sized.\ -""") - - -def read_config(): - with open(os.environ['ENFORCER_MYSQL_PASSWORD']) as f: - mysql_passwd = f.read().strip() - with open(os.environ['ENFORCER_WAYOUT_PASSWORD']) as f: - wayout_passwd = f.read().strip() - return 'ocfprinting', mysql_passwd, wayout_passwd - - -def page_count(env): - filepath = env.get('TEADATAFILE') - job_id = env.get('TEAJOBID') - pages = 0 - copies = 1 - - # 1. Grab copies directly from Tea4CUPS - tea_copies = env.get('TEACOPIES') - if tea_copies: - try: - copies = int(tea_copies) - except ValueError: - pass - - try: - with open(filepath, 'rb') as f: - # ========================================== - # POSTSCRIPT PARSING LOGIC - # ========================================== - for line in f: - if b'%%Pages:' in line or b'%RBINumCopies:' in line: - line_str = line.decode('utf-8', errors='ignore').strip() - - pages_match = re.search(r'^%%Pages:\s+(\d+)', line_str) - if pages_match: - try: - pages = int(pages_match.group(1)) - except ValueError: - syslog(f"non-integer output when processing PS pages: {pages_match}") - pass - - # always use PostScript copies - copies_match = re.search(r'^%RBINumCopies:\s+(\d+)', line_str) - if copies_match: - try: - copies = int(copies_match.group(1)) - except ValueError: - syslog(f"non-integer output when processing PS copies: {copies_match}") - pass - except Exception as e: - syslog(f"Page count error: {e}") - pass - - total_sides = pages * copies - if total_sides <= 0: - syslog(f"failed to get document sides (pages={pages}, copies={copies})") - raise ValueError(f"failed to get document sides (pages={pages}, copies={copies})") - return total_sides - - -LETTER_SIZES = {'Letter', '279x215mm', '215x279mm', '279x216mm', '216x279mm'} - - -def page_size(env): - """Read the page size from PostScript %%PageMedia: or %%DocumentMedia: comments. - - Mirrors the old enforcer-size bash script: check the first and last 20 lines - of the PostScript file for these DSC comments. Returns None for non-PostScript - files (the check is skipped in that case). - """ - path = env['TEADATAFILE'] - with open(path, 'rb') as f: - header_chunk = f.read(4096) - if b'%!' not in header_chunk: - return None - with open(path, 'r', errors='ignore') as f: - lines = f.readlines() - candidates = lines[:20] + lines[-20:] - for line in candidates: - if line.startswith('%%PageMedia:'): - return line.split()[1] if len(line.split()) > 1 else None - if line.startswith('%%DocumentMedia:'): - return line.split()[1] if len(line.split()) > 1 else None - return None - - -def create_job(env): - printer = env['TEAPRINTERNAME'] - queue = env['CLASS'] - return quota.Job( - user=env['TEAUSERNAME'], - time=datetime.now(), - pages=page_count(env), - queue=queue, - printer=printer, - doc_name=env['TEATITLE'], - filesize=env['TEAJOBSIZE'], - ) - - -def send_printer_mail(message, job, quo): - body = message.body.format( - user=job.user, - time=job.time, - doc_name=job.doc_name, - pages=job.pages, - daily_pages=quo.daily, - semester_pages=quo.semesterly, - color_pages=quo.color, - daily_quota=quota.daily_quota(), - semester_quota=quota.SEMESTERLY_QUOTA, - color_quota=quota.COLOR_QUOTA, - ) - send_mail_user(job.user, message.subject, body) - - -def send_notification(wayout_pass, summary, body): - try: - job_id = int(os.environ['TEAJOBID']) - conn = cups.Connection() - job_attrs = conn.getJobAttributes(job_id, requested_attributes=['job-originating-host-name']) - hostname = job_attrs.get('job-originating-host-name') - - if not hostname: - syslog(f'ERROR: no hostname found for job id: {job_id}') - return - url = 'http://' + hostname + ':' + str(PORT) + '/notify' - data = { - 'summary': summary, - 'body': body, - 'app_name': APP_NAME, - } - headers = { - 'Authorization': wayout_pass, - 'Content-Type': 'application/json' - } - requests.post(url=url, json=data, headers=headers, timeout=5) - except Exception as e: - syslog('Exception: ' + str(e)) - - -def prehook(c, job, wayout_pass): - quo = quota.get_quota(c, job.user) - - size = page_size(os.environ) - if size is not None and size not in LETTER_SIZES: - send_printer_mail(NON_LETTER_ERROR_MESSAGE, job, quo) - msg = NOTIFY_NON_LETTER.format(document=job.doc_name) - send_notification(wayout_pass, 'Non Letter Error', msg) - sys.exit(255) - - if job.pages > quo.daily: - send_printer_mail(INSUFFICIENT_QUOTA_MESSAGE, job, quo) - msg = NOTIFY_QUOTA_MESSAGE.format( - pages=job.pages, - quota=quo.daily, - ) - send_notification(wayout_pass, 'Insufficient Quota', msg) - sys.exit(255) - elif job.queue in COLOR_QUEUES and job.pages > quo.color: - send_printer_mail(INSUFFICIENT_COLOR_QUOTA_MESSAGE, job, quo) - msg = NOTIFY_COLOR_QUOTA_MESSAGE.format( - pages=job.pages, - quota=quo.color, - ) - send_notification(wayout_pass, 'Insufficient Color Quota', msg) - sys.exit(255) - - -def posthook(c, job, success, wayout_pass): - msg = '' - if success: - quota.add_job(c, job) - msg = NOTIFY_JOB_QUEUED.format( - document=job.doc_name, - printer=job.printer - ) - send_notification(wayout_pass, 'Job Queued', msg) - else: - quo = quota.get_quota(c, job.user) - msg = NOTIFY_JOB_ERROR.format(document=job.doc_name) - send_printer_mail(PRINTER_ERROR_MESSAGE, job, quo) - - err_msg = dedent("""\ - enforcer encountered a printer error while processing a job - - backend environment variables: - {vars} - """).format( - vars='\n'.join(' {}: {}'.format(k, v) for k, v in - os.environ.items() if k.startswith('TEA')) - ) - - syslog(err_msg) - send_problem_report(err_msg) - send_notification(wayout_pass, 'Printer Error', msg) - - -def main(argv): - job, quo = None, None - try: - parser = argparse.ArgumentParser( - description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - parser.add_argument('command', - choices={'prehook', 'posthook'}) - args = parser.parse_args(argv[1:]) - job = create_job(os.environ) - mysql_user, mysql_pass, wayout_pass = read_config() - with quota.get_connection(user=mysql_user, password=mysql_pass) as c: - if args.command == 'prehook': - prehook(c, job, wayout_pass) - else: - success = os.environ['TEASTATUS'] == '0' - posthook(c, job, success, wayout_pass) - except SystemExit as e: - sys.exit(e.code) - except Exception: - msg = dedent("""\ - enforcer encountered the following error while processing a job: - - {traceback} - - - backend environment variables: - {vars} - """).format( - traceback=format_exc(), - vars='\n'.join(' {}: {}'.format(k, v) for k, v in - os.environ.items() if k.startswith('TEA')) - ) - - syslog(msg) - send_problem_report(msg) - if job and args.command == 'prehook': - try: - send_printer_mail( - ENFORCER_ERROR_MESSAGE, - job, - quo or quota.UserQuota(user=job.user, daily='Unknown', - semesterly='Unknown', color='Unknown') - ) - except Exception: - pass - # Don't retry; it's not going to print the second time. - sys.exit(255) - - -if __name__ == '__main__': - main(sys.argv) diff --git a/modules/printhost/scripts/ocf-cups-backend b/modules/printhost/scripts/ocf-cups-backend deleted file mode 100644 index 109b22db..00000000 --- a/modules/printhost/scripts/ocf-cups-backend +++ /dev/null @@ -1,144 +0,0 @@ -#!/usr/bin/env python3 -""" -OCF CUPS backend wrapper replacing tea4cups. - -Intercepts print jobs to run quota enforcement (prehook) before sending to -the printer and accounting (posthook) afterward. - -Device URI format: ocfbackend: - e.g. ocfbackend:socket://169.229.226.92:9100/ (HP printers) - ocfbackend:ipp://169.229.226.96/ipp/print (Epson) - -The real backend URI scheme determines which CUPS backend binary is invoked -(socket, ipp, etc.) from $CUPS_SERVERBIN/backend/. - -Paths substituted by Nix at build time: - @enforcer@ path to the enforcer binary - @mysqlPasswordFile@ path to MySQL password file - @wayoutPasswordFile@ path to wayout password file -""" -import os -import sys -import subprocess -import tempfile -from urllib.parse import urlparse - -ENFORCER = '@enforcer@' -MYSQL_PASSWORD_FILE = '@mysqlPasswordFile@' -WAYOUT_PASSWORD_FILE = '@wayoutPasswordFile@' - -# CUPS backend exit codes -CUPS_BACKEND_OK = 0 -CUPS_BACKEND_FAILED = 1 # retry job -CUPS_BACKEND_CANCEL = 5 # cancel job, don't retry - - -def main(): - # Called with no args: CUPS is probing for available devices. - # We don't enumerate devices; just exit successfully. - if len(sys.argv) == 1: - sys.exit(CUPS_BACKEND_OK) - - if len(sys.argv) not in (6, 7): - print( - f'Usage: {sys.argv[0]} job-id user title copies options [file]', - file=sys.stderr, - ) - sys.exit(CUPS_BACKEND_FAILED) - - job_id, user, title, copies, options = sys.argv[1:6] - data_file = sys.argv[6] if len(sys.argv) == 7 else None - - device_uri = os.environ.get('DEVICE_URI', '') - printer_name = os.environ.get('PRINTER', '') - class_name = os.environ.get('CLASS', printer_name) - cups_serverbin = os.environ.get('CUPS_SERVERBIN', '/usr/lib/cups') - - if not device_uri.startswith('ocfbackend:'): - print( - f'ocf-cups-backend: unexpected DEVICE_URI: {device_uri!r}', - file=sys.stderr, - ) - sys.exit(CUPS_BACKEND_FAILED) - - real_uri = device_uri[len('ocfbackend:'):] - scheme = urlparse(real_uri).scheme - # ipps uses the same backend binary as ipp - backend_scheme = 'ipp' if scheme == 'ipps' else scheme - backend_bin = os.path.join(cups_serverbin, 'backend', backend_scheme) - - if not os.path.exists(backend_bin): - print( - f'ocf-cups-backend: backend not found: {backend_bin}', - file=sys.stderr, - ) - sys.exit(CUPS_BACKEND_FAILED) - - # If job data comes from stdin, buffer it to a temp file so enforcer-pc - # and enforcer-size can read it. With the ocfps filter in the pipeline, - # CUPS almost always provides a file path, but handle stdin defensively. - tmp = None - if data_file is None: - tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.ps') - try: - tmp.write(sys.stdin.buffer.read()) - tmp.flush() - data_file = tmp.name - finally: - tmp.close() - - try: - try: - file_size = str(os.path.getsize(data_file)) - except OSError: - file_size = '0' - - tea_env = dict(os.environ) - tea_env.update({ - 'TEAJOBID': job_id, - 'TEAUSERNAME': user, - 'TEACOPIES': copies, - 'TEATITLE': title, - 'TEAOPTIONS': options, - 'TEAPRINTERNAME': printer_name, - 'TEADATAFILE': data_file, - 'TEAJOBSIZE': file_size, - 'CLASS': class_name, - 'ENFORCER_MYSQL_PASSWORD': MYSQL_PASSWORD_FILE, - 'ENFORCER_WAYOUT_PASSWORD': WAYOUT_PASSWORD_FILE, - }) - - # Prehook: check quota before sending to printer. - # enforcer exits 255 to reject; any non-zero cancels the job. - prehook = subprocess.run([ENFORCER, 'prehook'], env=tea_env) - if prehook.returncode != 0: - sys.exit(CUPS_BACKEND_CANCEL) - - # Forward job to the real CUPS backend. - backend_env = dict(os.environ) - backend_env['DEVICE_URI'] = real_uri - - # passing copies=1 to the backend, otherwise the backend - # would re-apply copies, resulting in desiredCopies^2 - # this is only an issue on the epson because the ocf-cups-backend - # forwards directly to it rather than to a class and raw queue first - cmd = [backend_bin, job_id, user, title, "1", options, data_file] - result = subprocess.run(cmd, env=backend_env) - - tea_env['TEASTATUS'] = str(result.returncode) - - # Posthook: log completed job to database. - subprocess.run([ENFORCER, 'posthook'], env=tea_env) - - sys.exit(result.returncode) - - finally: - if tmp is not None: - try: - os.unlink(tmp.name) - except OSError: - pass - - -if __name__ == '__main__': - main() diff --git a/modules/printing.nix b/modules/printing.nix index 21e399da..7c8894ab 100644 --- a/modules/printing.nix +++ b/modules/printing.nix @@ -1,6 +1,5 @@ { lib, - pkgs, config, ... }: @@ -14,42 +13,17 @@ in printhostURL = lib.mkOption { type = lib.types.str; - default = "printhost.ocf.berkeley.edu"; + default = "printhost-dev.ocf.berkeley.edu"; }; }; config = lib.mkIf cfg.enable { - services.avahi.enable = lib.mkForce false; # prevent printer discovery by cups client - services.printing = { - enable = true; - startWhenNeeded = false; # print dialog does not wait for cups to start, will show no printers first time if socket-activated - browsed.enable = false; - browsing = false; - stateless = true; - drivers = [ pkgs.ocf-hplip ]; - }; - hardware.printers = { - ensureDefaultPrinter = "OCF-BW"; - ensurePrinters = [ - { - deviceUri = "ipps://${cfg.printhostURL}/classes/OCF-BW-Group?waitjob=false&waitprinter=false"; - name = "OCF-BW"; - model = "HP/hp-laserjet_m806-ps.ppd.gz"; - location = "OCF lab"; - ppdOptions = { - Duplex = "DuplexNoTumble"; - }; - } - { - deviceUri = "ipps://${cfg.printhostURL}/classes/fishpaper?waitjob=false&waitprinter=false"; - name = "OCF-Color"; - model = "HP/hp-color_laserjet_m856-ps.ppd.gz"; - location = "OCF lab"; - ppdOptions = { - Duplex = "None"; - }; - } - ]; + environment.etc = { + "cups/lpoptions".text = "Default OCF-BW"; + "cups/client.conf".text = '' + ServerName ${cfg.printhostURL} + Encryption IfRequested + ''; }; }; }