Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

interfaces/cups: add cups-socket-directory attr, use to specify mount rules in backend #10427

Merged
Merged
Show file tree
Hide file tree
Changes from 10 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
12 changes: 12 additions & 0 deletions cmd/snap-exec/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,18 @@ func execApp(snapApp, revision, command string, args []string) error {
env.ExtendWithExpanded(eenv)
}

// this is a workaround for the lack of an environment backend in interfaces
// where we want certain interfaces when connected to add environment
// variables to plugging snap apps, but this is a lot simpler as a
// work-around
// we currently only handle the CUPS_SERVER environment variable, setting it
// to /var/cups/ if that directory exists - it should not exist anywhere
// except in a strictly confined snap where we setup the bind mount from the
// source cups slot snap to the plugging snap
if exists, _, _ := osutil.DirExists(dirs.GlobalRootDir + "/var/cups"); exists {
env["CUPS_SERVER"] = "/var/cups/cups.sock"
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a shame we couldn't get this working with /run/cups as the directory exposed to the snap. Although it is not clear it'd be better to special case the trespassing code to allow creating /run/cups without a mimic if it doesn't exist.


// strings.Split() is ok here because we validate all app fields and the
// whitelist is pretty strict (see snap/validate.go:appContentWhitelist)
// (see also overlord/snapstate/check_snap.go's normPath)
Expand Down
49 changes: 49 additions & 0 deletions cmd/snap-exec/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,55 @@ func (s *snapExecSuite) TestSnapExecAppIntegration(c *C) {
// See also https://bugs.launchpad.net/snapd/+bug/1860369
c.Check(execEnv, Not(testutil.Contains), "TEST_PATH=/vanilla")
c.Check(execEnv, testutil.Contains, "TEST_PATH=/custom")

// ensure that CUPS_SERVER is absent since we didn't mock the /var/cups dir
c.Check(execEnv, Not(testutil.Contains), "CUPS_SERVER=/var/cups")
}

func (s *snapExecSuite) TestSnapExecAppIntegrationCupsServerWorkaround(c *C) {
dir := c.MkDir()
dirs.SetRootDir(dir)
snaptest.MockSnap(c, string(mockYaml), &snap.SideInfo{
Revision: snap.R("42"),
})

// mock the /var/cups dir so that we observe CUPS_SERVER set
err := os.MkdirAll(filepath.Join(dir, "/var/cups"), 0755)
c.Assert(err, IsNil)

execArgv0 := ""
execArgs := []string{}
execEnv := []string{}
restore := snapExec.MockSyscallExec(func(argv0 string, argv []string, env []string) error {
execArgv0 = argv0
execArgs = argv
execEnv = env
return nil
})
defer restore()

// FIXME: TEST_PATH was meant to be just PATH but this uncovers another
// bug in the test suite where mocking binaries misbehaves.
oldPath := os.Getenv("TEST_PATH")
os.Setenv("TEST_PATH", "/vanilla")
defer os.Setenv("TEST_PATH", oldPath)
anonymouse64 marked this conversation as resolved.
Show resolved Hide resolved

// launch and verify its run the right way
err = snapExec.ExecApp("snapname.app", "42", "stop", []string{"arg1", "arg2"})
c.Assert(err, IsNil)
c.Check(execArgv0, Equals, fmt.Sprintf("%s/snapname/42/stop-app", dirs.SnapMountDir))
c.Check(execArgs, DeepEquals, []string{execArgv0, "arg1", "arg2"})
c.Check(execEnv, testutil.Contains, "BASE_PATH=/some/path")
c.Check(execEnv, testutil.Contains, "LD_LIBRARY_PATH=/some/path/lib")
c.Check(execEnv, testutil.Contains, fmt.Sprintf("MY_PATH=%s", os.Getenv("PATH")))
// TEST_PATH is properly handled and we only see one value, /custom, defined
// as an app-specific override.
// See also https://bugs.launchpad.net/snapd/+bug/1860369
c.Check(execEnv, Not(testutil.Contains), "TEST_PATH=/vanilla")
anonymouse64 marked this conversation as resolved.
Show resolved Hide resolved
c.Check(execEnv, testutil.Contains, "TEST_PATH=/custom")

// ensure that CUPS_SERVER is now set since we did mock the /var/cups dir
c.Check(execEnv, testutil.Contains, "CUPS_SERVER=/var/cups/cups.sock")
}

func (s *snapExecSuite) TestSnapExecAppCommandChainIntegration(c *C) {
Expand Down
172 changes: 164 additions & 8 deletions interfaces/builtin/cups.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@

package builtin

import (
"fmt"
"strings"

"github.com/snapcore/snapd/interfaces"
"github.com/snapcore/snapd/interfaces/apparmor"
"github.com/snapcore/snapd/interfaces/mount"
"github.com/snapcore/snapd/osutil"
"github.com/snapcore/snapd/snap"
)

// On systems where the slot is provided by an app snap, the cups interface is
// the companion interface to the cups-control interface. The design of these
// interfaces is based on the idea that the slot implementation (eg cupsd) is
Expand Down Expand Up @@ -46,17 +57,162 @@ const cupsBaseDeclarationSlots = `
const cupsConnectedPlugAppArmor = `
# Allow communicating with the cups server

#include <abstractions/cups-client>
# Do not allow reading the user or global client.conf for this snap, as this may
# allow a user to point an application at an unconfined cupsd which could be
# used to load printer drivers etc. We only want client snaps with the cups
# interface plug connected to be able to talk to a version of cupsd which is
# strictly confined and performs mediation. This means only allowing to talk to
# /var/cups/cups.sock and not /run/cups/cups.sock since snapd has no way to know
# if the latter cupsd is confined and performs mediation, but the upstream
# maintained cups snap providing a cups slot will always perform mediation.
# As such, do not use the <abstractions/cups-client> include file here.

# Allow reading the personal settings for cups like default printer, etc.
owner @{HOME}/.cups/lpoptions r,

/{,var/}run/cups/printcap r,

# Allow talking to the snap version of cupsd socket that we expose via bind
# mounts from a snap providing the cups slot to this snap.
/var/cups/cups.sock rw,
`

type cupsInterface struct {
commonInterface
}

func (iface *cupsInterface) AppArmorConnectedSlot(spec *apparmor.Specification, plug *interfaces.ConnectedPlug, slot *interfaces.ConnectedSlot) error {
return nil
}

func validateCupsSocketDirAttr(a interfaces.Attrer, snapInfo *snap.Info) (string, error) {
anonymouse64 marked this conversation as resolved.
Show resolved Hide resolved
// Allow an empty specification for the slot, in which case we don't perform
// any mounts, etc. This is mainly to prevent errors in systems which still
// have the old cups snap installed that haven't been updated to use the new
// snap with the new slot declaration
if _, ok := a.Lookup("cups-socket-dir"); !ok {
anonymouse64 marked this conversation as resolved.
Show resolved Hide resolved
return "", nil
}

var cupsdSocketSourceDir string
if err := a.Attr("cups-socket-dir", &cupsdSocketSourceDir); err != nil {
return "", err
}

// make sure that the cups socket dir is not an AppArmor Regular expression
if err := apparmor.ValidateNoAppArmorRegexp(cupsdSocketSourceDir); err != nil {
return "", fmt.Errorf("cups-socket-dir is not usable: %v", err)
}

if !cleanSubPath(cupsdSocketSourceDir) {
anonymouse64 marked this conversation as resolved.
Show resolved Hide resolved
return "", fmt.Errorf("cups-socket-dir is not clean: %q", cupsdSocketSourceDir)
anonymouse64 marked this conversation as resolved.
Show resolved Hide resolved
}

// validate that the setting for cups-socket-dir is in $SNAP_DATA or
// $SNAP_COMMON, we don't allow any other directories for the slot socket
// dir
// TODO: should we also allow /run/$SNAP_INSTANCE_NAME/ too ?
if !strings.HasPrefix(cupsdSocketSourceDir, "$SNAP_COMMON") && !strings.HasPrefix(cupsdSocketSourceDir, "$SNAP_DATA") {
return "", fmt.Errorf("cups-socket-dir must be a directory of $SNAP_COMMON or $SNAP_DATA")
}
// otherwise it must have a prefix of either SNAP_COMMON or SNAP_DATA,
// validate that it has no other variables in it
err := snap.ValidatePathVariables(cupsdSocketSourceDir)
if err != nil {
return "", err
}

// The path starts with $ and ValidatePathVariables() ensures
// path contains only $SNAP, $SNAP_DATA, $SNAP_COMMON, and no
// other $VARs are present. It is ok to use
// ExpandSnapVariables() since it only expands $SNAP, $SNAP_DATA
// and $SNAP_COMMON
return snapInfo.ExpandSnapVariables(cupsdSocketSourceDir), nil
}

func (iface *cupsInterface) BeforePrepareSlot(slot *snap.SlotInfo) error {
// verify that the snap has a cups-socket-dir interface attribute, which is
// needed to identify where to find the cups socket is located in the snap
// providing the cups socket
_, err := validateCupsSocketDirAttr(slot, slot.Snap)
return err
}

func (iface *cupsInterface) AppArmorConnectedPlug(spec *apparmor.Specification, plug *interfaces.ConnectedPlug, slot *interfaces.ConnectedSlot) error {
cupsdSocketSourceDir, err := validateCupsSocketDirAttr(slot, slot.Snap())
if err != nil {
return err
}

// add the base snippet
spec.AddSnippet(cupsConnectedPlugAppArmor)

if cupsdSocketSourceDir == "" {
// no other rules, this is the legacy slot without the additional
// attribute
return nil
}

// add rules to access the socket dir from the slot location directly
// this is necessary otherwise clients get denials like this:
// apparmor="DENIED" operation="connect"
// profile="snap.test-snapd-cups-consumer.bin"
// name="/var/snap/test-snapd-cups-provider/common/cups.sock"
// pid=3195747 comm="nc" requested_mask="wr" denied_mask="wr" fsuid=0 ouid=0
// this denial is the same that would happen for the content interface, so
// we employ the same workaround from the content interface here too
spec.AddSnippet(fmt.Sprintf(`
# In addition to the bind mount, add any AppArmor rules so that
# snaps may directly access the slot implementation's files. Due
# to a limitation in the kernel's LSM hooks for AF_UNIX, these
# are needed for using named sockets within the exported
# directory.
"%s/**" mrwklix,`, cupsdSocketSourceDir))

// setup the snap-update-ns rules for bind mounting for the plugging snap
emit := spec.AddUpdateNSf

emit(" # Mount cupsd socket from cups snap to client snap\n")
// note the trailing "/" is needed - we ensured that cupsdSocketSourceDir is
// clean when we validated it, so it will not have a trailing "/" so we are
// safe to add this here
emit(" mount options=(rw bind) \"%s/\" -> /var/cups/,\n", cupsdSocketSourceDir)
emit(" umount /var/cups/,\n")

apparmor.GenWritableProfile(emit, cupsdSocketSourceDir, 1)
apparmor.GenWritableProfile(emit, "/var/cups", 1)
anonymouse64 marked this conversation as resolved.
Show resolved Hide resolved

return nil
}

func (iface *cupsInterface) MountConnectedPlug(spec *mount.Specification, plug *interfaces.ConnectedPlug, slot *interfaces.ConnectedSlot) error {
anonymouse64 marked this conversation as resolved.
Show resolved Hide resolved
cupsdSocketSourceDir, err := validateCupsSocketDirAttr(slot, slot.Snap())
if err != nil {
return err
}

if cupsdSocketSourceDir == "" {
// no other rules, this is the legacy slot without the additional
// attribute
return nil
}

// add a bind mount of the cups-socket-dir to /var/cups of the plugging snap
return spec.AddMountEntry(osutil.MountEntry{
Name: cupsdSocketSourceDir,
Dir: "/var/cups/",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this requires a mimic for /var, not sure what are the consequences of that in terms namespace updating bugs.

is there a different place we could put this that doesn't have this sort of side-effect?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the CUPS point of view the socket can be in any writable space on the system. A client uses what CUPS_SERVER points at. No requirements of being /var/....
From the snapd point of view, we need the snapd experts to speak up ...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pedronis if we want the directory to be named "cups" with any prefix or suffix, then we cannot avoid creating a mimic entirely. If we wanted to avoid creating the mimic on /var/, we could go with /var/lib/cups instead, which would just create a mimic on /var/lib/, but that does involve /var/lib/snapd/ now being on a mimic (which I suppose is fine, snap apps from inside the namespace shouldn't need to care about anything in /var/lib/snapd), otherwise we could do something like /var/lib/misc/cups which would create a mimic over /var/lib/misc, but there is nothing inside that dir with core20 at least so the impact should be minimal.

In terms of avoiding the namespace updating bugs, I will look into that situation, I don't remember off the top of my head if it is better or worse for this to be in a deeper directory or not, I think it depends on ordering to make sure that directory mimics higher up get processed before deeper ones which my other PR would take care of I think.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could also update core{,18,20} to include an empty /var/cups directory, similar to what we did for /usr/share/fonts. That would avoid the mimic code firing for virtually all snaps, while still supporting any weird custom bases that don't provide that directory.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally I think the suggestion to add an empty /var/cups directory to base snaps would be best, I'm not sure there is a better directory that avoids creating a mimic entirely and still allows us to have cups in the directory name.

I don't think it's better personally to go down the route of opening up /run for mimics FWIW, but we could explore it if folks really don't like /var/cups and think it really needs to be in /run somewhere.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For me it is not important whether the socket directory is actually in /var/... or in /run/..., most important is for me that it all works just out-of-the-box, one installs a Snap which plugs cups, cups get auto-connected, and one can print right away.

So depends on file system conventions and also on convenience where it is easier to place the socket directory.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we have a plan now to add those empty dirs to the base snaps?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes if there are no objections I will propose PR's to the base snaps to add /var/cups to the base snaps as empty dirs which will avoid unnecessary writable mimics, but IMHO that's an internal optimization that doesn't need to be done before this PR has landed

Options: []string{"bind", "rw"},
})
anonymouse64 marked this conversation as resolved.
Show resolved Hide resolved
}

func init() {
registerIface(&commonInterface{
name: "cups",
summary: cupsSummary,
implicitOnCore: false,
implicitOnClassic: false,
baseDeclarationSlots: cupsBaseDeclarationSlots,
connectedPlugAppArmor: cupsConnectedPlugAppArmor,
registerIface(&cupsInterface{
commonInterface: commonInterface{
name: "cups",
summary: cupsSummary,
implicitOnCore: false,
implicitOnClassic: false,
baseDeclarationSlots: cupsBaseDeclarationSlots,
},
})
}
66 changes: 59 additions & 7 deletions interfaces/builtin/cups_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,19 @@ import (
)

type cupsSuite struct {
iface interfaces.Interface
plugInfo *snap.PlugInfo
plug *interfaces.ConnectedPlug
iface interfaces.Interface

plugInfo *snap.PlugInfo
plug *interfaces.ConnectedPlug

providerSlotInfo *snap.SlotInfo
providerSlot *interfaces.ConnectedSlot

providerLegacySlotInfo *snap.SlotInfo
providerLegacySlot *interfaces.ConnectedSlot

invalidSlotInfo *snap.SlotInfo
invalidSlot *interfaces.ConnectedSlot
}

var _ = Suite(&cupsSuite{iface: builtin.MustInterface("cups")})
Expand All @@ -49,14 +57,39 @@ apps:

const cupsProviderYaml = `name: provider
version: 0
slots:
cups-socket:
interface: cups
cups-socket-dir: $SNAP_COMMON/foo-subdir
apps:
app:
slots: [cups]
slots: [cups-socket]
`

const invalidCupsProviderYaml = `name: provider
version: 0
slots:
cups-socket:
interface: cups
# regex not allowed
cups-socket-dir: $SNAP_COMMON/foo-subdir/*
apps:
app:
slots: [cups-socket]
`

const cupsProviderLegacyYaml = `name: provider
version: 0
slots:
# no attribute
cups: {}
`

func (s *cupsSuite) SetUpTest(c *C) {
s.plug, s.plugInfo = MockConnectedPlug(c, cupsConsumerYaml, nil, "cups")
s.providerSlot, s.providerSlotInfo = MockConnectedSlot(c, cupsProviderYaml, nil, "cups")
s.providerSlot, s.providerSlotInfo = MockConnectedSlot(c, cupsProviderYaml, nil, "cups-socket")
s.providerLegacySlot, s.providerLegacySlotInfo = MockConnectedSlot(c, cupsProviderLegacyYaml, nil, "cups")
s.invalidSlot, s.invalidSlotInfo = MockConnectedSlot(c, invalidCupsProviderYaml, nil, "cups-socket")
}

func (s *cupsSuite) TestName(c *C) {
Expand All @@ -65,6 +98,9 @@ func (s *cupsSuite) TestName(c *C) {

func (s *cupsSuite) TestSanitizeSlot(c *C) {
c.Assert(interfaces.BeforePrepareSlot(s.iface, s.providerSlotInfo), IsNil)
c.Assert(interfaces.BeforePrepareSlot(s.iface, s.providerLegacySlotInfo), IsNil)
// the regex is not allowed however
c.Assert(interfaces.BeforePrepareSlot(s.iface, s.invalidSlotInfo), ErrorMatches, "cups-socket-dir is not usable: .* contains a reserved apparmor char .*")
}

func (s *cupsSuite) TestSanitizePlug(c *C) {
Expand All @@ -74,15 +110,31 @@ func (s *cupsSuite) TestSanitizePlug(c *C) {
func (s *cupsSuite) TestAppArmorSpec(c *C) {
for _, onClassic := range []bool{true, false} {
restore := release.MockOnClassic(onClassic)
defer restore()

// consumer to provider on core for ConnectedPlug
spec := &apparmor.Specification{}
c.Assert(spec.AddConnectedPlug(s.iface, s.plug, s.providerSlot), IsNil)
c.Assert(spec.SecurityTags(), DeepEquals, []string{"snap.consumer.app"})
c.Assert(spec.SnippetForTag("snap.consumer.app"), testutil.Contains, "# Allow communicating with the cups server")
c.Assert(spec.SnippetForTag("snap.consumer.app"), testutil.Contains, "#include <abstractions/cups-client>")
// no cups abstractions
c.Assert(spec.SnippetForTag("snap.consumer.app"), Not(testutil.Contains), "#include <abstractions/cups-client>")
// but has the lpoptions config file though
c.Assert(spec.SnippetForTag("snap.consumer.app"), testutil.Contains, `owner @{HOME}/.cups/lpoptions r,`)
// the special mount rules are present
c.Assert(spec.SnippetForTag("snap.consumer.app"), testutil.Contains, `"/var/snap/provider/common/foo-subdir/**" mrwklix,`)
restore()

// consumer to legacy provider
specLegacy := &apparmor.Specification{}
c.Assert(specLegacy.AddConnectedPlug(s.iface, s.plug, s.providerLegacySlot), IsNil)
c.Assert(specLegacy.SecurityTags(), DeepEquals, []string{"snap.consumer.app"})
c.Assert(specLegacy.SnippetForTag("snap.consumer.app"), testutil.Contains, "# Allow communicating with the cups server")
// no cups abstractions
c.Assert(spec.SnippetForTag("snap.consumer.app"), Not(testutil.Contains), "#include <abstractions/cups-client>")
// but has the lpoptions config file though
c.Assert(spec.SnippetForTag("snap.consumer.app"), testutil.Contains, `owner @{HOME}/.cups/lpoptions r,`)
// no special mounting rules
c.Assert(specLegacy.SnippetForTag("snap.consumer.app"), Not(testutil.Contains), "/var/snap/provider/common/foo-subdir/** mrwklix,")
}
}

Expand Down
Loading