github.com/meulengracht/snapd@v0.0.0-20210719210640-8bde69bcc84e/sysconfig/cloudinit.go (about)

     1  // -*- Mode: Go; indent-tabs-mode: t -*-
     2  
     3  /*
     4   * Copyright (C) 2020 Canonical Ltd
     5   *
     6   * This program is free software: you can redistribute it and/or modify
     7   * it under the terms of the GNU General Public License version 3 as
     8   * published by the Free Software Foundation.
     9   *
    10   * This program is distributed in the hope that it will be useful,
    11   * but WITHOUT ANY WARRANTY; without even the implied warranty of
    12   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    13   * GNU General Public License for more details.
    14   *
    15   * You should have received a copy of the GNU General Public License
    16   * along with this program.  If not, see <http://www.gnu.org/licenses/>.
    17   *
    18   */
    19  
    20  package sysconfig
    21  
    22  import (
    23  	"encoding/json"
    24  	"fmt"
    25  	"io/ioutil"
    26  	"os"
    27  	"os/exec"
    28  	"path/filepath"
    29  	"regexp"
    30  
    31  	"github.com/snapcore/snapd/dirs"
    32  	"github.com/snapcore/snapd/logger"
    33  	"github.com/snapcore/snapd/osutil"
    34  	"github.com/snapcore/snapd/strutil"
    35  )
    36  
    37  // HasGadgetCloudConf takes a gadget directory and returns whether there is
    38  // cloud-init config in the form of a cloud.conf file in the gadget.
    39  func HasGadgetCloudConf(gadgetDir string) bool {
    40  	return osutil.FileExists(filepath.Join(gadgetDir, "cloud.conf"))
    41  }
    42  
    43  func ubuntuDataCloudDir(rootdir string) string {
    44  	return filepath.Join(rootdir, "etc/cloud/")
    45  }
    46  
    47  // DisableCloudInit will disable cloud-init permanently by writing a
    48  // cloud-init.disabled config file in etc/cloud under the target dir, which
    49  // instructs cloud-init-generator to not trigger new cloud-init invocations.
    50  // Note that even with this disabled file, a root user could still manually run
    51  // cloud-init, but this capability is not provided to any strictly confined
    52  // snap.
    53  func DisableCloudInit(rootDir string) error {
    54  	ubuntuDataCloud := ubuntuDataCloudDir(rootDir)
    55  	if err := os.MkdirAll(ubuntuDataCloud, 0755); err != nil {
    56  		return fmt.Errorf("cannot make cloud config dir: %v", err)
    57  	}
    58  	if err := ioutil.WriteFile(filepath.Join(ubuntuDataCloud, "cloud-init.disabled"), nil, 0644); err != nil {
    59  		return fmt.Errorf("cannot disable cloud-init: %v", err)
    60  	}
    61  
    62  	return nil
    63  }
    64  
    65  // installCloudInitCfgDir installs glob cfg files from the source directory to
    66  // the cloud config dir.
    67  func installCloudInitCfgDir(src, targetdir, prefix string) error {
    68  	// TODO:UC20: enforce patterns on the glob files and their suffix ranges
    69  	ccl, err := filepath.Glob(filepath.Join(src, "*.cfg"))
    70  	if err != nil {
    71  		return err
    72  	}
    73  	if len(ccl) == 0 {
    74  		return nil
    75  	}
    76  
    77  	ubuntuDataCloudCfgDir := filepath.Join(ubuntuDataCloudDir(targetdir), "cloud.cfg.d/")
    78  	if err := os.MkdirAll(ubuntuDataCloudCfgDir, 0755); err != nil {
    79  		return fmt.Errorf("cannot make cloud config dir: %v", err)
    80  	}
    81  
    82  	for _, cc := range ccl {
    83  		if err := osutil.CopyFile(cc, filepath.Join(ubuntuDataCloudCfgDir, prefix+filepath.Base(cc)), 0); err != nil {
    84  			return err
    85  		}
    86  	}
    87  	return nil
    88  }
    89  
    90  // installGadgetCloudInitCfg installs a single cloud-init config file from the
    91  // gadget snap to the /etc/cloud config dir as "80_device_gadget.cfg".
    92  func installGadgetCloudInitCfg(src, targetdir string) error {
    93  	ubuntuDataCloudCfgDir := filepath.Join(ubuntuDataCloudDir(targetdir), "cloud.cfg.d/")
    94  	if err := os.MkdirAll(ubuntuDataCloudCfgDir, 0755); err != nil {
    95  		return fmt.Errorf("cannot make cloud config dir: %v", err)
    96  	}
    97  
    98  	configFile := filepath.Join(ubuntuDataCloudCfgDir, "80_device_gadget.cfg")
    99  	return osutil.CopyFile(src, configFile, 0)
   100  }
   101  
   102  func configureCloudInit(opts *Options) (err error) {
   103  	if opts.TargetRootDir == "" {
   104  		return fmt.Errorf("unable to configure cloud-init, missing target dir")
   105  	}
   106  
   107  	// first check if cloud-init should be disallowed entirely
   108  	if !opts.AllowCloudInit {
   109  		return DisableCloudInit(WritableDefaultsDir(opts.TargetRootDir))
   110  	}
   111  
   112  	// next check if there is a gadget cloud.conf to install
   113  	if HasGadgetCloudConf(opts.GadgetDir) {
   114  		// then copy / install the gadget config first
   115  		gadgetCloudConf := filepath.Join(opts.GadgetDir, "cloud.conf")
   116  		if err := installGadgetCloudInitCfg(gadgetCloudConf, WritableDefaultsDir(opts.TargetRootDir)); err != nil {
   117  			return err
   118  		}
   119  
   120  		// we don't return here to enable also copying any cloud-init config
   121  		// from ubuntu-seed in order for both to be used simultaneously for
   122  		// example on test devices where the gadget has a gadget.yaml, but for
   123  		// testing purposes you also want to provision another user with
   124  		// ubuntu-seed cloud-init config
   125  
   126  	}
   127  
   128  	// TODO:UC20: implement filtering of files from src when specified via a
   129  	//            specific Options for i.e. signed grade and MAAS, etc.
   130  
   131  	// finally check if there is a cloud-init src dir we should copy config
   132  	// files from
   133  
   134  	if opts.CloudInitSrcDir != "" {
   135  		// set the prefix such that any ubuntu-seed config that ends up getting
   136  		// installed takes precedence over the gadget config
   137  		return installCloudInitCfgDir(opts.CloudInitSrcDir, WritableDefaultsDir(opts.TargetRootDir), "90_")
   138  	}
   139  
   140  	// it's valid to allow cloud-init, but not set CloudInitSrcDir and not have
   141  	// a gadget cloud.conf, in this case cloud-init may pick up dynamic metadata
   142  	// and userdata from NoCloud sources such as a CD-ROM drive with label
   143  	// CIDATA, etc. during first-boot
   144  
   145  	return nil
   146  }
   147  
   148  // CloudInitState represents the various cloud-init states
   149  type CloudInitState int
   150  
   151  var (
   152  	// the (?m) is needed since cloud-init output will have newlines
   153  	cloudInitStatusRe = regexp.MustCompile(`(?m)^status: (.*)$`)
   154  	datasourceRe      = regexp.MustCompile(`DataSource([a-zA-Z0-9]+).*`)
   155  
   156  	cloudInitSnapdRestrictFile = "/etc/cloud/cloud.cfg.d/zzzz_snapd.cfg"
   157  	cloudInitDisabledFile      = "/etc/cloud/cloud-init.disabled"
   158  
   159  	// for NoCloud datasource, we need to specify "manual_cache_clean: true"
   160  	// because the default is false, and this key being true essentially informs
   161  	// cloud-init that it should always trust the instance-id it has cached in
   162  	// the image, and shouldn't assume that there is a new one on every boot, as
   163  	// otherwise we have bugs like https://bugs.launchpad.net/snapd/+bug/1905983
   164  	// where subsequent boots after cloud-init runs and gets restricted it will
   165  	// try to detect the instance_id by reading from the NoCloud datasource
   166  	// fs_label, but we set that to "null" so it fails to read anything and thus
   167  	// can't detect the effective instance_id and assumes it is different and
   168  	// applies default config which can overwrite valid config from the initial
   169  	// boot if that is not the default config
   170  	// see also https://cloudinit.readthedocs.io/en/latest/topics/boot.html?highlight=manual_cache_clean#first-boot-determination
   171  	nocloudRestrictYaml = []byte(`datasource_list: [NoCloud]
   172  datasource:
   173    NoCloud:
   174      fs_label: null
   175  manual_cache_clean: true
   176  `)
   177  
   178  	// don't use manual_cache_clean for real cloud datasources, the setting is
   179  	// used with ubuntu core only for sources where we can only get the
   180  	// instance_id through the fs_label for NoCloud and None (since we disable
   181  	// importing using the fs_label after the initial run).
   182  	genericCloudRestrictYamlPattern = `datasource_list: [%s]
   183  `
   184  
   185  	localDatasources = []string{"NoCloud", "None"}
   186  )
   187  
   188  const (
   189  	// CloudInitDisabledPermanently is when cloud-init is disabled as per the
   190  	// cloud-init.disabled file.
   191  	CloudInitDisabledPermanently CloudInitState = iota
   192  	// CloudInitRestrictedBySnapd is when cloud-init has been restricted by
   193  	// snapd with a specific config file.
   194  	CloudInitRestrictedBySnapd
   195  	// CloudInitUntriggered is when cloud-init is disabled because nothing has
   196  	// triggered it to run, but it could still be run.
   197  	CloudInitUntriggered
   198  	// CloudInitDone is when cloud-init has been run on this boot.
   199  	CloudInitDone
   200  	// CloudInitEnabled is when cloud-init is active, but not necessarily
   201  	// finished. This matches the "running" and "not run" states from cloud-init
   202  	// as well as any other state that does not match any of the other defined
   203  	// states, as we are conservative in assuming that cloud-init is doing
   204  	// something.
   205  	CloudInitEnabled
   206  	// CloudInitNotFound is when there is no cloud-init executable on the
   207  	// device.
   208  	CloudInitNotFound
   209  	// CloudInitErrored is when cloud-init tried to run, but failed or had invalid
   210  	// configuration.
   211  	CloudInitErrored
   212  )
   213  
   214  // CloudInitStatus returns the current status of cloud-init. Note that it will
   215  // first check for static file-based statuses first through the snapd
   216  // restriction file and the disabled file before consulting
   217  // cloud-init directly through the status command.
   218  // Also note that in unknown situations we are conservative in assuming that
   219  // cloud-init may be doing something and will return CloudInitEnabled when we
   220  // do not recognize the state returned by the cloud-init status command.
   221  func CloudInitStatus() (CloudInitState, error) {
   222  	// if cloud-init has been restricted by snapd, check that first
   223  	snapdRestrictingFile := filepath.Join(dirs.GlobalRootDir, cloudInitSnapdRestrictFile)
   224  	if osutil.FileExists(snapdRestrictingFile) {
   225  		return CloudInitRestrictedBySnapd, nil
   226  	}
   227  
   228  	// if it was explicitly disabled via the cloud-init disable file, then
   229  	// return special status for that
   230  	disabledFile := filepath.Join(dirs.GlobalRootDir, cloudInitDisabledFile)
   231  	if osutil.FileExists(disabledFile) {
   232  		return CloudInitDisabledPermanently, nil
   233  	}
   234  
   235  	ciBinary, err := exec.LookPath("cloud-init")
   236  	if err != nil {
   237  		logger.Noticef("cannot locate cloud-init executable: %v", err)
   238  		return CloudInitNotFound, nil
   239  	}
   240  
   241  	out, err := exec.Command(ciBinary, "status").CombinedOutput()
   242  	if err != nil {
   243  		return CloudInitErrored, osutil.OutputErr(out, err)
   244  	}
   245  	// output should just be "status: <state>"
   246  	match := cloudInitStatusRe.FindSubmatch(out)
   247  	if len(match) != 2 {
   248  		return CloudInitErrored, fmt.Errorf("invalid cloud-init output: %v", osutil.OutputErr(out, err))
   249  	}
   250  	switch string(match[1]) {
   251  	case "disabled":
   252  		// here since we weren't disabled by the file, we are in "disabled but
   253  		// could be enabled" state - arguably this should be a different state
   254  		// than "disabled", see
   255  		// https://bugs.launchpad.net/cloud-init/+bug/1883124 and
   256  		// https://bugs.launchpad.net/cloud-init/+bug/1883122
   257  		return CloudInitUntriggered, nil
   258  	case "error":
   259  		return CloudInitErrored, nil
   260  	case "done":
   261  		return CloudInitDone, nil
   262  	// "running" and "not run" are considered Enabled, see doc-comment
   263  	case "running", "not run":
   264  		fallthrough
   265  	default:
   266  		// these states are all
   267  		return CloudInitEnabled, nil
   268  	}
   269  }
   270  
   271  // these structs are externally defined by cloud-init
   272  type v1Data struct {
   273  	DataSource string `json:"datasource"`
   274  }
   275  
   276  type cloudInitStatus struct {
   277  	V1 v1Data `json:"v1"`
   278  }
   279  
   280  // CloudInitRestrictionResult is the result of calling RestrictCloudInit. The
   281  // values for Action are "disable" or "restrict", and the Datasource will be set
   282  // to the restricted datasource if Action is "restrict".
   283  type CloudInitRestrictionResult struct {
   284  	Action     string
   285  	DataSource string
   286  }
   287  
   288  // CloudInitRestrictOptions are options for how to restrict cloud-init with
   289  // RestrictCloudInit.
   290  type CloudInitRestrictOptions struct {
   291  	// ForceDisable will force disabling cloud-init even if it is
   292  	// in an active/running or errored state.
   293  	ForceDisable bool
   294  
   295  	// DisableAfterLocalDatasourcesRun modifies RestrictCloudInit to disable
   296  	// cloud-init after it has run on first-boot if the datasource detected is
   297  	// a local source such as NoCloud or None. If the datasource detected is not
   298  	// a local source, such as GCE or AWS EC2 it is merely restricted as
   299  	// described in the doc-comment on RestrictCloudInit.
   300  	DisableAfterLocalDatasourcesRun bool
   301  }
   302  
   303  // RestrictCloudInit will limit the operations of cloud-init on subsequent boots
   304  // by either disabling cloud-init in the untriggered state, or restrict
   305  // cloud-init to only use a specific datasource (additionally if the currently
   306  // detected datasource for this boot was NoCloud, it will disable the automatic
   307  // import of filesystems with labels such as CIDATA (or cidata) as datasources).
   308  // This is expected to be run when cloud-init is in a "steady" state such as
   309  // done or disabled (untriggered). If called in other states such as errored, it
   310  // will return an error, but it can be forced to disable cloud-init anyways in
   311  // these states with the opts parameter and the ForceDisable field.
   312  // This function is meant to protect against CVE-2020-11933.
   313  func RestrictCloudInit(state CloudInitState, opts *CloudInitRestrictOptions) (CloudInitRestrictionResult, error) {
   314  	res := CloudInitRestrictionResult{}
   315  
   316  	if opts == nil {
   317  		opts = &CloudInitRestrictOptions{}
   318  	}
   319  
   320  	switch state {
   321  	case CloudInitDone:
   322  		// handled below
   323  		break
   324  	case CloudInitRestrictedBySnapd:
   325  		return res, fmt.Errorf("cannot restrict cloud-init: already restricted")
   326  	case CloudInitDisabledPermanently:
   327  		return res, fmt.Errorf("cannot restrict cloud-init: already disabled")
   328  	case CloudInitErrored, CloudInitEnabled:
   329  		// if we are not forcing a disable, return error as these states are
   330  		// where cloud-init could still be running doing things
   331  		if !opts.ForceDisable {
   332  			return res, fmt.Errorf("cannot restrict cloud-init in error or enabled state")
   333  		}
   334  		fallthrough
   335  	case CloudInitUntriggered, CloudInitNotFound:
   336  		fallthrough
   337  	default:
   338  		res.Action = "disable"
   339  		return res, DisableCloudInit(dirs.GlobalRootDir)
   340  	}
   341  
   342  	// from here on out, we are taking the "restrict" action
   343  	res.Action = "restrict"
   344  
   345  	// first get the cloud-init data-source that was used from /
   346  	resultsFile := filepath.Join(dirs.GlobalRootDir, "/run/cloud-init/status.json")
   347  
   348  	f, err := os.Open(resultsFile)
   349  	if err != nil {
   350  		return res, err
   351  	}
   352  	defer f.Close()
   353  
   354  	var stat cloudInitStatus
   355  	err = json.NewDecoder(f).Decode(&stat)
   356  	if err != nil {
   357  		return res, err
   358  	}
   359  
   360  	// if the datasource was empty then cloud-init did something wrong or
   361  	// perhaps it incorrectly reported that it ran but something else deleted
   362  	// the file
   363  	datasourceRaw := stat.V1.DataSource
   364  	if datasourceRaw == "" {
   365  		return res, fmt.Errorf("cloud-init error: missing datasource from status.json")
   366  	}
   367  
   368  	// for some datasources there is additional data in this item, i.e. for
   369  	// NoCloud we will also see:
   370  	// "DataSourceNoCloud [seed=/dev/sr0][dsmode=net]"
   371  	// so hence we use a regexp to parse out just the name of the datasource
   372  	datasourceMatches := datasourceRe.FindStringSubmatch(datasourceRaw)
   373  	if len(datasourceMatches) != 2 {
   374  		return res, fmt.Errorf("cloud-init error: unexpected datasource format %q", datasourceRaw)
   375  	}
   376  	res.DataSource = datasourceMatches[1]
   377  
   378  	cloudInitRestrictFile := filepath.Join(dirs.GlobalRootDir, cloudInitSnapdRestrictFile)
   379  
   380  	switch {
   381  	case opts.DisableAfterLocalDatasourcesRun && strutil.ListContains(localDatasources, res.DataSource):
   382  		// On UC20, DisableAfterLocalDatasourcesRun will be set, where we want
   383  		// to disable local sources like NoCloud and None after first-boot
   384  		// instead of just restricting them like we do below for UC16 and UC18.
   385  
   386  		// as such, change the action taken to disable and disable cloud-init
   387  		res.Action = "disable"
   388  		err = DisableCloudInit(dirs.GlobalRootDir)
   389  	case res.DataSource == "NoCloud":
   390  		// With the NoCloud datasource (which is one of the local datasources),
   391  		// we also need to restrict/disable the import of arbitrary filesystem
   392  		// labels to use as datasources, i.e. a USB drive inserted by an
   393  		// attacker with label CIDATA will defeat security measures on Ubuntu
   394  		// Core, so with the additional fs_label spec, we disable that import.
   395  		err = ioutil.WriteFile(cloudInitRestrictFile, nocloudRestrictYaml, 0644)
   396  	default:
   397  		// all other cases are either not local on UC20, or not NoCloud and as
   398  		// such we simply restrict cloud-init to the specific datasource used so
   399  		// that an attack via NoCloud is protected against
   400  		yaml := []byte(fmt.Sprintf(genericCloudRestrictYamlPattern, res.DataSource))
   401  		err = ioutil.WriteFile(cloudInitRestrictFile, yaml, 0644)
   402  	}
   403  
   404  	return res, err
   405  }