From 04ee66f0d080e02e44561e7b1d574fa7ad3de6e2 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Sat, 2 May 2026 22:11:25 -0700 Subject: [PATCH] feat(labctl): protect Talos cidata as secret material and size dynamically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Talos machine configuration embedded in NoCloud user-data carries the cluster's PKI and API credentials and must be protected accordingly. Previously the cidata image landed at the platform default (typically 0644) and was hard-capped at a 16 MiB FAT32 volume regardless of payload — a controlplane.yaml that inlined extra config or extensions would surface a low-level diskfs failure rather than a clear error. - nocloudcidata.Builder.Build now chmods the cidata image to 0600 right after diskfs.Create, before any FAT32 content is written. Aligns with Talos's own security checklist guidance to treat machine config as root-secret material. - Image size is now derived from the payload: payload + 4 MiB FAT32 overhead, floored at the 16 MiB working size diskfs needs, rounded up to the nearest MiB. Cap at 64 MiB (CidataMaxSize); payloads larger than that surface a clear "exceeds the maximum cidata image size" error instead of an opaque diskfs failure. Tests cover the 0600 mode, a 32 MiB user-data payload that previously would not fit in the fixed 16 MiB image, and the over-cap rejection with no partial image left on disk. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../adapters/nocloudcidata/builder.go | 70 +++++++++++++++++-- .../adapters/nocloudcidata/builder_test.go | 56 ++++++++++++++- 2 files changed, 118 insertions(+), 8 deletions(-) diff --git a/tools/labctl/internal/adapters/nocloudcidata/builder.go b/tools/labctl/internal/adapters/nocloudcidata/builder.go index 895f07a..8bc8bfe 100644 --- a/tools/labctl/internal/adapters/nocloudcidata/builder.go +++ b/tools/labctl/internal/adapters/nocloudcidata/builder.go @@ -12,8 +12,21 @@ import ( ) const ( - cidataSizeBytes = 16 * 1024 * 1024 + mibibyte = 1024 * 1024 + // cidataMinSize is the floor used by Builder. FAT32 needs at least this + // many bytes to format reliably with the diskfs defaults. + cidataMinSize int64 = 16 * mibibyte + // CidataMaxSize bounds the cidata image. A Talos machine config larger + // than the resulting payload limit is a strong signal that the operator + // is embedding something that should live in a side channel rather than + // on a NoCloud cidata disk. + CidataMaxSize int64 = 64 * mibibyte + // cidataOverhead is a generous reserve for FAT32 tables, reserved + // sectors, and cluster alignment so that small payloads always fit. + cidataOverhead int64 = 4 * mibibyte + volumeLabel = "CIDATA" + cidataImageMode = 0o600 ) // Builder writes NoCloud cidata disk images. @@ -25,16 +38,34 @@ func New() Builder { } // Build writes a FAT32 NoCloud cidata image to path. +// +// The image is sized to fit the payload with FAT32 overhead, capped at +// [CidataMaxSize]; payloads that would require a larger image surface a +// clear error rather than the diskfs library's lower-level failure. The +// output file is set to mode 0600 because Talos machine configuration +// embedded in user-data is secret material — it carries cluster PKI and +// API credentials and must be protected accordingly. func (Builder) Build(path string, payload talosimage.ConfigDiskPayload) error { - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + size, err := sizeFor(payload) + if err != nil { + return err + } + + err = os.Remove(path) + if err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove existing cidata image %q: %w", path, err) } - diskImage, err := diskfs.Create(path, cidataSizeBytes, diskfs.SectorSize512) + diskImage, err := diskfs.Create(path, size, diskfs.SectorSize512) if err != nil { return fmt.Errorf("create cidata image %q: %w", path, err) } + err = os.Chmod(path, cidataImageMode) + if err != nil { + return fmt.Errorf("set cidata image %q mode: %w", path, err) + } + cidata, err := diskImage.CreateFilesystem(disk.FilesystemSpec{ Partition: 0, FSType: filesystem.TypeFat32, @@ -46,14 +77,17 @@ func (Builder) Build(path string, payload talosimage.ConfigDiskPayload) error { } defer cidata.Close() - if err := writeFile(cidata, "/user-data", payload.UserData); err != nil { + err = writeFile(cidata, "/user-data", payload.UserData) + if err != nil { return err } - if err := writeFile(cidata, "/meta-data", payload.MetaData); err != nil { + err = writeFile(cidata, "/meta-data", payload.MetaData) + if err != nil { return err } if len(payload.NetworkConfig) > 0 { - if err := writeFile(cidata, "/network-config", payload.NetworkConfig); err != nil { + err = writeFile(cidata, "/network-config", payload.NetworkConfig) + if err != nil { return err } } @@ -61,6 +95,27 @@ func (Builder) Build(path string, payload talosimage.ConfigDiskPayload) error { return nil } +func sizeFor(payload talosimage.ConfigDiskPayload) (int64, error) { + payloadSize := int64(len(payload.UserData) + len(payload.MetaData) + len(payload.NetworkConfig)) + + want := roundUpMiB(max(payloadSize+cidataOverhead, cidataMinSize)) + + if want > CidataMaxSize { + return 0, fmt.Errorf( + "cidata payload (%d bytes) requires %d bytes which exceeds the maximum cidata image size of %d bytes", + payloadSize, + want, + CidataMaxSize, + ) + } + + return want, nil +} + +func roundUpMiB(n int64) int64 { + return ((n + mibibyte - 1) / mibibyte) * mibibyte +} + func writeFile(cidata filesystem.FileSystem, path string, data []byte) error { file, err := cidata.OpenFile(path, os.O_CREATE|os.O_RDWR) if err != nil { @@ -68,7 +123,8 @@ func writeFile(cidata filesystem.FileSystem, path string, data []byte) error { } defer file.Close() - if _, err := file.Write(data); err != nil { + _, err = file.Write(data) + if err != nil { return fmt.Errorf("write cidata file %q: %w", path, err) } diff --git a/tools/labctl/internal/adapters/nocloudcidata/builder_test.go b/tools/labctl/internal/adapters/nocloudcidata/builder_test.go index f2fb4ce..c856b25 100644 --- a/tools/labctl/internal/adapters/nocloudcidata/builder_test.go +++ b/tools/labctl/internal/adapters/nocloudcidata/builder_test.go @@ -1,7 +1,10 @@ package nocloudcidata_test import ( + "bytes" "io/fs" + "os" + "path/filepath" "strings" "testing" @@ -14,7 +17,7 @@ import ( ) func TestBuilderWritesNoCloudCIDATAImage(t *testing.T) { - path := t.TempDir() + "/cidata.img" + path := filepath.Join(t.TempDir(), "cidata.img") payload := talosimage.ConfigDiskPayload{ UserData: []byte("machine:\n type: controlplane\n"), MetaData: []byte("instance-id: test\nlocal-hostname: bootstrap\n"), @@ -36,6 +39,57 @@ func TestBuilderWritesNoCloudCIDATAImage(t *testing.T) { assert.Equal(t, payload.NetworkConfig, readFile(t, cidata, "/network-config")) } +func TestBuilderSetsImageMode0600(t *testing.T) { + path := filepath.Join(t.TempDir(), "cidata.img") + + err := nocloudcidata.New().Build(path, talosimage.ConfigDiskPayload{ + UserData: []byte("machine:\n type: controlplane\n"), + MetaData: []byte("instance-id: test\nlocal-hostname: bootstrap\n"), + }) + + require.NoError(t, err) + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, fs.FileMode(0o600), info.Mode().Perm(), "cidata image must be 0600") +} + +func TestBuilderHandlesPayloadLargerThanDefaultFloor(t *testing.T) { + path := filepath.Join(t.TempDir(), "cidata.img") + bigUserData := bytes.Repeat([]byte("a"), 32*1024*1024) + + err := nocloudcidata.New().Build(path, talosimage.ConfigDiskPayload{ + UserData: bigUserData, + MetaData: []byte("instance-id: test\nlocal-hostname: bootstrap\n"), + }) + + require.NoError(t, err) + + diskImage, err := diskfs.Open(path, diskfs.WithOpenMode(diskfs.ReadOnly)) + require.NoError(t, err) + cidata, err := diskImage.GetFilesystem(0) + require.NoError(t, err) + defer cidata.Close() + + got := readFile(t, cidata, "/user-data") + assert.Equal(t, bigUserData, got) +} + +func TestBuilderRejectsPayloadOverMaxSize(t *testing.T) { + path := filepath.Join(t.TempDir(), "cidata.img") + overflow := bytes.Repeat([]byte("a"), int(nocloudcidata.CidataMaxSize)+1) + + err := nocloudcidata.New().Build(path, talosimage.ConfigDiskPayload{ + UserData: overflow, + MetaData: []byte("instance-id: test\nlocal-hostname: bootstrap\n"), + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds the maximum cidata image size") + + _, statErr := os.Stat(path) + assert.True(t, os.IsNotExist(statErr), "no partial image must be left on disk") +} + func readFile(t *testing.T, fsys fs.ReadFileFS, name string) []byte { t.Helper()