From 6e6658e31bfe0608024321ed8904636bd6547b49 Mon Sep 17 00:00:00 2001 From: Peyton Date: Mon, 1 Dec 2025 21:59:57 -0800 Subject: [PATCH 1/7] azure: generate admin config from IMDS/ovf --- docs/release-notes.md | 1 + dracut/30ignition/ignition-generator | 14 +- internal/exec/engine.go | 78 +++++- internal/main.go | 40 +-- internal/platform/platform.go | 9 + internal/providers/azure/azure.go | 329 +++++++++++++++++++++---- internal/providers/azure/azure_test.go | 146 +++++++++++ 7 files changed, 547 insertions(+), 70 deletions(-) create mode 100644 internal/providers/azure/azure_test.go diff --git a/docs/release-notes.md b/docs/release-notes.md index 80c9ebe9c..5733ecbab 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -10,6 +10,7 @@ nav_order: 9 ### Features - The name for custom clevis pins is not validated by Ignition anymore, enabling the use of arbitrary custom pins. +- Azure images can opt into `--generate-cloud-config=azure`, letting `ignition-fetch.service` synthesize an admin-user config from IMDS metadata and the provisioning media. ### Changes diff --git a/dracut/30ignition/ignition-generator b/dracut/30ignition/ignition-generator index 1dff74423..03539a83a 100755 --- a/dracut/30ignition/ignition-generator +++ b/dracut/30ignition/ignition-generator @@ -59,4 +59,16 @@ else fi fi -echo "PLATFORM_ID=$(cmdline_arg ignition.platform.id)" > /run/ignition.env +platform_id="$(cmdline_arg ignition.platform.id)" +ignition_args=() +if [[ "${platform_id}" == "azure" ]]; then + ignition_args+=("--generate-cloud-config=azure") + demo_config="/usr/lib/ignition/azure-generated-config.ign" + if [[ -f "${demo_config}" ]]; then + ignition_args+=("--generated-config-override=${demo_config}") + fi +fi +{ + echo "PLATFORM_ID=${platform_id}" + echo "IGNITION_ARGS=${ignition_args[*]}" +} > /run/ignition.env diff --git a/internal/exec/engine.go b/internal/exec/engine.go index c2e61f7eb..f3c74255e 100644 --- a/internal/exec/engine.go +++ b/internal/exec/engine.go @@ -23,6 +23,7 @@ import ( "time" "github.com/coreos/go-systemd/v22/journal" + "github.com/coreos/ignition/v2/config" "github.com/coreos/ignition/v2/config/shared/errors" latest "github.com/coreos/ignition/v2/config/v3_6_experimental" "github.com/coreos/ignition/v2/config/v3_6_experimental/types" @@ -55,14 +56,16 @@ var ( // Engine represents the entity that fetches and executes a configuration. type Engine struct { - ConfigCache string - FetchTimeout time.Duration - Logger *log.Logger - NeedNet string - Root string - PlatformConfig platform.Config - Fetcher *resource.Fetcher - State *state.State + ConfigCache string + FetchTimeout time.Duration + GenerateCloudConfig string + GeneratedConfigOverride string + Logger *log.Logger + NeedNet string + Root string + PlatformConfig platform.Config + Fetcher *resource.Fetcher + State *state.State } // Run executes the stage of the given name. It returns true if the stage @@ -286,6 +289,10 @@ func (e *Engine) acquireProviderConfig() (cfg types.Config, err error) { // is unavailable. This will also render the config (see renderConfig) before // returning. func (e *Engine) fetchProviderConfig() (types.Config, error) { + if e.GenerateCloudConfig != "" { + return e.fetchGeneratedConfig() + } + platformConfigs := []platform.Config{ cmdline.Config, system.Config, @@ -331,6 +338,61 @@ func (e *Engine) fetchProviderConfig() (types.Config, error) { return configFetcher.RenderConfig(cfg) } +func (e *Engine) fetchGeneratedConfig() (types.Config, error) { + if e.GeneratedConfigOverride != "" { + return e.loadOverrideConfig() + } + + if e.GenerateCloudConfig != e.PlatformConfig.Name() { + return types.Config{}, fmt.Errorf("cannot generate %q config on %q platform", e.GenerateCloudConfig, e.PlatformConfig.Name()) + } + + cfg, err := e.PlatformConfig.GenerateConfig(e.Fetcher) + if err != nil { + return types.Config{}, err + } + + e.State.FetchedConfigs = append(e.State.FetchedConfigs, state.FetchedConfig{ + Kind: "user", + Source: fmt.Sprintf("%s-generator", e.GenerateCloudConfig), + Referenced: false, + }) + + configFetcher := ConfigFetcher{ + Logger: e.Logger, + Fetcher: e.Fetcher, + State: e.State, + } + + return configFetcher.RenderConfig(cfg) +} + +func (e *Engine) loadOverrideConfig() (types.Config, error) { + blob, err := os.ReadFile(e.GeneratedConfigOverride) + if err != nil { + return types.Config{}, fmt.Errorf("reading generated config override at %q: %w", e.GeneratedConfigOverride, err) + } + cfg, rpt, err := config.Parse(blob) + e.Logger.LogReport(rpt) + if err != nil { + return types.Config{}, err + } + + e.State.FetchedConfigs = append(e.State.FetchedConfigs, state.FetchedConfig{ + Kind: "user", + Source: fmt.Sprintf("override:%s", e.GeneratedConfigOverride), + Referenced: false, + }) + + configFetcher := ConfigFetcher{ + Logger: e.Logger, + Fetcher: e.Fetcher, + State: e.State, + } + + return configFetcher.RenderConfig(cfg) +} + func (e *Engine) signalNeedNet() error { if err := executil.MkdirForFile(e.NeedNet); err != nil { return err diff --git a/internal/main.go b/internal/main.go index 4d636a399..359c3300f 100644 --- a/internal/main.go +++ b/internal/main.go @@ -48,20 +48,24 @@ func main() { func ignitionMain() { flags := struct { - configCache string - fetchTimeout time.Duration - needNet string - platform platform.Name - root string - stage stages.Name - stateFile string - version bool - logToStdout bool + configCache string + fetchTimeout time.Duration + generateCloudConfig string + generatedConfigOverride string + needNet string + platform platform.Name + root string + stage stages.Name + stateFile string + version bool + logToStdout bool }{} flag.StringVar(&flags.configCache, "config-cache", "/run/ignition.json", "where to cache the config") flag.DurationVar(&flags.fetchTimeout, "fetch-timeout", exec.DefaultFetchTimeout, "initial duration for which to wait for config") flag.StringVar(&flags.needNet, "neednet", "/run/ignition/neednet", "flag file to write from fetch-offline if networking is needed") + flag.StringVar(&flags.generateCloudConfig, "generate-cloud-config", "", "generate a platform-specific config instead of fetching (e.g. azure)") + flag.StringVar(&flags.generatedConfigOverride, "generated-config-override", "", "path to a pre-generated config to use instead of contacting metadata services") flag.Var(&flags.platform, "platform", fmt.Sprintf("current platform. %v", platform.Names())) flag.StringVar(&flags.root, "root", "/", "root of the filesystem") flag.Var(&flags.stage, "stage", fmt.Sprintf("execution stage. %v", stages.Names())) @@ -104,14 +108,16 @@ func ignitionMain() { os.Exit(3) } engine := exec.Engine{ - Root: flags.root, - FetchTimeout: flags.fetchTimeout, - Logger: &logger, - NeedNet: flags.needNet, - ConfigCache: flags.configCache, - PlatformConfig: platformConfig, - Fetcher: &fetcher, - State: &state, + Root: flags.root, + FetchTimeout: flags.fetchTimeout, + GenerateCloudConfig: flags.generateCloudConfig, + GeneratedConfigOverride: flags.generatedConfigOverride, + Logger: &logger, + NeedNet: flags.needNet, + ConfigCache: flags.configCache, + PlatformConfig: platformConfig, + Fetcher: &fetcher, + State: &state, } err = engine.Run(flags.stage.String()) diff --git a/internal/platform/platform.go b/internal/platform/platform.go index 9b01df0cc..c12c47991 100644 --- a/internal/platform/platform.go +++ b/internal/platform/platform.go @@ -48,6 +48,8 @@ type Provider struct { Init func(f *resource.Fetcher) error Status func(stageName string, f resource.Fetcher, e error) error DelConfig func(f *resource.Fetcher) error + // Generates an azure-specific Ignition config. + GenerateCloudConfig func(f *resource.Fetcher) (types.Config, error) // Fetch, and also save output files to be written during files stage. // Avoid, unless you're certain you need it. @@ -104,6 +106,13 @@ func (c Config) DelConfig(f *resource.Fetcher) error { } } +func (c Config) GenerateConfig(f *resource.Fetcher) (types.Config, error) { + if c.p.GenerateCloudConfig != nil { + return c.p.GenerateCloudConfig(f) + } + return types.Config{}, ErrNoProvider +} + var configs = registry.Create("platform configs") func Register(provider Provider) { diff --git a/internal/providers/azure/azure.go b/internal/providers/azure/azure.go index 09de9591f..8fd96d1dc 100644 --- a/internal/providers/azure/azure.go +++ b/internal/providers/azure/azure.go @@ -18,21 +18,27 @@ package azure import ( "encoding/base64" + "encoding/json" + "encoding/xml" "fmt" "net/http" "net/url" "os" "path/filepath" + "strconv" + "strings" "time" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/coreos/ignition/v2/config/shared/errors" + cfgutil "github.com/coreos/ignition/v2/config/util" "github.com/coreos/ignition/v2/config/v3_6_experimental/types" execUtil "github.com/coreos/ignition/v2/internal/exec/util" "github.com/coreos/ignition/v2/internal/log" "github.com/coreos/ignition/v2/internal/platform" "github.com/coreos/ignition/v2/internal/providers/util" "github.com/coreos/ignition/v2/internal/resource" + "github.com/vincent-petithory/dataurl" "github.com/coreos/vcontext/report" "golang.org/x/sys/unix" @@ -68,13 +74,26 @@ var ( Path: "metadata/instance/compute/userData", RawQuery: "api-version=2021-01-01&format=text", } + imdsInstanceURL = url.URL{ + Scheme: "http", + Host: "169.254.169.254", + Path: "metadata/instance", + RawQuery: "api-version=2021-01-01&format=json&extended=true", + } ) +var imdsRetryCodes = []int{ + 404, + 410, + 429, +} + func init() { platform.Register(platform.Provider{ - Name: "azure", - NewFetcher: newFetcher, - Fetch: fetchConfig, + Name: "azure", + NewFetcher: newFetcher, + Fetch: fetchConfig, + GenerateCloudConfig: generateCloudConfig, }) } @@ -134,12 +153,7 @@ func fetchFromIMDS(f *resource.Fetcher) ([]byte, error) { // Here, we match the cloud-init set. // https://github.com/canonical/cloud-init/commit/c1a2047cf291 // https://github.com/coreos/ignition/issues/1806 - retryCodes := []int{ - 404, // not found - 410, // gone - 429, // rate-limited - } - data, err := f.FetchToBuffer(imdsUserdataURL, resource.FetchOptions{Headers: headers, RetryCodes: retryCodes}) + data, err := f.FetchToBuffer(imdsUserdataURL, resource.FetchOptions{Headers: headers, RetryCodes: imdsRetryCodes}) if err != nil { return nil, fmt.Errorf("fetching to buffer: %w", err) } @@ -200,42 +214,8 @@ func FetchFromOvfDevice(f *resource.Fetcher, ovfFsTypes []string) (types.Config, // getRawConfig returns the config by mounting the given block device func getRawConfig(f *resource.Fetcher, devicePath string, fstype string) ([]byte, error) { logger := f.Logger - mnt, err := os.MkdirTemp("", "ignition-azure") - if err != nil { - return nil, fmt.Errorf("failed to create temp directory: %v", err) - } - defer func() { - if removeErr := os.Remove(mnt); removeErr != nil { - logger.Warning("failed to remove temp directory %q: %v", mnt, removeErr) - } - }() - - logger.Debug("mounting config device") - if err := logger.LogOp( - func() error { return unix.Mount(devicePath, mnt, fstype, unix.MS_RDONLY, "") }, - "mounting %q at %q", devicePath, mnt, - ); err != nil { - return nil, fmt.Errorf("failed to mount device %q at %q: %v", devicePath, mnt, err) - } - defer func() { - _ = logger.LogOp( - func() error { return unix.Unmount(mnt, 0) }, - "unmounting %q at %q", devicePath, mnt, - ) - }() - - // detect the config drive by looking for a file which is always present - logger.Debug("checking for config drive") - if _, err := os.Stat(filepath.Join(mnt, "ovf-env.xml")); err != nil { - return nil, fmt.Errorf("device %q does not appear to be a config drive: %v", devicePath, err) - } - logger.Debug("reading config") - rawConfig, err := os.ReadFile(filepath.Join(mnt, configPath)) - if err != nil && !os.IsNotExist(err) { - return nil, fmt.Errorf("failed to read config from device %q: %v", devicePath, err) - } - return rawConfig, nil + return readFileFromDevice(f, devicePath, fstype, configPath) } // isCdromPresent verifies if the given config drive is CD-ROM @@ -275,3 +255,264 @@ func isCdromPresent(logger *log.Logger, devicePath string) bool { return (status == CDS_DISC_OK) } + +func readFileFromDevice(f *resource.Fetcher, devicePath string, fstype string, relativePath string) ([]byte, error) { + logger := f.Logger + mnt, err := os.MkdirTemp("", "ignition-azure") + if err != nil { + return nil, fmt.Errorf("failed to create temp directory: %v", err) + } + defer func() { + if removeErr := os.Remove(mnt); removeErr != nil { + logger.Warning("failed to remove temp directory %q: %v", mnt, removeErr) + } + }() + + logger.Debug("mounting config device") + if err := logger.LogOp( + func() error { return unix.Mount(devicePath, mnt, fstype, unix.MS_RDONLY, "") }, + "mounting %q at %q", devicePath, mnt, + ); err != nil { + return nil, fmt.Errorf("failed to mount device %q at %q: %v", devicePath, mnt, err) + } + defer func() { + _ = logger.LogOp( + func() error { return unix.Unmount(mnt, 0) }, + "unmounting %q at %q", devicePath, mnt, + ) + }() + + logger.Debug("checking for config drive") + if _, err := os.Stat(filepath.Join(mnt, "ovf-env.xml")); err != nil { + return nil, fmt.Errorf("device %q does not appear to be a config drive: %v", devicePath, err) + } + + target := filepath.Join(mnt, strings.TrimPrefix(relativePath, "/")) + data, err := os.ReadFile(target) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to read %q from device %q: %v", relativePath, devicePath, err) + } + return data, nil +} + +type instanceMetadata struct { + Compute instanceComputeMetadata `json:"compute"` +} + +type instanceComputeMetadata struct { + Hostname string `json:"hostname"` + OSProfile instanceOSProfile `json:"osProfile"` + PublicKeys []instancePublicKey `json:"publicKeys"` +} + +type instanceOSProfile struct { + AdminUsername string `json:"adminUsername"` +} + +type instancePublicKey struct { + KeyData string `json:"keyData"` +} + +type provisioningEnvelope struct { + LinuxProvisioningConfigurationSet linuxProvisioningConfigurationSet `xml:"LinuxProvisioningConfigurationSet"` +} + +type linuxProvisioningConfigurationSet struct { + HostName string `xml:"HostName"` + UserName string `xml:"UserName"` + UserPassword string `xml:"UserPassword"` + DisableSshPasswordAuthentication string `xml:"DisableSshPasswordAuthentication"` + SSH sshSection `xml:"SSH"` + CustomData string `xml:"CustomData"` + UserData string `xml:"UserData"` +} + +type sshSection struct { + PublicKeys []sshPublicKey `xml:"PublicKeys>PublicKey"` +} + +type sshPublicKey struct { + Value string `xml:"Value"` +} + +func (l linuxProvisioningConfigurationSet) passwordAuthDisabled() bool { + disabled, err := strconv.ParseBool(strings.TrimSpace(l.DisableSshPasswordAuthentication)) + if err != nil { + return false + } + return disabled +} + +func generateCloudConfig(f *resource.Fetcher) (types.Config, error) { + meta, err := fetchInstanceMetadata(f) + if err != nil { + return types.Config{}, fmt.Errorf("fetching instance metadata: %w", err) + } + + ovfRaw, err := readOvfEnvironment(f, []string{CDS_FSTYPE_UDF}) + if err != nil { + return types.Config{}, fmt.Errorf("reading provisioning metadata: %w", err) + } + if len(ovfRaw) == 0 { + return types.Config{}, fmt.Errorf("ovf-env.xml was empty") + } + + provisioning, err := parseProvisioningConfig(ovfRaw) + if err != nil { + return types.Config{}, fmt.Errorf("parsing provisioning metadata: %w", err) + } + + return buildGeneratedConfig(meta, provisioning) +} + +func fetchInstanceMetadata(f *resource.Fetcher) (*instanceMetadata, error) { + headers := make(http.Header) + headers.Set("Metadata", "true") + data, err := f.FetchToBuffer(imdsInstanceURL, resource.FetchOptions{Headers: headers, RetryCodes: imdsRetryCodes}) + if err != nil { + return nil, fmt.Errorf("fetching metadata: %w", err) + } + + var meta instanceMetadata + if err := json.Unmarshal(data, &meta); err != nil { + return nil, fmt.Errorf("decoding metadata: %w", err) + } + return &meta, nil +} + +func readOvfEnvironment(f *resource.Fetcher, ovfFsTypes []string) ([]byte, error) { + logger := f.Logger + checkedDevices := make(map[string]struct{}) + for { + for _, ovfFsType := range ovfFsTypes { + devices, err := execUtil.GetBlockDevices(ovfFsType) + if err != nil { + return nil, fmt.Errorf("failed to retrieve block devices with FSTYPE=%q: %v", ovfFsType, err) + } + for _, dev := range devices { + if _, checked := checkedDevices[dev]; checked { + continue + } + if isCdromPresent(logger, dev) { + data, err := readFileFromDevice(f, dev, ovfFsType, "ovf-env.xml") + if err != nil { + logger.Debug("failed to read ovf environment from device %q: %v", dev, err) + } else if len(data) > 0 { + return data, nil + } + } + checkedDevices[dev] = struct{}{} + } + } + time.Sleep(time.Second) + } +} + +func parseProvisioningConfig(raw []byte) (*linuxProvisioningConfigurationSet, error) { + var env provisioningEnvelope + if err := xml.Unmarshal(raw, &env); err != nil { + return nil, err + } + return &env.LinuxProvisioningConfigurationSet, nil +} + +func buildGeneratedConfig(meta *instanceMetadata, provisioning *linuxProvisioningConfigurationSet) (types.Config, error) { + username := strings.TrimSpace(meta.Compute.OSProfile.AdminUsername) + if username == "" { + username = strings.TrimSpace(provisioning.UserName) + } + if username == "" { + return types.Config{}, fmt.Errorf("unable to determine admin username from metadata or provisioning data") + } + + password := strings.TrimSpace(provisioning.UserPassword) + passwordAuthDisabled := provisioning.passwordAuthDisabled() + + sshKeys := collectSSHPublicKeys(meta, provisioning) + + user := types.PasswdUser{ + Name: username, + Groups: []types.Group{"wheel"}, + HomeDir: cfgutil.StrToPtr(fmt.Sprintf("/home/%s", username)), + Shell: cfgutil.StrToPtr("/bin/bash"), + SSHAuthorizedKeys: sshKeys, + } + if password != "" { + user.PasswordHash = cfgutil.StrToPtr(password) + } + + sudoersFile := newDataFile("/etc/sudoers.d/99_wheel_nopasswd", 0440, "%wheel ALL=(ALL) NOPASSWD:ALL\n") + passwordSetting := "yes" + if passwordAuthDisabled { + passwordSetting = "no" + } + sshConfig := fmt.Sprintf(`# Custom SSHD settings +PasswordAuthentication %s +PermitRootLogin no +AllowUsers %s +`, passwordSetting, username) + sshdFile := newDataFile("/etc/ssh/sshd_config.d/10-custom.conf", 0644, sshConfig) + + return types.Config{ + Ignition: types.Ignition{ + Version: types.MaxVersion.String(), + }, + Passwd: types.Passwd{ + Users: []types.PasswdUser{user}, + }, + Storage: types.Storage{ + Files: []types.File{sudoersFile, sshdFile}, + }, + }, nil +} + +func collectSSHPublicKeys(meta *instanceMetadata, provisioning *linuxProvisioningConfigurationSet) []types.SSHAuthorizedKey { + seen := make(map[string]struct{}) + var keys []types.SSHAuthorizedKey + + if meta != nil { + for _, k := range meta.Compute.PublicKeys { + key := strings.TrimSpace(k.KeyData) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + keys = append(keys, types.SSHAuthorizedKey(key)) + } + } + + if provisioning != nil { + for _, pk := range provisioning.SSH.PublicKeys { + key := strings.TrimSpace(pk.Value) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + keys = append(keys, types.SSHAuthorizedKey(key)) + } + } + + return keys +} + +func newDataFile(path string, mode int, contents string) types.File { + encoded := dataurl.EncodeBytes([]byte(contents)) + return types.File{ + Node: types.Node{ + Path: path, + }, + FileEmbedded1: types.FileEmbedded1{ + Mode: cfgutil.IntToPtr(mode), + Contents: types.Resource{Source: &encoded}, + }, + } +} diff --git a/internal/providers/azure/azure_test.go b/internal/providers/azure/azure_test.go new file mode 100644 index 000000000..b5cc6732d --- /dev/null +++ b/internal/providers/azure/azure_test.go @@ -0,0 +1,146 @@ +package azure + +import ( + "strings" + "testing" + + "github.com/coreos/ignition/v2/config/v3_6_experimental/types" +) + +func TestParseProvisioningConfig(t *testing.T) { + raw := []byte(` + + + myhost + azureuser + password + false + + + + ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCu + + + + +`) + + cfg, err := parseProvisioningConfig(raw) + if err != nil { + t.Fatalf("parseProvisioningConfig() err = %v", err) + } + if cfg.UserName != "azureuser" { + t.Fatalf("expected username azureuser, got %s", cfg.UserName) + } + if len(cfg.SSH.PublicKeys) != 1 { + t.Fatalf("expected 1 ssh key, got %d", len(cfg.SSH.PublicKeys)) + } +} + +func TestBuildGeneratedConfig(t *testing.T) { + meta := &instanceMetadata{ + Compute: instanceComputeMetadata{ + Hostname: "example", + OSProfile: instanceOSProfile{ + AdminUsername: "meta-user", + }, + PublicKeys: []instancePublicKey{ + {KeyData: "ssh-rsa AAAAB3Nza meta"}, + }, + }, + } + prov := &linuxProvisioningConfigurationSet{ + UserName: "prov-user", + SSH: sshSection{ + PublicKeys: []sshPublicKey{ + {Value: "ssh-ed25519 AAAAC3Nza prov"}, + }, + }, + UserPassword: "plaintext", + } + + cfg, err := buildGeneratedConfig(meta, prov) + if err != nil { + t.Fatalf("buildGeneratedConfig() err = %v", err) + } + + if len(cfg.Passwd.Users) != 1 { + t.Fatalf("expected 1 user, got %d", len(cfg.Passwd.Users)) + } + user := cfg.Passwd.Users[0] + if user.Name != "meta-user" { + t.Fatalf("expected user meta-user, got %s", user.Name) + } + if len(user.SSHAuthorizedKeys) != 2 { + t.Fatalf("expected 2 ssh keys, got %d", len(user.SSHAuthorizedKeys)) + } + if user.PasswordHash == nil || *user.PasswordHash != "plaintext" { + t.Fatalf("expected password hash to be plaintext") + } + + if len(cfg.Storage.Files) != 2 { + t.Fatalf("expected 2 files, got %d", len(cfg.Storage.Files)) + } +} + +func TestCollectSSHPublicKeysDedup(t *testing.T) { + meta := &instanceMetadata{ + Compute: instanceComputeMetadata{ + PublicKeys: []instancePublicKey{ + {KeyData: "ssh-rsa AAAA"}, + {KeyData: "ssh-rsa AAAA"}, + }, + }, + } + prov := &linuxProvisioningConfigurationSet{ + SSH: sshSection{ + PublicKeys: []sshPublicKey{ + {Value: "ssh-rsa BBBB"}, + {Value: "ssh-rsa AAAA"}, + }, + }, + } + keys := collectSSHPublicKeys(meta, prov) + if len(keys) != 2 { + t.Fatalf("expected 2 unique keys, got %d", len(keys)) + } +} + +func TestPasswordAuthDisabledParsing(t *testing.T) { + trueCases := []string{"true", "TRUE", "1", " yes "} + for _, tc := range trueCases { + prov := linuxProvisioningConfigurationSet{DisableSshPasswordAuthentication: tc} + if !prov.passwordAuthDisabled() { + t.Fatalf("expected %q to disable password auth", tc) + } + } + falseCases := []string{"false", "0", "no", ""} + for _, tc := range falseCases { + prov := linuxProvisioningConfigurationSet{DisableSshPasswordAuthentication: tc} + if prov.passwordAuthDisabled() { + t.Fatalf("expected %q to allow password auth", tc) + } + } +} + +func TestNewDataFile(t *testing.T) { + content := "line1\n" + file := newDataFile("/tmp/example", 0640, content) + if file.Path != "/tmp/example" { + t.Fatalf("unexpected path %s", file.Path) + } + if file.Mode == nil || *file.Mode != 0640 { + t.Fatalf("unexpected mode %#v", file.Mode) + } + if file.Contents.Source == nil || !strings.Contains(*file.Contents.Source, content) { + t.Fatalf("expected contents to include original data") + } +} + +func TestBuildGeneratedConfigErrors(t *testing.T) { + meta := &instanceMetadata{} + prov := &linuxProvisioningConfigurationSet{} + if _, err := buildGeneratedConfig(meta, prov); err == nil { + t.Fatalf("expected error when username missing") + } +} From 18457c012cc0b7ccb8084fcd4a00239bd6c23a73 Mon Sep 17 00:00:00 2001 From: Peyton Date: Tue, 2 Dec 2025 10:51:56 -0800 Subject: [PATCH 2/7] azure: add opt-in admin user config generation from IMDS/OVF Add support for generating an Ignition config from Azure's Instance Metadata Service (IMDS) and OVF provisioning data. This allows VMs to be provisioned with the admin user, SSH keys, and password configured in Azure without requiring a user-provided Ignition config. This feature is opt-in via kernel cmdline parameter: ignition.config.generate=azure When enabled, Ignition will: - Fetch extended metadata from IMDS (/metadata/instance?extended=true) - Mount the provisioning CD-ROM and parse ovf-env.xml - Generate an Ignition config with: - Admin user (from IMDS osProfile.adminUsername or OVF UserName) - SSH authorized keys (from both sources, deduplicated) - Hashed password (SHA-512 crypt format) - Sudoers config for passwordless sudo - SSHD config based on DisableSshPasswordAuthentication setting Key implementation details: - Added GenerateCloudConfig to platform.Provider interface - Added --generate-cloud-config CLI flag to ignition binary - Password hashing uses github.com/GehirnInc/crypt library - OVF reading has 30-second timeout to prevent boot hangs - Fixed yes/no parsing for DisableSshPasswordAuthentication field --- dracut/30ignition/ignition-generator | 13 +- go.mod | 1 + go.sum | 2 + internal/exec/engine.go | 50 +---- internal/main.go | 41 ++-- internal/providers/azure/azure.go | 44 +++- internal/providers/azure/azure_test.go | 107 +++++++++- internal/providers/azure/crypt.go | 47 +++++ vendor/github.com/GehirnInc/crypt/.travis.yml | 7 + vendor/github.com/GehirnInc/crypt/AUTHORS.md | 8 + vendor/github.com/GehirnInc/crypt/LICENSE | 26 +++ vendor/github.com/GehirnInc/crypt/README.rst | 61 ++++++ .../GehirnInc/crypt/common/base64.go | 59 ++++++ .../github.com/GehirnInc/crypt/common/doc.go | 10 + .../github.com/GehirnInc/crypt/common/salt.go | 148 ++++++++++++++ vendor/github.com/GehirnInc/crypt/crypt.go | 121 +++++++++++ .../GehirnInc/crypt/internal/utils.go | 41 ++++ .../crypt/sha512_crypt/sha512_crypt.go | 188 ++++++++++++++++++ vendor/modules.txt | 6 + 19 files changed, 901 insertions(+), 79 deletions(-) create mode 100644 internal/providers/azure/crypt.go create mode 100644 vendor/github.com/GehirnInc/crypt/.travis.yml create mode 100644 vendor/github.com/GehirnInc/crypt/AUTHORS.md create mode 100644 vendor/github.com/GehirnInc/crypt/LICENSE create mode 100644 vendor/github.com/GehirnInc/crypt/README.rst create mode 100644 vendor/github.com/GehirnInc/crypt/common/base64.go create mode 100644 vendor/github.com/GehirnInc/crypt/common/doc.go create mode 100644 vendor/github.com/GehirnInc/crypt/common/salt.go create mode 100644 vendor/github.com/GehirnInc/crypt/crypt.go create mode 100644 vendor/github.com/GehirnInc/crypt/internal/utils.go create mode 100644 vendor/github.com/GehirnInc/crypt/sha512_crypt/sha512_crypt.go diff --git a/dracut/30ignition/ignition-generator b/dracut/30ignition/ignition-generator index 03539a83a..3fca62279 100755 --- a/dracut/30ignition/ignition-generator +++ b/dracut/30ignition/ignition-generator @@ -61,13 +61,14 @@ fi platform_id="$(cmdline_arg ignition.platform.id)" ignition_args=() -if [[ "${platform_id}" == "azure" ]]; then - ignition_args+=("--generate-cloud-config=azure") - demo_config="/usr/lib/ignition/azure-generated-config.ign" - if [[ -f "${demo_config}" ]]; then - ignition_args+=("--generated-config-override=${demo_config}") - fi + +# Check if cloud config generation is requested via kernel cmdline +# Usage: ignition.config.generate=azure +generate_config="$(cmdline_arg ignition.config.generate)" +if [[ -n "${generate_config}" ]]; then + ignition_args+=("--generate-cloud-config=${generate_config}") fi + { echo "PLATFORM_ID=${platform_id}" echo "IGNITION_ARGS=${ignition_args[*]}" diff --git a/go.mod b/go.mod index f8b16f836..ab4fa0fbd 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( cloud.google.com/go/storage v1.57.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.0 github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 + github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 github.com/aws/aws-sdk-go-v2 v1.39.3 github.com/aws/aws-sdk-go-v2/credentials v1.18.17 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 diff --git a/go.sum b/go.sum index a22d889f2..9ec1365ed 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,8 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0 h1:XkkQbfMyuH2jTSjQjSoihryI8GINRcs4xp8lNawg0FI= github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI= +github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 h1:UQUsRi8WTzhZntp5313l+CHIAT95ojUI2lpP/ExlZa4= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM= diff --git a/internal/exec/engine.go b/internal/exec/engine.go index f3c74255e..3aea2ffad 100644 --- a/internal/exec/engine.go +++ b/internal/exec/engine.go @@ -23,7 +23,6 @@ import ( "time" "github.com/coreos/go-systemd/v22/journal" - "github.com/coreos/ignition/v2/config" "github.com/coreos/ignition/v2/config/shared/errors" latest "github.com/coreos/ignition/v2/config/v3_6_experimental" "github.com/coreos/ignition/v2/config/v3_6_experimental/types" @@ -56,16 +55,15 @@ var ( // Engine represents the entity that fetches and executes a configuration. type Engine struct { - ConfigCache string - FetchTimeout time.Duration - GenerateCloudConfig string - GeneratedConfigOverride string - Logger *log.Logger - NeedNet string - Root string - PlatformConfig platform.Config - Fetcher *resource.Fetcher - State *state.State + ConfigCache string + FetchTimeout time.Duration + GenerateCloudConfig string + Logger *log.Logger + NeedNet string + Root string + PlatformConfig platform.Config + Fetcher *resource.Fetcher + State *state.State } // Run executes the stage of the given name. It returns true if the stage @@ -339,10 +337,6 @@ func (e *Engine) fetchProviderConfig() (types.Config, error) { } func (e *Engine) fetchGeneratedConfig() (types.Config, error) { - if e.GeneratedConfigOverride != "" { - return e.loadOverrideConfig() - } - if e.GenerateCloudConfig != e.PlatformConfig.Name() { return types.Config{}, fmt.Errorf("cannot generate %q config on %q platform", e.GenerateCloudConfig, e.PlatformConfig.Name()) } @@ -367,32 +361,6 @@ func (e *Engine) fetchGeneratedConfig() (types.Config, error) { return configFetcher.RenderConfig(cfg) } -func (e *Engine) loadOverrideConfig() (types.Config, error) { - blob, err := os.ReadFile(e.GeneratedConfigOverride) - if err != nil { - return types.Config{}, fmt.Errorf("reading generated config override at %q: %w", e.GeneratedConfigOverride, err) - } - cfg, rpt, err := config.Parse(blob) - e.Logger.LogReport(rpt) - if err != nil { - return types.Config{}, err - } - - e.State.FetchedConfigs = append(e.State.FetchedConfigs, state.FetchedConfig{ - Kind: "user", - Source: fmt.Sprintf("override:%s", e.GeneratedConfigOverride), - Referenced: false, - }) - - configFetcher := ConfigFetcher{ - Logger: e.Logger, - Fetcher: e.Fetcher, - State: e.State, - } - - return configFetcher.RenderConfig(cfg) -} - func (e *Engine) signalNeedNet() error { if err := executil.MkdirForFile(e.NeedNet); err != nil { return err diff --git a/internal/main.go b/internal/main.go index 359c3300f..a10e92073 100644 --- a/internal/main.go +++ b/internal/main.go @@ -48,24 +48,22 @@ func main() { func ignitionMain() { flags := struct { - configCache string - fetchTimeout time.Duration - generateCloudConfig string - generatedConfigOverride string - needNet string - platform platform.Name - root string - stage stages.Name - stateFile string - version bool - logToStdout bool + configCache string + fetchTimeout time.Duration + generateCloudConfig string + needNet string + platform platform.Name + root string + stage stages.Name + stateFile string + version bool + logToStdout bool }{} flag.StringVar(&flags.configCache, "config-cache", "/run/ignition.json", "where to cache the config") flag.DurationVar(&flags.fetchTimeout, "fetch-timeout", exec.DefaultFetchTimeout, "initial duration for which to wait for config") flag.StringVar(&flags.needNet, "neednet", "/run/ignition/neednet", "flag file to write from fetch-offline if networking is needed") flag.StringVar(&flags.generateCloudConfig, "generate-cloud-config", "", "generate a platform-specific config instead of fetching (e.g. azure)") - flag.StringVar(&flags.generatedConfigOverride, "generated-config-override", "", "path to a pre-generated config to use instead of contacting metadata services") flag.Var(&flags.platform, "platform", fmt.Sprintf("current platform. %v", platform.Names())) flag.StringVar(&flags.root, "root", "/", "root of the filesystem") flag.Var(&flags.stage, "stage", fmt.Sprintf("execution stage. %v", stages.Names())) @@ -108,16 +106,15 @@ func ignitionMain() { os.Exit(3) } engine := exec.Engine{ - Root: flags.root, - FetchTimeout: flags.fetchTimeout, - GenerateCloudConfig: flags.generateCloudConfig, - GeneratedConfigOverride: flags.generatedConfigOverride, - Logger: &logger, - NeedNet: flags.needNet, - ConfigCache: flags.configCache, - PlatformConfig: platformConfig, - Fetcher: &fetcher, - State: &state, + Root: flags.root, + FetchTimeout: flags.fetchTimeout, + GenerateCloudConfig: flags.generateCloudConfig, + Logger: &logger, + NeedNet: flags.needNet, + ConfigCache: flags.configCache, + PlatformConfig: platformConfig, + Fetcher: &fetcher, + State: &state, } err = engine.Run(flags.stage.String()) diff --git a/internal/providers/azure/azure.go b/internal/providers/azure/azure.go index 8fd96d1dc..a30763b02 100644 --- a/internal/providers/azure/azure.go +++ b/internal/providers/azure/azure.go @@ -339,11 +339,20 @@ type sshPublicKey struct { } func (l linuxProvisioningConfigurationSet) passwordAuthDisabled() bool { - disabled, err := strconv.ParseBool(strings.TrimSpace(l.DisableSshPasswordAuthentication)) - if err != nil { + val := strings.ToLower(strings.TrimSpace(l.DisableSshPasswordAuthentication)) + switch val { + case "true", "1", "yes": + return true + case "false", "0", "no", "": return false + default: + // Try parsing as bool for any other values + disabled, err := strconv.ParseBool(val) + if err != nil { + return false + } + return disabled } - return disabled } func generateCloudConfig(f *resource.Fetcher) (types.Config, error) { @@ -383,10 +392,18 @@ func fetchInstanceMetadata(f *resource.Fetcher) (*instanceMetadata, error) { return &meta, nil } +const ( + // maxOvfRetries is the maximum number of attempts to find the OVF environment + maxOvfRetries = 30 + // ovfRetryInterval is the time between retries + ovfRetryInterval = time.Second +) + func readOvfEnvironment(f *resource.Fetcher, ovfFsTypes []string) ([]byte, error) { logger := f.Logger checkedDevices := make(map[string]struct{}) - for { + + for attempt := 0; attempt < maxOvfRetries; attempt++ { for _, ovfFsType := range ovfFsTypes { devices, err := execUtil.GetBlockDevices(ovfFsType) if err != nil { @@ -407,8 +424,12 @@ func readOvfEnvironment(f *resource.Fetcher, ovfFsTypes []string) ([]byte, error checkedDevices[dev] = struct{}{} } } - time.Sleep(time.Second) + if attempt < maxOvfRetries-1 { + time.Sleep(ovfRetryInterval) + } } + + return nil, fmt.Errorf("failed to find OVF environment after %d attempts", maxOvfRetries) } func parseProvisioningConfig(raw []byte) (*linuxProvisioningConfigurationSet, error) { @@ -441,7 +462,18 @@ func buildGeneratedConfig(meta *instanceMetadata, provisioning *linuxProvisionin SSHAuthorizedKeys: sshKeys, } if password != "" { - user.PasswordHash = cfgutil.StrToPtr(password) + // Hash the password if it's not already hashed + var passwordHash string + if IsPasswordHashed(password) { + passwordHash = password + } else { + var err error + passwordHash, err = HashPassword(password) + if err != nil { + return types.Config{}, fmt.Errorf("hashing password: %w", err) + } + } + user.PasswordHash = cfgutil.StrToPtr(passwordHash) } sudoersFile := newDataFile("/etc/sudoers.d/99_wheel_nopasswd", 0440, "%wheel ALL=(ALL) NOPASSWD:ALL\n") diff --git a/internal/providers/azure/azure_test.go b/internal/providers/azure/azure_test.go index b5cc6732d..45703f548 100644 --- a/internal/providers/azure/azure_test.go +++ b/internal/providers/azure/azure_test.go @@ -74,8 +74,12 @@ func TestBuildGeneratedConfig(t *testing.T) { if len(user.SSHAuthorizedKeys) != 2 { t.Fatalf("expected 2 ssh keys, got %d", len(user.SSHAuthorizedKeys)) } - if user.PasswordHash == nil || *user.PasswordHash != "plaintext" { - t.Fatalf("expected password hash to be plaintext") + // Password should be hashed (starts with $6$ for SHA-512) + if user.PasswordHash == nil { + t.Fatalf("expected password hash to be set") + } + if !strings.HasPrefix(*user.PasswordHash, "$6$") { + t.Fatalf("expected password hash to be SHA-512 (start with $6$), got %s", *user.PasswordHash) } if len(cfg.Storage.Files) != 2 { @@ -83,6 +87,31 @@ func TestBuildGeneratedConfig(t *testing.T) { } } +func TestBuildGeneratedConfigWithPrehashedPassword(t *testing.T) { + // Test that pre-hashed passwords are not double-hashed + prehashedPassword := "$6$rounds=5000$saltsalt$hashedvalue" + meta := &instanceMetadata{ + Compute: instanceComputeMetadata{ + OSProfile: instanceOSProfile{ + AdminUsername: "testuser", + }, + }, + } + prov := &linuxProvisioningConfigurationSet{ + UserPassword: prehashedPassword, + } + + cfg, err := buildGeneratedConfig(meta, prov) + if err != nil { + t.Fatalf("buildGeneratedConfig() err = %v", err) + } + + user := cfg.Passwd.Users[0] + if user.PasswordHash == nil || *user.PasswordHash != prehashedPassword { + t.Fatalf("expected pre-hashed password to be preserved, got %v", user.PasswordHash) + } +} + func TestCollectSSHPublicKeysDedup(t *testing.T) { meta := &instanceMetadata{ Compute: instanceComputeMetadata{ @@ -107,14 +136,14 @@ func TestCollectSSHPublicKeysDedup(t *testing.T) { } func TestPasswordAuthDisabledParsing(t *testing.T) { - trueCases := []string{"true", "TRUE", "1", " yes "} + trueCases := []string{"true", "TRUE", "1", " yes ", "YES"} for _, tc := range trueCases { prov := linuxProvisioningConfigurationSet{DisableSshPasswordAuthentication: tc} if !prov.passwordAuthDisabled() { t.Fatalf("expected %q to disable password auth", tc) } } - falseCases := []string{"false", "0", "no", ""} + falseCases := []string{"false", "0", "no", "", "NO", "False"} for _, tc := range falseCases { prov := linuxProvisioningConfigurationSet{DisableSshPasswordAuthentication: tc} if prov.passwordAuthDisabled() { @@ -144,3 +173,73 @@ func TestBuildGeneratedConfigErrors(t *testing.T) { t.Fatalf("expected error when username missing") } } + +func TestHashPassword(t *testing.T) { + password := "testpassword123" + hash, err := HashPassword(password) + if err != nil { + t.Fatalf("HashPassword() err = %v", err) + } + + // Verify hash format + if !strings.HasPrefix(hash, "$6$") { + t.Fatalf("expected SHA-512 hash prefix $6$, got %s", hash) + } + + // Verify hash has expected structure: $6$$ + parts := strings.Split(hash, "$") + if len(parts) != 4 { + t.Fatalf("expected 4 parts in hash, got %d: %s", len(parts), hash) + } + if parts[1] != "6" { + t.Fatalf("expected algorithm identifier '6', got %s", parts[1]) + } + if len(parts[2]) != 16 { + t.Fatalf("expected 16 character salt, got %d: %s", len(parts[2]), parts[2]) + } + if len(parts[3]) != 86 { + t.Fatalf("expected 86 character hash, got %d: %s", len(parts[3]), parts[3]) + } +} + +func TestHashPasswordDifferentSalts(t *testing.T) { + password := "testpassword123" + hash1, err := HashPassword(password) + if err != nil { + t.Fatalf("HashPassword() err = %v", err) + } + hash2, err := HashPassword(password) + if err != nil { + t.Fatalf("HashPassword() err = %v", err) + } + + // Hashes should be different due to random salt + if hash1 == hash2 { + t.Fatalf("expected different hashes for same password (different salts)") + } +} + +func TestIsPasswordHashed(t *testing.T) { + tests := []struct { + password string + expected bool + }{ + {"$6$salt$hash", true}, + {"$5$salt$hash", true}, + {"$y$salt$hash", true}, + {"$2a$10$hash", true}, + {"$2b$10$hash", true}, + {"$2y$10$hash", true}, + {"$1$salt$hash", true}, + {"plaintext", false}, + {"$invalid", false}, + {"", false}, + } + + for _, tt := range tests { + result := IsPasswordHashed(tt.password) + if result != tt.expected { + t.Errorf("IsPasswordHashed(%q) = %v, expected %v", tt.password, result, tt.expected) + } + } +} diff --git a/internal/providers/azure/crypt.go b/internal/providers/azure/crypt.go new file mode 100644 index 000000000..20902e738 --- /dev/null +++ b/internal/providers/azure/crypt.go @@ -0,0 +1,47 @@ +// Copyright 2015 CoreOS, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package azure + +import ( + "strings" + + "github.com/GehirnInc/crypt" + _ "github.com/GehirnInc/crypt/sha512_crypt" // Register SHA-512 crypt +) + +// HashPassword hashes a plaintext password using SHA-512 crypt algorithm. +// Returns a string suitable for /etc/shadow in the format $6$$ +func HashPassword(password string) (string, error) { + crypter := crypt.SHA512.New() + return crypter.Generate([]byte(password), nil) +} + +// IsPasswordHashed checks if a password string appears to already be hashed +// (starts with a known crypt prefix like $6$, $5$, $y$, etc.) +func IsPasswordHashed(password string) bool { + hashPrefixes := []string{ + "$6$", // SHA-512 + "$5$", // SHA-256 + "$y$", // yescrypt + "$2a$", "$2b$", "$2y$", // bcrypt variants + "$1$", // MD5 (deprecated) + } + for _, prefix := range hashPrefixes { + if strings.HasPrefix(password, prefix) { + return true + } + } + return false +} diff --git a/vendor/github.com/GehirnInc/crypt/.travis.yml b/vendor/github.com/GehirnInc/crypt/.travis.yml new file mode 100644 index 000000000..6a63bc8e8 --- /dev/null +++ b/vendor/github.com/GehirnInc/crypt/.travis.yml @@ -0,0 +1,7 @@ +language: go +go: + - 1.6.x + - 1.7.x + - master +script: + - go test -v -race ./... diff --git a/vendor/github.com/GehirnInc/crypt/AUTHORS.md b/vendor/github.com/GehirnInc/crypt/AUTHORS.md new file mode 100644 index 000000000..4490cf22b --- /dev/null +++ b/vendor/github.com/GehirnInc/crypt/AUTHORS.md @@ -0,0 +1,8 @@ +### Initial author + +[Jeramey Crawford](https://github.com/jeramey) + +### Other authors + +- [Jonas mg](https://github.com/kless) +- [Kohei YOSHIDA](https://github.com/yosida95) diff --git a/vendor/github.com/GehirnInc/crypt/LICENSE b/vendor/github.com/GehirnInc/crypt/LICENSE new file mode 100644 index 000000000..7048fecec --- /dev/null +++ b/vendor/github.com/GehirnInc/crypt/LICENSE @@ -0,0 +1,26 @@ +Copyright (c) 2012, Jeramey Crawford +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/github.com/GehirnInc/crypt/README.rst b/vendor/github.com/GehirnInc/crypt/README.rst new file mode 100644 index 000000000..0608624fc --- /dev/null +++ b/vendor/github.com/GehirnInc/crypt/README.rst @@ -0,0 +1,61 @@ +.. image:: https://travis-ci.org/GehirnInc/crypt.svg?branch=master + :target: https://travis-ci.org/GehirnInc/crypt + +crypt - A password hashing library for Go +========================================= +crypt provides pure golang implementations of UNIX's crypt(3). + +The goal of crypt is to bring a library of many common and popular password +hashing algorithms to Go and to provide a simple and consistent interface to +each of them. As every hashing method is implemented in pure Go, this library +should be as portable as Go itself. + +All hashing methods come with a test suite which verifies their operation +against itself as well as the output of other password hashing implementations +to ensure compatibility with them. + +I hope you find this library to be useful and easy to use! + +Install +------- + +To install crypt, use the *go get* command. + +.. code-block:: sh + + go get github.com/GehirnInc/crypt + + +Usage +----- + +.. code-block:: go + + package main + + import ( + "fmt" + + "github.com/GehirnInc/crypt" + _ "github.com/GehirnInc/crypt/sha256_crypt" + ) + + func main() { + crypt := crypt.SHA256.New() + ret, _ := crypt.Generate([]byte("secret"), []byte("$5$salt")) + fmt.Println(ret) + + err := crypt.Verify(ret, []byte("secret")) + fmt.Println(err) + + // Output: + // $5$salt$kpa26zwgX83BPSR8d7w93OIXbFt/d3UOTZaAu5vsTM6 + // + } + +Documentation +------------- + +The documentation is available on GoDoc_. + +.. _GoDoc: https://godoc.org/github.com/GehirnInc/crypt diff --git a/vendor/github.com/GehirnInc/crypt/common/base64.go b/vendor/github.com/GehirnInc/crypt/common/base64.go new file mode 100644 index 000000000..ee5240e10 --- /dev/null +++ b/vendor/github.com/GehirnInc/crypt/common/base64.go @@ -0,0 +1,59 @@ +// (C) Copyright 2012, Jeramey Crawford . All +// rights reserved. Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package common + +const ( + alphabet = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +) + +// Base64_24Bit is a variant of Base64 encoding, commonly used with password +// hashing algorithms to encode the result of their checksum output. +// +// The algorithm operates on up to 3 bytes at a time, encoding the following +// 6-bit sequences into up to 4 hash64 ASCII bytes. +// +// 1. Bottom 6 bits of the first byte +// 2. Top 2 bits of the first byte, and bottom 4 bits of the second byte. +// 3. Top 4 bits of the second byte, and bottom 2 bits of the third byte. +// 4. Top 6 bits of the third byte. +// +// This encoding method does not emit padding bytes as Base64 does. +func Base64_24Bit(src []byte) []byte { + if len(src) == 0 { + return []byte{} // TODO: return nil + } + + dstlen := (len(src)*8 + 5) / 6 + dst := make([]byte, dstlen) + + di, si := 0, 0 + n := len(src) / 3 * 3 + for si < n { + val := uint(src[si+2])<<16 | uint(src[si+1])<<8 | uint(src[si]) + dst[di+0] = alphabet[val&0x3f] + dst[di+1] = alphabet[val>>6&0x3f] + dst[di+2] = alphabet[val>>12&0x3f] + dst[di+3] = alphabet[val>>18] + di += 4 + si += 3 + } + + rem := len(src) - si + if rem == 0 { + return dst + } + + val := uint(src[si+0]) + if rem == 2 { + val |= uint(src[si+1]) << 8 + } + + dst[di+0] = alphabet[val&0x3f] + dst[di+1] = alphabet[val>>6&0x3f] + if rem == 2 { + dst[di+2] = alphabet[val>>12] + } + return dst +} diff --git a/vendor/github.com/GehirnInc/crypt/common/doc.go b/vendor/github.com/GehirnInc/crypt/common/doc.go new file mode 100644 index 000000000..8ba84e960 --- /dev/null +++ b/vendor/github.com/GehirnInc/crypt/common/doc.go @@ -0,0 +1,10 @@ +// (C) Copyright 2012, Jeramey Crawford . All +// rights reserved. Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package common contains routines used by multiple password hashing +// algorithms. +// +// Generally, you will never import this package directly. Many of the +// *_crypt packages will import this package if they require it. +package common diff --git a/vendor/github.com/GehirnInc/crypt/common/salt.go b/vendor/github.com/GehirnInc/crypt/common/salt.go new file mode 100644 index 000000000..54372be0f --- /dev/null +++ b/vendor/github.com/GehirnInc/crypt/common/salt.go @@ -0,0 +1,148 @@ +// (C) Copyright 2012, Jeramey Crawford . All +// rights reserved. Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package common + +import ( + "bytes" + "crypto/rand" + "errors" + "strconv" +) + +var ( + ErrSaltPrefix = errors.New("invalid magic prefix") + ErrSaltFormat = errors.New("invalid salt format") + ErrSaltRounds = errors.New("invalid rounds") +) + +const ( + roundsPrefix = "rounds=" +) + +// Salt represents a salt. +type Salt struct { + MagicPrefix []byte + + SaltLenMin int + SaltLenMax int + + RoundsMin int + RoundsMax int + RoundsDefault int +} + +// Generate generates a random salt of a given length. +// +// The length is set thus: +// +// length > SaltLenMax: length = SaltLenMax +// length < SaltLenMin: length = SaltLenMin +func (s *Salt) Generate(length int) []byte { + if length > s.SaltLenMax { + length = s.SaltLenMax + } else if length < s.SaltLenMin { + length = s.SaltLenMin + } + + saltLen := (length * 6 / 8) + if (length*6)%8 != 0 { + saltLen += 1 + } + salt := make([]byte, saltLen) + rand.Read(salt) + + out := make([]byte, len(s.MagicPrefix)+length) + copy(out, s.MagicPrefix) + copy(out[len(s.MagicPrefix):], Base64_24Bit(salt)) + return out +} + +// GenerateWRounds creates a random salt with the random bytes being of the +// length provided, and the rounds parameter set as specified. +// +// The parameters are set thus: +// +// length > SaltLenMax: length = SaltLenMax +// length < SaltLenMin: length = SaltLenMin +// +// rounds < 0: rounds = RoundsDefault +// rounds < RoundsMin: rounds = RoundsMin +// rounds > RoundsMax: rounds = RoundsMax +// +// If rounds is equal to RoundsDefault, then the "rounds=" part of the salt is +// removed. +func (s *Salt) GenerateWRounds(length, rounds int) []byte { + if length > s.SaltLenMax { + length = s.SaltLenMax + } else if length < s.SaltLenMin { + length = s.SaltLenMin + } + if rounds < 0 { + rounds = s.RoundsDefault + } else if rounds < s.RoundsMin { + rounds = s.RoundsMin + } else if rounds > s.RoundsMax { + rounds = s.RoundsMax + } + + saltLen := (length * 6 / 8) + if (length*6)%8 != 0 { + saltLen += 1 + } + salt := make([]byte, saltLen) + rand.Read(salt) + + roundsText := "" + if rounds != s.RoundsDefault { + roundsText = roundsPrefix + strconv.Itoa(rounds) + "$" + } + + out := make([]byte, len(s.MagicPrefix)+len(roundsText)+length) + copy(out, s.MagicPrefix) + copy(out[len(s.MagicPrefix):], []byte(roundsText)) + copy(out[len(s.MagicPrefix)+len(roundsText):], Base64_24Bit(salt)) + return out +} + +func (s *Salt) Decode(raw []byte) (salt []byte, rounds int, isRoundsDef bool, rest []byte, err error) { + tokens := bytes.SplitN(raw, []byte{'$'}, 4) + if len(tokens) < 3 { + err = ErrSaltFormat + return + } + if !bytes.HasPrefix(raw, s.MagicPrefix) { + err = ErrSaltPrefix + return + } + + if bytes.HasPrefix(tokens[2], []byte(roundsPrefix)) { + if len(tokens) < 4 { + err = ErrSaltFormat + return + } + salt = tokens[3] + + rounds, err = strconv.Atoi(string(tokens[2][len(roundsPrefix):])) + if err != nil { + err = ErrSaltRounds + return + } + if rounds < s.RoundsMin { + rounds = s.RoundsMin + } + if rounds > s.RoundsMax { + rounds = s.RoundsMax + } + isRoundsDef = true + } else { + salt = tokens[2] + rounds = s.RoundsDefault + } + if len(salt) > s.SaltLenMax { + salt = salt[0:s.SaltLenMax] + } + + return +} diff --git a/vendor/github.com/GehirnInc/crypt/crypt.go b/vendor/github.com/GehirnInc/crypt/crypt.go new file mode 100644 index 000000000..1b4151f38 --- /dev/null +++ b/vendor/github.com/GehirnInc/crypt/crypt.go @@ -0,0 +1,121 @@ +// (C) Copyright 2013, Jonas mg. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file. + +// Package crypt provides interface for password crypt functions and collects +// common constants. +package crypt + +import ( + "errors" + "strings" + + "github.com/GehirnInc/crypt/common" +) + +var ErrKeyMismatch = errors.New("hashed value is not the hash of the given password") + +// Crypter is the common interface implemented by all crypt functions. +type Crypter interface { + // Generate performs the hashing algorithm, returning a full hash suitable + // for storage and later password verification. + // + // If the salt is empty, a randomly-generated salt will be generated with a + // length of SaltLenMax and number RoundsDefault of rounds. + // + // Any error only can be got when the salt argument is not empty. + Generate(key, salt []byte) (string, error) + + // Verify compares a hashed key with its possible key equivalent. + // Returns nil on success, or an error on failure; if the hashed key is + // diffrent, the error is "ErrKeyMismatch". + Verify(hashedKey string, key []byte) error + + // Cost returns the hashing cost (in rounds) used to create the given hashed + // key. + // + // When, in the future, the hashing cost of a key needs to be increased in + // order to adjust for greater computational power, this function allows one + // to establish which keys need to be updated. + // + // The algorithms based in MD5-crypt use a fixed value of rounds. + Cost(hashedKey string) (int, error) + + // SetSalt sets a different salt. It is used to easily create derivated + // algorithms, i.e. "apr1_crypt" from "md5_crypt". + SetSalt(salt common.Salt) +} + +// Crypt identifies a crypt function that is implemented in another package. +type Crypt uint + +const ( + APR1 Crypt = 1 + iota // import github.com/GehirnInc/crypt/apr1_crypt + MD5 // import github.com/GehirnInc/crypt/md5_crypt + SHA256 // import github.com/GehirnInc/crypt/sha256_crypt + SHA512 // import github.com/GehirnInc/crypt/sha512_crypt + maxCrypt +) + +var crypts = make([]func() Crypter, maxCrypt) + +// New returns new Crypter making the Crypt c. +// New panics if the Crypt c is unavailable. +func (c Crypt) New() Crypter { + if c > 0 && c < maxCrypt { + f := crypts[c] + if f != nil { + return f() + } + } + panic("crypt: requested crypt function is unavailable") +} + +// Available reports whether the Crypt c is available. +func (c Crypt) Available() bool { + return c > 0 && c < maxCrypt && crypts[c] != nil +} + +var cryptPrefixes = make([]string, maxCrypt) + +// RegisterCrypt registers a function that returns a new instance of the given +// crypt function. This is intended to be called from the init function in +// packages that implement crypt functions. +func RegisterCrypt(c Crypt, f func() Crypter, prefix string) { + if c >= maxCrypt { + panic("crypt: RegisterHash of unknown crypt function") + } + crypts[c] = f + cryptPrefixes[c] = prefix +} + +// New returns a new crypter. +func New(c Crypt) Crypter { + return c.New() +} + +// IsHashSupported returns true if hashedKey has a supported prefix. +// NewFromHash will not panic for this hashedKey +func IsHashSupported(hashedKey string) bool { + for i := range cryptPrefixes { + prefix := cryptPrefixes[i] + if crypts[i] != nil && strings.HasPrefix(hashedKey, prefix) { + return true + } + } + + return false +} + +// NewFromHash returns a new Crypter using the prefix in the given hashed key. +func NewFromHash(hashedKey string) Crypter { + for i := range cryptPrefixes { + prefix := cryptPrefixes[i] + if crypts[i] != nil && strings.HasPrefix(hashedKey, prefix) { + crypt := Crypt(uint(i)) + return crypt.New() + } + } + + panic("crypt: unknown crypt function") +} diff --git a/vendor/github.com/GehirnInc/crypt/internal/utils.go b/vendor/github.com/GehirnInc/crypt/internal/utils.go new file mode 100644 index 000000000..2d36e86ab --- /dev/null +++ b/vendor/github.com/GehirnInc/crypt/internal/utils.go @@ -0,0 +1,41 @@ +// Copyright (c) 2015 Kohei YOSHIDA. All rights reserved. +// This software is licensed under the 3-Clause BSD License +// that can be found in LICENSE file. +package internal + +const ( + cleanBytesLen = 64 +) + +var ( + cleanBytes = make([]byte, cleanBytesLen) +) + +func CleanSensitiveData(b []byte) { + l := len(b) + + for ; l > cleanBytesLen; l -= cleanBytesLen { + copy(b[l-cleanBytesLen:l], cleanBytes) + } + + if l > 0 { + copy(b[0:l], cleanBytes[0:l]) + } +} + +func RepeatByteSequence(input []byte, length int) []byte { + var ( + sequence = make([]byte, length) + unit = len(input) + ) + + j := length / unit * unit + for i := 0; i < j; i += unit { + copy(sequence[i:length], input) + } + if j < length { + copy(sequence[j:length], input[0:length-j]) + } + + return sequence +} diff --git a/vendor/github.com/GehirnInc/crypt/sha512_crypt/sha512_crypt.go b/vendor/github.com/GehirnInc/crypt/sha512_crypt/sha512_crypt.go new file mode 100644 index 000000000..1037d73ca --- /dev/null +++ b/vendor/github.com/GehirnInc/crypt/sha512_crypt/sha512_crypt.go @@ -0,0 +1,188 @@ +// (C) Copyright 2012, Jeramey Crawford . All +// rights reserved. Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package sha512_crypt implements Ulrich Drepper's SHA512-crypt password +// hashing algorithm. +// +// The specification for this algorithm can be found here: +// http://www.akkadia.org/drepper/SHA-crypt.txt +package sha512_crypt + +import ( + "bytes" + "crypto/sha512" + "crypto/subtle" + "strconv" + + "github.com/GehirnInc/crypt" + "github.com/GehirnInc/crypt/common" + "github.com/GehirnInc/crypt/internal" +) + +func init() { + crypt.RegisterCrypt(crypt.SHA512, New, MagicPrefix) +} + +const ( + MagicPrefix = "$6$" + SaltLenMin = 1 + SaltLenMax = 16 + RoundsMin = 1000 + RoundsMax = 999999999 + RoundsDefault = 5000 +) + +var _rounds = []byte("rounds=") + +type crypter struct{ Salt common.Salt } + +// New returns a new crypt.Crypter computing the SHA512-crypt password hashing. +func New() crypt.Crypter { + return &crypter{ + common.Salt{ + MagicPrefix: []byte(MagicPrefix), + SaltLenMin: SaltLenMin, + SaltLenMax: SaltLenMax, + RoundsDefault: RoundsDefault, + RoundsMin: RoundsMin, + RoundsMax: RoundsMax, + }, + } +} + +func (c *crypter) Generate(key, salt []byte) (string, error) { + if len(salt) == 0 { + salt = c.Salt.GenerateWRounds(SaltLenMax, RoundsDefault) + } + salt, rounds, isRoundsDef, _, err := c.Salt.Decode(salt) + if err != nil { + return "", err + } + + keyLen := len(key) + saltLen := len(salt) + h := sha512.New() + + // compute sumB + // step 4-8 + h.Write(key) + h.Write(salt) + h.Write(key) + sumB := h.Sum(nil) + + // Compute sumA + // step 1-3, 9-12 + h.Reset() + h.Write(key) + h.Write(salt) + h.Write(internal.RepeatByteSequence(sumB, keyLen)) + for i := keyLen; i > 0; i >>= 1 { + if i%2 == 0 { + h.Write(key) + } else { + h.Write(sumB) + } + } + sumA := h.Sum(nil) + internal.CleanSensitiveData(sumB) + + // Compute seqP + // step 13-16 + h.Reset() + for i := 0; i < keyLen; i++ { + h.Write(key) + } + seqP := internal.RepeatByteSequence(h.Sum(nil), keyLen) + + // Compute seqS + // step 17-20 + h.Reset() + for i := 0; i < 16+int(sumA[0]); i++ { + h.Write(salt) + } + seqS := internal.RepeatByteSequence(h.Sum(nil), saltLen) + + // step 21 + for i := 0; i < rounds; i++ { + h.Reset() + + if i&1 != 0 { + h.Write(seqP) + } else { + h.Write(sumA) + } + if i%3 != 0 { + h.Write(seqS) + } + if i%7 != 0 { + h.Write(seqP) + } + if i&1 != 0 { + h.Write(sumA) + } else { + h.Write(seqP) + } + copy(sumA, h.Sum(nil)) + } + internal.CleanSensitiveData(seqP) + internal.CleanSensitiveData(seqS) + + // make output + buf := bytes.Buffer{} + buf.Grow(len(c.Salt.MagicPrefix) + len(_rounds) + 9 + 1 + len(salt) + 1 + 86) + buf.Write(c.Salt.MagicPrefix) + if isRoundsDef { + buf.Write(_rounds) + buf.WriteString(strconv.Itoa(rounds)) + buf.WriteByte('$') + } + buf.Write(salt) + buf.WriteByte('$') + buf.Write(common.Base64_24Bit([]byte{ + sumA[42], sumA[21], sumA[0], + sumA[1], sumA[43], sumA[22], + sumA[23], sumA[2], sumA[44], + sumA[45], sumA[24], sumA[3], + sumA[4], sumA[46], sumA[25], + sumA[26], sumA[5], sumA[47], + sumA[48], sumA[27], sumA[6], + sumA[7], sumA[49], sumA[28], + sumA[29], sumA[8], sumA[50], + sumA[51], sumA[30], sumA[9], + sumA[10], sumA[52], sumA[31], + sumA[32], sumA[11], sumA[53], + sumA[54], sumA[33], sumA[12], + sumA[13], sumA[55], sumA[34], + sumA[35], sumA[14], sumA[56], + sumA[57], sumA[36], sumA[15], + sumA[16], sumA[58], sumA[37], + sumA[38], sumA[17], sumA[59], + sumA[60], sumA[39], sumA[18], + sumA[19], sumA[61], sumA[40], + sumA[41], sumA[20], sumA[62], + sumA[63], + })) + return buf.String(), nil +} + +func (c *crypter) Verify(hashedKey string, key []byte) error { + newHash, err := c.Generate(key, []byte(hashedKey)) + if err != nil { + return err + } + if subtle.ConstantTimeCompare([]byte(newHash), []byte(hashedKey)) != 1 { + return crypt.ErrKeyMismatch + } + return nil +} + +func (c *crypter) Cost(hashedKey string) (int, error) { + _, rounds, _, _, err := c.Salt.Decode([]byte(hashedKey)) + if err != nil { + return 0, err + } + return rounds, nil +} + +func (c *crypter) SetSalt(salt common.Salt) { c.Salt = salt } diff --git a/vendor/modules.txt b/vendor/modules.txt index 8c5df90d2..b2f65268e 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -123,6 +123,12 @@ github.com/AzureAD/microsoft-authentication-library-for-go/apps/internal/shared github.com/AzureAD/microsoft-authentication-library-for-go/apps/internal/version github.com/AzureAD/microsoft-authentication-library-for-go/apps/managedidentity github.com/AzureAD/microsoft-authentication-library-for-go/apps/public +# github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 +## explicit; go 1.19 +github.com/GehirnInc/crypt +github.com/GehirnInc/crypt/common +github.com/GehirnInc/crypt/internal +github.com/GehirnInc/crypt/sha512_crypt # github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 ## explicit; go 1.23.0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp From 757e888d028d9f9fc3d562c8b49721a7dcafc2ef Mon Sep 17 00:00:00 2001 From: Peyton Date: Tue, 2 Dec 2025 11:07:31 -0800 Subject: [PATCH 3/7] test --- internal/providers/azure/crypt.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/providers/azure/crypt.go b/internal/providers/azure/crypt.go index 20902e738..8b3d904a6 100644 --- a/internal/providers/azure/crypt.go +++ b/internal/providers/azure/crypt.go @@ -13,7 +13,6 @@ // limitations under the License. package azure - import ( "strings" From 370234245fe197c12ec8a0d23d111049e2b9aa28 Mon Sep 17 00:00:00 2001 From: Peyton Date: Tue, 2 Dec 2025 11:10:28 -0800 Subject: [PATCH 4/7] Gemini code suggestions --- dracut/30ignition/ignition-generator | 9 +++++++- internal/providers/azure/azure.go | 31 ++++++++++++---------------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/dracut/30ignition/ignition-generator b/dracut/30ignition/ignition-generator index 3fca62279..446c5fd15 100755 --- a/dracut/30ignition/ignition-generator +++ b/dracut/30ignition/ignition-generator @@ -66,7 +66,14 @@ ignition_args=() # Usage: ignition.config.generate=azure generate_config="$(cmdline_arg ignition.config.generate)" if [[ -n "${generate_config}" ]]; then - ignition_args+=("--generate-cloud-config=${generate_config}") + # Basic validation to prevent command injection. The value should be a + # simple platform identifier. + if [[ "${generate_config}" =~ ^[a-zA-Z0-9_-]+$ ]]; then + ignition_args+=("--generate-cloud-config=${generate_config}") + else + # Log to stderr, which is redirected to kmsg + echo "ignition-generator: invalid value for ignition.config.generate: ${generate_config}" >&2 + fi fi { diff --git a/internal/providers/azure/azure.go b/internal/providers/azure/azure.go index a30763b02..7459ce752 100644 --- a/internal/providers/azure/azure.go +++ b/internal/providers/azure/azure.go @@ -505,31 +505,26 @@ func collectSSHPublicKeys(meta *instanceMetadata, provisioning *linuxProvisionin seen := make(map[string]struct{}) var keys []types.SSHAuthorizedKey - if meta != nil { - for _, k := range meta.Compute.PublicKeys { - key := strings.TrimSpace(k.KeyData) - if key == "" { - continue - } - if _, ok := seen[key]; ok { - continue - } + addKey := func(keyData string) { + key := strings.TrimSpace(keyData) + if key == "" { + return + } + if _, ok := seen[key]; !ok { seen[key] = struct{}{} keys = append(keys, types.SSHAuthorizedKey(key)) } } + if meta != nil { + for _, k := range meta.Compute.PublicKeys { + addKey(k.KeyData) + } + } + if provisioning != nil { for _, pk := range provisioning.SSH.PublicKeys { - key := strings.TrimSpace(pk.Value) - if key == "" { - continue - } - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - keys = append(keys, types.SSHAuthorizedKey(key)) + addKey(pk.Value) } } From f1f891aa1b9b5a3f629f3fb13d8aed57644450c3 Mon Sep 17 00:00:00 2001 From: peytonr18 Date: Tue, 2 Dec 2025 11:25:16 -0800 Subject: [PATCH 5/7] Addressing unit test failures --- internal/providers/azure/azure_test.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/internal/providers/azure/azure_test.go b/internal/providers/azure/azure_test.go index 45703f548..79970f7b4 100644 --- a/internal/providers/azure/azure_test.go +++ b/internal/providers/azure/azure_test.go @@ -3,8 +3,6 @@ package azure import ( "strings" "testing" - - "github.com/coreos/ignition/v2/config/v3_6_experimental/types" ) func TestParseProvisioningConfig(t *testing.T) { @@ -161,8 +159,8 @@ func TestNewDataFile(t *testing.T) { if file.Mode == nil || *file.Mode != 0640 { t.Fatalf("unexpected mode %#v", file.Mode) } - if file.Contents.Source == nil || !strings.Contains(*file.Contents.Source, content) { - t.Fatalf("expected contents to include original data") + if file.Contents.Source == nil || !strings.HasPrefix(*file.Contents.Source, "data:") { + t.Fatalf("expected contents to be a data URL, got %v", file.Contents.Source) } } From 11b3b613fc50e56ea1b6d6310b69e68969552726 Mon Sep 17 00:00:00 2001 From: peytonr18 Date: Tue, 2 Dec 2025 11:38:04 -0800 Subject: [PATCH 6/7] Additional unit tests to cover edgecases --- internal/providers/azure/azure_test.go | 296 +++++++++++++++++++++++++ 1 file changed, 296 insertions(+) diff --git a/internal/providers/azure/azure_test.go b/internal/providers/azure/azure_test.go index 79970f7b4..f5912cb60 100644 --- a/internal/providers/azure/azure_test.go +++ b/internal/providers/azure/azure_test.go @@ -241,3 +241,299 @@ func TestIsPasswordHashed(t *testing.T) { } } } + +func TestBuildGeneratedConfigUsernamePriority(t *testing.T) { + // Test that IMDS AdminUsername takes priority over OVF UserName + meta := &instanceMetadata{ + Compute: instanceComputeMetadata{ + OSProfile: instanceOSProfile{ + AdminUsername: "imds-admin", + }, + }, + } + prov := &linuxProvisioningConfigurationSet{ + UserName: "ovf-user", + } + + cfg, err := buildGeneratedConfig(meta, prov) + if err != nil { + t.Fatalf("buildGeneratedConfig() err = %v", err) + } + if cfg.Passwd.Users[0].Name != "imds-admin" { + t.Fatalf("expected IMDS username 'imds-admin' to take priority, got %s", cfg.Passwd.Users[0].Name) + } +} + +func TestBuildGeneratedConfigUsernameFallback(t *testing.T) { + // Test fallback to OVF UserName when IMDS AdminUsername is empty + meta := &instanceMetadata{ + Compute: instanceComputeMetadata{ + OSProfile: instanceOSProfile{ + AdminUsername: "", + }, + }, + } + prov := &linuxProvisioningConfigurationSet{ + UserName: "ovf-user", + } + + cfg, err := buildGeneratedConfig(meta, prov) + if err != nil { + t.Fatalf("buildGeneratedConfig() err = %v", err) + } + if cfg.Passwd.Users[0].Name != "ovf-user" { + t.Fatalf("expected OVF username 'ovf-user' as fallback, got %s", cfg.Passwd.Users[0].Name) + } +} + +func TestBuildGeneratedConfigWhitespaceUsername(t *testing.T) { + // Test that whitespace-only usernames are treated as empty + meta := &instanceMetadata{ + Compute: instanceComputeMetadata{ + OSProfile: instanceOSProfile{ + AdminUsername: " ", + }, + }, + } + prov := &linuxProvisioningConfigurationSet{ + UserName: "ovf-user", + } + + cfg, err := buildGeneratedConfig(meta, prov) + if err != nil { + t.Fatalf("buildGeneratedConfig() err = %v", err) + } + if cfg.Passwd.Users[0].Name != "ovf-user" { + t.Fatalf("expected OVF username when IMDS is whitespace, got %s", cfg.Passwd.Users[0].Name) + } +} + +func TestBuildGeneratedConfigNilMetadata(t *testing.T) { + prov := &linuxProvisioningConfigurationSet{ + UserName: "ovf-user", + } + + // nil metadata should cause a panic or error - test the behavior + defer func() { + if r := recover(); r == nil { + // If no panic, the function should have returned an error or handled nil + } + }() + + cfg, err := buildGeneratedConfig(nil, prov) + if err != nil { + // Expected - nil metadata should cause an error + return + } + // If no error, verify the config still works with OVF data + if cfg.Passwd.Users[0].Name != "ovf-user" { + t.Fatalf("expected ovf-user, got %s", cfg.Passwd.Users[0].Name) + } +} + +func TestBuildGeneratedConfigNilProvisioning(t *testing.T) { + meta := &instanceMetadata{ + Compute: instanceComputeMetadata{ + OSProfile: instanceOSProfile{ + AdminUsername: "imds-admin", + }, + }, + } + + // nil provisioning should be handled gracefully + defer func() { + if r := recover(); r == nil { + // If no panic, the function handled nil correctly + } + }() + + cfg, err := buildGeneratedConfig(meta, nil) + if err != nil { + // Expected - nil provisioning may cause an error + return + } + if cfg.Passwd.Users[0].Name != "imds-admin" { + t.Fatalf("expected imds-admin, got %s", cfg.Passwd.Users[0].Name) + } +} + +func TestBuildGeneratedConfigEmptySSHKeys(t *testing.T) { + meta := &instanceMetadata{ + Compute: instanceComputeMetadata{ + OSProfile: instanceOSProfile{ + AdminUsername: "testuser", + }, + PublicKeys: []instancePublicKey{}, + }, + } + prov := &linuxProvisioningConfigurationSet{ + SSH: sshSection{ + PublicKeys: []sshPublicKey{}, + }, + } + + cfg, err := buildGeneratedConfig(meta, prov) + if err != nil { + t.Fatalf("buildGeneratedConfig() err = %v", err) + } + if len(cfg.Passwd.Users[0].SSHAuthorizedKeys) != 0 { + t.Fatalf("expected 0 SSH keys, got %d", len(cfg.Passwd.Users[0].SSHAuthorizedKeys)) + } +} + +func TestBuildGeneratedConfigNoPassword(t *testing.T) { + meta := &instanceMetadata{ + Compute: instanceComputeMetadata{ + OSProfile: instanceOSProfile{ + AdminUsername: "testuser", + }, + }, + } + prov := &linuxProvisioningConfigurationSet{} + + cfg, err := buildGeneratedConfig(meta, prov) + if err != nil { + t.Fatalf("buildGeneratedConfig() err = %v", err) + } + if cfg.Passwd.Users[0].PasswordHash != nil { + t.Fatalf("expected nil password hash when no password provided, got %v", *cfg.Passwd.Users[0].PasswordHash) + } +} + +func TestBuildGeneratedConfigWhitespacePassword(t *testing.T) { + meta := &instanceMetadata{ + Compute: instanceComputeMetadata{ + OSProfile: instanceOSProfile{ + AdminUsername: "testuser", + }, + }, + } + prov := &linuxProvisioningConfigurationSet{ + UserPassword: " ", + } + + cfg, err := buildGeneratedConfig(meta, prov) + if err != nil { + t.Fatalf("buildGeneratedConfig() err = %v", err) + } + // Whitespace-only password should be treated as empty + if cfg.Passwd.Users[0].PasswordHash != nil { + t.Fatalf("expected nil password hash for whitespace password, got %v", *cfg.Passwd.Users[0].PasswordHash) + } +} + +func TestParseProvisioningConfigMalformedXML(t *testing.T) { + malformed := []byte(` + + testuser + + `) + + _, err := parseProvisioningConfig(malformed) + if err == nil { + t.Fatalf("expected error for malformed XML") + } +} + +func TestParseProvisioningConfigEmptyXML(t *testing.T) { + empty := []byte(``) + + _, err := parseProvisioningConfig(empty) + if err == nil { + t.Fatalf("expected error for empty XML") + } +} + +func TestParseProvisioningConfigMinimalXML(t *testing.T) { + // XML with missing optional sections + minimal := []byte(` + + + minimaluser + +`) + + cfg, err := parseProvisioningConfig(minimal) + if err != nil { + t.Fatalf("parseProvisioningConfig() err = %v", err) + } + if cfg.UserName != "minimaluser" { + t.Fatalf("expected username 'minimaluser', got %s", cfg.UserName) + } + if cfg.UserPassword != "" { + t.Fatalf("expected empty password, got %s", cfg.UserPassword) + } + if len(cfg.SSH.PublicKeys) != 0 { + t.Fatalf("expected 0 SSH keys, got %d", len(cfg.SSH.PublicKeys)) + } +} + +func TestParseProvisioningConfigEmptySection(t *testing.T) { + // XML with empty LinuxProvisioningConfigurationSet + emptySection := []byte(` + + + +`) + + cfg, err := parseProvisioningConfig(emptySection) + if err != nil { + t.Fatalf("parseProvisioningConfig() err = %v", err) + } + if cfg.UserName != "" { + t.Fatalf("expected empty username, got %s", cfg.UserName) + } +} + +func TestCollectSSHPublicKeysNilInputs(t *testing.T) { + // Test with nil metadata + keys := collectSSHPublicKeys(nil, &linuxProvisioningConfigurationSet{ + SSH: sshSection{ + PublicKeys: []sshPublicKey{{Value: "ssh-rsa AAAA"}}, + }, + }) + if len(keys) != 1 { + t.Fatalf("expected 1 key with nil metadata, got %d", len(keys)) + } + + // Test with nil provisioning + keys = collectSSHPublicKeys(&instanceMetadata{ + Compute: instanceComputeMetadata{ + PublicKeys: []instancePublicKey{{KeyData: "ssh-rsa BBBB"}}, + }, + }, nil) + if len(keys) != 1 { + t.Fatalf("expected 1 key with nil provisioning, got %d", len(keys)) + } + + // Test with both nil + keys = collectSSHPublicKeys(nil, nil) + if len(keys) != 0 { + t.Fatalf("expected 0 keys with both nil, got %d", len(keys)) + } +} + +func TestCollectSSHPublicKeysWhitespaceOnly(t *testing.T) { + meta := &instanceMetadata{ + Compute: instanceComputeMetadata{ + PublicKeys: []instancePublicKey{ + {KeyData: " "}, + {KeyData: "ssh-rsa AAAA"}, + {KeyData: "\t\n"}, + }, + }, + } + prov := &linuxProvisioningConfigurationSet{ + SSH: sshSection{ + PublicKeys: []sshPublicKey{ + {Value: ""}, + {Value: "ssh-rsa BBBB"}, + }, + }, + } + + keys := collectSSHPublicKeys(meta, prov) + if len(keys) != 2 { + t.Fatalf("expected 2 non-empty keys, got %d", len(keys)) + } +} From 5a904ea048bc39ec376782c8c68ff3886c476065 Mon Sep 17 00:00:00 2001 From: peytonr18 Date: Tue, 2 Dec 2025 12:22:30 -0800 Subject: [PATCH 7/7] Removing unncesssary nil tests because provision and meta will never be nil --- internal/providers/azure/azure_test.go | 56 +------------------------- 1 file changed, 1 insertion(+), 55 deletions(-) diff --git a/internal/providers/azure/azure_test.go b/internal/providers/azure/azure_test.go index f5912cb60..4605a51f2 100644 --- a/internal/providers/azure/azure_test.go +++ b/internal/providers/azure/azure_test.go @@ -308,55 +308,6 @@ func TestBuildGeneratedConfigWhitespaceUsername(t *testing.T) { } } -func TestBuildGeneratedConfigNilMetadata(t *testing.T) { - prov := &linuxProvisioningConfigurationSet{ - UserName: "ovf-user", - } - - // nil metadata should cause a panic or error - test the behavior - defer func() { - if r := recover(); r == nil { - // If no panic, the function should have returned an error or handled nil - } - }() - - cfg, err := buildGeneratedConfig(nil, prov) - if err != nil { - // Expected - nil metadata should cause an error - return - } - // If no error, verify the config still works with OVF data - if cfg.Passwd.Users[0].Name != "ovf-user" { - t.Fatalf("expected ovf-user, got %s", cfg.Passwd.Users[0].Name) - } -} - -func TestBuildGeneratedConfigNilProvisioning(t *testing.T) { - meta := &instanceMetadata{ - Compute: instanceComputeMetadata{ - OSProfile: instanceOSProfile{ - AdminUsername: "imds-admin", - }, - }, - } - - // nil provisioning should be handled gracefully - defer func() { - if r := recover(); r == nil { - // If no panic, the function handled nil correctly - } - }() - - cfg, err := buildGeneratedConfig(meta, nil) - if err != nil { - // Expected - nil provisioning may cause an error - return - } - if cfg.Passwd.Users[0].Name != "imds-admin" { - t.Fatalf("expected imds-admin, got %s", cfg.Passwd.Users[0].Name) - } -} - func TestBuildGeneratedConfigEmptySSHKeys(t *testing.T) { meta := &instanceMetadata{ Compute: instanceComputeMetadata{ @@ -416,7 +367,7 @@ func TestBuildGeneratedConfigWhitespacePassword(t *testing.T) { if err != nil { t.Fatalf("buildGeneratedConfig() err = %v", err) } - // Whitespace-only password should be treated as empty + if cfg.Passwd.Users[0].PasswordHash != nil { t.Fatalf("expected nil password hash for whitespace password, got %v", *cfg.Passwd.Users[0].PasswordHash) } @@ -445,7 +396,6 @@ func TestParseProvisioningConfigEmptyXML(t *testing.T) { } func TestParseProvisioningConfigMinimalXML(t *testing.T) { - // XML with missing optional sections minimal := []byte(` @@ -469,7 +419,6 @@ func TestParseProvisioningConfigMinimalXML(t *testing.T) { } func TestParseProvisioningConfigEmptySection(t *testing.T) { - // XML with empty LinuxProvisioningConfigurationSet emptySection := []byte(` @@ -486,7 +435,6 @@ func TestParseProvisioningConfigEmptySection(t *testing.T) { } func TestCollectSSHPublicKeysNilInputs(t *testing.T) { - // Test with nil metadata keys := collectSSHPublicKeys(nil, &linuxProvisioningConfigurationSet{ SSH: sshSection{ PublicKeys: []sshPublicKey{{Value: "ssh-rsa AAAA"}}, @@ -496,7 +444,6 @@ func TestCollectSSHPublicKeysNilInputs(t *testing.T) { t.Fatalf("expected 1 key with nil metadata, got %d", len(keys)) } - // Test with nil provisioning keys = collectSSHPublicKeys(&instanceMetadata{ Compute: instanceComputeMetadata{ PublicKeys: []instancePublicKey{{KeyData: "ssh-rsa BBBB"}}, @@ -506,7 +453,6 @@ func TestCollectSSHPublicKeysNilInputs(t *testing.T) { t.Fatalf("expected 1 key with nil provisioning, got %d", len(keys)) } - // Test with both nil keys = collectSSHPublicKeys(nil, nil) if len(keys) != 0 { t.Fatalf("expected 0 keys with both nil, got %d", len(keys))