Handling of the "cups" plug by snapd, especially auto-connection

This is to discuss what needs to get added to snapd to make it safe to auto-connect the cups plug of applications which print, the original discussion is not very visible, hidden in this PR

Hi,

to get the best possible user experience with printing in the world of Snaps we have introduced a new interface named cups. If you snap an application which has print functionality (like LibreOffice, photo editors/managers, …) you should simply plug cups and with this the application is allowed to print, but not to manage CUPS (create queues, delete anyone’s jobs, …) and this interface should auto-connect when installing the application Snap. This way application developers can easily upload applications with printing functionality to the Snap Store and users can easily install, them, use them, and print their work, and that all without compromising the security of the printing service on the user’s system.

cups is different from cups-control. The latter interface allows full control of the system’s CUPS, including managing CUPS. Therefore cups-control should not auto-connect. See also my example Snaps to demonstrate the differences.

If one thinks that in a snappy world the available CUPS simply comes from the CUPS Snap which has Snap mediation, meaning that its cupsd checks for all administrative client inquiries (asking for creating/modifying queues, deleting anyone’s jobs, …) whether if they come from a fully confined Snap that the Snap actually plugs cups-control and not only cups before accepting them, one could easily let cups auto-connect.

But unfortunately, not every system which allows installing Snaps from the Snap Store (every system which has snapd installed) provides its printing services by the CUPS Snap (we cannot do that right now, as we need to test the CUPS Snap and we need to convert all printer drivers to Printer Applications as the CUPS Snap does not support classic printer drivers), most run classically installed CUPS, without Snap mediation. I have added the Snap mediation to CUPS upstream, but this support only come with the next release, not every distribution will make use of it as then CUPS would need libsnapd-glib, and there are many installed systems out there without it.

This means most systems run classically installed CUPS without Snap mediation and there a Snap plugging cups has full admin access to the classically installed CUPS. See the discussion which started in the Pull Request to make cups implicit in snapd.

Therefore one cannot generally say “auto-connect the cups plug when installing a Snap using it” but one would rather have to say “auto-connect the cups plug when installing a Snap using it if the CUPS Snap is present”, and one even had to change the snapcraft-runner script (the one wrapping all apps of a Snap to set things like LD_LIBRARY_PATH, PATH, …) adding something like this at the end:

[...]
if which cups.lpstat > /dev/null; then
  export CUPS_SERVER=`cups.lpstat -H`
fi
exec "$@"

to assure that any libcups function used accesses the snapped cupsd even if a classically installed one is there in addition.

For the case that the user has only a classically installed CUPS and wants to use the queues there (because his printers need classically installed drivers) we have already thought about weird scenarios of force installing the CUPS Snap configured as a “firewall cupsd” receiving the job and passing on to the system’s classic cupsd, but this gets too complex.

My idea now to make it possible to generally allow auto-connection of the cups plug would be to make the snapped user application drop any “lpadmin” (or generally system group) privileges, by some wrapper through which the actual application gets started. This way a classic CUPS rejects administrative inquiries, as the client process has now “lpadmin” group rights, even if the user who started the appication is member of the “lpadmin” group. Ideally this could be done by the snapcraft-runner script. Then we even would not ned the addition mentioned above to force the printing of the application into the CUPS Snap if both snapped and classic CUPS are present.

Note that the “lpadmin” group exists in all classic systems with classically installed CUPS and it does not exist in distributions like Ubuntu Core, but here CUPS can only be installed as the Snap, so we do not need to care of the “lpadmin” group.

So my feature request is:

Make snapd be able to safely auto-connect the cups plug of application Snaps in all cases, independent whether we use the CUPS Snap or any version of a classically installed CUPS

We need two measures to assure this:

  1. The CUPS Snap has Snap mediation. This is already implemented and working
  2. snapd somehow drops “lpadmin” group privileges from snapped applications which do not plug cups-control. This blocks admin inquiries on any classic CUPS. This needs to get added. I do not know whether one can do it in snapd or by modifying the snapcraft-runner script.

So we need to implement (2) for solving our problem.

Even better would be for Snaps and snapd in general, if fully confined Snaps generally drop all system group privileges and only if they plug admin (or any variant of admin or a special interface like “cups-control”) certain group privileges are conserved.

WDYT?

Till

It seems that the snapcraft-runner script, running as the user who has called the application, cannot drop group privileges (like user is in “lpadmin” group but application should run as the user was not in “lpadmin”) by itself, as for this action one needs root privileges. This is due to the fact as group properties cannot only get defined in a way that members gain privileges but also that they lose privileges, see this LWN article.

So what we need here is the help of the root-running snapd. It needs to wedge in somewhere to strip the “lpadmin” privileges (or any other unwished system group’s privileges) of the process depending on what interfaces the Snap has plugged. Or we need any snapd-supplied runs-only-in-a-confined-snap tool (like snapctl) which could be used as a wrapper in the snapcraft-runner script to strip the unwished privileges as long as the magic interface is not plugged.

This mechanism could not only prevent Snaps from managing CUPS but also from doing other administrative operations (especially which the first user in an Ubuntu installation is allowed to do by his group memberships).

WDYT?

Till

Can you detail why this is too complex? I think that this would be a great solution, is to have the cups snap operate in two modes, one where there is no other cupsd on the system in which case the cupsd from the cups snap does everything, and two where cupsd from the cups snap detects another cups on the system and instead runs itself in the cupsd firewall/proxy mode, forwarding all requests to the classic cupsd the user has installed.

This setup would ensure that we can have the cups snap always be installed even on other distros where there is a classic cupsd and optionally classic drivers as well but still have a good user experience, and also would let us safely make it so that installing a snap which plugs the cups interface ends up also installing the cups snap.

This is not enough, since any kind of action the snapcraft-runner script to prevent access to cups is done after the confinement has been setup, so someone could just manually craft a snap which sidesteps the snapcraft-runner and thus is able to escalate privileges to lpadmin. Any kind of group dropping operation would need to be done by snap-confine inside snapd, and I don’t think we have any precedence for always dropping a group from the currently executing process, it feels a bit user-hostile to do that IMHO…

Edit: actually I see you came to that realization in your followup post…

Proposal for a new direction

I don’t want to disregard all the existing work that has been done on this, but I do want to propose what I view as a fundamentally better setup than what we have today, albeit a slightly more complicated one, that has IMHO the best user experience and security properties. Here is my proposal:

  1. The cups snap exposes a content interface slot which provides the cupsd socket from somewhere in $SNAP_DATA, etc. and not the global location - this content interface slot can be made to auto-connect globally to any snap wishing to connect a plug to it via store assertions

  2. Application snaps wishing to print can plug a content interface which auto-connects to the above content interface and uses default-provider to ensure that the cups snap always gets installed and can always provide the cupsd socket

  3. A snapcraft generated wrapper script from one of the extensions sets the CUPS_SERVER env var to the location of the cupsd socket that is shared over the content interface, and the extension also ensures that the content interface plug is added to any snap that is desktopy. Or maybe all this cups stuff becomes it’s own separate, orthogonal extension (that might be better since there are probably a lot of desktop snaps that don’t need to print and thus don’t need to have the the cups snap installed on the users system).

  4. The cups snap auto-detects whether there is already a global cups socket from a classically installed cupsd, and if that exists, then it operates in the firewall/proxy mode. If there is no such socket already being listened on, then cupsd from the cups snap either listens on the global cups socket in addition to the one in $SNAP_DATA (I know that this kind of thing can be done via Go to listen on multiple unix sockets at the same time, I dunno how easy it is to do in cupsd source code), or it creates a symlink at the global location pointing to the one in $SNAP_DATA, etc. (then unconfined apps not run as snaps just end up talking directly to the socket in $SNAP_DATA by following the symlink). This ensures that when there is a classic cupsd, classic apps talk to it without need to care that the other side is a snap, and snap apps always talk to cupsd over the content interface shared from the cups snap and thus are always mediated.

  5. We could then simplify the cupsd snap implementation to one that checks if the client process connecting to it’s socket is a snap or not, if it is a snap, then we always provide it permission to print, and we deny it ability to do admin tasks if the client process does not have the cups-control interface plugged.

  6. All of the above means we can actually just get rid of the cups interface as it exists, and simply make the claim that snaps can by default always print if they are doing so via the cups snap content interface which is sharing the socket (and a user wanting to deny access to printing can disconnect the content interface, then the app snap does not have access to the socket at all).

  7. Due to 6, we can simplify the policy in snapd to only allow an application slotting cups-control to read/write to the global socket in /run, and this global socket access is not allowed anywhere else. The cups interface can go away entirely, and the plug side of the cups-control interface also can just be empty, effectively only existing for the mediating version of cupsd to inspect and grant admin access to a snap plugging cups-control. If we really wanted we could even introduce a new interface cups-support which allows doing all the things that cupsd needs to do from inside a snap, but that’s probably unnecessary at this point and we can just put all of those accesses to the permanent slot side of cups-control.

The above setup has the following nice properties:

  • app snaps never are provided access to the global cups socket (which leads to confusing policy in snapd where it’s unclear if the cups interface is providing access to a global socket from another snap or access to a socket that is coming from the classic host world)
  • app snaps always are able to print by talking to a version of cups that does mediation to deny admin actions, since the only way they can talk to a cupsd socket is through the content interface shared socket
  • classic apps wanting to print can continue to do so using the normal location of the cups socket
  • users who already have cupsd classically installed can continue to use it and manage queues and drivers and such, and it will appear to them that their snap apps printing things are connecting directly to the classic cupsd instance (when in fact they are being proxied via cupsd)
  • we have simpler interface policies in snapd, and less complicated auto-connect rules (we just have the global auto-connect for the cups content interface slot from the cups snap)
  • a snap that wants to print will be able to do so without the user granting any special privileges to it, on any distro with or without classic cupsd installed on it.

Note that this entirely depends on the cups snap being able to nicely forward/proxy requests from snaps wishing to print, which if that doesn’t work well or is not maintainable then causes the whole plan to fall apart…

Now all of that is basically a total left turn from the current plan, so maybe it doesn’t make sense to do this just because it is a lot of work to suddenly do this instead of the existing plan, but I think that at the very least we should consider following this sort of paradigm for future classic applications that we want to start mediating more finely by moving them to snaps, and the only requirements for that classic application/service are:

  • that it can peacefully co-exist with a classically installed version of itself, effectively becoming a firewall/proxy if the classic version exists on the system
  • that it can expose both the global resources that classic non-snap applications need to use/interact with it (in the case that the snap version of this software is the only one on the system), as well as very finely controlled resources that are entirely sharable via the content interface

I’m really curious if @jdstrand has time to think about this, as someone who initially worked on the current plan if there are things I am missing here.

1 Like

@ijohnson Brilliant plan, it is great. I like it a lot.

Some remarks:

  • The firewall cupsd (the snapped one which is running in parallel to a classic one) only can forward print requests not admin requests. This is no problem as we go the firewalled printing path only for snapped clients using the content interface as they are not plugging cups-control. Snaps plugging cups-control always communicate with the “standard” CUPS (classic if present, snapped otherwise) and manage this one.
  • If we have a classic CUPS, the user creates CUPS queues on this classic CUPS and the user only manages this classic CUPS with printer setup tools or the web interface. The snapped CUPS in firewall mode will run fully automatic without being managed by the user. It does not open port 10631 but only uses the socket in $SNAP_DATA and also has its web interface turned off. It has its cups-browsed attached to see the queues of the classic CUPS and replicate them on the snapped firewall CUPS. The classic CUPS therefore needs one little change in configuration: If it is configured to not share its print queues it now must share them but to localhost only, so that the cups-browsed of the snapped CUPS can pick them up and forward them. This would require some mechanism to change the classic CUPS’ configuration automatically when the CUPS Snap gets force-installed as firewall.
  • As the CUPS Snap already automatically switched between two modes, first beint the only stand-alone/system CUPS and second being alternative CUPS in parallel to classic CUPS (on alternative port and socket) it is easy to change the alternative mode to a firewall mode. The startup scripts for both cupsd and cups-browsed in the CUPS Snap do all required configuration changes.
  • You say: “(6) All of the above means we can actually just get rid of the cups interface as it exists …” We can simply make the interface named cups being this content interface mentioned in (1), but be careful, a print client Snap not only needs access to the cupsd socket but also to the D-Bus interfaces of CUPS, as some clients want to subscribe to notifications. The D-Bus interfaces are the same as in cups-control.

CUPS and cups-browsed should have enough configuration options to be able to auto-configure them appropriately in the start-up scripts of the CUPS Snap so that we can run a cupsd in firewall mode. If something is missing, no problem, I have the upstream maintainer power over both and so I can add the missing configuration options.

For me it looks all workable, the most difficult part is probably to automatically change the classic CUPS’ configuration so that its printers get shared localhost-only.

Till

How would a user normally make this change?

@ijohnson, to change the configuration of a classically installed cupsd one needs to change cupsd,conf. This can be done with either by a user with “lpadmin” group membership or by root. As it works as root, a program which installs a package can do that. A daemon in a Snap including its startup script also runs as root, but the confinements of the Snap usually block modifying external files or restarting an external daemon, but the CUPS Snap can access the global CUPS socket /run/cups/cups.sock and as its CUPS daemon’s startup script runs as root, it should be able to modify the classic CUPS’ configuration, with code like in cupsctl.

But I also came to another idea: As a locally running use application sees the locally available CUPS printers, lists them in its print dialog, and also prints on them, even without the local CUPS sharing its printers, I could add a mode to cups-browsed which discovers printers on a given CUPS socket (which is not the same as of the CUPS the cups-browsed is attached to) and creates queues on the Snap’s local CUPS appropriately, so that jobs to these queues get passed on to the classic CUPS. This would not need any configuration change on the classic CUPS.

Here I need to find out which is the better solution.

@ijohnson, good news, one does not need the classic CUPS to share printers. The shared bit is only about making the printers available to remote machines. On the local machine they are accessible through their usual URI ipp://localhost:631/printers/QUEUE. Without sharing they are only not DNS-SD-registered, so I need to add an option to cups-browsed to discover printers on a given socket via cupsGetDests() API.

@ijohnson, I am currently working on a cups-proxyd which mirrors the queues of the system’s cupsd to the Snap’s cupsd, so that the Snap’s cupsd has the same queues but passing the jobs unfiltered to the system’s cupsd (drivers are on the system’s cupsd).

I do not expand cups-browsed to use that, because operations here are well different, and complications as taking note of network interfaces or the implicitclass backend should be avoided. Also cups-browsed is supposed to get spun out of the CUPS Snap. cups-proxyd will then be run by the startup script for cupsd, no new “app” created in snapcraft.yaml.

There is only one problem which I discovered now with @alexmurray here, the “cups” plug for client Snaps only allows access to /run/cups/cups.sock not to the alternative socket in $SNAP_DATA, /var/snap/cups/common/run/cups.sock. This needs to be added, I am talking with @jamesh about this currently.

@ijohnson, @alexmurray, @jamesh, in case of the Snaps plugging “cups” auto-installing the CUPS Snap, we should even think about the “cups” plug only allowing access to /var/snap/cups/common/run/cups.sock and not to /run/cups/cups.sock.

Hi @till.kamppeter, from my proposal above, you don’t need to provide access to /var/snap/cups/common/run/cups.sock to client app snaps wishing to print via the cups interface. Instead in my proposal you would have definitions like this:

cups snap snapcraft.yaml (approximate)

slots:
  cups-socket:
    interface: content
    content: cups-socket
    source:
      write:
        - $SNAP_COMMON/cups.sock

apps:
  cupsd:
    slots: 
      - cups-control
  cups-print-only-test:
    environment:
      CUPS_SOCKET: /var/lib/cups/cups.sock
    plugs: {} # empty because it doesn't need any special permissions - 
              # it can access the socket hence it can print
  cups-admin-test:
    environment:
      CUPS_SOCKET: /var/lib/cups/cups.sock
    plugs:
      - cups-control # here we need to use cups-control since this test app
                     # needs to do more than just print, it needs admin things too

client snap wanting to print

plugs:
  cups-socket:
    interface: content
    content: cups-socket
    default-provider: cups # this line ensures that if the system installing this snap does not
                           # already have cups snap installed, it will get automatically installed
    target: ​/var/lib/cups/cups.sock # or wherever is convenient
environment:
  CUPS_SOCKET: /var/lib/cups/cups.sock

Under this situation as I described above, we don’t actually need to use the cups interface verbatim at all, we can resort to using only the cups-control interface and the cups-socket content interface.

Hopefully this helps.

@ijohnson, thank you very much.

You are using the CUPS socket /var/lib/cups/cups.sock for the client to access CUPS through. Is it intended that this is not the standard CUPS socket /run/cups/cups.sock? Or did you simply not remember correctly or using an old socket path?

@ijohnson, what will exactly happen if a Snap which wants to print is installed of a system without CUPS Snap and with classic CUPS installed? The classic CUPS is listening on /run/cups/cups.sock. The installation of the Snap triggers the installation of the CUPS Snap. Now the CUPS Snap sees the presence of the classic CUPS and therefore it goes into proxy/firewall mode (classic CUPS stays running without changes, Snap’s CUPS mirrror’s queues of classic CUPS and passes jobs on to there).

How does it now work that unsnapped apps printing to /run/cups/cups.sock print directly to the classic CUPS while the snapped app printing to /run/cups/cups.sock prints through the content interface connection to the snapped CUPS?

Or do you really mean /var/lib/cups/cups.sock as a different socket file compared to /run/cups/cups.sock? Why do you then use something in /var/lib/...? This would need a separate permission as it is outside the Snap. Would be $SNAP_COMMON/run/cups.sock not the better choice?

@jamesh, on Mattermost you told:

You probably can’t use the content interface directly, but having the cups and cups-control interfaces perform a bind mount is certainly possible.

How does this get implemented?

@jamesh on Mattermost:

you can’t use the content interface to bind mount to /run/cups. Also, if we put the logic into the cups interfaces, it hides the complexity from application snaps on the plug side, content the content interface can only mount to filesystem locations the snap controls like $SNAP, $SNAP_DATA, and $SNAP_COMMON.
While you can use layouts to mount from those locations to other parts of the file system, snapd doesn’t do well at always ordering the mounts (especially if it is updating an existing mount namespace)

@ijohnson, this means that you cannot use /var/lib/cups/cups.sock as target for the slot. It must be something like $SNAP_COMMON/run/cups.sock or so.

@ijohnson, @jamesh, I have now implemented the new proxy mode, as you have suggested:

It was a lot of work and took me a lot of time, as I had to create a new daemon (cups-proxyd) for auto-mirroring the queues of the system’s classic CUPS to the Snap’s CUPS, including the discoovered printers for which the classic CUPS auto-creates temporary queues.

I also had to create a new CUPS backend (proxy) to allow the mirrored queues of the snapped CUPS to pass the jobs on to their original queues on the classic CUPS without needing the classic CUPS to share the queues. So nothing on the configuration of the system’s CUPS has to be changed. The CUPS Snap in proxy mode does not do any administrative tasks on the system’s CUPS at all.

What is still missing and what I will do as the next step is to create the content interfaces.

Updated the README.md of the CUPS Snap:

This documents how the new proxy mode is working, how it is invoked, and also the stand-alone (CUPS Snap is system’s CUPS) and parallel (classic CUPS and snapped CUPS are two independent instances on one system) modes.

Some technical background info to the new proxy mode:

  • The proxy mode is invoked if there is an /etc/cups/cupsd.conf file present and readable (at least for root in the Snap), independent of whether there is actually a CUPS daemon running or not. This form of proxy mode invocation prevents race conditions on whether the CUPS Snap or the system’s CUPS starts first during boot. We cannot for example check for the presence of the cupsd executable file, as the Snap can only read /etc/cups in the system.
  • The mirroring of print queues from the system’s CUPS daemon to the Snap’s CUPS daemon is done by an auxiliary daemon named cups-proxyd. This daemon listens for appearing and disappearing of arbitrary IPP print services via DNS-SD (to get note of printers which could create temporary on-demand queues on the system’s CUPS) and for print queue addition/modification/removal and printer status change via system’s CUPS D-Bus notifications (to get note of queue modifications on the system’s CUPS even if the queues are not shared). On each event it freshly mirrors all queues from the system’s CUPS to the Snap’s CUPS and also removes disappeared queues from the Snap’s CUPS. It even mirrors temporary queues which CUPS creates on-demand for discovered IPP printers, by force-creating them on the system’s CUPS via a dummy access and then mirroring them.
  • There is no apps: entry in snapcraft.yaml for cups-proxyd. cups-proxyd is completely managed by the run-cupsd and stop-cupsd scripts. We do not run it as drop-in replacement for cups-browsed by the run-cups-browsed script as we want to spin out cups-browsed into its own Snap later.
  • cups-browsed is not run in proxy mode, the run-cups-browsed script is simply running alone, without cups-browsed, with a dummy daemon process which stop-cups-browsed can kill.
  • The configuration of the system’s CUPS does not need to be changed by the user for that, nor is it changed by the CUPS Snap (the CUPS Snap does no administrative action on the system’s CUPS at all). The printers of the system’s CUPS do not even need to get shared for the proxy to work. If unsnapped applications on the local machine can print, the proxy works.
  • To allow the queues of the Snap’s CUPS pass on the jobs to the original queues on the system’s CUPS without the system’s CUPS needing to share them, a special CUPS backend named proxy got created, which gets the system’s CUPS’ socket and the queue name via the device URI and prints the same way as lp would do, passing on the options of the original job.
  • The mirrored queues on the Snap’s CUPS do not filter the jobs any further than to PDF. The remaining filtering to the printer’s native language is done by the system’s CUPS. So the user’s drivers (especially proprietary ones) are continued to be used. The options are made available in the mirrored queus simply by copying the PPD files, but the filter rules in the PPD files are changed to stop the filtering at PDF.
  • cups-proxyd is simple, it has no configuration file, it is controlled only by its command line. It has a log file, in the CUPS Snap it is /var/snap/cups/current/var/log/cups-proxyd_log.
  • The source code of cups-proxyd and proxy is maintained in the CUPS Snap project, in the cups-proxyd/ subdirectory, as these only make sense in the Snap.

@ijohnson, CUPS listening on two domain sockets is no problem at all, it always was capable of this, simply two Listen ... lines in cupsd.conf pointing to the domain sockets do the trick. So (4) in your initial presentation of the proxy idea is solved by my following commit on the CUPS Snap (no change on CUPS itself needed):

It makes simply the run-cupsd script add two Listen ... lines to cupsd.conf when it finds out that we will run in stand-alone mode:

Listen /run/cups/cups.sock
Listen /var/snap/cups/common/run/cups.sock
Port 631
[...]

Now the snapped CUPS listens on /var/snap/cups/common/run/cups.sock in all three modes (stand-alone, proxy, parallel), so that snapped applications can always print to this domain socket. In stand-alone mode unsnapped applications print also to the snapped CUPS as it is also listening on /run/cups/cups.sock then.

1 Like

@ijohnson, @jamesh, now I only need to create the content interface for the snapped clients to force-install the CUPS Snap and having access to /var/snap/cups/common/run/cups.sock for printing, but at the same time the snapped clients need to also have access to D-Bus notifications of CUPS (push notifications from CUPS for printer status changes, job progress, …, no admin tasks possible via D-Bus) (as cups-control also provides).

What I like most here would be an interface named cups containing both the content interface and the D-Bus access. Or do we have to split into cups (or cups-dbus) for the D-Bus and cups-socket for the content interface (as in this example)?

@ijohnson, note that the name of the environment variable is CUPS_SERVER and not CUPS_SOCKET here.

Sorry for taking a while to respond on this. My thoughts are still that we should keep the things as simple as possible for applications, and preferably allow communication with the system CUPS instance.

Now for some specific feedback:

I think a bind-mount like the content interface provides here is going to be part of the solution, but I’m not sure we want to make use of the content interface directly. In particular:

  1. we can’t use this to talk to the host system CUPS.
  2. any application snap will need plug definition and environment variable boilerplate to make proper use of it.
  3. we can’t use it to make the CUPS socket appear at the default /run/cups/cups.sock location, since the content interface only allows bind mount targets under $SNAP/, $SNAP_DATA/, or $SNAP_COMMON/. While layouts allow creating bind mounts outside of those directories, trying to chain the content interface and layouts together is error prone.

One other option would be to modify the existing cups and cups-control interfaces to perform the mounts themselves. That would allow us to bind mount over /run/cups from some directory managed by the slot-side snap, and skip the bind mount when connecting to an implicit system slot.

As for what directory to use as the source for the bind mount, this could either be something fixed by the interface, or specified by an interface attribute on the slot. I don’t think we want to do anything fancy like perform a bind mount over /run/cups on the slot side. While that would allow an unmodified CUPS to create its socket in the normal location, it would also make it impossible to implement the “proxy cups” model, where a snapped CUPS talks to a system CUPS.