diff --git a/ad/DRACARYS/providers/azure/inventory b/ad/DRACARYS/providers/azure/inventory index f37bfbd5..c348fd95 100644 --- a/ad/DRACARYS/providers/azure/inventory +++ b/ad/DRACARYS/providers/azure/inventory @@ -1,3 +1,17 @@ +[all:vars] +domain_name=DRACARYS +admin_user=administrator +force_dns_server=no +dns_server=1.1.1.1 +dns_server_forwarder=1.1.1.1 +add_route=no +route_gateway=192.168.56.1 +route_network=10.0.0.0/8 +enable_http_proxy=no +ad_http_proxy=http://x.x.x.x:xxxx +ad_https_proxy=http://x.x.x.x:xxxx +keyboard_layouts=["en-US"] + [default] ; ------------------------------------------------ ; dracarys.lab @@ -6,5 +20,59 @@ dc01 ansible_host={{ip_range}}.10 dns_domain=dc01 dict_key=dc01 ansible_user=ans srv01 ansible_host={{ip_range}}.11 dns_domain=dc01 dict_key=srv01 ansible_user=ansible ansible_password=NgtkgtIAs75cKV+Pu lx01 ansible_host={{ip_range}}.12 dict_key=lx01 ansible_connection=ssh ansible_ssh_common_args='-o StrictHostKeyChecking=no' ansible_user=goadmin ansible_password=HGLXaxQSP@ssw_rd$ -[all:vars] -admin_user=goadmin +[domain] +dc01 +srv01 + +[linux_domain] +lx01 + +[dc] +dc01 + +[server] +srv01 + +[workstation] + +[parent_dc] +dc01 + +[child_dc] + +[trust] + +[adcs] + +[adcs_customtemplates] + +[iis] + +[mssql] + +[mssql_ssms] + +[webdav] + +[glpi] +lx01 + +[klink] +srv01 + +[keepass] +srv01 + +[update] +dc01 + +[no_update] +srv01 + +[defender_on] +dc01 +srv01 + +[defender_off] + +[extensions] diff --git a/ad/GOAD-Light/providers/azure/inventory b/ad/GOAD-Light/providers/azure/inventory index e1828f71..bd8beabe 100644 --- a/ad/GOAD-Light/providers/azure/inventory +++ b/ad/GOAD-Light/providers/azure/inventory @@ -1,6 +1,14 @@ +[all:vars] +domain_name=GOAD-Light +admin_user=administrator +force_dns_server=no +dns_server=1.1.1.1 +dns_server_forwarder=1.1.1.1 +enable_http_proxy=no +ad_http_proxy=http://x.x.x.x:xxxx +ad_https_proxy=http://x.x.x.x:xxxx + [default] -; Note: ansible_host *MUST* be an IPv4 address or setting things like DNS -; servers will break. ; ------------------------------------------------ ; sevenkingdoms.local ; ------------------------------------------------ @@ -11,5 +19,61 @@ dc01 ansible_host={{ip_range}}.10 dns_domain=dc01 dict_key=dc01 ansible_user=ans dc02 ansible_host={{ip_range}}.11 dns_domain=dc01 dict_key=dc02 ansible_user=ansible ansible_password=NgtI75cKV+Pu srv02 ansible_host={{ip_range}}.22 dns_domain=dc02 dict_key=srv02 ansible_user=ansible ansible_password=NgtI75cKV+Pu -[all:vars] -admin_user=goadmin +[domain] +dc01 +dc02 +srv02 + +[dc] +dc01 +dc02 + +[server] +srv02 + +[workstation] + +[parent_dc] +dc01 + +[child_dc] +dc02 + +[trust] + +[adcs] +dc01 + +[adcs_customtemplates] + +[iis] +srv02 + +[mssql] +srv02 + +[mssql_ssms] +srv02 + +[webdav] +srv02 + +[laps_dc] +[laps_server] +[laps_workstation] + +[update] +srv02 + +[no_update] +dc01 +dc02 + +[defender_on] +dc01 +dc02 + +[defender_off] +srv02 + +[extensions] diff --git a/ad/GOAD-Mini/providers/azure/inventory b/ad/GOAD-Mini/providers/azure/inventory index a4baec51..dcb9e408 100644 --- a/ad/GOAD-Mini/providers/azure/inventory +++ b/ad/GOAD-Mini/providers/azure/inventory @@ -1,10 +1,61 @@ +[all:vars] +domain_name=GOAD-Mini +admin_user=administrator +force_dns_server=no +dns_server=1.1.1.1 +dns_server_forwarder=1.1.1.1 +enable_http_proxy=no +ad_http_proxy=http://x.x.x.x:xxxx +ad_https_proxy=http://x.x.x.x:xxxx + [default] -; Note: ansible_host *MUST* be an IPv4 address or setting things like DNS -; servers will break. ; ------------------------------------------------ -; sevenkingdoms.local +; yourcompany.local ; ------------------------------------------------ dc01 ansible_host={{ip_range}}.10 dns_domain=dc01 dict_key=dc01 ansible_user=ansible ansible_password=8dCT-DJjgScp -[all:vars] -admin_user=goadmin +[domain] +dc01 + +[dc] +dc01 + +[server] + +[workstation] + +[parent_dc] +dc01 + +[child_dc] + +[trust] + +[adcs] +dc01 + +[adcs_customtemplates] + +[iis] + +[mssql] + +[mssql_ssms] + +[webdav] + +[laps_dc] +[laps_server] +[laps_workstation] + +[update] + +[no_update] +dc01 + +[defender_on] +dc01 + +[defender_off] + +[extensions] diff --git a/ad/GOAD/providers/azure/inventory b/ad/GOAD/providers/azure/inventory index 88ebfc1c..1b6059ee 100644 --- a/ad/GOAD/providers/azure/inventory +++ b/ad/GOAD/providers/azure/inventory @@ -1,3 +1,17 @@ +[all:vars] +domain_name=GOAD +admin_user=administrator +force_dns_server=no +dns_server=1.1.1.1 +dns_server_forwarder=1.1.1.1 +add_route=no +route_gateway=192.168.56.1 +route_network=10.0.0.0/8 +enable_http_proxy=no +ad_http_proxy=http://x.x.x.x:xxxx +ad_https_proxy=http://x.x.x.x:xxxx +keyboard_layouts=["en-US"] + [default] ; Note: ansible_host *MUST* be an IPv4 address or setting things like DNS ; servers will break. @@ -16,5 +30,78 @@ srv02 ansible_host={{ip_range}}.22 dns_domain=dc02 dict_key=srv02 ansible_user=a dc03 ansible_host={{ip_range}}.12 dns_domain=dc03 dict_key=dc03 ansible_user=ansible ansible_password=Ufe-bVXSx9rk srv03 ansible_host={{ip_range}}.23 dns_domain=dc03 dict_key=srv03 ansible_user=ansible ansible_password=978i2pF43UJ- -[all:vars] -admin_user=goadmin +[domain] +dc01 +dc02 +dc03 +srv02 +srv03 + +[dc] +dc01 +dc02 +dc03 + +[server] +srv02 +srv03 + +[workstation] + +[parent_dc] +dc01 +dc03 + +[child_dc] +dc02 + +[trust] +dc01 +dc03 + +[adcs] +dc01 +srv03 + +[adcs_customtemplates] +dc03 + +[iis] +srv02 + +[mssql] +srv02 +srv03 + +[mssql_ssms] +srv02 + +[webdav] +srv02 +srv03 + +[laps_dc] +dc03 +[laps_server] +srv03 +[laps_workstation] + +[update] +srv02 + +[no_update] +dc01 +dc02 +dc03 +srv03 + +[defender_on] +dc01 +dc02 +dc03 +srv03 + +[defender_off] +srv02 + +[extensions] diff --git a/ad/NHA/providers/azure/inventory b/ad/NHA/providers/azure/inventory index 196b2ca3..12b34975 100644 --- a/ad/NHA/providers/azure/inventory +++ b/ad/NHA/providers/azure/inventory @@ -1,3 +1,13 @@ +[all:vars] +domain_name=NHA +admin_user=administrator +force_dns_server=no +dns_server=1.1.1.1 +dns_server_forwarder=1.1.1.1 +enable_http_proxy=no +ad_http_proxy=http://x.x.x.x:xxxx +ad_https_proxy=http://x.x.x.x:xxxx + [default] dc01 ansible_host={{ip_range}}.10 dns_domain=dc01 dict_key=dc01 ansible_user=ansible ansible_password=8dCT-6546541qsdDJjgScp dc02 ansible_host={{ip_range}}.20 dns_domain=dc02 dict_key=dc02 ansible_user=ansible ansible_password=Ufe-qsdaz789bVXSx9rk @@ -5,5 +15,70 @@ srv01 ansible_host={{ip_range}}.21 dns_domain=dc02 dict_key=srv01 ansible_user=a srv02 ansible_host={{ip_range}}.22 dns_domain=dc02 dict_key=srv02 ansible_user=ansible ansible_password=978i2pF43UqsdqsdJ-qsd srv03 ansible_host={{ip_range}}.23 dns_domain=dc02 dict_key=srv03 ansible_user=ansible ansible_password=EalwxkfhqsdP+xh7sdfzaRk6j90 -[all:vars] -admin_user=goadmin +[domain] +dc01 +dc02 +srv01 +srv02 +srv03 + +[dc] +dc01 +dc02 + +[server] +srv01 +srv02 +srv03 + +[workstation] + +[parent_dc] +dc01 +dc02 + +[child_dc] + +[trust] +dc01 +dc02 + +[adcs] +dc01 + +[adcs_customtemplates] + +[iis] +srv01 + +[mssql] +srv02 + +[mssql_ssms] +srv02 + +[webdav] + +[laps_dc] +[laps_server] +[laps_workstation] + +[update] +dc01 +dc02 +srv01 +srv02 +srv03 + +[no_update] + +[defender_on] +dc01 +dc02 +srv01 +srv02 +srv03 + +[defender_off] + +[extensions] diff --git a/ad/SCCM/providers/azure/inventory b/ad/SCCM/providers/azure/inventory index 7ce7a58c..a8a5261f 100644 --- a/ad/SCCM/providers/azure/inventory +++ b/ad/SCCM/providers/azure/inventory @@ -1,9 +1,82 @@ +[all:vars] +domain_name=SCCM +admin_user=administrator +sql_instance_name=MSSQLSERVER +sql_version=MSSQL_2022 +force_dns_server=no +dns_server=1.1.1.1 +dns_server_forwarder=1.1.1.1 +two_adapters=yes +enable_http_proxy=no +ad_http_proxy=http://x.x.x.x:xxxx +ad_https_proxy=http://x.x.x.x:xxxx + [default] dc01 ansible_host={{ip_range}}.10 dns_domain=dc01 dict_key=dc01 ansible_user=ansible ansible_password=AZERTY*qsdfg srv01 ansible_host={{ip_range}}.11 dns_domain=dc01 dict_key=srv01 ansible_user=ansible ansible_password=NgtI75cKV+Pu srv02 ansible_host={{ip_range}}.12 dns_domain=dc01 dict_key=srv02 ansible_user=ansible ansible_password=NgtazecKV+Pu ws01 ansible_host={{ip_range}}.13 dns_domain=dc01 dict_key=ws01 ansible_user=ansible ansible_password=EP+xh7Rk6j90 +[domain] +dc01 +srv01 +srv02 +ws01 -[all:vars] -admin_user=goadmin +[dc] +dc01 + +[server] +srv01 +srv02 + +[workstation] +ws01 + +[parent_dc] +dc01 + +[child_dc] + +[trust] + +[adcs] + +[adcs_customtemplates] + +[iis] + +[mssql] +srv02 + +[mssql_ssms] +srv02 + +[mssql_reporting] +srv02 + +[webdav] + +[laps_dc] +[laps_server] +[laps_workstation] + +[sccm] +srv01 + +[update] +dc01 +srv01 +ws01 + +[no_update] + +[defender_on] +dc01 +srv01 +srv02 +ws01 + +[defender_off] + +[extensions] diff --git a/cli/cmd/env_cmd.go b/cli/cmd/env_cmd.go index 21ae8a88..321b3d20 100644 --- a/cli/cmd/env_cmd.go +++ b/cli/cmd/env_cmd.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "io/fs" + "net" "os" "path/filepath" "regexp" @@ -50,9 +51,9 @@ func init() { envCmd.AddCommand(envCreateCmd) envCmd.AddCommand(envListCmd) - envCreateCmd.Flags().String("region", "", "AWS region for the environment (required; or set via dreadgoad.yaml / DREADGOAD_REGION / --region)") - envCreateCmd.Flags().String("vpc-cidr", "", "VPC CIDR block (default: auto-assigned)") - envCreateCmd.Flags().String("reference", "staging", "Reference environment to copy infrastructure from") + envCreateCmd.Flags().String("region", "", "Region for the environment (e.g. us-west-2 for AWS, centralus for Azure)") + envCreateCmd.Flags().String("vpc-cidr", "", "VPC/VNet CIDR block (default: auto-assigned)") + envCreateCmd.Flags().String("reference", "staging", "Reference environment to copy infrastructure from (default: staging for AWS, test for Azure)") envCreateCmd.Flags().Bool("variant", false, "Generate randomized variant config") envCreateCmd.Flags().Bool("force", false, "Overwrite existing environment") } @@ -77,6 +78,9 @@ func runEnvCreate(cmd *cobra.Command, args []string) error { } vpcCIDR, _ := cmd.Flags().GetString("vpc-cidr") reference, _ := cmd.Flags().GetString("reference") + if !cmd.Flags().Changed("reference") && cfg.ResolvedProvider() == "azure" { + reference = "test" + } useVariant, _ := cmd.Flags().GetBool("variant") force, _ := cmd.Flags().GetBool("force") @@ -88,7 +92,8 @@ func runEnvCreate(cmd *cobra.Command, args []string) error { } func scaffoldEnv(cfg *config.Config, envName, region, vpcCIDR, reference string, useVariant, force bool) error { - infraBase := filepath.Join(cfg.ProjectRoot, "infra", cfg.Infra.Deployment) + provider := cfg.ResolvedProvider() + infraBase := cfg.InfraBasePathForProvider(provider) envDir := filepath.Join(infraBase, envName) regionDir := filepath.Join(envDir, region) @@ -101,49 +106,100 @@ func scaffoldEnv(cfg *config.Config, envName, region, vpcCIDR, reference string, return fmt.Errorf("reference environment %q not found in %s", reference, infraBase) } - color.Cyan("Creating environment: %s", envName) - fmt.Printf(" %-14s %s\n", "Region:", region) - fmt.Printf(" %-14s %s\n", "VPC CIDR:", vpcCIDR) - fmt.Printf(" %-14s %s\n", "Reference:", reference) - fmt.Printf(" %-14s %v\n", "Variant:", useVariant) - fmt.Println() + printEnvSummary(provider, envName, region, vpcCIDR, reference, useVariant) - if err := createEnvHCL(envDir, envName, vpcCIDR); err != nil { - return fmt.Errorf("create env.hcl: %w", err) - } - color.Green(" Created env.hcl") - - if err := createRegionHCL(regionDir, region); err != nil { - return fmt.Errorf("create region.hcl: %w", err) + if err := scaffoldHCL(provider, envDir, regionDir, envName, region, vpcCIDR); err != nil { + return err } - color.Green(" Created %s/region.hcl", region) if err := copyInfrastructure(refRegionDir, regionDir); err != nil { return fmt.Errorf("copy infrastructure: %w", err) } color.Green(" Copied infrastructure from %s", reference) - var configPath string - if useVariant { - if err := generateVariantConfig(cfg.ProjectRoot, envName); err != nil { - return fmt.Errorf("generate variant config: %w", err) + configPath, err := scaffoldLabConfig(cfg.ProjectRoot, envName, useVariant) + if err != nil { + return err + } + + invPath := filepath.Join(cfg.ProjectRoot, envName+"-inventory") + if err := scaffoldInventory(provider, cfg.ProjectRoot, envName, region, reference); err != nil { + return err + } + color.Green(" Created inventory: %s", filepath.Base(invPath)) + + printNextSteps(provider, envName, region, envDir, configPath, invPath) + return nil +} + +func printEnvSummary(provider, envName, region, vpcCIDR, reference string, useVariant bool) { + cidrLabel := "VPC CIDR:" + if provider == "azure" { + cidrLabel = "VNet CIDR:" + } + color.Cyan("Creating environment: %s", envName) + fmt.Printf(" %-14s %s\n", "Provider:", provider) + fmt.Printf(" %-14s %s\n", "Region:", region) + fmt.Printf(" %-14s %s\n", cidrLabel, vpcCIDR) + fmt.Printf(" %-14s %s\n", "Reference:", reference) + fmt.Printf(" %-14s %v\n", "Variant:", useVariant) + fmt.Println() +} + +func scaffoldHCL(provider, envDir, regionDir, envName, region, vpcCIDR string) error { + if provider == "azure" { + if err := createAzureEnvHCL(envDir, envName, vpcCIDR); err != nil { + return fmt.Errorf("create env.hcl: %w", err) } - configPath = filepath.Join(cfg.ProjectRoot, "ad", "GOAD-"+envName, "data") - color.Green(" Generated variant config in %s", configPath) + color.Green(" Created env.hcl (Azure)") + if err := createAzureRegionHCL(regionDir, region); err != nil { + return fmt.Errorf("create region.hcl: %w", err) + } + color.Green(" Created %s/region.hcl (location=%s)", region, region) } else { - if err := copyBaseConfig(cfg.ProjectRoot, envName); err != nil { - return fmt.Errorf("copy base config: %w", err) + if err := createEnvHCL(envDir, envName, vpcCIDR); err != nil { + return fmt.Errorf("create env.hcl: %w", err) + } + color.Green(" Created env.hcl") + if err := createRegionHCL(regionDir, region); err != nil { + return fmt.Errorf("create region.hcl: %w", err) } - configPath = filepath.Join(cfg.ProjectRoot, "ad", "GOAD", "data", envName+"-overlay.json") - color.Green(" Created overlay: %s-overlay.json", envName) + color.Green(" Created %s/region.hcl", region) } + return nil +} - invPath := filepath.Join(cfg.ProjectRoot, envName+"-inventory") - if err := generateInventory(cfg.ProjectRoot, envName, region, reference); err != nil { +func scaffoldLabConfig(projectRoot, envName string, useVariant bool) (string, error) { + if useVariant { + if err := generateVariantConfig(projectRoot, envName); err != nil { + return "", fmt.Errorf("generate variant config: %w", err) + } + configPath := filepath.Join(projectRoot, "ad", "GOAD-"+envName, "data") + color.Green(" Generated variant config in %s", configPath) + return configPath, nil + } + if err := copyBaseConfig(projectRoot, envName); err != nil { + return "", fmt.Errorf("copy base config: %w", err) + } + configPath := filepath.Join(projectRoot, "ad", "GOAD", "data", envName+"-overlay.json") + color.Green(" Created overlay: %s-overlay.json", envName) + return configPath, nil +} + +func scaffoldInventory(provider, projectRoot, envName, region, reference string) error { + var err error + if provider == "azure" { + err = generateAzureInventory(projectRoot, envName, reference) + } else { + err = generateInventory(projectRoot, envName, region, reference) + } + if err != nil { return fmt.Errorf("generate inventory: %w", err) } - color.Green(" Created inventory: %s", filepath.Base(invPath)) + return nil +} +func printNextSteps(provider, envName, region, envDir, configPath, invPath string) { fmt.Println() color.Green("Environment %q created successfully!", envName) fmt.Println() @@ -151,12 +207,14 @@ func scaffoldEnv(cfg *config.Config, envName, region, vpcCIDR, reference string, fmt.Printf(" 1. Review: %s\n", envDir) fmt.Printf(" 2. Review: %s\n", configPath) fmt.Printf(" 3. Review: %s\n", invPath) - fmt.Printf(" 4. Initialize: dreadgoad --env %s --region %s infra init\n", envName, region) - fmt.Printf(" 5. Plan: dreadgoad --env %s --region %s infra plan\n", envName, region) - fmt.Printf(" 6. Apply: dreadgoad --env %s --region %s infra apply --auto-approve\n", envName, region) - fmt.Printf(" 7. Sync IDs: dreadgoad --env %s --region %s inventory sync\n", envName, region) - - return nil + if provider == "azure" { + fmt.Printf(" 4. Deploy: dreadgoad -p azure -e %s --region %s up\n", envName, region) + } else { + fmt.Printf(" 4. Initialize: dreadgoad -e %s --region %s infra init\n", envName, region) + fmt.Printf(" 5. Plan: dreadgoad -e %s --region %s infra plan\n", envName, region) + fmt.Printf(" 6. Apply: dreadgoad -e %s --region %s infra apply --auto-approve\n", envName, region) + fmt.Printf(" 7. Sync IDs: dreadgoad -e %s --region %s inventory sync\n", envName, region) + } } func runEnvList(cmd *cobra.Command, args []string) error { @@ -165,8 +223,7 @@ func runEnvList(cmd *cobra.Command, args []string) error { return err } - deployment := cfg.Infra.Deployment - infraBase := filepath.Join(cfg.ProjectRoot, "infra", deployment) + infraBase := cfg.InfraBasePathForProvider(cfg.ResolvedProvider()) entries, err := os.ReadDir(infraBase) if err != nil { @@ -350,9 +407,15 @@ func generateInventory(projectRoot, envName, region, reference string) error { refRegion = strings.TrimSpace(m[2]) } - content = envRe.ReplaceAllString(content, "${1}"+envName) + content = envRe.ReplaceAllStringFunc(content, func(match string) string { + eq := strings.Index(match, "=") + return match[:eq+1] + envName + }) - content = regionRe.ReplaceAllString(content, "${1}"+region) + content = regionRe.ReplaceAllStringFunc(content, func(match string) string { + eq := strings.Index(match, "=") + return match[:eq+1] + region + }) if refRegion != "" { if m := bucketRe.FindStringSubmatch(content); len(m) > 2 { @@ -396,3 +459,97 @@ func generateVariantConfig(projectRoot, envName string) error { gen := variant.NewGenerator(source, target, envName) return gen.Run() } + +// deriveAzureSubnets computes bastion and controller subnet CIDRs from a /16 +// VNet CIDR. Given "10.X.0.0/16" it produces: +// +// bastion: 10.X.2.0/26 (64 IPs, required by Azure Bastion) +// controller: 10.X.3.0/28 (16 IPs, single Ansible controller) +func deriveAzureSubnets(vnetCIDR string) (bastionSubnet, controllerSubnet string, err error) { + _, ipnet, err := net.ParseCIDR(vnetCIDR) + if err != nil { + return "", "", fmt.Errorf("invalid VNet CIDR %q: %w", vnetCIDR, err) + } + ones, _ := ipnet.Mask.Size() + if ones != 16 { + return "", "", fmt.Errorf("VNet CIDR must be a /16, got /%d", ones) + } + base := ipnet.IP.To4() + if base == nil { + return "", "", fmt.Errorf("VNet CIDR must be IPv4, got %q", vnetCIDR) + } + bastionSubnet = fmt.Sprintf("%d.%d.2.0/26", base[0], base[1]) + controllerSubnet = fmt.Sprintf("%d.%d.3.0/28", base[0], base[1]) + return bastionSubnet, controllerSubnet, nil +} + +// createAzureEnvHCL writes an Azure-specific env.hcl with VNet, bastion, and +// controller subnet CIDRs auto-derived from the VNet CIDR. +func createAzureEnvHCL(envDir, envName, vnetCIDR string) error { + bastionSubnet, controllerSubnet, err := deriveAzureSubnets(vnetCIDR) + if err != nil { + return err + } + if err := os.MkdirAll(envDir, 0o755); err != nil { + return err + } + content := fmt.Sprintf(`locals { + deployment_name = "goad" + env = %q + vnet_cidr = %q + + bastion_sku = "Standard" + bastion_subnet_cidr = %q + bastion_tunneling_enabled = true + + controller_subnet_cidr = %q + controller_ssh_source_address_prefix = %q + controller_instance_size = "Standard_D2s_v3" +} +`, envName, vnetCIDR, bastionSubnet, controllerSubnet, bastionSubnet) + return os.WriteFile(filepath.Join(envDir, "env.hcl"), []byte(content), 0o644) +} + +// createAzureRegionHCL writes an Azure region.hcl with the location field. +func createAzureRegionHCL(regionDir, location string) error { + if err := os.MkdirAll(regionDir, 0o755); err != nil { + return err + } + content := fmt.Sprintf(`locals { + location = %q +} +`, location) + return os.WriteFile(filepath.Join(regionDir, "region.hcl"), []byte(content), 0o644) +} + +// generateAzureInventory creates an inventory file for Azure by copying the +// reference inventory and resetting host IPs to PENDING. +func generateAzureInventory(projectRoot, envName, reference string) error { + refInvPath, err := resolveReferenceInventory(projectRoot, reference) + if err != nil { + // Fall back to the base GOAD Azure provider inventory template. + fallback := filepath.Join(projectRoot, "ad", "GOAD", "providers", "azure", "inventory") + if _, ferr := os.Stat(fallback); ferr != nil { + return err // return original error + } + refInvPath = fallback + } + dstInvPath := filepath.Join(projectRoot, envName+"-inventory") + + data, err := os.ReadFile(refInvPath) + if err != nil { + return fmt.Errorf("read reference inventory %s: %w", filepath.Base(refInvPath), err) + } + content := string(data) + + envRe := regexp.MustCompile(`(?m)^(\s*env=).+$`) + content = envRe.ReplaceAllStringFunc(content, func(match string) string { + eq := strings.Index(match, "=") + return match[:eq+1] + envName + }) + + ipRe := regexp.MustCompile(`(ansible_host=)\S+`) + content = ipRe.ReplaceAllString(content, "${1}PENDING") + + return os.WriteFile(dstInvPath, []byte(content), 0o644) +} diff --git a/cli/cmd/env_cmd_test.go b/cli/cmd/env_cmd_test.go new file mode 100644 index 00000000..c8e0b28e --- /dev/null +++ b/cli/cmd/env_cmd_test.go @@ -0,0 +1,34 @@ +package cmd + +import "testing" + +func TestDeriveAzureSubnets(t *testing.T) { + tests := []struct { + name string + vnetCIDR string + wantBast string + wantCtrl string + wantErr bool + }{ + {"standard", "10.8.0.0/16", "10.8.2.0/26", "10.8.3.0/28", false}, + {"different octet", "10.1.0.0/16", "10.1.2.0/26", "10.1.3.0/28", false}, + {"high octet", "10.200.0.0/16", "10.200.2.0/26", "10.200.3.0/28", false}, + {"not /16", "10.8.0.0/24", "", "", true}, + {"invalid CIDR", "not-a-cidr", "", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bast, ctrl, err := deriveAzureSubnets(tt.vnetCIDR) + if (err != nil) != tt.wantErr { + t.Fatalf("error = %v, wantErr %v", err, tt.wantErr) + } + if bast != tt.wantBast { + t.Errorf("bastion = %q, want %q", bast, tt.wantBast) + } + if ctrl != tt.wantCtrl { + t.Errorf("controller = %q, want %q", ctrl, tt.wantCtrl) + } + }) + } +} diff --git a/cli/cmd/provision.go b/cli/cmd/provision.go index fae9aa71..11ebb492 100644 --- a/cli/cmd/provision.go +++ b/cli/cmd/provision.go @@ -133,7 +133,7 @@ func isSSMInventory(cfg *config.Config) bool { // preflightChecks validates tooling, builds the Ansible collection, and // prepares artifacts needed before provisioning playbooks run. func preflightChecks(ctx context.Context, cfg *config.Config) error { - if err := doctor.CheckAnsibleCoreVersion(); err != nil { + if err := doctor.CheckAnsibleCoreVersion(cfg.ResolvedProvider()); err != nil { return fmt.Errorf("ansible-core version check failed: %w", err) } if err := ansible.InstallRequirements(cfg.ProjectRoot); err != nil { @@ -192,7 +192,9 @@ func bootstrapInventory(invPath string) error { func bootstrapFromProviderTemplate(invPath string, cfg *config.Config) error { labName := "GOAD" - if cfg.ResolvedProvider() == "proxmox" { + if ec := cfg.ActiveEnvironment(); ec.Variant && ec.VariantTarget != "" { + labName = filepath.Base(ec.VariantTarget) + } else if cfg.ResolvedProvider() == "proxmox" { labName = cfg.ProxmoxLab() } providerName := cfg.ResolvedProvider() diff --git a/cli/internal/config/config.go b/cli/internal/config/config.go index 19fb5f05..cf7a1269 100644 --- a/cli/internal/config/config.go +++ b/cli/internal/config/config.go @@ -449,6 +449,15 @@ func (c *Config) InfraBasePath() string { return filepath.Join(c.ProjectRoot, "infra", c.Infra.Deployment) } +// InfraBasePathForProvider returns the base infra directory for the given provider. +// Azure uses infra/azure/{deployment}; other providers use infra/{deployment}. +func (c *Config) InfraBasePathForProvider(provider string) string { + if provider == "azure" { + return filepath.Join(c.ProjectRoot, "infra", "azure", c.Infra.Deployment) + } + return c.InfraBasePath() +} + // InfraWorkDir returns the working directory for terragrunt operations // at the region level: infra/{deployment}/{env}/{region}/ func (c *Config) InfraWorkDir() (string, error) { diff --git a/cli/internal/doctor/checks.go b/cli/internal/doctor/checks.go index 5adb6270..c9442c2e 100644 --- a/cli/internal/doctor/checks.go +++ b/cli/internal/doctor/checks.go @@ -52,7 +52,7 @@ type Options struct { func RunChecks(opts Options) []CheckResult { var results []CheckResult - results = append(results, checkAnsibleVersion()) + results = append(results, checkAnsibleVersion(opts.Provider)) results = append(results, checkCommand("python3", "Python 3")) results = append(results, checkCommand("jq", "jq")) results = append(results, checkCommand("zip", "zip")) @@ -84,6 +84,7 @@ func runAzureChecks() []CheckResult { results = append(results, checkAzureSSHExtension()) results = append(results, checkTerragrunt()) results = append(results, checkTerraformOrTofu()) + results = append(results, checkPyPSRP()) return results } @@ -173,15 +174,15 @@ func PrintResults(results []CheckResult) int { // compatible version range (<2.19). Returns an error if the version is // incompatible or ansible-core is not found. This is used as a pre-flight // gate before running playbooks. -func CheckAnsibleCoreVersion() error { - result := checkAnsibleVersion() +func CheckAnsibleCoreVersion(provider string) error { + result := checkAnsibleVersion(provider) if result.Status == "fail" { return fmt.Errorf("%s", result.Message) } return nil } -func checkAnsibleVersion() CheckResult { +func checkAnsibleVersion(provider string) CheckResult { out, err := exec.Command("ansible", "--version").CombinedOutput() if err != nil { return CheckResult{ @@ -202,6 +203,14 @@ func checkAnsibleVersion() CheckResult { version := fmt.Sprintf("%s.%s.%s", m[1], m[2], m[3]) if major > 2 || (major == 2 && minor >= 19) { + if provider == "azure" { + return CheckResult{ + Name: "ansible-core", + Status: "warn", + Message: fmt.Sprintf("v%s detected. Versions >=2.19 break Windows SSM (AWS) "+ + "but work fine with Azure WinRM/PSRP", version), + } + } return CheckResult{ Name: "ansible-core", Status: "fail", @@ -393,7 +402,7 @@ func checkPyPSRP() CheckResult { return CheckResult{ Name: "pypsrp + PySocks", Status: "fail", - Message: "missing Python packages required for SSH-mode provisioning. " + + Message: "missing Python packages required for WinRM/PSRP provisioning. " + "Install with: pip install pypsrp requests[socks]", } } diff --git a/cli/internal/variant/generator.go b/cli/internal/variant/generator.go index 629ccd5d..4c9f90fb 100644 --- a/cli/internal/variant/generator.go +++ b/cli/internal/variant/generator.go @@ -1,6 +1,7 @@ package variant import ( + "bytes" "encoding/json" "fmt" "io/fs" @@ -9,6 +10,7 @@ import ( "regexp" "sort" "strings" + "unicode/utf8" ) // LabConfig is the top-level structure of a GOAD config.json. @@ -159,8 +161,9 @@ type HostMapping struct { // replacement is an ordered old->new string replacement. type replacement struct { - Old string - New string + Old string + New string + WordBoundary bool // use word-boundary regex instead of plain replacement } // Generator creates GOAD variants with randomized entity names. @@ -175,6 +178,7 @@ type Generator struct { userPasswordMap map[string]string // new_username -> new_password preservedUsers map[string]bool pwdInDescUsers map[string]bool // new_username -> has password in description + nameComponents map[string]bool // Misc keys that are firstname/surname components } // hostnameAliases maps canonical hostnames to known typos/aliases in upstream GOAD. @@ -204,6 +208,7 @@ func NewGenerator(source, target, name string) *Generator { userPasswordMap: make(map[string]string), preservedUsers: map[string]bool{"sql_svc": true}, pwdInDescUsers: make(map[string]bool), + nameComponents: make(map[string]bool), } } @@ -287,6 +292,19 @@ func (g *Generator) generateMappings(config *LabConfig) { fmt.Println("\nMapping cities...") g.mapCities(config) + // Reconcile name-component Misc entries that conflict with Groups. + // Group names are explicit AD entities and take precedence over + // capitalized-surname convenience entries (e.g., "Targaryen" is both + // a group and a surname). Only remove entries that are name components + // (firstname/surname) — other Misc entries like hostnames, gMSA names, + // and cities must be preserved. + for groupName := range g.mappings.Groups { + if g.nameComponents[groupName] { + delete(g.mappings.Misc, groupName) + delete(g.nameComponents, groupName) + } + } + fmt.Println("\n=== Mapping Generation Complete ===") } @@ -363,6 +381,12 @@ func (g *Generator) mapUsers(config *LabConfig) { } newUsername := g.nameGen.GenerateUsername() + // Preserve single-name pattern: if original has no ".", use only firstname. + // Pass through EnsureUnique to avoid collisions when multiple dotless + // usernames truncate to the same firstname. + if !strings.Contains(username, ".") { + newUsername = g.nameGen.ensureUnique(strings.Split(newUsername, ".")[0]) + } g.mappings.Users[username] = newUsername if user != nil && user.Password != "" && user.Description != "" && @@ -381,28 +405,40 @@ func (g *Generator) mapUserNameComponents(user *UserConfig, newUsername string) if user == nil { return } - if user.Firstname != "" { - firstname := user.Firstname + if user.Firstname != "" && user.Firstname != "sql" { newFirst := strings.Split(newUsername, ".")[0] - g.mappings.Misc[firstname] = newFirst - if !isAllLower(firstname) && firstname != "sql" { - g.mappings.Misc[strings.ToLower(firstname)] = strings.ToLower(newFirst) - } - if isAllLower(firstname) && firstname != "sql" { - g.mappings.Misc[capitalize(firstname)] = capitalize(newFirst) - } + g.setNameComponent(user.Firstname, newFirst) } if user.Surname != "" && user.Surname != "-" { - surname := user.Surname parts := strings.SplitN(newUsername, ".", 2) newSurname := parts[0] if len(parts) > 1 { newSurname = parts[1] } - g.mappings.Misc[surname] = newSurname - if isAllLower(surname) { - g.mappings.Misc[capitalize(surname)] = capitalize(newSurname) + g.setNameComponent(user.Surname, newSurname) + } +} + +// setNameComponent adds a name mapping (and its case variant) to Misc, +// skipping entries that already exist to prevent collisions. +func (g *Generator) setNameComponent(original, replacement string) { + if _, exists := g.mappings.Misc[original]; !exists { + g.mappings.Misc[original] = replacement + g.nameComponents[original] = true + } + // Add the opposite case variant (lowercase ↔ capitalized). + if isAllLower(original) { + cap := capitalize(original) + if _, exists := g.mappings.Misc[cap]; !exists { + g.mappings.Misc[cap] = capitalize(replacement) + g.nameComponents[cap] = true + } + } else { + lower := strings.ToLower(original) + if _, exists := g.mappings.Misc[lower]; !exists { + g.mappings.Misc[lower] = strings.ToLower(replacement) + g.nameComponents[lower] = true } } } @@ -602,6 +638,18 @@ func (g *Generator) buildOrderedReplacements() { repls = appendMapReplacements(repls, g.mappings.NetBIOS, nil) repls = appendMapReplacements(repls, g.mappings.Misc, withoutSuffix("$")) + // Tag name-component replacements for word-boundary matching. + // Skip word-boundary for entries that also exist as group names — + // group names need plain string replacement to catch compound strings + // like "StarkWallpaper". + for i := range repls { + if _, isGroup := g.mappings.Groups[repls[i].Old]; isGroup { + repls[i].WordBoundary = false + } else { + repls[i].WordBoundary = g.isNameComponent(repls[i].Old) + } + } + sort.Slice(repls, func(i, j int) bool { return len(repls[i].Old) > len(repls[j].Old) }) @@ -633,17 +681,17 @@ func appendMapReplacements(repls []replacement, m map[string]string, filter func if filter != nil && !filter(old) { continue } - repls = append(repls, replacement{old, new}) + repls = append(repls, replacement{Old: old, New: new}) } return repls } func (g *Generator) appendHostReplacements(repls []replacement) []replacement { for _, hm := range g.mappings.Hosts { - repls = append(repls, replacement{hm.OldFQDN, hm.NewFQDN}) + repls = append(repls, replacement{Old: hm.OldFQDN, New: hm.NewFQDN}) } for _, hm := range g.mappings.Hosts { - repls = append(repls, replacement{hm.OldHostname, hm.NewHostname}) + repls = append(repls, replacement{Old: hm.OldHostname, New: hm.NewHostname}) } return repls } @@ -664,8 +712,8 @@ func (g *Generator) appendQualifiedUserReplacements(repls []replacement) []repla } for oldUser, newUser := range g.mappings.Users { repls = append(repls, - replacement{oldNB + "\\\\" + oldUser, newNB + "\\\\" + newUser}, - replacement{oldDomain + "\\\\" + oldUser, newDomain + "\\\\" + newUser}, + replacement{Old: oldNB + "\\\\" + oldUser, New: newNB + "\\\\" + newUser}, + replacement{Old: oldDomain + "\\\\" + oldUser, New: newDomain + "\\\\" + newUser}, ) } } @@ -685,8 +733,8 @@ func (g *Generator) appendDNReplacements(repls []replacement) []replacement { newDCs = append(newDCs, "DC="+p) } repls = append(repls, replacement{ - strings.Join(oldDCs, ",") + ",DC=local", - strings.Join(newDCs, ",") + ",DC=local", + Old: strings.Join(oldDCs, ",") + ",DC=local", + New: strings.Join(newDCs, ",") + ",DC=local", }) } return repls @@ -702,7 +750,7 @@ func (g *Generator) appendDomainReplacements(repls []replacement) []replacement return len(pairs[i].old) > len(pairs[j].old) }) for _, dp := range pairs { - repls = append(repls, replacement{dp.old, dp.new}) + repls = append(repls, replacement{Old: dp.old, New: dp.new}) } return repls } @@ -714,12 +762,21 @@ func (g *Generator) applyReplacements(content string) string { continue } - if g.isNameComponent(r.Old) { - pattern := `\b` + regexp.QuoteMeta(r.Old) + `\b` + if r.WordBoundary { + // Match name at boundaries, treating underscore as a word + // separator. Go's \b considers _ a word character, but GOAD + // uses underscore-delimited keys (e.g., "GenericWrite_missandei_viserys"). + // We pre-process underscores to temporary placeholders so \b works, + // then restore them. + escaped := regexp.QuoteMeta(r.Old) + const placeholder = "\x00USCORE\x00" + temp := strings.ReplaceAll(content, "_", placeholder) + pattern := `\b` + escaped + `\b` re, err := regexp.Compile(pattern) if err == nil { - content = re.ReplaceAllString(content, r.New) + temp = re.ReplaceAllString(temp, r.New) } + content = strings.ReplaceAll(temp, placeholder, "_") } else { content = strings.ReplaceAll(content, r.Old, r.New) } @@ -729,22 +786,7 @@ func (g *Generator) applyReplacements(content string) string { // isNameComponent returns true if old is a firstname/surname component needing word-boundary protection. func (g *Generator) isNameComponent(old string) bool { - if _, ok := g.mappings.Misc[old]; !ok { - return false - } - if strings.HasSuffix(old, "$") || strings.Contains(old, ".") || strings.Contains(old, "\\") { - return false - } - if len(old) >= 50 { - return false - } - cleaned := strings.ReplaceAll(strings.ReplaceAll(old, "-", ""), "'", "") - for _, c := range cleaned { - if ('a' > c || c > 'z') && ('A' > c || c > 'Z') { - return false - } - } - return true + return g.nameComponents[old] } // fixUserFirstnameSurname corrects firstname/surname fields to match generated usernames. @@ -843,16 +885,18 @@ var textFilenames = map[string]bool{ // transformFile transforms a single file with replacements and writes to target. func (g *Generator) transformFile(srcPath, relPath string) (transformed bool) { + // Rename file paths based on entity mappings (e.g., arya.txt -> thomas.txt). + newRelPath := g.applyReplacements(relPath) ext := filepath.Ext(srcPath) base := filepath.Base(srcPath) - targetFile := filepath.Join(g.TargetPath, relPath) + targetFile := filepath.Join(g.TargetPath, newRelPath) if err := os.MkdirAll(filepath.Dir(targetFile), 0o755); err != nil { fmt.Printf("Warning: mkdir failed for %s: %v\n", relPath, err) return false } - if textExtensions[ext] || textFilenames[base] { + if textExtensions[ext] || textFilenames[base] || (ext == "" && g.isTextFile(srcPath)) { content, err := os.ReadFile(srcPath) if err != nil { fmt.Printf("Warning: Could not read %s: %v\n", relPath, err) @@ -987,7 +1031,7 @@ func (g *Generator) findNameViolations() ([]violation, int) { return nil } ext := filepath.Ext(path) - if !textExtensions[ext] && !textFilenames[d.Name()] { + if !textExtensions[ext] && !textFilenames[d.Name()] && (ext != "" || !g.isTextFile(path)) { return nil } filesChecked++ @@ -1124,6 +1168,28 @@ func copyFile(src, dst string) { } } +// isTextFile returns true if the file appears to contain text (valid UTF-8 with +// no NUL bytes). Used as a fallback for extensionless files like "inventory_disable_vagrant". +func (g *Generator) isTextFile(path string) bool { + f, err := os.Open(path) + if err != nil { + return false + } + defer func() { _ = f.Close() }() + + // Only read the first 8 KiB — enough to detect binary content. + buf := make([]byte, 8192) + n, _ := f.Read(buf) + if n == 0 { + return false + } + check := buf[:n] + if bytes.ContainsRune(check, 0) { + return false + } + return utf8.Valid(check) +} + func capitalize(s string) string { if s == "" { return s diff --git a/cli/internal/variant/generator_test.go b/cli/internal/variant/generator_test.go index 44a99b1f..449e56a0 100644 --- a/cli/internal/variant/generator_test.go +++ b/cli/internal/variant/generator_test.go @@ -8,6 +8,44 @@ import ( "testing" ) +// setupTestSourceFull creates a test source with extensionless files, user files, +// and compound group name scripts to exercise all known edge cases. +func setupTestSourceFull(t *testing.T) (sourceDir, targetDir string) { + t.Helper() + sourceDir, targetDir = setupTestSource(t) + + // Extensionless inventory file (Bug 1) + inventoryContent := `[all:vars] +; sevenkingdoms.local +ansible_user=administrator@sevenkingdoms.local +` + if err := os.WriteFile(filepath.Join(sourceDir, "data", "inventory_disable_vagrant"), []byte(inventoryContent), 0o644); err != nil { + t.Fatal(err) + } + + // File named after a user (Bug 2) + if err := os.MkdirAll(filepath.Join(sourceDir, "files", "srv02", "all"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(sourceDir, "files", "srv02", "all", "arya.txt"), + []byte("Hey arya, here is your sword."), + 0o644, + ); err != nil { + t.Fatal(err) + } + + // Script with compound group name (Bug 3) + gpoScript := `$gpo = "StarkWallpaper" +Set-GPO -Name "StarkWallpaper" -Target "DC=north,DC=sevenkingdoms,DC=local" +` + if err := os.WriteFile(filepath.Join(sourceDir, "scripts", "gpo_abuse.ps1"), []byte(gpoScript), 0o644); err != nil { + t.Fatal(err) + } + + return sourceDir, targetDir +} + func setupTestSource(t *testing.T) (sourceDir, targetDir string) { t.Helper() tmpDir := t.TempDir() @@ -194,8 +232,8 @@ func TestApplyReplacements(t *testing.T) { gen := NewGenerator("", "", "test") gen.mappings.Misc["robert"] = "james" gen.replacements = []replacement{ - {"sevenkingdoms.local", "deltasystems.local"}, - {"robert", "james"}, + {Old: "sevenkingdoms.local", New: "deltasystems.local"}, + {Old: "robert", New: "james", WordBoundary: true}, } content := "domain: sevenkingdoms.local, user: robert" @@ -209,11 +247,37 @@ func TestApplyReplacements(t *testing.T) { } } +func TestApplyReplacementsUnderscoreDelimited(t *testing.T) { + gen := NewGenerator("", "", "test") + gen.replacements = []replacement{ + {Old: "missandei", New: "donna", WordBoundary: true}, + {Old: "viserys", New: "alexander", WordBoundary: true}, + } + + content := `"GenericWrite_missandei_viserys": null` + result := gen.applyReplacements(content) + + if strings.Contains(result, "missandei") { + t.Errorf("missandei not replaced in underscore-delimited key: %s", result) + } + if strings.Contains(result, "viserys") { + t.Errorf("viserys not replaced in underscore-delimited key: %s", result) + } + expected := `"GenericWrite_donna_alexander": null` + if result != expected { + t.Errorf("unexpected result:\n got: %s\n want: %s", result, expected) + } +} + func TestIsNameComponent(t *testing.T) { gen := NewGenerator("", "", "test") gen.mappings.Misc["robert"] = "james" + gen.nameComponents["robert"] = true gen.mappings.Misc["meereen$"] = "beacon$" gen.mappings.Misc["winterfell.domain"] = "cascade.domain" + // Group name that happens to also be a surname — should NOT be a name component + // unless explicitly registered via mapUserNameComponents. + gen.mappings.Misc["Stark"] = "OperationsGroup" tests := []struct { name string @@ -223,6 +287,7 @@ func TestIsNameComponent(t *testing.T) { {"meereen$", false}, {"winterfell.domain", false}, {"notinmisc", false}, + {"Stark", false}, // group name, not registered as name component } for _, tt := range tests { @@ -268,3 +333,163 @@ func TestSimplifyEntity(t *testing.T) { } } } + +func TestTransformEdgeCases(t *testing.T) { + sourceDir, targetDir := setupTestSourceFull(t) + + gen := NewGenerator(sourceDir, targetDir, "test-edges") + if err := gen.Run(); err != nil { + t.Fatalf("generator failed: %v", err) + } + + // Bug 1: extensionless files should be detected as text and transformed. + t.Run("extensionless_file", func(t *testing.T) { + data, err := os.ReadFile(filepath.Join(targetDir, "data", "inventory_disable_vagrant")) + if err != nil { + t.Fatal("inventory_disable_vagrant not created in target") + } + if strings.Contains(strings.ToLower(string(data)), "sevenkingdoms") { + t.Error("original domain 'sevenkingdoms' still found in extensionless inventory file") + } + }) + + // Bug 2: files named after entities should be renamed on disk. + t.Run("file_renamed", func(t *testing.T) { + oldPath := filepath.Join(targetDir, "files", "srv02", "all", "arya.txt") + if _, err := os.Stat(oldPath); err == nil { + t.Error("arya.txt still exists at original path — file was not renamed") + } + newFirstname := gen.mappings.Misc["arya"] + if newFirstname == "" { + t.Fatal("no mapping found for 'arya' in Misc") + } + newPath := filepath.Join(targetDir, "files", "srv02", "all", newFirstname+".txt") + if _, err := os.Stat(newPath); err != nil { + t.Errorf("renamed file %s.txt not found at expected path", newFirstname) + } + }) + + // Bug 3: group names in compound strings (e.g., "StarkWallpaper") should be replaced. + t.Run("compound_group_name", func(t *testing.T) { + data, err := os.ReadFile(filepath.Join(targetDir, "scripts", "gpo_abuse.ps1")) + if err != nil { + t.Fatal("gpo_abuse.ps1 not created in target") + } + if strings.Contains(string(data), "Stark") { + t.Errorf("original group name 'Stark' still found in gpo_abuse.ps1: %s", string(data)) + } + }) +} + +func TestGroupSurnameMappingConsistency(t *testing.T) { + sourceDir, targetDir := setupTestSourceFull(t) + + gen := NewGenerator(sourceDir, targetDir, "test-group-surname") + if err := gen.Run(); err != nil { + t.Fatalf("generator failed: %v", err) + } + + // "Stark" is both a group name and a capitalized surname (from arya.stark). + // The group mapping should win — Misc should not have a conflicting entry. + groupNew := gen.mappings.Groups["Stark"] + if groupNew == "" { + t.Fatal("group 'Stark' not found in mappings") + } + if miscNew, exists := gen.mappings.Misc["Stark"]; exists { + t.Errorf("Misc still has 'Stark' -> %q, should have been removed in favor of group mapping %q", miscNew, groupNew) + } + + // Verify the config.json actually uses the group name, not the surname. + varData, err := os.ReadFile(filepath.Join(targetDir, "data", "config.json")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(varData), groupNew) { + t.Errorf("group name %q not found in variant config.json", groupNew) + } +} + +func TestFirstnameCollisionNoOverwrite(t *testing.T) { + tmpDir := t.TempDir() + sourceDir := filepath.Join(tmpDir, "source") + targetDir := filepath.Join(tmpDir, "target") + + if err := os.MkdirAll(filepath.Join(sourceDir, "data"), 0o755); err != nil { + t.Fatal(err) + } + + // Two users with the same firstname in the same domain. + config := &LabConfig{} + config.Lab.Hosts = map[string]*HostConfig{ + "dc01": { + Hostname: "kingslanding", + Type: "dc", + Domain: "sevenkingdoms.local", + LocalAdminPassword: "TestPass123!", + }, + } + config.Lab.Domains = map[string]*DomainConfig{ + "sevenkingdoms.local": { + DomainPassword: "DomainPass1!", + Users: map[string]*UserConfig{ + "brandon.stark": { + Firstname: "brandon", + Surname: "stark", + Password: "BranPass1!", + }, + "brandon.lannister": { + Firstname: "brandon", + Surname: "lannister", + Password: "BranPass2!", + }, + }, + Groups: GroupsConfig{}, + }, + } + configData, _ := json.MarshalIndent(config, "", " ") + if err := os.WriteFile(filepath.Join(sourceDir, "data", "config.json"), configData, 0o644); err != nil { + t.Fatal(err) + } + + gen := NewGenerator(sourceDir, targetDir, "test-collision") + if err := gen.Run(); err != nil { + t.Fatalf("generator failed: %v", err) + } + + // Both users should have distinct mappings. + user1 := gen.mappings.Users["brandon.stark"] + user2 := gen.mappings.Users["brandon.lannister"] + if user1 == "" || user2 == "" { + t.Fatal("one or both users not mapped") + } + if user1 == user2 { + t.Errorf("both users mapped to same username: %s", user1) + } + + // The Misc entry for "brandon" should exist and not be empty. + miscBrandon := gen.mappings.Misc["brandon"] + if miscBrandon == "" { + t.Error("Misc mapping for 'brandon' is empty") + } + + // Verify both users exist in the output config with correct firstname/surname. + varData, err := os.ReadFile(filepath.Join(targetDir, "data", "config.json")) + if err != nil { + t.Fatal(err) + } + var varConfig LabConfig + if err := json.Unmarshal(varData, &varConfig); err != nil { + t.Fatal(err) + } + for domainName, domain := range varConfig.Lab.Domains { + for username, user := range domain.Users { + if strings.Contains(username, ".") { + parts := strings.SplitN(username, ".", 2) + if user.Firstname != parts[0] { + t.Errorf("domain %s user %s: firstname=%q doesn't match username prefix %q", + domainName, username, user.Firstname, parts[0]) + } + } + } + } +} diff --git a/cli/internal/variant/namegen.go b/cli/internal/variant/namegen.go index e7794d73..81c0f9b5 100644 --- a/cli/internal/variant/namegen.go +++ b/cli/internal/variant/namegen.go @@ -41,13 +41,13 @@ func NewNameGenerator() *NameGenerator { "ventures", "enterprises", "group", "labs", "dynamics", "works", }, firstNames: []string{ - "James", "Michael", "Robert", "John", "David", "William", + "James", "Michael", "John", "David", "William", "Richard", "Joseph", "Thomas", "Charles", "Christopher", "Daniel", "Matthew", "Anthony", "Mark", "Donald", "Steven", "Paul", "Andrew", "Joshua", "Kenneth", "Kevin", "Brian", "George", "Timothy", "Ronald", "Edward", "Jason", "Jeffrey", "Ryan", "Jacob", "Gary", "Nicholas", "Eric", "Jonathan", "Stephen", - "Larry", "Justin", "Scott", "Brandon", "Benjamin", "Samuel", + "Larry", "Justin", "Scott", "Benjamin", "Samuel", "Raymond", "Gregory", "Alexander", "Patrick", "Frank", "Dennis", "Mary", "Patricia", "Jennifer", "Linda", "Barbara", "Elizabeth", "Susan", "Jessica", "Sarah", "Karen", "Nancy", "Lisa", @@ -224,7 +224,7 @@ const ( lowerChars = "abcdefghijklmnopqrstuvwxyz" upperChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" digitChars = "0123456789" - specialChars = "!@#$%^&*()-_=+[]{}|;:,.<>?" + specialChars = "!@#$%^&*()-_=+|;:,.<>?" ) type charClasses struct { @@ -280,7 +280,7 @@ func (cc charClasses) seedRequired() []byte { seed = append(seed, secureChoiceByte(digitChars)) } if cc.special { - seed = append(seed, secureChoiceByte("!@#$%^&*()-_=+")) + seed = append(seed, secureChoiceByte(specialChars)) } return seed } diff --git a/cli/internal/variant/namegen_test.go b/cli/internal/variant/namegen_test.go index d72b690d..c125963e 100644 --- a/cli/internal/variant/namegen_test.go +++ b/cli/internal/variant/namegen_test.go @@ -93,6 +93,20 @@ func TestGeneratePassword(t *testing.T) { } } +func TestGeneratePasswordNoJinjaSequences(t *testing.T) { + ng := NewNameGenerator() + forbidden := []string{"{", "}", "[", "]"} + + for i := 0; i < 1000; i++ { + pw := ng.GeneratePassword("Password1!") + for _, seq := range forbidden { + if strings.Contains(pw, seq) { + t.Fatalf("password %q contains forbidden sequence %q (iteration %d)", pw, seq, i) + } + } + } +} + func TestGenerateGroupName(t *testing.T) { ng := NewNameGenerator() name := ng.GenerateGroupName() diff --git a/dreadgoad.yaml b/dreadgoad.yaml index 8fac57eb..6d0d2e0c 100644 --- a/dreadgoad.yaml +++ b/dreadgoad.yaml @@ -1,12 +1,12 @@ # DreadGOAD CLI Configuration -env: staging -provider: aws # Provider: aws, proxmox, ludus -# region: us-west-2 # Override AWS region (default: from inventory) +env: dreadindex +provider: azure # Provider: aws, azure, proxmox, ludus +region: centralus instance_profile: WarpgateImageBuilderInstanceProfile # IAM instance profile for EC2 Image Builder debug: false max_retries: 3 retry_delay: 30 -idle_timeout: 1200 +idle_timeout: 3600 # log_dir: ~/.ansible/logs/goad # project_root: /path/to/DreadGOAD # Auto-detected if omitted @@ -51,9 +51,22 @@ environments: variant_name: variant-1 vpc_cidr: "10.0.0.0/16" staging: - variant: false + variant: true + variant_source: ad/GOAD + variant_target: ad/GOAD-dreadindex + variant_name: dreadindex vpc_cidr: "10.1.0.0/16" prod: vpc_cidr: "10.2.0.0/16" test: + variant: true + variant_source: ad/GOAD + variant_target: ad/GOAD-dreadindex + variant_name: dreadindex vpc_cidr: "10.8.0.0/16" + dreadindex: + variant: true + variant_source: ad/GOAD + variant_target: ad/GOAD-dreadindex + variant_name: dreadindex + vpc_cidr: "10.1.0.0/16" diff --git a/infra/azure/goad-deployment/test/centralus/goad/templates/bootstrap.ps1.tpl b/infra/azure/goad-deployment/test/centralus/goad/templates/bootstrap.ps1.tpl index 2e582b70..c4b47e6b 100644 --- a/infra/azure/goad-deployment/test/centralus/goad/templates/bootstrap.ps1.tpl +++ b/infra/azure/goad-deployment/test/centralus/goad/templates/bootstrap.ps1.tpl @@ -5,6 +5,13 @@ $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' +# Pre-install NuGet provider. Azure Marketplace images sometimes ship with a +# corrupted PackageManagement.dll that breaks Install-PackageProvider later. +# Force-installing NuGet up front avoids errors in Ansible DSC module steps. +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +try { Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -ErrorAction Stop } +catch { Write-Warning "NuGet provider pre-install failed: $_" } + # Azure provisioning renames the built-in Administrator (SID-500) to whatever # admin_username we passed (here: goadadmin). The GOAD playbooks expect the # SID-500 account to be named 'administrator' — that's what becomes the domain diff --git a/modules/terraform-azure-net/README.md b/modules/terraform-azure-net/README.md index cce3e605..df7f3d95 100644 --- a/modules/terraform-azure-net/README.md +++ b/modules/terraform-azure-net/README.md @@ -144,8 +144,8 @@ No modules. | [deployment\_name](#input\_deployment\_name) | Name of the deployment (e.g. "goad"). | `string` | n/a | yes | | [env](#input\_env) | Environment name (e.g. test, staging). | `string` | n/a | yes | | [location](#input\_location) | Azure region. | `string` | n/a | yes | -| [private\_subnet\_cidr](#input\_private\_subnet\_cidr) | CIDR for the private subnet where lab VMs run. | `string` | `"10.8.1.0/24"` | no | -| [public\_subnet\_cidr](#input\_public\_subnet\_cidr) | CIDR for the public subnet (jumpbox/bastion). | `string` | `"10.8.0.0/24"` | no | +| [private\_subnet\_cidr](#input\_private\_subnet\_cidr) | CIDR for the private subnet where lab VMs run. Auto-derived from vnet\_cidr when empty. | `string` | `""` | no | +| [public\_subnet\_cidr](#input\_public\_subnet\_cidr) | CIDR for the public subnet (jumpbox/bastion). Auto-derived from vnet\_cidr when empty. | `string` | `""` | no | | [vnet\_cidr](#input\_vnet\_cidr) | CIDR block for the VNet. | `string` | `"10.8.0.0/16"` | no | ## Outputs diff --git a/modules/terraform-azure-net/main.tf b/modules/terraform-azure-net/main.tf index f3b4bfb1..06f889b0 100644 --- a/modules/terraform-azure-net/main.tf +++ b/modules/terraform-azure-net/main.tf @@ -1,6 +1,11 @@ locals { name_prefix = "${var.env}-${var.deployment_name}" + # Auto-derive subnet CIDRs from vnet_cidr when not explicitly provided. + # cidrsubnet(..., 8, 1) on a /16 gives the second /24 (e.g. 10.8.1.0/24). + private_subnet = var.private_subnet_cidr != "" ? var.private_subnet_cidr : cidrsubnet(var.vnet_cidr, 8, 1) + public_subnet = var.public_subnet_cidr != "" ? var.public_subnet_cidr : cidrsubnet(var.vnet_cidr, 8, 0) + base_tags = { Module = "terraform-azure-net" Environment = var.env @@ -28,7 +33,7 @@ resource "azurerm_subnet" "private" { name = "${local.name_prefix}-private" resource_group_name = azurerm_resource_group.this.name virtual_network_name = azurerm_virtual_network.this.name - address_prefixes = [var.private_subnet_cidr] + address_prefixes = [local.private_subnet] } resource "azurerm_subnet" "public" { @@ -36,7 +41,7 @@ resource "azurerm_subnet" "public" { name = "${local.name_prefix}-public" resource_group_name = azurerm_resource_group.this.name virtual_network_name = azurerm_virtual_network.this.name - address_prefixes = [var.public_subnet_cidr] + address_prefixes = [local.public_subnet] } # NAT gateway gives outbound-only internet to private-subnet VMs. diff --git a/modules/terraform-azure-net/outputs.tf b/modules/terraform-azure-net/outputs.tf index 25e62c06..b93c4830 100644 --- a/modules/terraform-azure-net/outputs.tf +++ b/modules/terraform-azure-net/outputs.tf @@ -29,7 +29,7 @@ output "private_subnet_id" { } output "private_subnet_cidr" { - value = var.private_subnet_cidr + value = local.private_subnet description = "Private subnet CIDR." } diff --git a/modules/terraform-azure-net/variables.tf b/modules/terraform-azure-net/variables.tf index 292b0817..f3500bf6 100644 --- a/modules/terraform-azure-net/variables.tf +++ b/modules/terraform-azure-net/variables.tf @@ -20,15 +20,15 @@ variable "vnet_cidr" { } variable "private_subnet_cidr" { - description = "CIDR for the private subnet where lab VMs run." + description = "CIDR for the private subnet where lab VMs run. Auto-derived from vnet_cidr when empty." type = string - default = "10.8.1.0/24" + default = "" } variable "public_subnet_cidr" { - description = "CIDR for the public subnet (jumpbox/bastion)." + description = "CIDR for the public subnet (jumpbox/bastion). Auto-derived from vnet_cidr when empty." type = string - default = "10.8.0.0/24" + default = "" } variable "create_public_subnet" {