github.com/anonymouse64/snapd@v0.0.0-20210824153203-04c4c42d842d/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  	"sort"
    31  	"strings"
    32  
    33  	yaml "gopkg.in/yaml.v2"
    34  
    35  	"github.com/snapcore/snapd/asserts"
    36  	"github.com/snapcore/snapd/dirs"
    37  	"github.com/snapcore/snapd/logger"
    38  	"github.com/snapcore/snapd/osutil"
    39  	"github.com/snapcore/snapd/strutil"
    40  )
    41  
    42  // HasGadgetCloudConf takes a gadget directory and returns whether there is
    43  // cloud-init config in the form of a cloud.conf file in the gadget.
    44  func HasGadgetCloudConf(gadgetDir string) bool {
    45  	return osutil.FileExists(filepath.Join(gadgetDir, "cloud.conf"))
    46  }
    47  
    48  func ubuntuDataCloudDir(rootdir string) string {
    49  	return filepath.Join(rootdir, "etc/cloud/")
    50  }
    51  
    52  // DisableCloudInit will disable cloud-init permanently by writing a
    53  // cloud-init.disabled config file in etc/cloud under the target dir, which
    54  // instructs cloud-init-generator to not trigger new cloud-init invocations.
    55  // Note that even with this disabled file, a root user could still manually run
    56  // cloud-init, but this capability is not provided to any strictly confined
    57  // snap.
    58  func DisableCloudInit(rootDir string) error {
    59  	ubuntuDataCloud := ubuntuDataCloudDir(rootDir)
    60  	if err := os.MkdirAll(ubuntuDataCloud, 0755); err != nil {
    61  		return fmt.Errorf("cannot make cloud config dir: %v", err)
    62  	}
    63  	if err := ioutil.WriteFile(filepath.Join(ubuntuDataCloud, "cloud-init.disabled"), nil, 0644); err != nil {
    64  		return fmt.Errorf("cannot disable cloud-init: %v", err)
    65  	}
    66  
    67  	return nil
    68  }
    69  
    70  // supportedFilteredCloudConfig is a struct of the supported values for
    71  // cloud-init configuration file.
    72  type supportedFilteredCloudConfig struct {
    73  	Datasource map[string]supportedFilteredDatasource `yaml:"datasource,omitempty"`
    74  	Network    map[string]interface{}                 `yaml:"network,omitempty"`
    75  	// DatasourceList is a pointer so we can distinguish between:
    76  	// datasource_list: []
    77  	// and not setting the datasource at all
    78  	// for example there might be gadgets which don't want to use any
    79  	// datasources, but still wants to set some networking config
    80  	DatasourceList *[]string                             `yaml:"datasource_list,omitempty"`
    81  	Reporting      map[string]supportedFilteredReporting `yaml:"reporting,omitempty"`
    82  }
    83  
    84  type supportedFilteredDatasource struct {
    85  	// these are for MAAS
    86  	ConsumerKey string `yaml:"consumer_key,omitempty"`
    87  	MetadataURL string `yaml:"metadata_url,omitempty"`
    88  	TokenKey    string `yaml:"token_key,omitempty"`
    89  	TokenSecret string `yaml:"token_secret,omitempty"`
    90  }
    91  
    92  type supportedFilteredReporting struct {
    93  	Type        string `yaml:"type,omitempty"`
    94  	Endpoint    string `yaml:"endpoint,omitempty"`
    95  	ConsumerKey string `yaml:"consumer_key,omitempty"`
    96  	TokenKey    string `yaml:"token_key,omitempty"`
    97  	TokenSecret string `yaml:"token_secret,omitempty"`
    98  }
    99  
   100  // filterCloudCfg filters a cloud-init configuration struct parsed from a single
   101  // cloud-init configuration file. The config provided here may be a subset of
   102  // the full cloud-init configuration from the file in that there may be
   103  // top-level keys in the YAML file that we did not parse and as such they are
   104  // dropped and filtered automatically. For other keys, we must parse part of the
   105  // configuration struct and remove nested keys while keeping other parts of the
   106  // same section.
   107  func filterCloudCfg(cfg *supportedFilteredCloudConfig, allowedDatasources []string) error {
   108  	// TODO: should we track modifications / filters applied to log/notify about
   109  	//       what is dropped / not supported?
   110  
   111  	// first filter out the disallowed datasources
   112  	for dsName := range cfg.Datasource {
   113  		// remove unsupported or unrecognized datasources
   114  		if !strutil.ListContains(allowedDatasources, strings.ToUpper(dsName)) {
   115  			delete(cfg.Datasource, dsName)
   116  			continue
   117  		}
   118  	}
   119  
   120  	// next handle the datasource list setting, if it was not empty, reset it to
   121  	// the allowedDatasources we were provided
   122  	if cfg.DatasourceList != nil {
   123  		deepCpy := make([]string, 0, len(allowedDatasources))
   124  		deepCpy = append(deepCpy, allowedDatasources...)
   125  		cfg.DatasourceList = &deepCpy
   126  	}
   127  
   128  	// next handle the reporting setting
   129  	for dsName := range cfg.Reporting {
   130  		// remove unsupported or unrecognized datasources
   131  		if !strutil.ListContains(allowedDatasources, strings.ToUpper(dsName)) {
   132  			delete(cfg.Reporting, dsName)
   133  			continue
   134  		}
   135  	}
   136  
   137  	return nil
   138  }
   139  
   140  // filterCloudCfgFile takes a cloud config file as input and filters out unknown
   141  // and unsupported keys from the config, returning a new file. It also will
   142  // filter out configuration that is specific to a datasource if that datasource
   143  // is not specified in the allowedDatasources argument. The empty string will be
   144  // returned if the input file was entirely filtered out and there is nothing
   145  // left.
   146  func filterCloudCfgFile(in string, allowedDatasources []string) (string, error) {
   147  	dstFileName := filepath.Base(in)
   148  	filteredFile, err := ioutil.TempFile("", dstFileName)
   149  	if err != nil {
   150  		return "", err
   151  	}
   152  	defer filteredFile.Close()
   153  
   154  	// open the source and unmarshal it as yaml
   155  	unfilteredFileBytes, err := ioutil.ReadFile(in)
   156  	if err != nil {
   157  		return "", err
   158  	}
   159  
   160  	var cfg supportedFilteredCloudConfig
   161  	if err := yaml.Unmarshal(unfilteredFileBytes, &cfg); err != nil {
   162  		return "", err
   163  	}
   164  
   165  	if err := filterCloudCfg(&cfg, allowedDatasources); err != nil {
   166  		return "", err
   167  	}
   168  
   169  	// write out cfg to the filtered file now
   170  	b, err := yaml.Marshal(cfg)
   171  	if err != nil {
   172  		return "", err
   173  	}
   174  
   175  	// check if we need to write a file at all, if the yaml serialization was
   176  	// entirely filtered out, then we don't need to write anything
   177  	if strings.TrimSpace(string(b)) == "{}" {
   178  		return "", nil
   179  	}
   180  
   181  	// add the #cloud-config prefix to all files we write
   182  	if _, err := filteredFile.Write([]byte("#cloud-config\n")); err != nil {
   183  		return "", err
   184  	}
   185  
   186  	if _, err := filteredFile.Write(b); err != nil {
   187  		return "", err
   188  	}
   189  
   190  	// use the newly filtered temp file as the source to copy
   191  	return filteredFile.Name(), nil
   192  }
   193  
   194  type cloudDatasourcesInUseResult struct {
   195  	// ExplicitlyAllowed is the value of datasource_list. If this is empty,
   196  	// consult ExplicitlyNoneAllowed to tell if it was specified as empty in the
   197  	// config or if it was just absent from the config
   198  	ExplicitlyAllowed []string
   199  	// ExplicitlyNoneAllowed is true when datasource_list was set to
   200  	// specifically the empty list, thus disallowing use of any datasource
   201  	ExplicitlyNoneAllowed bool
   202  	// Mentioned is the full set of datasources mentioned in the yaml config.
   203  	Mentioned []string
   204  }
   205  
   206  // cloudDatasourcesInUse returns the datasources in use by the specified config
   207  // file. All datasource names are made upper case to be comparable. This is an
   208  // arbitrary choice between making them upper case or making them lower case,
   209  // but cloud-init treats "maas" the same as "MAAS", so we need to treat them the
   210  // same too.
   211  func cloudDatasourcesInUse(configFile string) (*cloudDatasourcesInUseResult, error) {
   212  	// TODO: are there other keys in addition to those that we support in
   213  	// filtering that might mention datasources ?
   214  
   215  	b, err := ioutil.ReadFile(configFile)
   216  	if err != nil {
   217  		return nil, err
   218  	}
   219  
   220  	var cfg supportedFilteredCloudConfig
   221  	if err := yaml.Unmarshal(b, &cfg); err != nil {
   222  		return nil, err
   223  	}
   224  
   225  	res := &cloudDatasourcesInUseResult{}
   226  
   227  	sourcesMentionedInCfg := map[string]bool{}
   228  
   229  	// datasource key is a map with the datasource name as a key
   230  	for ds := range cfg.Datasource {
   231  		sourcesMentionedInCfg[strings.ToUpper(ds)] = true
   232  	}
   233  
   234  	// same for reporting
   235  	for ds := range cfg.Reporting {
   236  		sourcesMentionedInCfg[strings.ToUpper(ds)] = true
   237  	}
   238  
   239  	// we can also have datasources mentioned in the datasource list config
   240  	if cfg.DatasourceList != nil {
   241  		if len(*cfg.DatasourceList) == 0 {
   242  			res.ExplicitlyNoneAllowed = true
   243  		} else {
   244  			explicitlyAllowed := map[string]bool{}
   245  			for _, ds := range *cfg.DatasourceList {
   246  				dsName := strings.ToUpper(ds)
   247  				sourcesMentionedInCfg[dsName] = true
   248  				explicitlyAllowed[dsName] = true
   249  			}
   250  			res.ExplicitlyAllowed = make([]string, 0, len(explicitlyAllowed))
   251  			for ds := range explicitlyAllowed {
   252  				res.ExplicitlyAllowed = append(res.ExplicitlyAllowed, ds)
   253  			}
   254  			sort.Strings(res.ExplicitlyAllowed)
   255  		}
   256  	}
   257  
   258  	for ds := range sourcesMentionedInCfg {
   259  		res.Mentioned = append(res.Mentioned, strings.ToUpper(ds))
   260  	}
   261  	sort.Strings(res.Mentioned)
   262  
   263  	return res, nil
   264  }
   265  
   266  type cloudInitConfigInstallOptions struct {
   267  	// Prefix is the prefix to add to files when installing them.
   268  	Prefix string
   269  	// Filter is whether to filter the config files when installing them.
   270  	Filter bool
   271  	// AllowedDatasources is the set of datasources to allow config that is
   272  	// specific to a datasource in when filtering. An empty list and setting
   273  	// Filter to false is equivalent to allowing any datasource to be installed,
   274  	// while an empty list and setting Filter to true means that no config that
   275  	// is specific to a datasource should be installed, but config that is not
   276  	// specific to a datasource (such as networking config) is allowed to be
   277  	// installed.
   278  	AllowedDatasources []string
   279  }
   280  
   281  // installCloudInitCfgDir installs glob cfg files from the source directory to
   282  // the cloud config dir, optionally filtering the files for safe and supported
   283  // keys in the configuration before installing them.
   284  func installCloudInitCfgDir(src, targetdir string, opts *cloudInitConfigInstallOptions) error {
   285  	if opts == nil {
   286  		opts = &cloudInitConfigInstallOptions{}
   287  	}
   288  
   289  	// TODO:UC20: enforce patterns on the glob files and their suffix ranges
   290  	ccl, err := filepath.Glob(filepath.Join(src, "*.cfg"))
   291  	if err != nil {
   292  		return err
   293  	}
   294  	if len(ccl) == 0 {
   295  		return nil
   296  	}
   297  
   298  	ubuntuDataCloudCfgDir := filepath.Join(ubuntuDataCloudDir(targetdir), "cloud.cfg.d/")
   299  	if err := os.MkdirAll(ubuntuDataCloudCfgDir, 0755); err != nil {
   300  		return fmt.Errorf("cannot make cloud config dir: %v", err)
   301  	}
   302  
   303  	for _, cc := range ccl {
   304  		if err := osutil.CopyFile(cc, filepath.Join(ubuntuDataCloudCfgDir, opts.Prefix+filepath.Base(cc)), 0); err != nil {
   305  			return err
   306  		}
   307  	}
   308  	return nil
   309  }
   310  
   311  // installGadgetCloudInitCfg installs a single cloud-init config file from the
   312  // gadget snap to the /etc/cloud config dir as "80_device_gadget.cfg". It also
   313  // parses and returns what datasources are detected to be in use for the gadget
   314  // cloud-config.
   315  func installGadgetCloudInitCfg(src, targetdir string) (*cloudDatasourcesInUseResult, error) {
   316  	ubuntuDataCloudCfgDir := filepath.Join(ubuntuDataCloudDir(targetdir), "cloud.cfg.d/")
   317  	if err := os.MkdirAll(ubuntuDataCloudCfgDir, 0755); err != nil {
   318  		return nil, fmt.Errorf("cannot make cloud config dir: %v", err)
   319  	}
   320  
   321  	datasourcesRes, err := cloudDatasourcesInUse(src)
   322  	if err != nil {
   323  		return nil, err
   324  	}
   325  
   326  	configFile := filepath.Join(ubuntuDataCloudCfgDir, "80_device_gadget.cfg")
   327  	if err := osutil.CopyFile(src, configFile, 0); err != nil {
   328  		return nil, err
   329  	}
   330  	return datasourcesRes, nil
   331  }
   332  
   333  func configureCloudInit(model *asserts.Model, opts *Options) (err error) {
   334  	if opts.TargetRootDir == "" {
   335  		return fmt.Errorf("unable to configure cloud-init, missing target dir")
   336  	}
   337  
   338  	// first check if cloud-init should be disallowed entirely
   339  	if !opts.AllowCloudInit {
   340  		return DisableCloudInit(WritableDefaultsDir(opts.TargetRootDir))
   341  	}
   342  
   343  	// otherwise cloud-init is allowed to run, we need to decide where to
   344  	// permit configuration to come from, if opts.CloudInitSrcDir is non-empty
   345  	// there is at least a cloud-config dir on ubuntu-seed we could install
   346  	// config from
   347  
   348  	// check if we should filter cloud-init config on ubuntu-seed, we do this
   349  	// for grade signed only (we don't allow any config for grade secured, and we
   350  	// allow any config on grade dangerous)
   351  
   352  	grade := model.Grade()
   353  
   354  	// we always allow gadget cloud config, so install that first
   355  	if HasGadgetCloudConf(opts.GadgetDir) {
   356  		// then copy / install the gadget config first
   357  		gadgetCloudConf := filepath.Join(opts.GadgetDir, "cloud.conf")
   358  
   359  		// TODO: save the gadget datasource and use it below in deciding what to
   360  		// allow through for grade: signed
   361  		if _, err := installGadgetCloudInitCfg(gadgetCloudConf, WritableDefaultsDir(opts.TargetRootDir)); err != nil {
   362  			return err
   363  		}
   364  
   365  		// we don't return here to enable also copying any cloud-init config
   366  		// from ubuntu-seed in order for both to be used simultaneously for
   367  		// example on test devices where the gadget has a gadget.yaml, but for
   368  		// testing purposes you also want to provision another user with
   369  		// ubuntu-seed cloud-init config
   370  	}
   371  
   372  	installOpts := &cloudInitConfigInstallOptions{
   373  		// set the prefix such that any ubuntu-seed config that ends up getting
   374  		// installed takes precedence over the gadget config
   375  		Prefix: "90_",
   376  	}
   377  
   378  	switch grade {
   379  	case asserts.ModelSecured:
   380  		// for secured we are done, we only allow gadget cloud-config on secured
   381  		return nil
   382  	case asserts.ModelSigned:
   383  		// TODO: for grade signed, we will install ubuntu-seed config but filter
   384  		// it and ensure that the ubuntu-seed config matches the config from the
   385  		// gadget if that exists
   386  		// for now though, just return
   387  		return nil
   388  	case asserts.ModelDangerous:
   389  		// for grade dangerous we just install all the config from ubuntu-seed
   390  		installOpts.Filter = false
   391  	default:
   392  		return fmt.Errorf("internal error: unknown model assertion grade %s", grade)
   393  	}
   394  
   395  	if opts.CloudInitSrcDir != "" {
   396  		return installCloudInitCfgDir(opts.CloudInitSrcDir, WritableDefaultsDir(opts.TargetRootDir), installOpts)
   397  	}
   398  
   399  	// it's valid to allow cloud-init, but not set CloudInitSrcDir and not have
   400  	// a gadget cloud.conf, in this case cloud-init may pick up dynamic metadata
   401  	// and userdata from NoCloud sources such as a CD-ROM drive with label
   402  	// CIDATA, etc. during first-boot
   403  
   404  	return nil
   405  }
   406  
   407  // CloudInitState represents the various cloud-init states
   408  type CloudInitState int
   409  
   410  var (
   411  	// the (?m) is needed since cloud-init output will have newlines
   412  	cloudInitStatusRe = regexp.MustCompile(`(?m)^status: (.*)$`)
   413  	datasourceRe      = regexp.MustCompile(`DataSource([a-zA-Z0-9]+).*`)
   414  
   415  	cloudInitSnapdRestrictFile = "/etc/cloud/cloud.cfg.d/zzzz_snapd.cfg"
   416  	cloudInitDisabledFile      = "/etc/cloud/cloud-init.disabled"
   417  
   418  	// for NoCloud datasource, we need to specify "manual_cache_clean: true"
   419  	// because the default is false, and this key being true essentially informs
   420  	// cloud-init that it should always trust the instance-id it has cached in
   421  	// the image, and shouldn't assume that there is a new one on every boot, as
   422  	// otherwise we have bugs like https://bugs.launchpad.net/snapd/+bug/1905983
   423  	// where subsequent boots after cloud-init runs and gets restricted it will
   424  	// try to detect the instance_id by reading from the NoCloud datasource
   425  	// fs_label, but we set that to "null" so it fails to read anything and thus
   426  	// can't detect the effective instance_id and assumes it is different and
   427  	// applies default config which can overwrite valid config from the initial
   428  	// boot if that is not the default config
   429  	// see also https://cloudinit.readthedocs.io/en/latest/topics/boot.html?highlight=manual_cache_clean#first-boot-determination
   430  	nocloudRestrictYaml = []byte(`datasource_list: [NoCloud]
   431  datasource:
   432    NoCloud:
   433      fs_label: null
   434  manual_cache_clean: true
   435  `)
   436  
   437  	// don't use manual_cache_clean for real cloud datasources, the setting is
   438  	// used with ubuntu core only for sources where we can only get the
   439  	// instance_id through the fs_label for NoCloud and None (since we disable
   440  	// importing using the fs_label after the initial run).
   441  	genericCloudRestrictYamlPattern = `datasource_list: [%s]
   442  `
   443  
   444  	localDatasources = []string{"NoCloud", "None"}
   445  )
   446  
   447  const (
   448  	// CloudInitDisabledPermanently is when cloud-init is disabled as per the
   449  	// cloud-init.disabled file.
   450  	CloudInitDisabledPermanently CloudInitState = iota
   451  	// CloudInitRestrictedBySnapd is when cloud-init has been restricted by
   452  	// snapd with a specific config file.
   453  	CloudInitRestrictedBySnapd
   454  	// CloudInitUntriggered is when cloud-init is disabled because nothing has
   455  	// triggered it to run, but it could still be run.
   456  	CloudInitUntriggered
   457  	// CloudInitDone is when cloud-init has been run on this boot.
   458  	CloudInitDone
   459  	// CloudInitEnabled is when cloud-init is active, but not necessarily
   460  	// finished. This matches the "running" and "not run" states from cloud-init
   461  	// as well as any other state that does not match any of the other defined
   462  	// states, as we are conservative in assuming that cloud-init is doing
   463  	// something.
   464  	CloudInitEnabled
   465  	// CloudInitNotFound is when there is no cloud-init executable on the
   466  	// device.
   467  	CloudInitNotFound
   468  	// CloudInitErrored is when cloud-init tried to run, but failed or had invalid
   469  	// configuration.
   470  	CloudInitErrored
   471  )
   472  
   473  // CloudInitStatus returns the current status of cloud-init. Note that it will
   474  // first check for static file-based statuses first through the snapd
   475  // restriction file and the disabled file before consulting
   476  // cloud-init directly through the status command.
   477  // Also note that in unknown situations we are conservative in assuming that
   478  // cloud-init may be doing something and will return CloudInitEnabled when we
   479  // do not recognize the state returned by the cloud-init status command.
   480  func CloudInitStatus() (CloudInitState, error) {
   481  	// if cloud-init has been restricted by snapd, check that first
   482  	snapdRestrictingFile := filepath.Join(dirs.GlobalRootDir, cloudInitSnapdRestrictFile)
   483  	if osutil.FileExists(snapdRestrictingFile) {
   484  		return CloudInitRestrictedBySnapd, nil
   485  	}
   486  
   487  	// if it was explicitly disabled via the cloud-init disable file, then
   488  	// return special status for that
   489  	disabledFile := filepath.Join(dirs.GlobalRootDir, cloudInitDisabledFile)
   490  	if osutil.FileExists(disabledFile) {
   491  		return CloudInitDisabledPermanently, nil
   492  	}
   493  
   494  	ciBinary, err := exec.LookPath("cloud-init")
   495  	if err != nil {
   496  		logger.Noticef("cannot locate cloud-init executable: %v", err)
   497  		return CloudInitNotFound, nil
   498  	}
   499  
   500  	out, err := exec.Command(ciBinary, "status").CombinedOutput()
   501  	if err != nil {
   502  		return CloudInitErrored, osutil.OutputErr(out, err)
   503  	}
   504  	// output should just be "status: <state>"
   505  	match := cloudInitStatusRe.FindSubmatch(out)
   506  	if len(match) != 2 {
   507  		return CloudInitErrored, fmt.Errorf("invalid cloud-init output: %v", osutil.OutputErr(out, err))
   508  	}
   509  	switch string(match[1]) {
   510  	case "disabled":
   511  		// here since we weren't disabled by the file, we are in "disabled but
   512  		// could be enabled" state - arguably this should be a different state
   513  		// than "disabled", see
   514  		// https://bugs.launchpad.net/cloud-init/+bug/1883124 and
   515  		// https://bugs.launchpad.net/cloud-init/+bug/1883122
   516  		return CloudInitUntriggered, nil
   517  	case "error":
   518  		return CloudInitErrored, nil
   519  	case "done":
   520  		return CloudInitDone, nil
   521  	// "running" and "not run" are considered Enabled, see doc-comment
   522  	case "running", "not run":
   523  		fallthrough
   524  	default:
   525  		// these states are all
   526  		return CloudInitEnabled, nil
   527  	}
   528  }
   529  
   530  // these structs are externally defined by cloud-init
   531  type v1Data struct {
   532  	DataSource string `json:"datasource"`
   533  }
   534  
   535  type cloudInitStatus struct {
   536  	V1 v1Data `json:"v1"`
   537  }
   538  
   539  // CloudInitRestrictionResult is the result of calling RestrictCloudInit. The
   540  // values for Action are "disable" or "restrict", and the Datasource will be set
   541  // to the restricted datasource if Action is "restrict".
   542  type CloudInitRestrictionResult struct {
   543  	Action     string
   544  	DataSource string
   545  }
   546  
   547  // CloudInitRestrictOptions are options for how to restrict cloud-init with
   548  // RestrictCloudInit.
   549  type CloudInitRestrictOptions struct {
   550  	// ForceDisable will force disabling cloud-init even if it is
   551  	// in an active/running or errored state.
   552  	ForceDisable bool
   553  
   554  	// DisableAfterLocalDatasourcesRun modifies RestrictCloudInit to disable
   555  	// cloud-init after it has run on first-boot if the datasource detected is
   556  	// a local source such as NoCloud or None. If the datasource detected is not
   557  	// a local source, such as GCE or AWS EC2 it is merely restricted as
   558  	// described in the doc-comment on RestrictCloudInit.
   559  	DisableAfterLocalDatasourcesRun bool
   560  }
   561  
   562  // RestrictCloudInit will limit the operations of cloud-init on subsequent boots
   563  // by either disabling cloud-init in the untriggered state, or restrict
   564  // cloud-init to only use a specific datasource (additionally if the currently
   565  // detected datasource for this boot was NoCloud, it will disable the automatic
   566  // import of filesystems with labels such as CIDATA (or cidata) as datasources).
   567  // This is expected to be run when cloud-init is in a "steady" state such as
   568  // done or disabled (untriggered). If called in other states such as errored, it
   569  // will return an error, but it can be forced to disable cloud-init anyways in
   570  // these states with the opts parameter and the ForceDisable field.
   571  // This function is meant to protect against CVE-2020-11933.
   572  func RestrictCloudInit(state CloudInitState, opts *CloudInitRestrictOptions) (CloudInitRestrictionResult, error) {
   573  	res := CloudInitRestrictionResult{}
   574  
   575  	if opts == nil {
   576  		opts = &CloudInitRestrictOptions{}
   577  	}
   578  
   579  	switch state {
   580  	case CloudInitDone:
   581  		// handled below
   582  		break
   583  	case CloudInitRestrictedBySnapd:
   584  		return res, fmt.Errorf("cannot restrict cloud-init: already restricted")
   585  	case CloudInitDisabledPermanently:
   586  		return res, fmt.Errorf("cannot restrict cloud-init: already disabled")
   587  	case CloudInitErrored, CloudInitEnabled:
   588  		// if we are not forcing a disable, return error as these states are
   589  		// where cloud-init could still be running doing things
   590  		if !opts.ForceDisable {
   591  			return res, fmt.Errorf("cannot restrict cloud-init in error or enabled state")
   592  		}
   593  		fallthrough
   594  	case CloudInitUntriggered, CloudInitNotFound:
   595  		fallthrough
   596  	default:
   597  		res.Action = "disable"
   598  		return res, DisableCloudInit(dirs.GlobalRootDir)
   599  	}
   600  
   601  	// from here on out, we are taking the "restrict" action
   602  	res.Action = "restrict"
   603  
   604  	// first get the cloud-init data-source that was used from /
   605  	resultsFile := filepath.Join(dirs.GlobalRootDir, "/run/cloud-init/status.json")
   606  
   607  	f, err := os.Open(resultsFile)
   608  	if err != nil {
   609  		return res, err
   610  	}
   611  	defer f.Close()
   612  
   613  	var stat cloudInitStatus
   614  	err = json.NewDecoder(f).Decode(&stat)
   615  	if err != nil {
   616  		return res, err
   617  	}
   618  
   619  	// if the datasource was empty then cloud-init did something wrong or
   620  	// perhaps it incorrectly reported that it ran but something else deleted
   621  	// the file
   622  	datasourceRaw := stat.V1.DataSource
   623  	if datasourceRaw == "" {
   624  		return res, fmt.Errorf("cloud-init error: missing datasource from status.json")
   625  	}
   626  
   627  	// for some datasources there is additional data in this item, i.e. for
   628  	// NoCloud we will also see:
   629  	// "DataSourceNoCloud [seed=/dev/sr0][dsmode=net]"
   630  	// so hence we use a regexp to parse out just the name of the datasource
   631  	datasourceMatches := datasourceRe.FindStringSubmatch(datasourceRaw)
   632  	if len(datasourceMatches) != 2 {
   633  		return res, fmt.Errorf("cloud-init error: unexpected datasource format %q", datasourceRaw)
   634  	}
   635  	res.DataSource = datasourceMatches[1]
   636  
   637  	cloudInitRestrictFile := filepath.Join(dirs.GlobalRootDir, cloudInitSnapdRestrictFile)
   638  
   639  	switch {
   640  	case opts.DisableAfterLocalDatasourcesRun && strutil.ListContains(localDatasources, res.DataSource):
   641  		// On UC20, DisableAfterLocalDatasourcesRun will be set, where we want
   642  		// to disable local sources like NoCloud and None after first-boot
   643  		// instead of just restricting them like we do below for UC16 and UC18.
   644  
   645  		// as such, change the action taken to disable and disable cloud-init
   646  		res.Action = "disable"
   647  		err = DisableCloudInit(dirs.GlobalRootDir)
   648  	case res.DataSource == "NoCloud":
   649  		// With the NoCloud datasource (which is one of the local datasources),
   650  		// we also need to restrict/disable the import of arbitrary filesystem
   651  		// labels to use as datasources, i.e. a USB drive inserted by an
   652  		// attacker with label CIDATA will defeat security measures on Ubuntu
   653  		// Core, so with the additional fs_label spec, we disable that import.
   654  		err = ioutil.WriteFile(cloudInitRestrictFile, nocloudRestrictYaml, 0644)
   655  	default:
   656  		// all other cases are either not local on UC20, or not NoCloud and as
   657  		// such we simply restrict cloud-init to the specific datasource used so
   658  		// that an attack via NoCloud is protected against
   659  		yaml := []byte(fmt.Sprintf(genericCloudRestrictYamlPattern, res.DataSource))
   660  		err = ioutil.WriteFile(cloudInitRestrictFile, yaml, 0644)
   661  	}
   662  
   663  	return res, err
   664  }