diff --git a/kola/tests/misc/cloudinit.go b/kola/tests/misc/cloudinit.go index af5070661..9ef74d541 100644 --- a/kola/tests/misc/cloudinit.go +++ b/kola/tests/misc/cloudinit.go @@ -15,13 +15,136 @@ package misc import ( + "fmt" "strings" + "github.com/coreos/go-semver/semver" "github.com/flatcar/mantle/kola/cluster" "github.com/flatcar/mantle/kola/register" "github.com/flatcar/mantle/platform/conf" ) +var multipartMimeUserdata = `Content-Type: multipart/mixed; boundary="MIMEMULTIPART" +MIME-Version: 1.0 + +--MIMEMULTIPART +Content-Type: text/cloud-config; charset="us-ascii" +MIME-Version: 1.0 +Content-Transfer-Encoding: 7bit +Content-Disposition: attachment; filename="cloud-config" + +ssh_authorized_keys: + - ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEftQIHTRvUmyDCN7VGve4srz03Jmq6rPnqq+XMHMQUIL9c/b0l7B5tWfQvQecKyLte94HOPzAyMJlktWTVGQnY= + +--MIMEMULTIPART +Content-Type: text/cloud-config; charset="us-ascii" +MIME-Version: 1.0 +Content-Transfer-Encoding: 7bit +Content-Disposition: attachment; filename="cloud-config" + +hostname: "example" + +--MIMEMULTIPART +Content-Type: text/cloud-config; charset="us-ascii" +MIME-Version: 1.0 +Content-Transfer-Encoding: 7bit +Content-Disposition: attachment; filename="cloud-config" + +write_files: +- encoding: b64 + content: NDI= + path: /tmp/kola_b64 + permissions: '0644' +- encoding: base64 + content: NDI= + path: /tmp/kola_b64_1 + permissions: '0644' +- encoding: gzip + content: !!binary | + H4sIAGUfoFQC/zMxAgCIsCQyAgAAAA== + path: /tmp/kola_gzip + permissions: '0644' +- encoding: gz + content: !!binary | + H4sIAGUfoFQC/zMxAgCIsCQyAgAAAA== + path: /tmp/kola_gzip_1 + permissions: '0644' +- encoding: gz+base64 + content: H4sIAGUfoFQC/zMxAgCIsCQyAgAAAA== + path: /tmp/kola_gzip_base64 + permissions: '0644' +- encoding: gzip+base64 + content: H4sIAGUfoFQC/zMxAgCIsCQyAgAAAA== + path: /tmp/kola_gzip_base64_1 + permissions: '0644' +- encoding: gz+b64 + content: H4sIAGUfoFQC/zMxAgCIsCQyAgAAAA== + path: /tmp/kola_gzip_base64_2 + permissions: '0644' + +--MIMEMULTIPART +Content-Type: text/x-shellscript; charset="us-ascii" +MIME-Version: 1.0 +Content-Transfer-Encoding: 7bit +Content-Disposition: attachment; filename="create_file.ps1" + +#!/bin/sh +touch /coreos-cloudinit_multipart.txt + +--MIMEMULTIPART +Content-Type: text/cloud-config; charset="us-ascii" +MIME-Version: 1.0 +Content-Transfer-Encoding: 7bit +Content-Disposition: attachment; filename="cloud-config" + +#test_to_check_if_cloud_config_can_contain_a_comment + +--MIMEMULTIPART +Content-Type: text/plain; charset="us-ascii" +MIME-Version: 1.0 +Content-Transfer-Encoding: 7bit +Content-Disposition: attachment; filename="some_text.txt" + +This is just some random text. + +--MIMEMULTIPART +Content-Type: application/json; charset="us-ascii" +MIME-Version: 1.0 +Content-Transfer-Encoding: 7bit +Content-Disposition: attachment; filename="ignition.txt" + +{ + "ignitionVersion": 1, + "This ignition configuration will be ignored because it's just embedded": "only cloud-init will run", + "ignition": { + "version": "3.0.0" + }, + "systemd": { + "units": [{ + "name": "example.service", + "enabled": true, + "contents": "[Service]\nType=oneshot\nExecStart=/usr/bin/echo Hello World\n\n[Install]\nWantedBy=multi-user.target" + }] + } +} + +--MIMEMULTIPART +Content-Type: text/plain; charset="us-ascii" +MIME-Version: 1.0 +Content-Transfer-Encoding: 7bit +Content-Disposition: attachment; filename="incognito_cloud_config.txt" + +#cloud-config + +write_files: +- encoding: b64 + content: NDI= + path: /kola_undercover + permissions: '0644' + +--MIMEMULTIPART-- +` + func init() { register.Register(®ister.Test{ Run: CloudInitBasic, @@ -54,6 +177,15 @@ chmod 600 ~core/.ssh/authorized_keys`), // When cl.cloudinit.basic passed we don't need to run this on all clouds Platforms: []string{"qemu", "qemu-unpriv"}, }) + register.Register(®ister.Test{ + Run: CloudInitMultipartMime, + ClusterSize: 1, + Name: "cl.cloudinit.multipart-mime", + UserData: conf.MultipartMimeConfig(multipartMimeUserdata), + Distros: []string{"cl"}, + Platforms: []string{"qemu", "qemu-unpriv"}, + MinVersion: semver.Version{Major: 3620}, + }) } func CloudInitBasic(c cluster.TestCluster) { @@ -78,3 +210,31 @@ func CloudInitScript(c cluster.TestCluster) { c.Fatalf("userdata script produced unexpected value %q", out) } } + +func CloudInitMultipartMime(c cluster.TestCluster) { + m := c.Machines()[0] + + expectKey := "AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEftQIHTRvUmyDCN7VGve4srz03Jmq6rPnqq+XMHMQUIL9c/b0l7B5tWfQvQecKyLte94HOPzAyMJlktWTVGQnY=" + + // Test that the hostname was set by the first multipart mime part that declares the "hostname" + // cloud-config option. The second one at the end should be ignored. + out := c.MustSSH(m, "hostnamectl") + if !strings.Contains(string(out), "Static hostname: example") { + c.Fatalf("hostname wasn't set correctly:\n%s", out) + } + + // we can ignore the output. If the command fails, MustSSH will fail the test. + c.MustSSH(m, fmt.Sprintf("grep %s ~core/.ssh/authorized_keys", expectKey)) + + out = c.MustSSH(m, "ls -l /tmp/kola_*| wc -l") + if string(strings.Replace(string(out), "\n", "", -1)) != "7" { + c.Fatalf("expected 7 files in /tmp, found %q", out) + } + + // All files should have the same content (42). These files should have been created by the cloud-config part + // that declares the write_files option. + c.MustSSH(m, `for f in /tmp/kola_*; do OUT=$(cat $f); if [ "$OUT" != 42 ]; then exit 1; fi; done`) + // Check that the x-shellscript part was executed. + c.MustSSH(m, "test -f /coreos-cloudinit_multipart.txt") + c.MustSSH(m, "test -f /kola_undercover") +} diff --git a/platform/conf/conf.go b/platform/conf/conf.go index 33da2a10e..8e9b3d826 100644 --- a/platform/conf/conf.go +++ b/platform/conf/conf.go @@ -15,9 +15,13 @@ package conf import ( + "bufio" + "bytes" + "compress/gzip" "encoding/json" "fmt" "io/ioutil" + "net/textproto" "net/url" "os" "reflect" @@ -52,6 +56,7 @@ import ( ignvalidate "github.com/flatcar/ignition/config/validate" "github.com/vincent-petithory/dataurl" "golang.org/x/crypto/ssh/agent" + "gopkg.in/yaml.v3" ) type kind int @@ -63,6 +68,7 @@ const ( kindContainerLinuxConfig kindScript kindButane + kindMultipartMime ) var plog = capnslog.NewPackageLogger("github.com/flatcar/mantle", "platform/conf") @@ -80,18 +86,19 @@ type UserData struct { // Conf is a configuration for a Container Linux machine. It may be either a // coreos-cloudconfig or an ignition configuration. type Conf struct { - ignitionV1 *v1types.Config - ignitionV2 *v2types.Config - ignitionV21 *v21types.Config - ignitionV22 *v22types.Config - ignitionV23 *v23types.Config - ignitionV3 *v3types.Config - ignitionV31 *v31types.Config - ignitionV32 *v32types.Config - ignitionV33 *v33types.Config - cloudconfig *cci.CloudConfig - script string - user string + ignitionV1 *v1types.Config + ignitionV2 *v2types.Config + ignitionV21 *v21types.Config + ignitionV22 *v22types.Config + ignitionV23 *v23types.Config + ignitionV3 *v3types.Config + ignitionV31 *v31types.Config + ignitionV32 *v32types.Config + ignitionV33 *v33types.Config + cloudconfig *cci.CloudConfig + script string + multipartMime *MultipartUserdata + user string } func Empty() *UserData { @@ -100,6 +107,13 @@ func Empty() *UserData { } } +func MultipartMimeConfig(data string) *UserData { + return &UserData{ + kind: kindMultipartMime, + data: data, + } +} + func ContainerLinuxConfig(data string) *UserData { return &UserData{ kind: kindContainerLinuxConfig, @@ -135,6 +149,32 @@ func Script(data string) *UserData { } } +func decompressIfGzipped(data []byte) []byte { + if reader, err := gzip.NewReader(bytes.NewReader(data)); err == nil { + uncompressedData, err := ioutil.ReadAll(reader) + reader.Close() + if err == nil { + return uncompressedData + } else { + return data + } + } else { + return data + } +} + +func isMultipartMime(userdata []byte) bool { + userdata = decompressIfGzipped(userdata) + mimeReader := textproto.NewReader(bufio.NewReader(bytes.NewReader(userdata))) + header, err := mimeReader.ReadMIMEHeader() + if err != nil { + return false + } + contentType := header.Get("Content-Type") + + return strings.Contains(contentType, "multipart/mixed") +} + func Unknown(data string) *UserData { u := &UserData{ data: data, @@ -149,6 +189,10 @@ func Unknown(data string) *UserData { case ignerr.ErrScript: u.kind = kindScript default: + if isMultipartMime([]byte(data)) { + u.kind = kindMultipartMime + break + } // Guess whether this is an Ignition config or a CLC. // This treats an invalid Ignition config as a CLC, and a // CLC in the JSON subset of YAML as an Ignition config. @@ -291,6 +335,12 @@ func (u *UserData) Render(ctPlatform string) (*Conf, error) { case kindScript: // pass through scripts unmodified, you are on your own. c.script = u.data + case kindMultipartMime: + data, err := NewMultipartUserdata(u.data) + if err != nil { + return nil, err + } + c.multipartMime = data case kindIgnition: err := renderIgnition() if err != nil { @@ -388,6 +438,9 @@ func (c *Conf) String() string { return c.cloudconfig.String() } else if c.script != "" { return c.script + } else if c.multipartMime != nil { + data, _ := c.multipartMime.Serialize() + return data } return "" @@ -1245,6 +1298,27 @@ func (c *Conf) copyKeysScript(keys []*agent.Key) { c.script = strings.Replace(c.script, "@SSH_KEYS@", keyString, -1) } +func (c *Conf) copyKeysMultipartMime(keys []*agent.Key) { + keysAsStrings := keysToStrings(keys) + header := textproto.MIMEHeader{ + "Content-Type": []string{"text/cloud-config; charset=\"us-ascii\""}, + "MIME-Version": []string{"1.0"}, + "Content-Transfer-Encoding": []string{"7bit"}, + "Content-Disposition": []string{"attachment; filename=\"testing-keys.yaml\""}, + } + + udata := map[string][]string{ + "ssh_authorized_keys": keysAsStrings, + } + + asYaml, err := yaml.Marshal(udata) + if err != nil { + plog.Errorf("failed to marshal yaml: %v", err) + return + } + c.multipartMime.AddPart(header, asYaml) +} + // CopyKeys copies public keys from agent ag into the configuration to the // appropriate configuration section for the core user. func (c *Conf) CopyKeys(keys []*agent.Key) { @@ -1270,6 +1344,8 @@ func (c *Conf) CopyKeys(keys []*agent.Key) { c.copyKeysCloudConfig(keys) } else if c.script != "" { c.copyKeysScript(keys) + } else if c.multipartMime != nil { + c.copyKeysMultipartMime(keys) } } diff --git a/platform/conf/multipart.go b/platform/conf/multipart.go new file mode 100644 index 000000000..0c4474654 --- /dev/null +++ b/platform/conf/multipart.go @@ -0,0 +1,179 @@ +package conf + +import ( + "bytes" + "errors" + "fmt" + "io" + "mime" + "mime/multipart" + "net/mail" + "net/textproto" + "strings" +) + +func NewMultipartUserdata(data string) (*MultipartUserdata, error) { + m, err := mail.ReadMessage(strings.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("error parsing multipart MIME: %w", err) + } + parts, hdrInfo, err := messageToPartsAndHeaderInfo(m) + if err != nil { + return nil, fmt.Errorf("error parsing multipart MIME: %w", err) + } + + buf := &bytes.Buffer{} + mpWr := multipart.NewWriter(buf) + mpWr.SetBoundary(hdrInfo.params["boundary"]) + + for _, part := range parts { + partWr, err := mpWr.CreatePart(part.header) + if err != nil { + return nil, err + } + _, err = io.Copy(partWr, part.body) + if err != nil { + return nil, err + } + } + + mpMsg := &MultipartUserdata{ + header: hdrInfo, + parts: parts, + + writer: mpWr, + newMultipart: buf, + } + + return mpMsg, nil +} + +type MultipartUserdata struct { + // Using the header and parts we can reconstitute the original MIME message + header headerInfo + parts []partInfo + + // We import the parts into a multipart.Writer to allow adding new parts + writer *multipart.Writer + newMultipart *bytes.Buffer +} + +func (m *MultipartUserdata) AddPart(header textproto.MIMEHeader, body []byte) error { + if m.writer == nil { + return errors.New("cannot add part to read-only multipart") + } + partWr, err := m.writer.CreatePart(header) + if err != nil { + return err + } + _, err = io.Copy(partWr, bytes.NewReader(body)) + if err != nil { + return err + } + return nil +} + +func (m *MultipartUserdata) Serialize() (string, error) { + if m.writer == nil { + return "", errors.New("cannot serialize read-only multipart") + } + + err := m.writer.Close() + if err != nil { + return "", err + } + asStr := &bytes.Buffer{} + for k, v := range m.header.origHeader { + asStr.Write([]byte(fmt.Sprintf("%s: %s\n", k, v[0]))) + } + asStr.Write([]byte("\n")) + + _, err = io.Copy(asStr, m.newMultipart) + if err != nil { + return "", err + } + return asStr.String(), nil +} + +func parseMimeHeader(header mail.Header) (headerInfo, error) { + contentType := header.Get("Content-Type") + if contentType == "" { + return headerInfo{}, errors.New("no Content-Type header found") + } + + mediaType, params, err := mime.ParseMediaType(contentType) + if err != nil { + return headerInfo{}, fmt.Errorf("error parsing header: %w", err) + } + partTransferEncoding := header.Get("Content-Transfer-Encoding") + + var contentDisposition string + var fileName string + contentDispositionHeader := header.Get("Content-Disposition") + if contentDispositionHeader != "" { + mediaType, params, err := mime.ParseMediaType(contentDispositionHeader) + if err != nil { + return headerInfo{}, fmt.Errorf("error parsing header: %w", err) + } + contentDisposition = mediaType + fileName = params["filename"] + } + return headerInfo{ + mediaType: mediaType, + params: params, + transferEncoding: partTransferEncoding, + contentDisposition: contentDisposition, + fileName: fileName, + origHeader: header, + }, nil +} + +type headerInfo struct { + mediaType string + params map[string]string + fileName string + contentDisposition string + transferEncoding string + origHeader mail.Header +} + +type partInfo struct { + header textproto.MIMEHeader + body *bytes.Buffer +} + +func messageToPartsAndHeaderInfo(m *mail.Message) ([]partInfo, headerInfo, error) { + hdrInfo, err := parseMimeHeader(m.Header) + if err != nil { + return nil, headerInfo{}, fmt.Errorf("error parsing MIME header: %w", err) + } + + boundary, ok := hdrInfo.params["boundary"] + if !ok { + return nil, headerInfo{}, errors.New("no boundary found in MIME header") + } + + multipartReader := multipart.NewReader(m.Body, boundary) + + parts := []partInfo{} + for { + part, err := multipartReader.NextPart() + if err == io.EOF { + break + } + if err != nil { + return nil, headerInfo{}, fmt.Errorf("error reading part: %w", err) + } + partHeader := part.Header + partBody := &bytes.Buffer{} + _, err = io.Copy(partBody, part) + if err != nil { + return nil, headerInfo{}, fmt.Errorf("error reading part: %w", err) + } + parts = append(parts, partInfo{ + header: partHeader, + body: partBody, + }) + } + return parts, hdrInfo, nil +}