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 }