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
+ '';
};
};
}