nixos/printers: declarative configuration

This commit is contained in:
Florian Jacob 2019-08-05 20:24:18 +02:00
parent d4283f47dc
commit 18a5d23b55
4 changed files with 242 additions and 81 deletions

View File

@ -77,7 +77,17 @@
<literal>./programs/dwm-status.nix</literal>
</para>
</listitem>
<listitem>
<para>
The new <varname>hardware.printers</varname> module allows to declaratively configure CUPS printers
via the <varname>ensurePrinters</varname> and
<varname>ensureDefaultPrinter</varname> options.
<varname>ensurePrinters</varname> will never delete existing printers,
but will make sure that the given printers are configured as declared.
</para>
</listitem>
</itemizedlist>
</section>
<section xmlns="http://docbook.org/ns/docbook"

View File

@ -0,0 +1,135 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.hardware.printers;
ppdOptionsString = options: optionalString (options != {})
(concatStringsSep " "
(mapAttrsToList (name: value: "-o '${name}'='${value}'") options)
);
ensurePrinter = p: ''
${pkgs.cups}/bin/lpadmin -p '${p.name}' -E \
${optionalString (p.location != null) "-L '${p.location}'"} \
${optionalString (p.description != null) "-D '${p.description}'"} \
-v '${p.deviceUri}' \
-m '${p.model}' \
${ppdOptionsString p.ppdOptions}
'';
ensureDefaultPrinter = name: ''
${pkgs.cups}/bin/lpoptions -d '${name}'
'';
# "graph but not # or /" can't be implemented as regex alone due to missing lookahead support
noInvalidChars = str: all (c: c != "#" && c != "/") (stringToCharacters str);
printerName = (types.addCheck (types.strMatching "[[:graph:]]+") noInvalidChars)
// { description = "printable string without spaces, # and /"; };
in {
options = {
hardware.printers = {
ensureDefaultPrinter = mkOption {
type = types.nullOr printerName;
default = null;
description = ''
Ensures the named printer is the default CUPS printer / printer queue.
'';
};
ensurePrinters = mkOption {
description = ''
Will regularly ensure that the given CUPS printers are configured as declared here.
If a printer's options are manually changed afterwards, they will be overwritten eventually.
This option will never delete any printer, even if removed from this list.
You can check existing printers with <command>lpstat -s</command>
and remove printers with <command>lpadmin -x &lt;printer-name&gt;</command>.
Printers not listed here can still be manually configured.
'';
default = [];
type = types.listOf (types.submodule {
options = {
name = mkOption {
type = printerName;
example = "BrotherHL_Workroom";
description = ''
Name of the printer / printer queue.
May contain any printable characters except "/", "#", and space.
'';
};
location = mkOption {
type = types.nullOr types.str;
default = null;
example = "Workroom";
description = ''
Optional human-readable location.
'';
};
description = mkOption {
type = types.nullOr types.str;
default = null;
example = "Brother HL-5140";
description = ''
Optional human-readable description.
'';
};
deviceUri = mkOption {
type = types.str;
example = [
"ipp://printserver.local/printers/BrotherHL_Workroom"
"usb://HP/DESKJET%20940C?serial=CN16E6C364BH"
];
description = ''
How to reach the printer.
<command>lpinfo -v</command> shows a list of supported device URIs and schemes.
'';
};
model = mkOption {
type = types.str;
example = literalExample ''
gutenprint.''${lib.version.majorMinor (lib.getVersion pkgs.cups)}://brother-hl-5140/expert
'';
description = ''
Location of the ppd driver file for the printer.
<command>lpinfo -m</command> shows a list of supported models.
'';
};
ppdOptions = mkOption {
type = types.attrsOf types.str;
example = {
"PageSize" = "A4";
"Duplex" = "DuplexNoTumble";
};
default = {};
description = ''
Sets PPD options for the printer.
<command>lpoptions [-p printername] -l</command> shows suported PPD options for the given printer.
'';
};
};
});
};
};
};
config = mkIf (cfg.ensurePrinters != [] && config.services.printing.enable) {
systemd.services."ensure-printers" = let
cupsUnit = if config.services.printing.startWhenNeeded then "cups.socket" else "cups.service";
in {
description = "Ensure NixOS-configured CUPS printers";
wantedBy = [ "multi-user.target" ];
requires = [ cupsUnit ];
# in contrast to cups.socket, for cups.service, this is actually not enough,
# as the cups service reports its activation before clients can actually interact with it.
# Because of this, commands like `lpinfo -v` will report a bad file descriptor
# due to the missing UNIX socket without sufficient sleep time.
after = [ cupsUnit ];
serviceConfig = {
Type = "oneshot";
};
# sleep 10 is required to wait until cups.service is actually initialized and has created its UNIX socket file
script = (optionalString (!config.services.printing.startWhenNeeded) "sleep 10\n")
+ (concatMapStringsSep "\n" ensurePrinter cfg.ensurePrinters)
+ optionalString (cfg.ensureDefaultPrinter != null) (ensureDefaultPrinter cfg.ensureDefaultPrinter);
};
};
}

View File

@ -59,6 +59,7 @@
./hardware/nitrokey.nix
./hardware/opengl.nix
./hardware/pcmcia.nix
./hardware/printers.nix
./hardware/raid/hpsa.nix
./hardware/steam-hardware.nix
./hardware/usb-wwan.nix

View File

@ -1,99 +1,114 @@
# Test printing via CUPS.
import ./make-test.nix ({pkgs, ... }: {
import ./make-test.nix ({pkgs, ... }:
let
printingServer = startWhenNeeded: {
services.printing.enable = true;
services.printing.startWhenNeeded = startWhenNeeded;
services.printing.listenAddresses = [ "*:631" ];
services.printing.defaultShared = true;
services.printing.extraConf =
''
<Location />
Order allow,deny
Allow from all
</Location>
'';
networking.firewall.allowedTCPPorts = [ 631 ];
# Add a HP Deskjet printer connected via USB to the server.
hardware.printers.ensurePrinters = [{
name = "DeskjetLocal";
deviceUri = "usb://foobar/printers/foobar";
model = "drv:///sample.drv/deskjet.ppd";
}];
};
printingClient = startWhenNeeded: {
services.printing.enable = true;
services.printing.startWhenNeeded = startWhenNeeded;
# Add printer to the client as well, via IPP.
hardware.printers.ensurePrinters = [{
name = "DeskjetRemote";
deviceUri = "ipp://${if startWhenNeeded then "socketActivatedServer" else "serviceServer"}/printers/DeskjetLocal";
model = "drv:///sample.drv/deskjet.ppd";
}];
hardware.printers.ensureDefaultPrinter = "DeskjetRemote";
};
in
{
name = "printing";
meta = with pkgs.stdenv.lib.maintainers; {
maintainers = [ domenkozar eelco matthewbauer ];
};
nodes = {
socketActivatedServer = { ... }: (printingServer true);
serviceServer = { ... }: (printingServer false);
server =
{ ... }:
{ services.printing.enable = true;
services.printing.listenAddresses = [ "*:631" ];
services.printing.defaultShared = true;
services.printing.extraConf =
''
<Location />
Order allow,deny
Allow from all
</Location>
'';
networking.firewall.allowedTCPPorts = [ 631 ];
};
client =
{ ... }:
{ services.printing.enable = true;
};
socketActivatedClient = { ... }: (printingClient true);
serviceClient = { ... }: (printingClient false);
};
testScript =
''
startAll;
$client->succeed("lpstat -r") =~ /scheduler is running/ or die;
# check local encrypted connections work without error
$client->succeed("lpstat -E -r") =~ /scheduler is running/ or die;
# Test that UNIX socket is used for connections.
$client->succeed("lpstat -H") =~ "/run/cups/cups.sock" or die;
# Test that HTTP server is available too.
$client->succeed("curl --fail http://localhost:631/");
$client->succeed("curl --fail http://server:631/");
$server->fail("curl --fail --connect-timeout 2 http://client:631/");
# Add a HP Deskjet printer connected via USB to the server.
$server->succeed("lpadmin -p DeskjetLocal -E -v usb://foobar/printers/foobar");
# Add it to the client as well via IPP.
$client->succeed("lpadmin -p DeskjetRemote -E -v ipp://server/printers/DeskjetLocal");
$client->succeed("lpadmin -d DeskjetRemote");
# Do some status checks.
$client->succeed("lpstat -a") =~ /DeskjetRemote accepting requests/ or die;
$client->succeed("lpstat -h server:631 -a") =~ /DeskjetLocal accepting requests/ or die;
$client->succeed("cupsdisable DeskjetRemote");
$client->succeed("lpq") =~ /DeskjetRemote is not ready.*no entries/s or die;
$client->succeed("cupsenable DeskjetRemote");
$client->succeed("lpq") =~ /DeskjetRemote is ready.*no entries/s or die;
# Test printing various file types.
foreach my $file ("${pkgs.groff.doc}/share/doc/*/examples/mom/penguin.pdf",
"${pkgs.groff.doc}/share/doc/*/meref.ps",
"${pkgs.cups.out}/share/doc/cups/images/cups.png",
"${pkgs.pcre.doc}/share/doc/pcre/pcre.txt")
{
$file =~ /([^\/]*)$/; my $fn = $1;
subtest "print $fn", sub {
# Print the file on the client.
$client->succeed("lp $file");
$client->sleep(10);
$client->succeed("lpq") =~ /active.*root.*$fn/ or die;
# Ensure that a raw PCL file appeared in the server's queue
# (showing that the right filters have been applied). Of
# course, since there is no actual USB printer attached, the
# file will stay in the queue forever.
$server->waitForFile("/var/spool/cups/d*-001");
$server->sleep(10);
$server->succeed("lpq -a") =~ /$fn/ or die;
# Delete the job on the client. It should disappear on the
# server as well.
$client->succeed("lprm");
$client->sleep(10);
$client->succeed("lpq -a") =~ /no entries/;
Machine::retry sub {
return 1 if $server->succeed("lpq -a") =~ /no entries/;
# Make sure that cups is up on both sides.
$serviceServer->waitForUnit("cups.service");
$serviceClient->waitForUnit("cups.service");
# wait until cups is fully initialized and ensure-printers has executed with 10s delay
$serviceClient->sleep(20);
$socketActivatedClient->waitUntilSucceeds("systemctl status ensure-printers | grep -q -E 'code=exited, status=0/SUCCESS'");
sub testPrinting {
my ($client, $server) = (@_);
my $clientHostname = $client->name();
my $serverHostname = $server->name();
$client->succeed("lpstat -r") =~ /scheduler is running/ or die;
# Test that UNIX socket is used for connections.
$client->succeed("lpstat -H") =~ "/var/run/cups/cups.sock" or die;
# Test that HTTP server is available too.
$client->succeed("curl --fail http://localhost:631/");
$client->succeed("curl --fail http://$serverHostname:631/");
$server->fail("curl --fail --connect-timeout 2 http://$clientHostname:631/");
# Do some status checks.
$client->succeed("lpstat -a") =~ /DeskjetRemote accepting requests/ or die;
$client->succeed("lpstat -h $serverHostname:631 -a") =~ /DeskjetLocal accepting requests/ or die;
$client->succeed("cupsdisable DeskjetRemote");
$client->succeed("lpq") =~ /DeskjetRemote is not ready.*no entries/s or die;
$client->succeed("cupsenable DeskjetRemote");
$client->succeed("lpq") =~ /DeskjetRemote is ready.*no entries/s or die;
# Test printing various file types.
foreach my $file ("${pkgs.groff.doc}/share/doc/*/examples/mom/penguin.pdf",
"${pkgs.groff.doc}/share/doc/*/meref.ps",
"${pkgs.cups.out}/share/doc/cups/images/cups.png",
"${pkgs.pcre.doc}/share/doc/pcre/pcre.txt")
{
$file =~ /([^\/]*)$/; my $fn = $1;
subtest "print $fn", sub {
# Print the file on the client.
$client->succeed("lp $file");
$client->waitUntilSucceeds("lpq | grep -q -E 'active.*root.*$fn'");
# Ensure that a raw PCL file appeared in the server's queue
# (showing that the right filters have been applied). Of
# course, since there is no actual USB printer attached, the
# file will stay in the queue forever.
$server->waitForFile("/var/spool/cups/d*-001");
$server->waitUntilSucceeds("lpq -a | grep -q -E '$fn'");
# Delete the job on the client. It should disappear on the
# server as well.
$client->succeed("lprm");
$client->waitUntilSucceeds("lpq -a | grep -q -E 'no entries'");
Machine::retry sub {
return 1 if $server->succeed("lpq -a") =~ /no entries/;
};
# The queue is empty already, so this should be safe.
# Otherwise, pairs of "c*"-"d*-001" files might persist.
$server->execute("rm /var/spool/cups/*");
};
# The queue is empty already, so this should be safe.
# Otherwise, pairs of "c*"-"d*-001" files might persist.
$server->execute("rm /var/spool/cups/*");
};
}
}
'';
testPrinting($serviceClient, $serviceServer);
testPrinting($socketActivatedClient, $socketActivatedServer);
'';
})