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