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