1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963
|
// -*- Mode: Go; indent-tabs-mode: t -*-
/*
* Copyright (C) 2020, 2024 Canonical Ltd
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 3 as
* published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
package sysconfig
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
yaml "gopkg.in/yaml.v2"
"github.com/snapcore/snapd/asserts"
"github.com/snapcore/snapd/dirs"
"github.com/snapcore/snapd/logger"
"github.com/snapcore/snapd/osutil"
"github.com/snapcore/snapd/osutil/kcmdline"
"github.com/snapcore/snapd/strutil"
)
// HasGadgetCloudConf takes a gadget directory and returns whether there is
// cloud-init config in the form of a cloud.conf file in the gadget.
func HasGadgetCloudConf(gadgetDir string) bool {
return osutil.FileExists(filepath.Join(gadgetDir, "cloud.conf"))
}
func ubuntuDataCloudDir(rootdir string) string {
return filepath.Join(rootdir, "etc/cloud/")
}
// DisableCloudInit will disable cloud-init permanently by writing a
// cloud-init.disabled config file in etc/cloud under the target dir, which
// instructs cloud-init-generator to not trigger new cloud-init invocations.
// Note that even with this disabled file, a root user could still manually run
// cloud-init, but this capability is not provided to any strictly confined
// snap.
func DisableCloudInit(rootDir string) error {
ubuntuDataCloud := ubuntuDataCloudDir(rootDir)
if err := os.MkdirAll(ubuntuDataCloud, 0755); err != nil {
return fmt.Errorf("cannot make cloud config dir: %v", err)
}
if err := os.WriteFile(filepath.Join(ubuntuDataCloud, "cloud-init.disabled"), nil, 0644); err != nil {
return fmt.Errorf("cannot disable cloud-init: %v", err)
}
return nil
}
// supportedFilteredCloudConfig is a struct of the supported values for
// cloud-init configuration file.
type supportedFilteredCloudConfig struct {
Datasource map[string]supportedFilteredDatasource `yaml:"datasource,omitempty"`
Network map[string]any `yaml:"network,omitempty"`
// DatasourceList is a pointer so we can distinguish between:
// datasource_list: []
// and not setting the datasource at all
// for example there might be gadgets which don't want to use any
// datasources, but still wants to set some networking config
DatasourceList *[]string `yaml:"datasource_list,omitempty"`
Reporting map[string]supportedFilteredReporting `yaml:"reporting,omitempty"`
}
type supportedFilteredDatasource struct {
// these are for MAAS
ConsumerKey string `yaml:"consumer_key,omitempty"`
MetadataURL string `yaml:"metadata_url,omitempty"`
TokenKey string `yaml:"token_key,omitempty"`
TokenSecret string `yaml:"token_secret,omitempty"`
}
type supportedFilteredReporting struct {
Type string `yaml:"type,omitempty"`
Endpoint string `yaml:"endpoint,omitempty"`
ConsumerKey string `yaml:"consumer_key,omitempty"`
TokenKey string `yaml:"token_key,omitempty"`
TokenSecret string `yaml:"token_secret,omitempty"`
}
// supportedFilteredDatasources is the set of datasources we support filtering
// cloud-init config for. It is expected that this list grows as we support for
// more clouds.
var supportedFilteredDatasources = []string{
"MAAS",
}
// filterCloudCfg filters a cloud-init configuration struct parsed from a single
// cloud-init configuration file. The config provided here may be a subset of
// the full cloud-init configuration from the file in that there may be
// top-level keys in the YAML file that we did not parse and as such they are
// dropped and filtered automatically. For other keys, we must parse part of the
// configuration struct and remove nested keys while keeping other parts of the
// same section.
func filterCloudCfg(cfg *supportedFilteredCloudConfig, allowedDatasources []string) error {
// TODO: should we track modifications / filters applied to log/notify about
// what is dropped / not supported?
// first filter out the disallowed datasources
for dsName := range cfg.Datasource {
// remove unsupported or unrecognized datasources
if !strutil.ListContains(allowedDatasources, strings.ToUpper(dsName)) {
delete(cfg.Datasource, dsName)
continue
}
}
// next handle the datasource list setting, if it was not empty, reset it to
// the allowedDatasources we were provided
if cfg.DatasourceList != nil {
deepCpy := make([]string, 0, len(allowedDatasources))
deepCpy = append(deepCpy, allowedDatasources...)
cfg.DatasourceList = &deepCpy
}
// next handle the reporting setting
for dsName := range cfg.Reporting {
// remove unsupported or unrecognized datasources
if !strutil.ListContains(allowedDatasources, strings.ToUpper(dsName)) {
delete(cfg.Reporting, dsName)
continue
}
}
return nil
}
// filterCloudCfgFile takes a cloud config file as input and filters out unknown
// and unsupported keys from the config, returning a new file. It also will
// filter out configuration that is specific to a datasource if that datasource
// is not specified in the allowedDatasources argument. The empty string will be
// returned if the input file was entirely filtered out and there is nothing
// left.
func filterCloudCfgFile(in string, allowedDatasources []string) (string, error) {
// we don't allow any files to be installed/filtered from ubuntu-seed if
// there are no datasources at all
if len(allowedDatasources) == 0 {
return "", nil
}
// otherwise if there are datasources that are allowed, then we perform
// filtering on the file
// note that this logic means that "generic" cloud-init config which is not
// specific to a datasource will not get installed unless either:
// * there is another file specifying a datasource that intersects with the
// set of datasources mentioned in the gadget and intersects with what we
// support
// * there are no datasources mentioned in the gadget and there are other
// cloud-init files on ubuntu-seed which specify a datasource and
// intersect with what we support
dstFileName := filepath.Base(in)
filteredFile, err := os.CreateTemp("", dstFileName)
if err != nil {
return "", err
}
defer filteredFile.Close()
// open the source and unmarshal it as yaml
unfilteredFileBytes, err := os.ReadFile(in)
if err != nil {
return "", err
}
var cfg supportedFilteredCloudConfig
if err := yaml.Unmarshal(unfilteredFileBytes, &cfg); err != nil {
return "", err
}
if err := filterCloudCfg(&cfg, allowedDatasources); err != nil {
return "", err
}
// write out cfg to the filtered file now
b, err := yaml.Marshal(cfg)
if err != nil {
return "", err
}
// check if we need to write a file at all, if the yaml serialization was
// entirely filtered out, then we don't need to write anything
if strings.TrimSpace(string(b)) == "{}" {
return "", nil
}
// add the #cloud-config prefix to all files we write
if _, err := filteredFile.Write([]byte("#cloud-config\n")); err != nil {
return "", err
}
if _, err := filteredFile.Write(b); err != nil {
return "", err
}
// use the newly filtered temp file as the source to copy
return filteredFile.Name(), nil
}
type cloudDatasourcesInUseResult struct {
// ExplicitlyAllowed is the value of datasource_list. If this is empty,
// consult ExplicitlyNoneAllowed to tell if it was specified as empty in the
// config or if it was just absent from the config
ExplicitlyAllowed []string
// ExplicitlyNoneAllowed is true when datasource_list was set to
// specifically the empty list, thus disallowing use of any datasource
ExplicitlyNoneAllowed bool
// Mentioned is the full set of datasources mentioned in the yaml config,
// both sources from ExplicitlyAllowed and from implicitly mentioned in the
// config.
Mentioned []string
}
// cloudDatasourcesInUse returns the datasources in use by the specified config
// file. All datasource names are made upper case to be comparable. This is an
// arbitrary choice between making them upper case or making them lower case,
// but cloud-init treats "maas" the same as "MAAS", so we need to treat them the
// same too.
func cloudDatasourcesInUse(configFile string) (*cloudDatasourcesInUseResult, error) {
// TODO: are there other keys in addition to those that we support in
// filtering that might mention datasources ?
b, err := os.ReadFile(configFile)
if err != nil {
return nil, err
}
var cfg supportedFilteredCloudConfig
if err := yaml.Unmarshal(b, &cfg); err != nil {
return nil, err
}
res := &cloudDatasourcesInUseResult{}
sourcesMentionedInCfg := map[string]bool{}
// datasource key is a map with the datasource name as a key
for ds := range cfg.Datasource {
sourcesMentionedInCfg[strings.ToUpper(ds)] = true
}
// same for reporting
for ds := range cfg.Reporting {
sourcesMentionedInCfg[strings.ToUpper(ds)] = true
}
// we can also have datasources mentioned in the datasource list config
if cfg.DatasourceList != nil {
if len(*cfg.DatasourceList) == 0 {
res.ExplicitlyNoneAllowed = true
} else {
explicitlyAllowed := map[string]bool{}
for _, ds := range *cfg.DatasourceList {
dsName := strings.ToUpper(ds)
sourcesMentionedInCfg[dsName] = true
explicitlyAllowed[dsName] = true
}
res.ExplicitlyAllowed = make([]string, 0, len(explicitlyAllowed))
for ds := range explicitlyAllowed {
res.ExplicitlyAllowed = append(res.ExplicitlyAllowed, ds)
}
sort.Strings(res.ExplicitlyAllowed)
}
}
for ds := range sourcesMentionedInCfg {
res.Mentioned = append(res.Mentioned, strings.ToUpper(ds))
}
sort.Strings(res.Mentioned)
return res, nil
}
// cloudDatasourcesInUseForDir considers all files in a directory as individual
// cloud-init config files, and analyzes all datasources in use for each file
// and returns their union. It does not distinguish between mentioned,
// explicitly allowed, or explicitly disallowed, but it does follow cloud-init's
// logic for determining the overwriting of properties. So, for example, if a
// file sets datasource_list: [] and no other file processed later (files are
// processed in lexical order) sets this property to another value, it will be
// treated as if the config explicitly disallows no datasources. If, on the
// other hand, a file processed later sets datasource_list: [foo], then foo is
// used instead and the explicit disallowing is ignored/overwritten.
func cloudDatasourcesInUseForDir(dir string) (*cloudDatasourcesInUseResult, error) {
// cloud-init only considers files with file extension .cfg so we do too.
files, err := filepath.Glob(filepath.Join(dir, "*.cfg"))
if err != nil {
return nil, err
}
// sort the filenames so they are in lexographical order - this is the same
// order that cloud-init processes them
sort.Strings(files)
res := &cloudDatasourcesInUseResult{}
resMentionedMap := map[string]bool{}
for _, f := range files {
fRes, err := cloudDatasourcesInUse(f)
// TODO: or should we fail on broken individual files? probably?
if err != nil {
logger.Noticef("error analyzing cloud-init datasources in use for file %s: %v", f, err)
continue
}
// if we have an explicit setting for what is allowed, then that always
// overwrites previous settings of ExplicitlyAllowed
if len(fRes.ExplicitlyAllowed) != 0 {
res.ExplicitlyNoneAllowed = false
res.ExplicitlyAllowed = fRes.ExplicitlyAllowed
} else if fRes.ExplicitlyNoneAllowed {
// if we are now explicitly disallowing datasources, then overwrite that
// setting - this is mutually exclusive with ExplicitlyAllowed
// having a non-zero length
res.ExplicitlyNoneAllowed = true
res.ExplicitlyAllowed = nil
}
// we always keep track of the mentioned datasources, it's not an issue
// to mention datasources and also have datasources disallowed, the
// higher level logic is expected to handle this properly
for _, ds := range fRes.Mentioned {
if !resMentionedMap[ds] {
res.Mentioned = append(res.Mentioned, ds)
resMentionedMap[ds] = true
}
}
}
sort.Strings(res.Mentioned)
sort.Strings(res.ExplicitlyAllowed)
return res, nil
}
type cloudInitConfigInstallOptions struct {
// Prefix is the prefix to add to files when installing them.
Prefix string
// Filter is whether to filter the config files when installing them.
Filter bool
// AllowedDatasources is the set of datasources to allow config that is
// specific to a datasource in when filtering. An empty list and setting
// Filter to false is equivalent to allowing any datasource to be installed,
// while an empty list and setting Filter to true means that no config that
// is specific to a datasource should be installed, but config that is not
// specific to a datasource (such as networking config) is allowed to be
// installed.
AllowedDatasources []string
}
// installCloudInitCfgDir installs glob cfg files from the source directory to
// the cloud config dir, optionally filtering the files for safe and supported
// keys in the configuration before installing them.
func installCloudInitCfgDir(src, targetdir string, opts *cloudInitConfigInstallOptions) (installedFiles []string, err error) {
if opts == nil {
opts = &cloudInitConfigInstallOptions{}
}
// TODO:UC20: enforce patterns on the glob files and their suffix ranges
ccl, err := filepath.Glob(filepath.Join(src, "*.cfg"))
if err != nil {
return nil, err
}
if len(ccl) == 0 {
return nil, nil
}
ubuntuDataCloudCfgDir := filepath.Join(ubuntuDataCloudDir(targetdir), "cloud.cfg.d/")
if err := os.MkdirAll(ubuntuDataCloudCfgDir, 0755); err != nil {
return nil, fmt.Errorf("cannot make cloud config dir: %v", err)
}
for _, cc := range ccl {
src := cc
baseName := filepath.Base(cc)
dst := filepath.Join(ubuntuDataCloudCfgDir, opts.Prefix+baseName)
if opts.Filter {
filteredFile, err := filterCloudCfgFile(cc, opts.AllowedDatasources)
if err != nil {
return nil, fmt.Errorf("error while filtering cloud-config file %s: %v", baseName, err)
}
src = filteredFile
}
// src may be the empty string if we were copying a file that got
// entirely emptied, in which case we shouldn't copy anything since
// there's nothing to install from this config file
if src == "" {
logger.Noticef("cloud-init config file %s was filtered out", baseName)
continue
}
if err := osutil.CopyFile(src, dst, 0); err != nil {
return nil, err
}
// make sure that the new file is world readable, since cloud-init does
// not run as root (somehow?)
if err := os.Chmod(dst, 0644); err != nil {
return nil, err
}
installedFiles = append(installedFiles, dst)
}
return installedFiles, nil
}
// installGadgetCloudInitCfg installs a single cloud-init config file from the
// gadget snap to the /etc/cloud config dir as "80_device_gadget.cfg". It also
// parses and returns what datasources are detected to be in use for the gadget
// cloud-config.
func installGadgetCloudInitCfg(src, targetdir string) (*cloudDatasourcesInUseResult, error) {
ubuntuDataCloudCfgDir := filepath.Join(ubuntuDataCloudDir(targetdir), "cloud.cfg.d/")
if err := os.MkdirAll(ubuntuDataCloudCfgDir, 0755); err != nil {
return nil, fmt.Errorf("cannot make cloud config dir: %v", err)
}
datasourcesRes, err := cloudDatasourcesInUse(src)
if err != nil {
return nil, err
}
configFile := filepath.Join(ubuntuDataCloudCfgDir, "80_device_gadget.cfg")
if err := osutil.CopyFile(src, configFile, 0); err != nil {
return nil, err
}
return datasourcesRes, nil
}
func configureCloudInit(model *asserts.Model, opts *Options) (err error) {
if opts.TargetRootDir == "" {
return fmt.Errorf("unable to configure cloud-init, missing target dir")
}
// first check if cloud-init should be disallowed entirely
if !opts.AllowCloudInit {
return DisableCloudInit(WritableDefaultsDir(opts.TargetRootDir))
}
// otherwise cloud-init is allowed to run, we need to decide where to
// permit configuration to come from, if opts.CloudInitSrcDir is non-empty
// there is at least a cloud-config dir on ubuntu-seed we could install
// config from
// check if we should filter cloud-init config on ubuntu-seed, we do this
// for grade signed only (we don't allow any config for grade secured, and we
// allow any config on grade dangerous)
grade := model.Grade()
gadgetDatasourcesRes := &cloudDatasourcesInUseResult{}
// we always allow gadget cloud config, so install that first
if HasGadgetCloudConf(opts.GadgetDir) {
// then copy / install the gadget config first
gadgetCloudConf := filepath.Join(opts.GadgetDir, "cloud.conf")
datasourcesRes, err := installGadgetCloudInitCfg(gadgetCloudConf, WritableDefaultsDir(opts.TargetRootDir))
if err != nil {
return err
}
gadgetDatasourcesRes = datasourcesRes
// we don't return here to enable also copying any cloud-init config
// from ubuntu-seed in order for both to be used simultaneously for
// example on test devices where the gadget has a gadget.yaml, but for
// testing purposes you also want to provision another user with
// ubuntu-seed cloud-init config
}
// after installing gadget config, check if we have to consider ubuntu-seed
// at all, if a source dir wasn't provided to us we can just exit early
// here, note that it's valid to allow cloud-init, but not set
// CloudInitSrcDir and not have a gadget cloud.conf, in this case cloud-init
// may pick up dynamic metadata and userdata from NoCloud sources such as a
// USB or CD-ROM drive with label CIDATA, etc. during first-boot
if opts.CloudInitSrcDir == "" {
return nil
}
// otherwise there is most likely something on ubuntu-seed
installOpts := &cloudInitConfigInstallOptions{
// set the prefix such that any ubuntu-seed config that ends up getting
// installed takes precedence over the gadget config
Prefix: "90_",
}
switch grade {
case asserts.ModelSecured:
// for secured we are done, we only allow gadget cloud-config on secured
return nil
case asserts.ModelSigned:
// for grade signed, we filter config coming from ubuntu-seed
installOpts.Filter = true
// in order to decide what to allow through the filter, we need to
// consider the whole set of config files on ubuntu-seed as a single
// bundle of files and determine the datasource(s) in use there, and
// compare this with the datasource(s) we support through the gadget and
// in supportedFilteredDatasources
ubuntuSeedDatasourceRes, err := cloudDatasourcesInUseForDir(opts.CloudInitSrcDir)
if err != nil {
return err
}
// handle the various permutations for the datasources mentioned in the
// gadget
switch {
case gadgetDatasourcesRes.ExplicitlyNoneAllowed:
// no datasources were allowed, so set it to the empty list to
// disallow anything being installed
installOpts.AllowedDatasources = nil
// consider the case where the gadget explicitly allows specific
// datasources before considering any of the implicit mentions
case len(gadgetDatasourcesRes.ExplicitlyAllowed) != 0:
// allow the intersection of what the gadget explicitly allows, what
// ubuntu-seed either explicitly allows (or what it mentions), and
// what we statically support
if len(ubuntuSeedDatasourceRes.ExplicitlyAllowed) != 0 {
// use ubuntu-seed explicitly allowed in the intersection computation
installOpts.AllowedDatasources = strutil.Intersection(
supportedFilteredDatasources,
ubuntuSeedDatasourceRes.ExplicitlyAllowed,
gadgetDatasourcesRes.ExplicitlyAllowed,
)
} else if len(ubuntuSeedDatasourceRes.Mentioned) != 0 && !ubuntuSeedDatasourceRes.ExplicitlyNoneAllowed {
// use ubuntu-seed mentioned in the intersection computation
installOpts.AllowedDatasources = strutil.Intersection(
supportedFilteredDatasources,
ubuntuSeedDatasourceRes.Mentioned,
gadgetDatasourcesRes.ExplicitlyAllowed,
)
} else {
// then the ubuntu-seed datasources didn't either mention any
// datasources, or it explicitly disallowed any datasources (
// which would be weird to have config on ubuntu-seed which says
// "please ignore this other config on ubuntu-seed")
// but in any case we know a priori that the intersection will
// be empty
installOpts.AllowedDatasources = nil
}
case len(gadgetDatasourcesRes.Mentioned) != 0:
// allow the intersection of what the gadget mentions, what
// ubuntu-seed either explicitly allows (or what it mentions), and
// what we statically support
if len(ubuntuSeedDatasourceRes.ExplicitlyAllowed) != 0 {
// use ubuntu-seed explicitly allowed in the intersection computation
installOpts.AllowedDatasources = strutil.Intersection(
supportedFilteredDatasources,
ubuntuSeedDatasourceRes.ExplicitlyAllowed,
gadgetDatasourcesRes.Mentioned,
)
} else if len(ubuntuSeedDatasourceRes.Mentioned) != 0 && !ubuntuSeedDatasourceRes.ExplicitlyNoneAllowed {
// use ubuntu-seed mentioned in the intersection computation
installOpts.AllowedDatasources = strutil.Intersection(
supportedFilteredDatasources,
ubuntuSeedDatasourceRes.Mentioned,
gadgetDatasourcesRes.Mentioned,
)
} else {
// then the ubuntu-seed datasources didn't either mention any
// datasources, or it explicitly disallowed any datasources (
// which would be weird to have config on ubuntu-seed which says
// "please ignore this other config on ubuntu-seed")
// but in any case we know a priori that the intersection will
// be empty
installOpts.AllowedDatasources = nil
}
default:
// gadget had no opinion on the datasources used, so we allow the
// intersection of what ubuntu-seed explicitly allowed (or
// mentioned) with what we statically allow
if len(ubuntuSeedDatasourceRes.ExplicitlyAllowed) != 0 {
// use ubuntu-seed explicitly allowed in the intersection computation
installOpts.AllowedDatasources = strutil.Intersection(
supportedFilteredDatasources,
ubuntuSeedDatasourceRes.ExplicitlyAllowed,
)
} else if len(ubuntuSeedDatasourceRes.Mentioned) != 0 && !ubuntuSeedDatasourceRes.ExplicitlyNoneAllowed {
// use ubuntu-seed mentioned in the intersection computation
installOpts.AllowedDatasources = strutil.Intersection(
supportedFilteredDatasources,
ubuntuSeedDatasourceRes.Mentioned,
)
} else {
// then the ubuntu-seed datasources didn't either mention any
// datasources, or it explicitly disallowed any datasources (
// which would be weird to have config on ubuntu-seed which says
// "please ignore this other config on ubuntu-seed")
// but in any case we know a priori that the intersection will
// be empty
installOpts.AllowedDatasources = nil
}
}
case asserts.ModelDangerous:
// for grade dangerous we just install all the config from ubuntu-seed
installOpts.Filter = false
default:
return fmt.Errorf("internal error: unknown model assertion grade %s", grade)
}
// check if we will actually be able to install anything
if installOpts.Filter && len(installOpts.AllowedDatasources) == 0 {
return nil
}
// try installing the files, this is the case either where we are filtering
// and there are some files that will be filtered, or where we are not
// filtering and thus don't know anything about what files we might install,
// but we will install them all because we are in grade dangerous
installedFiles, err := installCloudInitCfgDir(opts.CloudInitSrcDir, WritableDefaultsDir(opts.TargetRootDir), installOpts)
if err != nil {
return err
}
if installOpts.Filter && len(installedFiles) != 0 {
// we are filtering files and we installed some, so we also need to
// install a datasource restriction file at the end just as a paranoia
// measure
yaml := []byte(fmt.Sprintf(genericCloudRestrictYamlPattern, strings.Join(installOpts.AllowedDatasources, ",")))
restrictFile := filepath.Join(ubuntuDataCloudDir(WritableDefaultsDir(opts.TargetRootDir)), "cloud.cfg.d/99_snapd_datasource.cfg")
return os.WriteFile(restrictFile, yaml, 0644)
}
return nil
}
// CloudInitState represents the various cloud-init states
type CloudInitState int
var (
// the (?m) is needed since cloud-init output will have newlines
cloudInitStatusRe = regexp.MustCompile(`(?m)^status: (.*)$`)
datasourceRe = regexp.MustCompile(`DataSource([a-zA-Z0-9]+).*`)
cloudInitSnapdRestrictFile = "/etc/cloud/cloud.cfg.d/zzzz_snapd.cfg"
cloudInitDisabledFile = "/etc/cloud/cloud-init.disabled"
// for NoCloud datasource, we need to specify "manual_cache_clean: true"
// because the default is false, and this key being true essentially informs
// cloud-init that it should always trust the instance-id it has cached in
// the image, and shouldn't assume that there is a new one on every boot, as
// otherwise we have bugs like https://bugs.launchpad.net/snapd/+bug/1905983
// where subsequent boots after cloud-init runs and gets restricted it will
// try to detect the instance_id by reading from the NoCloud datasource
// fs_label, but we set that to "null" so it fails to read anything and thus
// can't detect the effective instance_id and assumes it is different and
// applies default config which can overwrite valid config from the initial
// boot if that is not the default config
// see also https://cloudinit.readthedocs.io/en/latest/topics/boot.html?highlight=manual_cache_clean#first-boot-determination
nocloudRestrictYaml = []byte(`datasource_list: [NoCloud]
datasource:
NoCloud:
fs_label: null
manual_cache_clean: true
`)
// don't use manual_cache_clean for real cloud datasources, the setting is
// used with ubuntu core only for sources where we can only get the
// instance_id through the fs_label for NoCloud and None (since we disable
// importing using the fs_label after the initial run).
genericCloudRestrictYamlPattern = `datasource_list: [%s]
`
localDatasources = []string{"NoCloud", "None"}
)
const (
// CloudInitDisabledPermanently is when cloud-init is disabled as per the
// cloud-init.disabled file.
CloudInitDisabledPermanently CloudInitState = iota
// CloudInitRestrictedBySnapd is when cloud-init has been restricted by
// snapd with a specific config file.
CloudInitRestrictedBySnapd
// CloudInitUntriggered is when cloud-init is disabled because nothing has
// triggered it to run, but it could still be run.
CloudInitUntriggered
// CloudInitDone is when cloud-init has been run on this boot.
CloudInitDone
// CloudInitEnabled is when cloud-init is active, but not necessarily
// finished. This matches the "running" and "not run" states from cloud-init
// as well as any other state that does not match any of the other defined
// states, as we are conservative in assuming that cloud-init is doing
// something.
CloudInitEnabled
// CloudInitNotFound is when there is no cloud-init executable on the
// device.
CloudInitNotFound
// CloudInitErrored is when cloud-init tried to run, but failed or had invalid
// configuration.
CloudInitErrored
)
// CloudInitStatus returns the current status of cloud-init. Note that it will
// first check for static file-based statuses first through the snapd
// restriction file and the disabled file before consulting
// cloud-init directly through the status command.
// Also note that in unknown situations we are conservative in assuming that
// cloud-init may be doing something and will return CloudInitEnabled when we
// do not recognize the state returned by the cloud-init status command.
func CloudInitStatus() (CloudInitState, error) {
// if cloud-init has been restricted by snapd, check that first
snapdRestrictingFile := filepath.Join(dirs.GlobalRootDir, cloudInitSnapdRestrictFile)
if osutil.FileExists(snapdRestrictingFile) {
return CloudInitRestrictedBySnapd, nil
}
// if it was explicitly disabled via the cloud-init disable file, then
// return special status for that
disabledFile := filepath.Join(dirs.GlobalRootDir, cloudInitDisabledFile)
if osutil.FileExists(disabledFile) {
return CloudInitDisabledPermanently, nil
}
// if it was explicitly disabled via the kernel commandline, then
// return special status for that
cmdline, err := kcmdline.KeyValues("cloud-init")
if err != nil {
logger.Noticef("WARNING: cannot obtain cloud-init from kernel command line: %v", err)
} else if cmdline["cloud-init"] == "disabled" {
return CloudInitDisabledPermanently, nil
}
ciBinary, err := exec.LookPath("cloud-init")
if err != nil {
logger.Noticef("cannot locate cloud-init executable: %v", err)
return CloudInitNotFound, nil
}
out, stderr, err := osutil.RunSplitOutput(ciBinary, "status")
// in the case where cloud-init is actually in an error condition, like
// where MAAS is the datasource but there is no MAAS server for example,
// then cloud-init will exit with status 1 and output `status: error`
// we want to handle that case specially below by returning non-nil error,
// but also CloudInitErrored, so first inspect the output to see if it
// matches
// output should just be "status: <state>"
match := cloudInitStatusRe.FindSubmatch(out)
if len(match) != 2 {
// check if running the command had an error, if it did then return that
if err != nil {
return CloudInitErrored, osutil.OutputErrCombine(out, stderr, err)
}
// otherwise we had some sort of malformed output
return CloudInitErrored, fmt.Errorf("invalid cloud-init output: %v", osutil.OutputErrCombine(out, stderr, err))
}
hasError := false
if err != nil {
exitError, isExitError := err.(*exec.ExitError)
if isExitError && exitError.ExitCode() == 2 {
logger.Noticef("cloud-init status returned 'recoverable error' status: cloud-init completed but experienced errors")
} else {
hasError = true
}
}
// otherwise we had a successful match, but we need to check if the status
// command errored itself
if hasError {
if string(match[1]) == "error" {
// then the status was indeed error and we should treat this as the
// "positively identified" error case
return CloudInitErrored, nil
}
// otherwise just ignore the parsing of the output and just return the
// error normally
return CloudInitErrored, fmt.Errorf("cloud-init errored: %v", osutil.OutputErrCombine(out, stderr, err))
}
// otherwise no error from cloud-init
switch string(match[1]) {
case "disabled":
// here since we weren't disabled by the file, we are in "disabled but
// could be enabled" state - arguably this should be a different state
// than "disabled", see
// https://bugs.launchpad.net/cloud-init/+bug/1883124 and
// https://bugs.launchpad.net/cloud-init/+bug/1883122
return CloudInitUntriggered, nil
case "error":
// this shouldn't happen in practice, but handle it here anyways in case
// cloud-init ever changes it's mind and starts reporting error state
// with a 0 exit code
return CloudInitErrored, nil
case "done":
return CloudInitDone, nil
// "running" and "not run" are considered Enabled, see doc-comment
case "running", "not run":
fallthrough
default:
// these states are all the generic "enabled" state
return CloudInitEnabled, nil
}
}
// these structs are externally defined by cloud-init
type v1Data struct {
DataSource string `json:"datasource"`
}
type cloudInitStatus struct {
V1 v1Data `json:"v1"`
}
// CloudInitRestrictionResult is the result of calling RestrictCloudInit. The
// values for Action are "disable" or "restrict", and the Datasource will be set
// to the restricted datasource if Action is "restrict".
type CloudInitRestrictionResult struct {
Action string
DataSource string
}
// CloudInitRestrictOptions are options for how to restrict cloud-init with
// RestrictCloudInit.
type CloudInitRestrictOptions struct {
// ForceDisable will force disabling cloud-init even if it is
// in an active/running or errored state.
ForceDisable bool
// DisableAfterLocalDatasourcesRun modifies RestrictCloudInit to disable
// cloud-init after it has run on first-boot if the datasource detected is
// a local source such as NoCloud or None. If the datasource detected is not
// a local source, such as GCE or AWS EC2 it is merely restricted as
// described in the doc-comment on RestrictCloudInit.
DisableAfterLocalDatasourcesRun bool
}
// RestrictCloudInit will limit the operations of cloud-init on subsequent boots
// by either disabling cloud-init in the untriggered state, or restrict
// cloud-init to only use a specific datasource (additionally if the currently
// detected datasource for this boot was NoCloud, it will disable the automatic
// import of filesystems with labels such as CIDATA (or cidata) as datasources).
// This is expected to be run when cloud-init is in a "steady" state such as
// done or disabled (untriggered). If called in other states such as errored, it
// will return an error, but it can be forced to disable cloud-init anyways in
// these states with the opts parameter and the ForceDisable field.
// This function is meant to protect against CVE-2020-11933.
func RestrictCloudInit(state CloudInitState, opts *CloudInitRestrictOptions) (CloudInitRestrictionResult, error) {
res := CloudInitRestrictionResult{}
if opts == nil {
opts = &CloudInitRestrictOptions{}
}
switch state {
case CloudInitDone:
// handled below
break
case CloudInitRestrictedBySnapd:
return res, fmt.Errorf("cannot restrict cloud-init: already restricted")
case CloudInitDisabledPermanently:
return res, fmt.Errorf("cannot restrict cloud-init: already disabled")
case CloudInitErrored, CloudInitEnabled:
// if we are not forcing a disable, return error as these states are
// where cloud-init could still be running doing things
if !opts.ForceDisable {
return res, fmt.Errorf("cannot restrict cloud-init in error or enabled state")
}
fallthrough
case CloudInitUntriggered, CloudInitNotFound:
fallthrough
default:
res.Action = "disable"
return res, DisableCloudInit(dirs.GlobalRootDir)
}
// from here on out, we are taking the "restrict" action
res.Action = "restrict"
// first get the cloud-init data-source that was used from /
resultsFile := filepath.Join(dirs.GlobalRootDir, "/run/cloud-init/status.json")
f, err := os.Open(resultsFile)
if err != nil {
return res, err
}
defer f.Close()
var stat cloudInitStatus
err = json.NewDecoder(f).Decode(&stat)
if err != nil {
return res, err
}
// if the datasource was empty then cloud-init did something wrong or
// perhaps it incorrectly reported that it ran but something else deleted
// the file
datasourceRaw := stat.V1.DataSource
if datasourceRaw == "" {
return res, fmt.Errorf("cloud-init error: missing datasource from status.json")
}
// for some datasources there is additional data in this item, i.e. for
// NoCloud we will also see:
// "DataSourceNoCloud [seed=/dev/sr0][dsmode=net]"
// so hence we use a regexp to parse out just the name of the datasource
datasourceMatches := datasourceRe.FindStringSubmatch(datasourceRaw)
if len(datasourceMatches) != 2 {
return res, fmt.Errorf("cloud-init error: unexpected datasource format %q", datasourceRaw)
}
res.DataSource = datasourceMatches[1]
cloudInitRestrictFile := filepath.Join(dirs.GlobalRootDir, cloudInitSnapdRestrictFile)
switch {
case opts.DisableAfterLocalDatasourcesRun && strutil.ListContains(localDatasources, res.DataSource):
// On UC20, DisableAfterLocalDatasourcesRun will be set, where we want
// to disable local sources like NoCloud and None after first-boot
// instead of just restricting them like we do below for UC16 and UC18.
// as such, change the action taken to disable and disable cloud-init
res.Action = "disable"
err = DisableCloudInit(dirs.GlobalRootDir)
case res.DataSource == "NoCloud":
// With the NoCloud datasource (which is one of the local datasources),
// we also need to restrict/disable the import of arbitrary filesystem
// labels to use as datasources, i.e. a USB drive inserted by an
// attacker with label CIDATA will defeat security measures on Ubuntu
// Core, so with the additional fs_label spec, we disable that import.
err = os.WriteFile(cloudInitRestrictFile, nocloudRestrictYaml, 0644)
default:
// all other cases are either not local on UC20, or not NoCloud and as
// such we simply restrict cloud-init to the specific datasource used so
// that an attack via NoCloud is protected against
yaml := []byte(fmt.Sprintf(genericCloudRestrictYamlPattern, res.DataSource))
err = os.WriteFile(cloudInitRestrictFile, yaml, 0644)
}
return res, err
}
|