-
Notifications
You must be signed in to change notification settings - Fork 562
/
cloudinit.go
953 lines (828 loc) · 35.8 KB
/
cloudinit.go
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
// -*- 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/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]interface{} `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
}
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
}