Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions modules/printhost/cleanup.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
69 changes: 13 additions & 56 deletions modules/printhost/cups.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -65,6 +28,7 @@ in
browsing = false;
stateless = true;
webInterface = true;
defaultShared = true;
listenAddresses = [
"*:631"
"*:443"
Expand All @@ -81,8 +45,6 @@ in

ErrorPolicy retry-job

DefaultShared Yes

<Location />
Order allow,deny
Allow from 169.229.226.0/24
Expand Down Expand Up @@ -139,10 +101,6 @@ in
SystemGroup ocfstaff opstaff
ServerKeychain /etc/cups-certs
'';
drivers = [
ocfCupsBackend
pkgs.ocf-hplip
];
};

hardware.printers =
Expand All @@ -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;
};
}
12 changes: 6 additions & 6 deletions modules/printhost/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,6 @@ let
cfg = config.ocf.printhost;
in
{
imports = [
./cups.nix
./cleanup.nix
];

options.ocf.printhost = {
enable = lib.mkEnableOption "OCF print server";

Expand All @@ -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" ];
Expand Down Expand Up @@ -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"
Expand Down
67 changes: 67 additions & 0 deletions modules/printhost/enforcer.nix
Original file line number Diff line number Diff line change
@@ -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
};
};
};
}
94 changes: 94 additions & 0 deletions modules/printhost/enforcer/enforcer.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading