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 }