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/builtin/mount_control: add support for nfs mounts #14694

Merged
merged 14 commits into from
Nov 18, 2024
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
71 changes: 56 additions & 15 deletions interfaces/builtin/mount_control.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,6 @@ var allowedFilesystemSpecificMountOptions = map[string][]string{
"jfs": {"iocharset=", "resize=", "nointegrity", "integrity", "errors=", "noquota", "quota", "usrquota", "grpquota"},
"msdos": {"blocksize=", "uid=", "gid=", "umask=", "dmask=", "fmask=", "allow_utime=", "check=", "codepage=", "conv=", "cvf_format=", "cvf_option", "debug", "discard", "dos1xfloppy", "errors=", "fat=", "iocharset=", "nfs=", "tz=", "time_offset=", "quiet", "rodir", "showexec", "sys_immutable", "flush", "usefree", "dots", "nodots", "dotsOK="},
"nfs": {"nfsvers=", "vers=", "soft", "hard", "softreval", "nosoftreval", "intr", "nointr", "timeo=", "retrans=", "rsize=", "wsize=", "ac", "noac", "acregmin=", "acregmax=", "acdirmin=", "acdirmax=", "actimeo=", "bg", "fg", "nconnect=", "max_connect=", "rdirplus", "nordirplus", "retry=", "sec=", "sharecache", "nosharecache", "revsport", "norevsport", "lookupcache=", "fsc", "nofsc", "sloppy", "proto=", "udp", "tcp", "rdma", "port=", "mountport=", "mountproto=", "mounthost=", "mountvers=", "namlen=", "lock", "nolock", "cto", "nocto", "acl", "noacl", "local_lock=", "minorversion=", "clientaddr=", "migration", "nomigration"},
"nfs4": {"nfsvers=", "vers=", "soft", "hard", "softreval", "nosoftreval", "intr", "nointr", "timeo=", "retrans=", "rsize=", "wsize=", "ac", "noac", "acregmin=", "acregmax=", "acdirmin=", "acdirmax=", "actimeo=", "bg", "fg", "nconnect=", "max_connect=", "rdirplus", "nordirplus", "retry=", "sec=", "sharecache", "nosharecache", "revsport", "norevsport", "lookupcache=", "fsc", "nofsc", "sloppy", "proto=", "minorversion=", "port=", "cto", "nocto", "clientaddr=", "migration", "nomigration"},
"ntfs": {"iocharset=", "nls=", "utf8", "uni_xlate=", "posix=", "uid=", "gid=", "umask="},
"ntfs-3g": {"acl", "allow_other", "big_writes", "compression", "debug", "delay_mtime", "delay_mtime=", "dmask=", "efs_raw", "fmask=", "force", "hide_dot_files", "hide_hid_files", "inherit", "locale=", "max_read=", "no_def_opts", "no_detach", "nocompression", "norecover", "permissions", "posix_nlink", "recover", "remove_hiberfile", "show_sys_files", "silent", "special_files=", "streams_interface=", "uid=", "gid=", "umask=", "usermapping=", "user_xattr", "windows_names"},
"lowntfs-3g": {"acl", "allow_other", "big_writes", "compression", "debug", "delay_mtime", "delay_mtime=", "dmask=", "efs_raw", "fmask=", "force", "hide_dot_files", "hide_hid_files", "ignore_case", "inherit", "locale=", "max_read=", "no_def_opts", "no_detach", "nocompression", "norecover", "permissions", "posix_nlink", "recover", "remove_hiberfile", "show_sys_files", "silent", "special_files=", "streams_interface=", "uid=", "gid=", "umask=", "usermapping=", "user_xattr", "windows_names"},
Expand Down Expand Up @@ -244,6 +243,11 @@ type mountControlInterface struct {
// nearly any path, and due to the super-privileged nature of this interface it
// is expected that sensible values of what are enforced by the store manual
// review queue and security teams.
//
// Certain filesystem types impose additional restrictions on the allowed values
// for "what" attribute:
// - "tmpfs" - "what" must be set to "none"
// - "nfs" - "what" must be unset
var (
whatRegexp = regexp.MustCompile(`^(none|/[^"@]*)$`)
whereRegexp = regexp.MustCompile(`^(\$SNAP_COMMON|\$SNAP_DATA)?/[^\$"@]+$`)
Expand All @@ -253,8 +257,16 @@ var (
// malicious string like
//
// auto) options=() /malicious/content /var/lib/snapd/hostfs/...,\n mount fstype=(
//
// The "type" attribute is an optional list of expected filesystem types. It
// most useful in situations when it is known upfront that only a handful of
bboozzoo marked this conversation as resolved.
Show resolved Hide resolved
// types are accepted for a given mount.
var typeRegexp = regexp.MustCompile(`^[a-z0-9]+$`)

// Because of additional rules imposed on mount attributes, some filesystems can
// only be specified as a single "type" entry.
var exclusiveFsTypes = []string{"tmpfs", "nfs"}
zyga marked this conversation as resolved.
Show resolved Hide resolved

type MountInfo struct {
what string
where string
Expand Down Expand Up @@ -298,8 +310,18 @@ func enumerateMounts(plug interfaces.Attrer, fn func(mountInfo *MountInfo) error
}

for _, mount := range mounts {
types, err := parseStringList(mount, "type")
if err != nil {
return err
}

allowNoSource := false
Copy link
Collaborator

Choose a reason for hiding this comment

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

Consider allowNoSource -> disallowSource

if strutil.ListContains(types, "nfs") {
Copy link
Contributor

Choose a reason for hiding this comment

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

Also nfs4

allowNoSource = true
}

what, ok := mount["what"].(string)
if !ok {
if !ok && !allowNoSource {
return fmt.Errorf(`mount-control "what" must be a string`)
}

Expand All @@ -316,11 +338,6 @@ func enumerateMounts(plug interfaces.Attrer, fn func(mountInfo *MountInfo) error
}
}

types, err := parseStringList(mount, "type")
if err != nil {
return err
}

options, err := parseStringList(mount, "options")
if err != nil {
return err
Expand Down Expand Up @@ -360,6 +377,14 @@ func validateWhatAttr(mountInfo *MountInfo) error {
return validateNoAppArmorRegexpWithError(`cannot use mount-control "what" attribute`, what)
}

if mountInfo.isType("nfs") {
if what != "" {
return fmt.Errorf(`mount-control "what" attribute must not be specified for nfs mounts`)
}
// that's it for nfs
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is this comment required?

return nil
}

if !whatRegexp.MatchString(what) {
return fmt.Errorf(`mount-control "what" attribute is invalid: must start with / and not contain special characters`)
}
Expand Down Expand Up @@ -404,22 +429,29 @@ func validateWhereAttr(where string) error {
}

func validateMountTypes(types []string) error {
includesTmpfs := false
exclusiveFsType := ""

// multiple types specified in "type" are useful when the accepted
// filesystem type is known upfront or the mount uses one of the special
// types, such as "nfs" or "tmpfs"
for _, t := range types {
if !typeRegexp.MatchString(t) {
return fmt.Errorf(`mount-control filesystem type invalid: %q`, t)
}

if strutil.ListContains(disallowedFSTypes, t) {
return fmt.Errorf(`mount-control forbidden filesystem type: %q`, t)
}
if t == "tmpfs" {
includesTmpfs = true

if exclusiveFsType == "" && strutil.ListContains(exclusiveFsTypes, t) {
exclusiveFsType = t
}
}

if includesTmpfs && len(types) > 1 {
return errors.New(`mount-control filesystem type "tmpfs" cannot be listed with other types`)
if exclusiveFsType != "" && len(types) > 1 {
return fmt.Errorf(`mount-control filesystem type %q cannot be listed with other types`, exclusiveFsType)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should something about this be documented?
https://snapcraft.io/docs/mount-control-interface

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, but editing pages in the forum does not work atm

}

return nil
}

Expand Down Expand Up @@ -480,15 +512,15 @@ func isAllowedFilesystemSpecificMountOption(types []string, optionName string) b
}

func validateMountInfo(mountInfo *MountInfo) error {
if err := validateWhatAttr(mountInfo); err != nil {
if err := validateMountTypes(mountInfo.types); err != nil {
return err
}

if err := validateWhereAttr(mountInfo.where); err != nil {
if err := validateWhatAttr(mountInfo); err != nil {
return err
}

if err := validateMountTypes(mountInfo.types); err != nil {
if err := validateWhereAttr(mountInfo.where); err != nil {
return err
}

Expand Down Expand Up @@ -583,6 +615,15 @@ func (iface *mountControlInterface) AppArmorConnectedPlug(spec *apparmor.Specifi
target = expanded + target[variableEnd:]
}

if mountInfo.isType("nfs") {
// override NFS share source, also see 'nfs-mount' interface
source = "*:**"
Copy link
Member

Choose a reason for hiding this comment

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

Does this mean that there can only be one entry for nfs in the mount-control plug? Does this mean that we allow any nfs source, so the snap could mount any source(s) it wants, mediated by one rule?

Would it be feasible to specify the source one wants to allow connections to (and deny others), or is that beyond the scope of the interface to specify?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The host address and the share path describe some remote system and are not controlled by snap in any way, so I'm not sure we can expect to have something reliable here. I would prefer not to rebuild a snap or need to regenerate an assertion each time this changes. We have a similar rule in nfs-mount and cifs-mount interfaces.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I assume this was agreed with Samuele?

ernestl marked this conversation as resolved.
Show resolved Hide resolved

// emit additional rule required by NFS
emit(" # Allow lookup of RPC program numbers (due to mount-control)\n")
emit(" /etc/rpc r,\n")
}

var typeRule string
if optionIncompatibleWithFsType(mountInfo.options) != "" {
// In this rule the FS type will not match unless it's empty
Expand Down
40 changes: 40 additions & 0 deletions interfaces/builtin/mount_control_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ plugs:
where: $SNAP_COMMON/mnt/**
type: [aufs]
options: [br:/mnt/a, add:0:/mnt/b, dirwh=1, rw]
- type: [nfs]
where: /media/foo/**
options: [rw]
apps:
app:
plugs: [mntctl]
Expand Down Expand Up @@ -307,6 +310,35 @@ func (s *MountControlInterfaceSuite) TestSanitizePlugUnhappy(c *C) {
"mount:\n - what: diag\n where: /dev/ffs-diag\n type: [functionfs]\n options: [rw,uid=*]",
`cannot use mount-control "option" attribute: "uid=\*" contains a reserved apparmor char from.*`,
},
{
"mount:\n - what: diag\n where: /media/foo\n type: [nfs]\n options: [rw]",
`mount-control "what" attribute must not be specified for nfs mounts.*`,
},
{
"mount:\n - where: /media/foo\n type: [nfs, ext4]\n options: [rw]",
`mount-control filesystem type "nfs" cannot be listed with other types`,
},
{
"mount:\n - where: /media/foo\n type: [tmpfs, nfs, ext4]\n options: [rw]",
`mount-control filesystem type "tmpfs" cannot be listed with other types`,
},
{
"mount:\n - what: 123\n where: /media/foo\n type: [ext4]\n options: [rw]",
`mount-control "what" must be a string`,
},
// the deprecated nfs4 isn't explicitly forbidden, but it is not
Copy link
Collaborator

Choose a reason for hiding this comment

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

We should probably document something about this:
https://snapcraft.io/docs/mount-control-interface

// possible construct a valid and useful specification using this
// type
{
// deprecated nfs4
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should we update nfs_mount.go ?

mount fstype=nfs{,4} *:** -> ...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In a separate PR perhaps

"mount:\n - where: /media/foo\n type: [nfs4]\n options: [rw]",
`mount-control "what" must be a string`,
},
{
// deprecated nfs4
"mount:\n - where: /media/foo\n type: [nfs4]\n options: [rw]\n what: 127.0.0.1:/share",
`mount-control "what" attribute is invalid: must start with / and not contain special characters`,
Copy link
Contributor

Choose a reason for hiding this comment

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

I would have expected this to say "nfs4" is not supported.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

it keeps the current status quo wrt nfs4, i.e. it's not possible to construct an mount-control entry which would work

Copy link
Collaborator

Choose a reason for hiding this comment

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

This might be more confusing than helpful, what is the impact of changing this to be more accurate.

},
}

for _, testData := range data {
Expand Down Expand Up @@ -376,6 +408,14 @@ func (s *MountControlInterfaceSuite) TestAppArmorSpec(c *C) {
c.Assert(spec.SnippetForTag("snap.consumer.app"), testutil.Contains, expectedMountLine7)
expectedUmountLine7 := `umount "/var/snap/consumer/common/mnt/**{,/}",`
c.Assert(spec.SnippetForTag("snap.consumer.app"), testutil.Contains, expectedUmountLine7)

expectedMountLine8 := `mount fstype=(nfs) options=(rw) ` +
`"*:**" -> "/media/foo/**{,/}",`
c.Assert(spec.SnippetForTag("snap.consumer.app"), testutil.Contains, expectedMountLine8)
expectedUmountLine8 := `umount "/media/foo/**{,/}",`
c.Assert(spec.SnippetForTag("snap.consumer.app"), testutil.Contains, expectedUmountLine8)
expectedExtraLine8 := ` /etc/rpc r,`
c.Assert(spec.SnippetForTag("snap.consumer.app"), testutil.Contains, expectedExtraLine8)
}

func (s *MountControlInterfaceSuite) TestStaticInfo(c *C) {
Expand Down
36 changes: 28 additions & 8 deletions overlord/hookstate/ctlcmd/mount.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,17 +72,29 @@
return err == nil && pp.Matches(path)
}

// matchConnection checks whether the given mount connection attributes give
// the snap permission to execute the mount command
func (m *mountCommand) matchConnection(attributes map[string]interface{}) bool {
if !matchMountPathAttribute(m.Positional.What, attributes["what"], m.snapInfo) {
return false
}
func matchMountSourceAttribute(path string, attribute interface{}, fsType string, snapInfo *snap.Info) bool {
if fsType == "nfs" {
zyga marked this conversation as resolved.
Show resolved Hide resolved
// NFS mount source AppArmor profiles expects a match for "*:**", so
// make sure that the attribute is unset, and the path matches the
// format
if _, ok := attribute.(string); ok {
return false
zyga marked this conversation as resolved.
Show resolved Hide resolved
}

Check warning on line 82 in overlord/hookstate/ctlcmd/mount.go

View check run for this annotation

Codecov / codecov/patch

overlord/hookstate/ctlcmd/mount.go#L81-L82

Added lines #L81 - L82 were not covered by tests

if !matchMountPathAttribute(m.Positional.Where, attributes["where"], m.snapInfo) {
return false
host, share, found := strings.Cut(path, ":")
if !found || host == "" || strings.Contains(host, "/") || share == "" {
return false
}

return true
}

return matchMountPathAttribute(path, attribute, snapInfo)
}

// matchConnection checks whether the given mount connection attributes give
// the snap permission to execute the mount command
func (m *mountCommand) matchConnection(attributes map[string]interface{}) bool {
if m.Type != "" {
if types, ok := attributes["type"].([]interface{}); ok {
found := false
Expand All @@ -106,6 +118,14 @@
}
}

if !matchMountSourceAttribute(m.Positional.What, attributes["what"], m.Type, m.snapInfo) {
return false
}

if !matchMountPathAttribute(m.Positional.Where, attributes["where"], m.snapInfo) {
return false
}

if optionsIfaces, ok := attributes["options"].([]interface{}); ok {
var allowedOptions []string
for _, iface := range optionsIfaces {
Expand Down
35 changes: 35 additions & 0 deletions overlord/hookstate/ctlcmd/mount_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,11 @@ func (s *mountSuite) SetUpTest(c *C) {
"options": []string{"ro"},
"persistent": false,
},
map[string]interface{}{
"where": "/nfs-dest",
"options": []string{"rw"},
"type": []string{"nfs"},
},
},
},
}
Expand Down Expand Up @@ -270,6 +275,15 @@ func (s *mountSuite) TestMissingProperPlug(c *C) {
_, _, err = ctlcmd.Run(s.mockContext, []string{"mount", "--persistent", "-o", "bind,rw", "/src", "/dest"}, 0)
c.Check(err, ErrorMatches, `.*no matching mount-control connection found`)
c.Check(s.sysd.EnsureMountUnitFileWithOptionsCalls, HasLen, 0)

// bad NFS source format
_, _, err = ctlcmd.Run(s.mockContext, []string{"mount", "-o", "rw", "-t", "nfs", "/src", "/dest"}, 0)
c.Check(err, ErrorMatches, `.*no matching mount-control connection found`)
_, _, err = ctlcmd.Run(s.mockContext, []string{"mount", "-o", "rw", "-t", "nfs", "/host:/src", "/dest"}, 0)
c.Check(err, ErrorMatches, `.*no matching mount-control connection found`)
_, _, err = ctlcmd.Run(s.mockContext, []string{"mount", "-o", "rw", "-t", "nfs", ":/share", "/dest"}, 0)
c.Check(err, ErrorMatches, `.*no matching mount-control connection found`)
c.Check(s.sysd.EnsureMountUnitFileWithOptionsCalls, HasLen, 0)
}

func (s *mountSuite) TestUnitCreationFailure(c *C) {
Expand Down Expand Up @@ -353,6 +367,27 @@ func (s *mountSuite) TestHappyWithCommasInPath(c *C) {
})
}

func (s *mountSuite) TestHappyNFS(c *C) {
s.injectSnapWithProperPlug(c)

s.sysd.EnsureMountUnitFileWithOptionsResult = ResultForEnsureMountUnitFileWithOptions{"/path/unit.mount", nil}

// Now try with commas in the paths
_, _, err := ctlcmd.Run(s.mockContext, []string{"mount", "-o", "rw", "-t", "nfs", "localhost:/var/share", "/nfs-dest"}, 0)
c.Check(err, IsNil)
c.Check(s.sysd.EnsureMountUnitFileWithOptionsCalls, DeepEquals, []*systemd.MountUnitOptions{
{
Lifetime: systemd.Transient,
Description: "Mount unit for snap1, revision 1 via mount-control",
What: "localhost:/var/share",
Where: "/nfs-dest",
Fstype: "nfs",
Options: []string{"rw"},
Origin: "mount-control",
},
})
}

func (s *mountSuite) TestEnsureMountUnitFailed(c *C) {
s.injectSnapWithProperPlug(c)

Expand Down
4 changes: 4 additions & 0 deletions tests/lib/snaps/store/test-snapd-mount-control-nfs/bin/cmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/bin/sh
PS1='$ '

exec "$@"
34 changes: 34 additions & 0 deletions tests/lib/snaps/store/test-snapd-mount-control-nfs/snapcraft.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: test-snapd-mount-control-nfs
summary: Snap for testing mount-control with NFS
description: Snap for testing mount-control with NFS
version: "1.0"
base: core22
confinement: strict

apps:
cmd:
command: bin/cmd
plugs:
- mntctl
- network
- removable-media

plugs:
mntctl:
interface: mount-control
mount:
- type: [nfs]
where: /media/**
options: [rw]

parts:
apps:
plugin: dump
source: .

network-shares:
plugin: nil
stage-packages:
- nfs-common
stage:
- -lib/systemd/system/nfs-common.service
Loading
Loading