github.com/hugh712/snapd@v0.0.0-20200910133618-1a99902bd583/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/osutil"
    33  )
    34  
    35  // HasGadgetCloudConf takes a gadget directory and returns whether there is
    36  // cloud-init config in the form of a cloud.conf file in the gadget.
    37  func HasGadgetCloudConf(gadgetDir string) bool {
    38  	return osutil.FileExists(filepath.Join(gadgetDir, "cloud.conf"))
    39  }
    40  
    41  func ubuntuDataCloudDir(rootdir string) string {
    42  	return filepath.Join(rootdir, "etc/cloud/")
    43  }
    44  
    45  // DisableCloudInit will disable cloud-init permanently by writing a
    46  // cloud-init.disabled config file in etc/cloud under the target dir, which
    47  // instructs cloud-init-generator to not trigger new cloud-init invocations.
    48  // Note that even with this disabled file, a root user could still manually run
    49  // cloud-init, but this capability is not provided to any strictly confined
    50  // snap.
    51  func DisableCloudInit(rootDir string) error {
    52  	ubuntuDataCloud := ubuntuDataCloudDir(rootDir)
    53  	if err := os.MkdirAll(ubuntuDataCloud, 0755); err != nil {
    54  		return fmt.Errorf("cannot make cloud config dir: %v", err)
    55  	}
    56  	if err := ioutil.WriteFile(filepath.Join(ubuntuDataCloud, "cloud-init.disabled"), nil, 0644); err != nil {
    57  		return fmt.Errorf("cannot disable cloud-init: %v", err)
    58  	}
    59  
    60  	return nil
    61  }
    62  
    63  // installCloudInitCfgDir installs glob cfg files from the source directory to
    64  // the cloud config dir. For installing single files from anywhere with any
    65  // name, use installUnifiedCloudInitCfg
    66  func installCloudInitCfgDir(src, targetdir string) error {
    67  	// TODO:UC20: enforce patterns on the glob files and their suffix ranges
    68  	ccl, err := filepath.Glob(filepath.Join(src, "*.cfg"))
    69  	if err != nil {
    70  		return err
    71  	}
    72  	if len(ccl) == 0 {
    73  		return nil
    74  	}
    75  
    76  	ubuntuDataCloudCfgDir := filepath.Join(ubuntuDataCloudDir(targetdir), "cloud.cfg.d/")
    77  	if err := os.MkdirAll(ubuntuDataCloudCfgDir, 0755); err != nil {
    78  		return fmt.Errorf("cannot make cloud config dir: %v", err)
    79  	}
    80  
    81  	for _, cc := range ccl {
    82  		if err := osutil.CopyFile(cc, filepath.Join(ubuntuDataCloudCfgDir, filepath.Base(cc)), 0); err != nil {
    83  			return err
    84  		}
    85  	}
    86  	return nil
    87  }
    88  
    89  // installGadgetCloudInitCfg installs a single cloud-init config file from the
    90  // gadget snap to the /etc/cloud config dir as "80_device_gadget.cfg".
    91  func installGadgetCloudInitCfg(src, targetdir string) error {
    92  	ubuntuDataCloudCfgDir := filepath.Join(ubuntuDataCloudDir(targetdir), "cloud.cfg.d/")
    93  	if err := os.MkdirAll(ubuntuDataCloudCfgDir, 0755); err != nil {
    94  		return fmt.Errorf("cannot make cloud config dir: %v", err)
    95  	}
    96  
    97  	configFile := filepath.Join(ubuntuDataCloudCfgDir, "80_device_gadget.cfg")
    98  	return osutil.CopyFile(src, configFile, 0)
    99  }
   100  
   101  func configureCloudInit(opts *Options) (err error) {
   102  	if opts.TargetRootDir == "" {
   103  		return fmt.Errorf("unable to configure cloud-init, missing target dir")
   104  	}
   105  
   106  	// first check if cloud-init should be disallowed entirely
   107  	if !opts.AllowCloudInit {
   108  		return DisableCloudInit(WritableDefaultsDir(opts.TargetRootDir))
   109  	}
   110  
   111  	// next check if there is a gadget cloud.conf to install
   112  	if HasGadgetCloudConf(opts.GadgetDir) {
   113  		// then copy / install the gadget config and return without considering
   114  		// CloudInitSrcDir
   115  		// TODO:UC20: we may eventually want to consider both CloudInitSrcDir
   116  		// and the gadget cloud.conf so returning here may be wrong
   117  		gadgetCloudConf := filepath.Join(opts.GadgetDir, "cloud.conf")
   118  		return installGadgetCloudInitCfg(gadgetCloudConf, WritableDefaultsDir(opts.TargetRootDir))
   119  	}
   120  
   121  	// TODO:UC20: implement filtering of files from src when specified via a
   122  	//            specific Options for i.e. signed grade and MAAS, etc.
   123  
   124  	// finally check if there is a cloud-init src dir we should copy config
   125  	// files from
   126  
   127  	if opts.CloudInitSrcDir != "" {
   128  		return installCloudInitCfgDir(opts.CloudInitSrcDir, WritableDefaultsDir(opts.TargetRootDir))
   129  	}
   130  
   131  	// it's valid to allow cloud-init, but not set CloudInitSrcDir and not have
   132  	// a gadget cloud.conf, in this case cloud-init may pick up dynamic metadata
   133  	// and userdata from NoCloud sources such as a CD-ROM drive with label
   134  	// CIDATA, etc. during first-boot
   135  
   136  	return nil
   137  }
   138  
   139  // CloudInitState represents the various cloud-init states
   140  type CloudInitState int
   141  
   142  var (
   143  	// the (?m) is needed since cloud-init output will have newlines
   144  	cloudInitStatusRe = regexp.MustCompile(`(?m)^status: (.*)$`)
   145  	datasourceRe      = regexp.MustCompile(`DataSource([a-zA-Z0-9]+).*`)
   146  
   147  	cloudInitSnapdRestrictFile = "/etc/cloud/cloud.cfg.d/zzzz_snapd.cfg"
   148  	cloudInitDisabledFile      = "/etc/cloud/cloud-init.disabled"
   149  
   150  	nocloudRestrictYaml = []byte(`datasource_list: [NoCloud]
   151  datasource:
   152    NoCloud:
   153      fs_label: null`)
   154  
   155  	genericCloudRestrictYamlPattern = `datasource_list: [%s]`
   156  )
   157  
   158  const (
   159  	// CloudInitDisabledPermanently is when cloud-init is disabled as per the
   160  	// cloud-init.disabled file.
   161  	CloudInitDisabledPermanently CloudInitState = iota
   162  	// CloudInitRestrictedBySnapd is when cloud-init has been restricted by
   163  	// snapd with a specific config file.
   164  	CloudInitRestrictedBySnapd
   165  	// CloudInitUntriggered is when cloud-init is disabled because nothing has
   166  	// triggered it to run, but it could still be run.
   167  	CloudInitUntriggered
   168  	// CloudInitDone is when cloud-init has been run on this boot.
   169  	CloudInitDone
   170  	// CloudInitEnabled is when cloud-init is active, but not necessarily
   171  	// finished. This matches the "running" and "not run" states from cloud-init
   172  	// as well as any other state that does not match any of the other defined
   173  	// states, as we are conservative in assuming that cloud-init is doing
   174  	// something.
   175  	CloudInitEnabled
   176  	// CloudInitErrored is when cloud-init tried to run, but failed or had invalid
   177  	// configuration.
   178  	CloudInitErrored
   179  )
   180  
   181  // CloudInitStatus returns the current status of cloud-init. Note that it will
   182  // first check for static file-based statuses first through the snapd
   183  // restriction file and the disabled file before consulting
   184  // cloud-init directly through the status command.
   185  // Also note that in unknown situations we are conservative in assuming that
   186  // cloud-init may be doing something and will return CloudInitEnabled when we
   187  // do not recognize the state returned by the cloud-init status command.
   188  func CloudInitStatus() (CloudInitState, error) {
   189  	// if cloud-init has been restricted by snapd, check that first
   190  	snapdRestrictingFile := filepath.Join(dirs.GlobalRootDir, cloudInitSnapdRestrictFile)
   191  	if osutil.FileExists(snapdRestrictingFile) {
   192  		return CloudInitRestrictedBySnapd, nil
   193  	}
   194  
   195  	// if it was explicitly disabled via the cloud-init disable file, then
   196  	// return special status for that
   197  	disabledFile := filepath.Join(dirs.GlobalRootDir, cloudInitDisabledFile)
   198  	if osutil.FileExists(disabledFile) {
   199  		return CloudInitDisabledPermanently, nil
   200  	}
   201  
   202  	out, err := exec.Command("cloud-init", "status").CombinedOutput()
   203  	if err != nil {
   204  		return CloudInitErrored, osutil.OutputErr(out, err)
   205  	}
   206  	// output should just be "status: <state>"
   207  	match := cloudInitStatusRe.FindSubmatch(out)
   208  	if len(match) != 2 {
   209  		return CloudInitErrored, fmt.Errorf("invalid cloud-init output: %v", osutil.OutputErr(out, err))
   210  	}
   211  	switch string(match[1]) {
   212  	case "disabled":
   213  		// here since we weren't disabled by the file, we are in "disabled but
   214  		// could be enabled" state - arguably this should be a different state
   215  		// than "disabled", see
   216  		// https://bugs.launchpad.net/cloud-init/+bug/1883124 and
   217  		// https://bugs.launchpad.net/cloud-init/+bug/1883122
   218  		return CloudInitUntriggered, nil
   219  	case "error":
   220  		return CloudInitErrored, nil
   221  	case "done":
   222  		return CloudInitDone, nil
   223  	// "running" and "not run" are considered Enabled, see doc-comment
   224  	case "running", "not run":
   225  		fallthrough
   226  	default:
   227  		// these states are all
   228  		return CloudInitEnabled, nil
   229  	}
   230  }
   231  
   232  // these structs are externally defined by cloud-init
   233  type v1Data struct {
   234  	DataSource string `json:"datasource"`
   235  }
   236  
   237  type cloudInitStatus struct {
   238  	V1 v1Data `json:"v1"`
   239  }
   240  
   241  // CloudInitRestrictionResult is the result of calling RestrictCloudInit. The
   242  // values for Action are "disable" or "restrict", and the Datasource will be set
   243  // to the restricted datasource if Action is "restrict".
   244  type CloudInitRestrictionResult struct {
   245  	Action     string
   246  	DataSource string
   247  }
   248  
   249  // CloudInitRestrictOptions are options for how to restrict cloud-init with
   250  // RestrictCloudInit.
   251  type CloudInitRestrictOptions struct {
   252  	// ForceDisable will force disabling cloud-init even if it is
   253  	// in an active/running or errored state.
   254  	ForceDisable bool
   255  
   256  	// DisableNoCloud modifies the behavior to whole-sale disable cloud-init,
   257  	// if the datasource detected is NoCloud, if the datasource detected is
   258  	// anything other than NoCloud then it is merely restricted as described in
   259  	// the doc-comment on RestrictCloudInit.
   260  	DisableNoCloud bool
   261  }
   262  
   263  // RestrictCloudInit will limit the operations of cloud-init on subsequent boots
   264  // by either disabling cloud-init in the untriggered state, or restrict
   265  // cloud-init to only use a specific datasource (additionally if the currently
   266  // detected datasource for this boot was NoCloud, it will disable the automatic
   267  // import of filesystems with labels such as CIDATA (or cidata) as datasources).
   268  // This is expected to be run when cloud-init is in a "steady" state such as
   269  // done or disabled (untriggered). If called in other states such as errored, it
   270  // will return an error, but it can be forced to disable cloud-init anyways in
   271  // these states with the opts parameter and the ForceDisable field.
   272  // This function is meant to protect against CVE-2020-11933.
   273  func RestrictCloudInit(state CloudInitState, opts *CloudInitRestrictOptions) (CloudInitRestrictionResult, error) {
   274  	res := CloudInitRestrictionResult{}
   275  
   276  	if opts == nil {
   277  		opts = &CloudInitRestrictOptions{}
   278  	}
   279  
   280  	switch state {
   281  	case CloudInitDone:
   282  		// handled below
   283  		break
   284  	case CloudInitRestrictedBySnapd:
   285  		return res, fmt.Errorf("cannot restrict cloud-init: already restricted")
   286  	case CloudInitDisabledPermanently:
   287  		return res, fmt.Errorf("cannot restrict cloud-init: already disabled")
   288  	case CloudInitErrored, CloudInitEnabled:
   289  		// if we are not forcing a disable, return error as these states are
   290  		// where cloud-init could still be running doing things
   291  		if !opts.ForceDisable {
   292  			return res, fmt.Errorf("cannot restrict cloud-init in error or enabled state")
   293  		}
   294  		fallthrough
   295  	case CloudInitUntriggered:
   296  		fallthrough
   297  	default:
   298  		res.Action = "disable"
   299  		return res, DisableCloudInit(dirs.GlobalRootDir)
   300  	}
   301  
   302  	// from here on out, we are taking the "restrict" action
   303  	res.Action = "restrict"
   304  
   305  	// first get the cloud-init data-source that was used from /
   306  	resultsFile := filepath.Join(dirs.GlobalRootDir, "/run/cloud-init/status.json")
   307  
   308  	f, err := os.Open(resultsFile)
   309  	if err != nil {
   310  		return res, err
   311  	}
   312  	defer f.Close()
   313  
   314  	var stat cloudInitStatus
   315  	err = json.NewDecoder(f).Decode(&stat)
   316  	if err != nil {
   317  		return res, err
   318  	}
   319  
   320  	// if the datasource was empty then cloud-init did something wrong or
   321  	// perhaps it incorrectly reported that it ran but something else deleted
   322  	// the file
   323  	datasourceRaw := stat.V1.DataSource
   324  	if datasourceRaw == "" {
   325  		return res, fmt.Errorf("cloud-init error: missing datasource from status.json")
   326  	}
   327  
   328  	// for some datasources there is additional data in this item, i.e. for
   329  	// NoCloud we will also see:
   330  	// "DataSourceNoCloud [seed=/dev/sr0][dsmode=net]"
   331  	// so hence we use a regexp to parse out just the name of the datasource
   332  	datasourceMatches := datasourceRe.FindStringSubmatch(datasourceRaw)
   333  	if len(datasourceMatches) != 2 {
   334  		return res, fmt.Errorf("cloud-init error: unexpected datasource format %q", datasourceRaw)
   335  	}
   336  	res.DataSource = datasourceMatches[1]
   337  
   338  	cloudInitRestrictFile := filepath.Join(dirs.GlobalRootDir, cloudInitSnapdRestrictFile)
   339  
   340  	switch res.DataSource {
   341  	case "NoCloud":
   342  		// With the NoCloud datasource, we also need to restrict/disable the
   343  		// import of arbitrary filesystem labels to use as datasources, i.e. a
   344  		// USB drive inserted by an attacker with label CIDATA will defeat
   345  		// security measures on Ubuntu Core, so with the additional fs_label
   346  		// spec, we disable that import.
   347  
   348  		// Note that on UC20, we will also specify DisableNoCloud, to disable
   349  		// cloud-init even after the first boot
   350  		if opts.DisableNoCloud {
   351  			// change the action taken to disable
   352  			res.Action = "disable"
   353  			err = DisableCloudInit(dirs.GlobalRootDir)
   354  		} else {
   355  			err = ioutil.WriteFile(cloudInitRestrictFile, nocloudRestrictYaml, 0644)
   356  		}
   357  	default:
   358  		// all other datasources that are not NoCloud will be restricted to only
   359  		// allow this specific datasource to prevent an attack via NoCloud for
   360  		// example
   361  		yaml := []byte(fmt.Sprintf(genericCloudRestrictYamlPattern, res.DataSource))
   362  		err = ioutil.WriteFile(cloudInitRestrictFile, yaml, 0644)
   363  	}
   364  
   365  	return res, err
   366  }