diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml index 8b6e2027..5f319cbf 100644 --- a/.github/workflows/pre-commit.yaml +++ b/.github/workflows/pre-commit.yaml @@ -55,6 +55,19 @@ jobs: ansible-galaxy collection build --force ansible-galaxy collection install dreadnode-goad-*.tar.gz -p ~/.ansible/collections --force --pre + - name: Set up Terraform + uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3 + with: + terraform_version: "1.9.7" + + - name: Set up TFLint + uses: terraform-linters/setup-tflint@90f302c255ef959cbfb4bd10581afecdb7ece3e6 # v4 + with: + tflint_version: latest + + - name: Init TFLint + run: tflint --init --config .hooks/linters/.tflint.hcl + - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: diff --git a/.gitignore b/.gitignore index 8cbbbd14..508ed74c 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,13 @@ ad/*/providers/*/ssh_keys/*id_rsa* ad/*/providers/*/ssh_keys/*.pub ad/*/providers/*/extensions/*.rb +# Terraform +.terraform/ +.terraform.lock.hcl +*.tfstate +*.tfstate.backup +*.tfplan + # IDE / OS .vscode temp/ diff --git a/.hooks/linters/.tflint.hcl b/.hooks/linters/.tflint.hcl new file mode 100644 index 00000000..d1aa98bf --- /dev/null +++ b/.hooks/linters/.tflint.hcl @@ -0,0 +1,21 @@ +plugin "aws" { + enabled = true + version = "0.36.0" + source = "github.com/terraform-linters/tflint-ruleset-aws" +} + +rule "terraform_naming_convention" { + enabled = true +} + +rule "terraform_documented_outputs" { + enabled = true +} + +rule "terraform_documented_variables" { + enabled = true +} + +rule "terraform_unused_declarations" { + enabled = true +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4f68dfa3..83f49a38 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -67,6 +67,20 @@ repos: # version of Ansible you want to install as a dependency. additional_dependencies: [".[community]"] + - repo: https://github.com/antonbabenko/pre-commit-terraform + rev: v1.99.2 + hooks: + - id: terraform_fmt + files: ^(modules|infra)/ + - id: terraform_validate + files: ^modules/ + args: + - --hook-config=--retry-once-with-cleanup=true + - id: terraform_tflint + files: ^(modules|infra)/ + args: + - --args=--config=__GIT_WORKING_DIR__/.hooks/linters/.tflint.hcl + - repo: local hooks: - id: prettier diff --git a/cli/cmd/infra_cmd.go b/cli/cmd/infra_cmd.go new file mode 100644 index 00000000..2901d22c --- /dev/null +++ b/cli/cmd/infra_cmd.go @@ -0,0 +1,271 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/dreadnode/dreadgoad/internal/config" + "github.com/dreadnode/dreadgoad/internal/terragrunt" + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +var infraCmd = &cobra.Command{ + Use: "infra", + Short: "Manage GOAD infrastructure via Terragrunt", + Long: `Manage the GOAD lab infrastructure lifecycle using Terragrunt. + +Operates on the infra/ directory which contains Terragrunt configurations +for deploying the GOAD lab (VPC, EC2 instances, security groups, etc.). + +By default, commands operate on all modules (run-all). Use --module to +target a specific module (e.g. network, goad/dc01).`, +} + +var infraInitCmd = &cobra.Command{ + Use: "init", + Short: "Initialize Terragrunt modules", + RunE: runInfraAction("init"), +} + +var infraPlanCmd = &cobra.Command{ + Use: "plan", + Short: "Plan infrastructure changes", + RunE: runInfraAction("plan"), +} + +var infraApplyCmd = &cobra.Command{ + Use: "apply", + Short: "Apply infrastructure changes", + RunE: runInfraAction("apply"), +} + +var infraDestroyCmd = &cobra.Command{ + Use: "destroy", + Short: "Destroy infrastructure", + RunE: runInfraAction("destroy"), +} + +var infraOutputCmd = &cobra.Command{ + Use: "output", + Short: "Show Terragrunt outputs (JSON)", + RunE: runInfraOutput, +} + +var infraValidateCmd = &cobra.Command{ + Use: "validate", + Short: "Validate environment configuration", + RunE: runInfraValidate, +} + +func init() { + rootCmd.AddCommand(infraCmd) + infraCmd.AddCommand(infraInitCmd) + infraCmd.AddCommand(infraPlanCmd) + infraCmd.AddCommand(infraApplyCmd) + infraCmd.AddCommand(infraDestroyCmd) + infraCmd.AddCommand(infraOutputCmd) + infraCmd.AddCommand(infraValidateCmd) + + // Shared flags for action commands + for _, cmd := range []*cobra.Command{infraInitCmd, infraPlanCmd, infraApplyCmd, infraDestroyCmd, infraOutputCmd} { + cmd.Flags().StringP("module", "m", "", "Target module path (e.g. network, goad/dc01)") + cmd.Flags().String("exclude", "", "Exclude modules (comma-separated, e.g. goad/dc01,goad/dc02)") + } + + infraApplyCmd.Flags().Bool("auto-approve", false, "Skip confirmation prompt") + infraApplyCmd.Flags().Bool("individual", false, "Apply each subdirectory individually (for module groups like goad/)") + infraDestroyCmd.Flags().Bool("auto-approve", false, "Skip confirmation prompt") + + infraCmd.PersistentFlags().StringP("deployment", "d", "", "Deployment name (default: from config)") +} + +func runInfraAction(action string) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + cfg, err := config.Get() + if err != nil { + return err + } + + module, _ := cmd.Flags().GetString("module") + exclude, _ := cmd.Flags().GetString("exclude") + deployment := resolveDeployment(cmd, cfg) + + region := cfg.Region + if region == "" { + region = "us-west-1" + } + + opts := terragrunt.Options{ + Action: action, + TerragruntBinary: cfg.Infra.TerragruntBinary, + TerraformBinary: cfg.Infra.TerraformBinary, + NonInteractive: true, + ExcludeDirs: exclude, + Debug: cfg.Debug, + } + + if action == "apply" || action == "destroy" { + autoApprove, _ := cmd.Flags().GetBool("auto-approve") + opts.AutoApprove = autoApprove + } + + // Build working directory + basePath := filepath.Join(cfg.ProjectRoot, "infra", deployment) + workDir := filepath.Join(basePath, cfg.Env, region) + + if _, err := os.Stat(workDir); os.IsNotExist(err) { + return fmt.Errorf("infra working directory not found: %s\nRun 'dreadgoad infra validate' to check your setup", workDir) + } + + // Set up log file + logDir := cfg.LogDir + if logDir == "" { + home, _ := os.UserHomeDir() + logDir = filepath.Join(home, ".ansible", "logs", "goad") + } + timestamp := time.Now().Format("20060102_150405") + moduleSlug := "all" + if module != "" { + moduleSlug = strings.ReplaceAll(module, "/", "_") + } + opts.LogFile = filepath.Join(logDir, fmt.Sprintf("infra_%s_%s_%s_%s_%s.log", + action, deployment, cfg.Env, moduleSlug, timestamp)) + + fmt.Printf("Infra %s (%s/%s)\n", action, cfg.Env, region) + if module != "" { + fmt.Printf("Module: %s\n", module) + } + fmt.Printf("Log: %s\n\n", opts.LogFile) + + ctx := context.Background() + + if module != "" { + modulePath := filepath.Join(workDir, module) + if _, err := os.Stat(modulePath); os.IsNotExist(err) { + return fmt.Errorf("module not found: %s", modulePath) + } + + // Check if --individual flag is set and module has subdirectories + if action == "apply" { + individual, _ := cmd.Flags().GetBool("individual") + if individual { + var excludeList []string + if exclude != "" { + excludeList = strings.Split(exclude, ",") + } + results, err := terragrunt.RunIndividual(ctx, opts, modulePath, excludeList) + if err != nil { + return err + } + return printIndividualResults(results) + } + } + + opts.WorkDir = modulePath + return terragrunt.Run(ctx, opts) + } + + // Full stack: run-all + opts.WorkDir = workDir + return terragrunt.RunAll(ctx, opts) + } +} + +func runInfraOutput(cmd *cobra.Command, args []string) error { + cfg, err := config.Get() + if err != nil { + return err + } + + module, _ := cmd.Flags().GetString("module") + deployment := resolveDeployment(cmd, cfg) + + region := cfg.Region + if region == "" { + region = "us-west-1" + } + + workDir := filepath.Join(cfg.ProjectRoot, "infra", deployment, cfg.Env, region) + if module != "" { + workDir = filepath.Join(workDir, module) + } + + if _, err := os.Stat(workDir); os.IsNotExist(err) { + return fmt.Errorf("directory not found: %s", workDir) + } + + opts := terragrunt.Options{ + Action: "output", + WorkDir: workDir, + TerragruntBinary: cfg.Infra.TerragruntBinary, + TerraformBinary: cfg.Infra.TerraformBinary, + } + + ctx := context.Background() + out, err := terragrunt.Output(ctx, opts) + if err != nil { + return err + } + + fmt.Println(string(out)) + return nil +} + +func runInfraValidate(cmd *cobra.Command, args []string) error { + cfg, err := config.Get() + if err != nil { + return err + } + + deployment := resolveDeployment(cmd, cfg) + + region := cfg.Region + if region == "" { + region = "us-west-1" + } + + basePath := filepath.Join(cfg.ProjectRoot, "infra", deployment) + result := terragrunt.ValidateEnvironment(basePath, cfg.Env, region) + terragrunt.PrintValidationResult(result, cfg.Env, region) + + if !result.OK() { + return fmt.Errorf("validation failed") + } + return nil +} + +func resolveDeployment(cmd *cobra.Command, cfg *config.Config) string { + if d, _ := cmd.Flags().GetString("deployment"); d != "" { + return d + } + return cfg.Infra.Deployment +} + +func printIndividualResults(results []terragrunt.Result) error { + fmt.Printf("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") + fmt.Println("Summary") + fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") + + var failed []string + for _, r := range results { + if r.Success { + color.Green(" OK: %s", r.Module) + } else { + color.Red(" FAIL: %s", r.Module) + failed = append(failed, r.Module) + } + } + + fmt.Printf("\nTotal: %d, Succeeded: %d, Failed: %d\n", + len(results), len(results)-len(failed), len(failed)) + + if len(failed) > 0 { + return fmt.Errorf("failed modules: %s", strings.Join(failed, ", ")) + } + return nil +} diff --git a/cli/cmd/lab.go b/cli/cmd/lab.go index 1197a9e1..4252f938 100644 --- a/cli/cmd/lab.go +++ b/cli/cmd/lab.go @@ -33,11 +33,43 @@ var labStopCmd = &cobra.Command{ RunE: runLabAction("stop"), } +var labStartVMCmd = &cobra.Command{ + Use: "start-vm ", + Short: "Start a specific lab VM by hostname", + Args: cobra.ExactArgs(1), + RunE: runVMAction("start"), +} + +var labStopVMCmd = &cobra.Command{ + Use: "stop-vm ", + Short: "Stop a specific lab VM by hostname", + Args: cobra.ExactArgs(1), + RunE: runVMAction("stop"), +} + +var labRestartVMCmd = &cobra.Command{ + Use: "restart-vm ", + Short: "Restart a specific lab VM by hostname", + Args: cobra.ExactArgs(1), + RunE: runVMAction("restart"), +} + +var labDestroyVMCmd = &cobra.Command{ + Use: "destroy-vm ", + Short: "Terminate a specific lab VM by hostname", + Args: cobra.ExactArgs(1), + RunE: runVMAction("destroy"), +} + func init() { rootCmd.AddCommand(labCmd) labCmd.AddCommand(labStatusCmd) labCmd.AddCommand(labStartCmd) labCmd.AddCommand(labStopCmd) + labCmd.AddCommand(labStartVMCmd) + labCmd.AddCommand(labStopVMCmd) + labCmd.AddCommand(labRestartVMCmd) + labCmd.AddCommand(labDestroyVMCmd) } func runLabStatus(cmd *cobra.Command, args []string) error { @@ -57,7 +89,7 @@ func runLabStatus(cmd *cobra.Command, args []string) error { return err } - instances, err := client.DiscoverInstances(ctx, cfg.Env) + instances, err := client.DiscoverAllInstances(ctx, cfg.Env) if err != nil { return err } @@ -125,3 +157,77 @@ func runLabAction(action string) func(*cobra.Command, []string) error { return nil } } + +func execVMAction(ctx context.Context, client *daws.Client, inst *daws.Instance, action string) error { + ids := []string{inst.InstanceID} + switch action { + case "start": + if err := client.StartInstances(ctx, ids); err != nil { + return fmt.Errorf("start VM: %w", err) + } + fmt.Printf("Start initiated for %s\n", inst.Name) + case "stop": + if err := client.StopInstances(ctx, ids); err != nil { + return fmt.Errorf("stop VM: %w", err) + } + fmt.Printf("Stop initiated for %s\n", inst.Name) + case "restart": + if inst.State == "running" { + if err := client.StopInstances(ctx, ids); err != nil { + return fmt.Errorf("stop VM: %w", err) + } + fmt.Printf("Stop initiated for %s, waiting for stop...\n", inst.Name) + } + if err := client.StartInstances(ctx, ids); err != nil { + return fmt.Errorf("start VM: %w", err) + } + fmt.Printf("Start initiated for %s\n", inst.Name) + case "destroy": + return destroyVM(ctx, client, inst) + } + return nil +} + +func destroyVM(ctx context.Context, client *daws.Client, inst *daws.Instance) error { + fmt.Printf("WARNING: This will terminate %s (%s) permanently.\n", inst.Name, inst.InstanceID) + fmt.Print("Type the instance ID to confirm: ") + var confirm string + if _, err := fmt.Scanln(&confirm); err != nil || confirm != inst.InstanceID { + fmt.Println("Aborted.") + return nil + } + if err := client.TerminateInstances(ctx, []string{inst.InstanceID}); err != nil { + return fmt.Errorf("terminate VM: %w", err) + } + fmt.Printf("Terminate initiated for %s\n", inst.Name) + return nil +} + +func runVMAction(action string) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + hostname := args[0] + cfg, err := config.Get() + if err != nil { + return err + } + ctx := context.Background() + + region := cfg.Region + if region == "" { + region = "us-west-1" + } + + client, err := daws.NewClient(ctx, region) + if err != nil { + return err + } + + inst, err := client.FindInstanceByHostnameAll(ctx, cfg.Env, hostname) + if err != nil { + return err + } + + fmt.Printf("Found: %s (%s) [%s]\n", inst.Name, inst.InstanceID, inst.State) + return execVMAction(ctx, client, inst, action) + } +} diff --git a/cli/cmd/lab_list.go b/cli/cmd/lab_list.go new file mode 100644 index 00000000..1342aa90 --- /dev/null +++ b/cli/cmd/lab_list.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/dreadnode/dreadgoad/internal/config" + "github.com/dreadnode/dreadgoad/internal/lab" + "github.com/spf13/cobra" +) + +var labListCmd = &cobra.Command{ + Use: "list", + Short: "List available GOAD labs and their providers", + RunE: runLabList, +} + +func init() { + labCmd.AddCommand(labListCmd) + labListCmd.Flags().Bool("json", false, "Output as JSON") +} + +func runLabList(cmd *cobra.Command, args []string) error { + cfg, err := config.Get() + if err != nil { + return err + } + + labs, err := lab.DiscoverLabs(cfg.ProjectRoot) + if err != nil { + return err + } + + if len(labs) == 0 { + fmt.Println("No labs found in ad/ directory") + return nil + } + + jsonFlag, _ := cmd.Flags().GetBool("json") + if jsonFlag { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(labs) + } + + fmt.Printf("%-20s %-40s %s\n", "LAB", "PROVIDERS", "HOSTS") + fmt.Println(strings.Repeat("-", 80)) + for _, l := range labs { + fmt.Printf("%-20s %-40s %s\n", + l.Name, + strings.Join(l.Providers, ", "), + strings.Join(l.Hosts, ", "), + ) + } + return nil +} diff --git a/cli/cmd/provision.go b/cli/cmd/provision.go index aa7fc64e..bc27d44c 100644 --- a/cli/cmd/provision.go +++ b/cli/cmd/provision.go @@ -12,6 +12,7 @@ import ( "github.com/dreadnode/dreadgoad/internal/ansible" "github.com/dreadnode/dreadgoad/internal/config" "github.com/dreadnode/dreadgoad/internal/doctor" + "github.com/dreadnode/dreadgoad/internal/lab" "github.com/dreadnode/dreadgoad/internal/variant" "github.com/spf13/cobra" ) @@ -25,6 +26,7 @@ Executes the full playbook sequence (or a subset) with error-specific retry strategies, SSM session management, and idle timeout monitoring.`, Example: ` dreadgoad provision dreadgoad provision --plays build.yml,ad-servers.yml + dreadgoad provision --from ad-data.yml dreadgoad provision --env staging --debug dreadgoad provision --plays ad-data.yml --limit dc01 dreadgoad provision --max-retries 5 --retry-delay 60`, @@ -48,6 +50,7 @@ func init() { rootCmd.AddCommand(adUsersCmd) provisionCmd.Flags().String("plays", "", "Comma-separated playbooks to run (default: all)") + provisionCmd.Flags().String("from", "", "Resume provisioning from this playbook onward") provisionCmd.Flags().String("limit", "", "Limit execution to specific hosts") provisionCmd.Flags().Int("max-retries", 0, "Max retry attempts (default: from config)") provisionCmd.Flags().Int("retry-delay", 0, "Delay between retries in seconds (default: from config)") @@ -59,6 +62,53 @@ func init() { adUsersCmd.Flags().Int("retry-delay", 0, "Delay between retries in seconds") } +func resolvePlaybooks(cfg *config.Config, playsFlag, fromFlag string) ([]string, error) { + if playsFlag != "" && fromFlag != "" { + return nil, fmt.Errorf("--plays and --from are mutually exclusive") + } + + var playbooks []string + if playsFlag != "" { + playbooks = strings.Split(playsFlag, ",") + } else { + playbooks = lab.PlaybooksForLab(cfg.ProjectRoot, "", cfg.Playbooks) + } + + if fromFlag == "" { + return playbooks, nil + } + + for i, p := range playbooks { + if p == fromFlag { + return playbooks[i:], nil + } + } + return nil, fmt.Errorf("playbook %q not found in playbook list: %v", fromFlag, playbooks) +} + +func ensureVariant(cfg *config.Config) error { + envCfg := cfg.ActiveEnvironment() + if !envCfg.Variant { + return nil + } + source, target := cfg.ResolvedVariantPaths() + variantName := envCfg.VariantName + if variantName == "" { + variantName = "variant-1" + } + if _, err := os.Stat(target); !os.IsNotExist(err) { + slog.Info("Variant directory already exists, skipping generation", "target", target) + return nil + } + fmt.Printf("Environment %q has variant=true, generating variant...\n", cfg.Env) + gen := variant.NewGenerator(source, target, variantName) + if err := gen.Run(); err != nil { + return fmt.Errorf("auto variant generation failed: %w", err) + } + fmt.Printf("Variant generated: %s\n", target) + return nil +} + func runProvision(cmd *cobra.Command, args []string) error { cfg, err := config.Get() if err != nil { @@ -66,55 +116,31 @@ func runProvision(cmd *cobra.Command, args []string) error { } ctx := context.Background() - // Determine playbooks playsFlag, _ := cmd.Flags().GetString("plays") - var playbooks []string - if playsFlag != "" { - playbooks = strings.Split(playsFlag, ",") - } else { - playbooks = cfg.Playbooks + fromFlag, _ := cmd.Flags().GetString("from") + playbooks, err := resolvePlaybooks(cfg, playsFlag, fromFlag) + if err != nil { + return err } limit, _ := cmd.Flags().GetString("limit") maxRetries, _ := cmd.Flags().GetInt("max-retries") retryDelay, _ := cmd.Flags().GetInt("retry-delay") - // Ensure log directory _ = os.MkdirAll(cfg.LogDir, 0o755) logFile := filepath.Join(cfg.LogDir, fmt.Sprintf("%s-dreadgoad-%s.log", cfg.Env, time.Now().Format("20060102_150405"))) - // Pre-flight: verify ansible-core version compatibility if err := doctor.CheckAnsibleCoreVersion(); err != nil { return fmt.Errorf("ansible-core version check failed: %w", err) } - - // Pre-flight: prepare ADCS zips if err := ansible.PrepareADCSZips(cfg.ProjectRoot); err != nil { slog.Warn("ADCS zip preparation failed", "error", err) } - - // Pre-flight: auto-generate variant if environment requires it - envCfg := cfg.ActiveEnvironment() - if envCfg.Variant { - source, target := cfg.ResolvedVariantPaths() - variantName := envCfg.VariantName - if variantName == "" { - variantName = "variant-1" - } - if _, err := os.Stat(target); os.IsNotExist(err) { - fmt.Printf("Environment %q has variant=true, generating variant...\n", cfg.Env) - gen := variant.NewGenerator(source, target, variantName) - if err := gen.Run(); err != nil { - return fmt.Errorf("auto variant generation failed: %w", err) - } - fmt.Printf("Variant generated: %s\n", target) - } else { - slog.Info("Variant directory already exists, skipping generation", "target", target) - } + if err := ensureVariant(cfg); err != nil { + return err } - // Log header fmt.Println("===============================================") fmt.Printf("DreadGOAD provisioning started at %s\n", time.Now().Format(time.RFC3339)) fmt.Printf("Environment: %s\n", cfg.Env) @@ -129,7 +155,6 @@ func runProvision(cmd *cobra.Command, args []string) error { } fmt.Println("-----------------------------------------------") - // Run each playbook for _, playbook := range playbooks { opts := ansible.RetryOptions{ Playbook: playbook, diff --git a/cli/go.mod b/cli/go.mod index edaea3ab..c463f32b 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -11,6 +11,7 @@ require ( github.com/fatih/color v1.19.0 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 + go.yaml.in/yaml/v3 v3.0.4 ) require ( @@ -37,7 +38,6 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.28.0 // indirect ) diff --git a/cli/internal/aws/ec2.go b/cli/internal/aws/ec2.go index 5cf5f577..1f71ac85 100644 --- a/cli/internal/aws/ec2.go +++ b/cli/internal/aws/ec2.go @@ -83,6 +83,64 @@ func (c *Client) StopInstances(ctx context.Context, instanceIDs []string) error return err } +// DiscoverAllInstances finds GOAD instances in any state (including stopped). +func (c *Client) DiscoverAllInstances(ctx context.Context, env string) ([]Instance, error) { + pattern := fmt.Sprintf("*%s*dreadgoad*", env) + out, err := c.EC2.DescribeInstances(ctx, &ec2.DescribeInstancesInput{ + Filters: []types.Filter{ + {Name: Ptr("tag:Name"), Values: []string{pattern}}, + }, + }) + if err != nil { + return nil, fmt.Errorf("describe instances: %w", err) + } + + var instances []Instance + for _, r := range out.Reservations { + for _, i := range r.Instances { + // Skip terminated instances + if i.State.Name == types.InstanceStateNameTerminated { + continue + } + inst := Instance{ + InstanceID: deref(i.InstanceId), + PrivateIP: deref(i.PrivateIpAddress), + State: string(i.State.Name), + } + for _, t := range i.Tags { + if deref(t.Key) == "Name" { + inst.Name = deref(t.Value) + } + } + instances = append(instances, inst) + } + } + return instances, nil +} + +// FindInstanceByHostnameAll finds an instance (any state except terminated) whose Name tag contains the hostname. +func (c *Client) FindInstanceByHostnameAll(ctx context.Context, env, hostname string) (*Instance, error) { + instances, err := c.DiscoverAllInstances(ctx, env) + if err != nil { + return nil, err + } + hostname = strings.ToUpper(hostname) + for _, inst := range instances { + if strings.Contains(strings.ToUpper(inst.Name), hostname) { + return &inst, nil + } + } + return nil, fmt.Errorf("instance not found for hostname %s", hostname) +} + +// TerminateInstances terminates the given EC2 instances. +func (c *Client) TerminateInstances(ctx context.Context, instanceIDs []string) error { + _, err := c.EC2.TerminateInstances(ctx, &ec2.TerminateInstancesInput{ + InstanceIds: instanceIDs, + }) + return err +} + // FindInstanceByHostname finds an instance whose Name tag contains the hostname. func (c *Client) FindInstanceByHostname(ctx context.Context, env, hostname string) (*Instance, error) { instances, err := c.DiscoverInstances(ctx, env) diff --git a/cli/internal/config/config.go b/cli/internal/config/config.go index 82e88dbb..a2963dc0 100644 --- a/cli/internal/config/config.go +++ b/cli/internal/config/config.go @@ -29,6 +29,13 @@ type EnvironmentConfig struct { EnabledExtensions []string `mapstructure:"enabled_extensions"` } +// InfraConfig holds infrastructure/terragrunt settings. +type InfraConfig struct { + Deployment string `mapstructure:"deployment"` + TerragruntBinary string `mapstructure:"terragrunt_binary"` + TerraformBinary string `mapstructure:"terraform_binary"` +} + // Config holds all CLI configuration. type Config struct { Env string `mapstructure:"env"` @@ -42,6 +49,7 @@ type Config struct { ProjectRoot string `mapstructure:"project_root"` Environments map[string]EnvironmentConfig `mapstructure:"environments"` Extensions map[string]ExtensionConfig `mapstructure:"extensions"` + Infra InfraConfig `mapstructure:"infra"` } var ( @@ -186,9 +194,9 @@ func (c *Config) ExtensionDataDir(name string) string { } // ExtensionProviderPath returns the path to an extension's provider-specific config -// at the repository root (providers///). +// at the repository root (extensions///). func (c *Config) ExtensionProviderPath(name, provider string) string { - return filepath.Join(c.ProjectRoot, "providers", name, provider) + return filepath.Join(c.ProjectRoot, "extensions", name, provider) } // IsExtensionCompatible checks if an extension is compatible with the given lab. @@ -210,6 +218,26 @@ func (c *Config) EnabledExtensionsForEnv() []string { return c.ActiveEnvironment().EnabledExtensions } +// InfraBasePath returns the base path for a deployment's infra directory. +func (c *Config) InfraBasePath() string { + return filepath.Join(c.ProjectRoot, "infra", c.Infra.Deployment) +} + +// InfraWorkDir returns the working directory for terragrunt operations +// at the region level: infra/{deployment}/{env}/{region}/ +func (c *Config) InfraWorkDir() string { + region := c.Region + if region == "" { + region = "us-west-1" + } + return filepath.Join(c.InfraBasePath(), c.Env, region) +} + +// InfraModulePath returns the path for a specific module within the infra working directory. +func (c *Config) InfraModulePath(module string) string { + return filepath.Join(c.InfraWorkDir(), module) +} + func findProjectRoot() (string, error) { cwd, err := os.Getwd() if err != nil { diff --git a/cli/internal/config/defaults.go b/cli/internal/config/defaults.go index 22a5988a..587a4bda 100644 --- a/cli/internal/config/defaults.go +++ b/cli/internal/config/defaults.go @@ -79,6 +79,11 @@ func setDefaults() { viper.SetDefault("extensions.ws01.playbook", "ext-ws01.yml") viper.SetDefault("extensions.ws01.data_dir", "ws01/data") + // Infrastructure defaults + viper.SetDefault("infra.deployment", "goad-deployment") + viper.SetDefault("infra.terragrunt_binary", "terragrunt") + viper.SetDefault("infra.terraform_binary", "tofu") + viper.SetDefault("environments", map[string]interface{}{ "dev": map[string]interface{}{ "variant": true, diff --git a/cli/internal/doctor/checks.go b/cli/internal/doctor/checks.go index 9c3cfa2a..70fc11cb 100644 --- a/cli/internal/doctor/checks.go +++ b/cli/internal/doctor/checks.go @@ -29,6 +29,8 @@ func RunChecks(inventoryPath, projectRoot string) []CheckResult { results = append(results, checkCommand("zip", "zip")) results = append(results, checkAWSCredentials()) results = append(results, checkInventoryFile(inventoryPath)) + results = append(results, checkTerragrunt()) + results = append(results, checkTerraformOrTofu()) results = append(results, checkAnsibleCollections()...) return results @@ -150,6 +152,41 @@ func checkInventoryFile(path string) CheckResult { return CheckResult{Name: "Inventory", Status: "pass", Message: path} } +func checkTerragrunt() CheckResult { + out, err := exec.Command("terragrunt", "--version").CombinedOutput() + if err != nil { + return CheckResult{ + Name: "Terragrunt", + Status: "warn", + Message: "not found in PATH (required for infra commands)", + } + } + version := strings.TrimSpace(string(out)) + // Extract just the version line + for _, line := range strings.Split(version, "\n") { + if strings.Contains(line, "terragrunt version") || strings.HasPrefix(line, "v") { + version = strings.TrimSpace(line) + break + } + } + return CheckResult{Name: "Terragrunt", Status: "pass", Message: version} +} + +func checkTerraformOrTofu() CheckResult { + // Check for tofu first (preferred), then terraform + if path, err := exec.LookPath("tofu"); err == nil { + return CheckResult{Name: "Terraform/Tofu", Status: "pass", Message: fmt.Sprintf("tofu: %s", path)} + } + if path, err := exec.LookPath("terraform"); err == nil { + return CheckResult{Name: "Terraform/Tofu", Status: "pass", Message: fmt.Sprintf("terraform: %s", path)} + } + return CheckResult{ + Name: "Terraform/Tofu", + Status: "warn", + Message: "neither tofu nor terraform found in PATH (required for infra commands)", + } +} + func checkAnsibleCollections() []CheckResult { required := []string{ "ansible.windows", diff --git a/cli/internal/lab/discovery.go b/cli/internal/lab/discovery.go new file mode 100644 index 00000000..f0273f10 --- /dev/null +++ b/cli/internal/lab/discovery.go @@ -0,0 +1,117 @@ +package lab + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "go.yaml.in/yaml/v3" +) + +// Lab represents a discovered GOAD lab definition. +type Lab struct { + Name string `json:"name"` + Path string `json:"path"` + Providers []string `json:"providers"` + Hosts []string `json:"hosts"` +} + +// labConfig is the minimal structure of ad//data/config.json. +type labConfig struct { + Lab struct { + Hosts map[string]json.RawMessage `json:"hosts"` + } `json:"lab"` +} + +// DiscoverLabs scans the ad/ directory for lab definitions. +// Excludes TEMPLATE and variant directories (containing "-variant-"). +func DiscoverLabs(projectRoot string) ([]Lab, error) { + adDir := filepath.Join(projectRoot, "ad") + entries, err := os.ReadDir(adDir) + if err != nil { + return nil, fmt.Errorf("reading ad/ directory: %w", err) + } + + var labs []Lab + for _, e := range entries { + if !e.IsDir() { + continue + } + name := e.Name() + if name == "TEMPLATE" || strings.Contains(name, "-variant-") { + continue + } + + labPath := filepath.Join(adDir, name) + lab := Lab{ + Name: name, + Path: labPath, + } + + // Discover providers + provDir := filepath.Join(labPath, "providers") + if provEntries, err := os.ReadDir(provDir); err == nil { + for _, p := range provEntries { + if p.IsDir() { + lab.Providers = append(lab.Providers, p.Name()) + } + } + sort.Strings(lab.Providers) + } + + // Discover hosts from config.json + configPath := filepath.Join(labPath, "data", "config.json") + if data, err := os.ReadFile(configPath); err == nil { + var cfg labConfig + if json.Unmarshal(data, &cfg) == nil { + for host := range cfg.Lab.Hosts { + lab.Hosts = append(lab.Hosts, host) + } + sort.Strings(lab.Hosts) + } + } + + labs = append(labs, lab) + } + + sort.Slice(labs, func(i, j int) bool { + return labs[i].Name < labs[j].Name + }) + return labs, nil +} + +// LoadPlaybookConfig reads playbooks.yml and returns lab-specific playbook lists. +// The "default" key is used as fallback for labs without explicit entries. +func LoadPlaybookConfig(projectRoot string) (map[string][]string, error) { + path := filepath.Join(projectRoot, "playbooks.yml") + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading playbooks.yml: %w", err) + } + + var raw map[string][]string + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parsing playbooks.yml: %w", err) + } + return raw, nil +} + +// PlaybooksForLab returns the playbook list for a given lab name, +// falling back to "default" if no lab-specific entry exists. +// Returns the config default playbooks if playbooks.yml cannot be loaded. +func PlaybooksForLab(projectRoot, labName string, fallback []string) []string { + cfg, err := LoadPlaybookConfig(projectRoot) + if err != nil { + return fallback + } + if pbs, ok := cfg[labName]; ok { + return pbs + } + if pbs, ok := cfg["default"]; ok { + return pbs + } + return fallback +} diff --git a/cli/internal/terragrunt/runner.go b/cli/internal/terragrunt/runner.go new file mode 100644 index 00000000..bee305c0 --- /dev/null +++ b/cli/internal/terragrunt/runner.go @@ -0,0 +1,225 @@ +package terragrunt + +import ( + "context" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" +) + +// Options configures a terragrunt execution. +type Options struct { + // Action is the terraform action: init, plan, apply, destroy, output. + Action string + // WorkDir is the directory to run terragrunt in. + WorkDir string + // TerragruntBinary is the path to the terragrunt binary. + TerragruntBinary string + // TerraformBinary is the path to the terraform/tofu binary. + TerraformBinary string + // AutoApprove skips confirmation prompts (apply/destroy). + AutoApprove bool + // NonInteractive disables interactive prompts. + NonInteractive bool + // ExcludeDirs is a comma-separated list of dirs to exclude from run-all. + ExcludeDirs string + // LogFile is an optional path to write output to. + LogFile string + // Debug enables verbose output. + Debug bool +} + +// Result holds the outcome of a terragrunt execution. +type Result struct { + Module string + Success bool + Error error +} + +// Run executes a single terragrunt command in the given working directory. +func Run(ctx context.Context, opts Options) error { + args := buildArgs(opts) + + slog.Info("running terragrunt", + "action", opts.Action, + "dir", opts.WorkDir, + "args", strings.Join(args, " "), + ) + + cmd := exec.CommandContext(ctx, opts.TerragruntBinary, args...) + cmd.Dir = opts.WorkDir + cmd.Env = buildEnv(opts) + + writer, cleanup, err := outputWriter(opts.LogFile) + if err != nil { + return fmt.Errorf("setup output: %w", err) + } + defer cleanup() + + cmd.Stdout = writer + cmd.Stderr = writer + + if err := cmd.Run(); err != nil { + return fmt.Errorf("terragrunt %s failed: %w", opts.Action, err) + } + return nil +} + +// RunAll executes `terragrunt run-all ` across all modules in the working directory. +func RunAll(ctx context.Context, opts Options) error { + args := []string{"run-all", opts.Action} + if opts.AutoApprove && (opts.Action == "apply" || opts.Action == "destroy") { + args = append(args, "-auto-approve") + } + if opts.NonInteractive { + args = append(args, "--non-interactive") + } + + slog.Info("running terragrunt run-all", + "action", opts.Action, + "dir", opts.WorkDir, + ) + + cmd := exec.CommandContext(ctx, opts.TerragruntBinary, args...) + cmd.Dir = opts.WorkDir + cmd.Env = buildEnv(opts) + + if opts.ExcludeDirs != "" { + cmd.Env = append(cmd.Env, "TG_QUEUE_EXCLUDE_DIR="+opts.ExcludeDirs) + } + + writer, cleanup, err := outputWriter(opts.LogFile) + if err != nil { + return fmt.Errorf("setup output: %w", err) + } + defer cleanup() + + cmd.Stdout = writer + cmd.Stderr = writer + + if err := cmd.Run(); err != nil { + return fmt.Errorf("terragrunt run-all %s failed: %w", opts.Action, err) + } + return nil +} + +// RunIndividual iterates subdirectories of modulePath and runs terragrunt +// in each one individually. Returns results for each subdirectory. +func RunIndividual(ctx context.Context, opts Options, modulePath string, exclude []string) ([]Result, error) { + entries, err := os.ReadDir(modulePath) + if err != nil { + return nil, fmt.Errorf("read module directory %s: %w", modulePath, err) + } + + excludeSet := make(map[string]bool, len(exclude)) + for _, e := range exclude { + excludeSet[strings.TrimSpace(e)] = true + } + + var subdirs []string + for _, entry := range entries { + if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { + continue + } + subdirs = append(subdirs, entry.Name()) + } + sort.Strings(subdirs) + + if len(subdirs) == 0 { + return nil, fmt.Errorf("no subdirectories found in %s", modulePath) + } + + var results []Result + for i, subdir := range subdirs { + if excludeSet[subdir] { + slog.Info("skipping excluded subdirectory", "subdir", subdir) + results = append(results, Result{Module: subdir, Success: true}) + continue + } + + fmt.Printf("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") + fmt.Printf("Processing: %s (%d/%d)\n", subdir, i+1, len(subdirs)) + fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") + + subdirOpts := opts + subdirOpts.WorkDir = filepath.Join(modulePath, subdir) + + if opts.LogFile != "" { + ext := filepath.Ext(opts.LogFile) + base := strings.TrimSuffix(opts.LogFile, ext) + subdirOpts.LogFile = fmt.Sprintf("%s_%s%s", base, subdir, ext) + } + + runErr := Run(ctx, subdirOpts) + results = append(results, Result{ + Module: subdir, + Success: runErr == nil, + Error: runErr, + }) + + if runErr != nil { + fmt.Printf("FAILED: %s - %v\n", subdir, runErr) + } else { + fmt.Printf("OK: %s\n", subdir) + } + } + + return results, nil +} + +// Output runs `terragrunt output -json` and returns the raw JSON bytes. +func Output(ctx context.Context, opts Options) ([]byte, error) { + args := []string{"output", "-json"} + + cmd := exec.CommandContext(ctx, opts.TerragruntBinary, args...) + cmd.Dir = opts.WorkDir + cmd.Env = buildEnv(opts) + + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("terragrunt output failed: %w", err) + } + return out, nil +} + +func buildArgs(opts Options) []string { + args := []string{opts.Action} + if opts.AutoApprove && (opts.Action == "apply" || opts.Action == "destroy") { + args = append(args, "-auto-approve") + } + if opts.NonInteractive { + args = append(args, "--non-interactive") + } + return args +} + +func buildEnv(opts Options) []string { + env := os.Environ() + if opts.TerraformBinary != "" { + env = append(env, "TERRAGRUNT_TFPATH="+opts.TerraformBinary) + } + return env +} + +func outputWriter(logFile string) (io.Writer, func(), error) { + if logFile == "" { + return os.Stdout, func() {}, nil + } + + if err := os.MkdirAll(filepath.Dir(logFile), 0o755); err != nil { + return nil, nil, err + } + + f, err := os.Create(logFile) + if err != nil { + return nil, nil, err + } + + mw := io.MultiWriter(os.Stdout, f) + return mw, func() { _ = f.Close() }, nil +} diff --git a/cli/internal/terragrunt/validate.go b/cli/internal/terragrunt/validate.go new file mode 100644 index 00000000..45361f47 --- /dev/null +++ b/cli/internal/terragrunt/validate.go @@ -0,0 +1,140 @@ +package terragrunt + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/fatih/color" +) + +// ValidationResult holds the outcome of environment validation. +type ValidationResult struct { + Errors []string + Warnings []string +} + +// OK returns true if there are no errors. +func (v *ValidationResult) OK() bool { + return len(v.Errors) == 0 +} + +// ValidateEnvironment checks that the infra environment directory is correctly +// structured and configured. basePath is the deployment dir (infra/goad-deployment), +// env is the environment name, and region is the AWS region. +func ValidateEnvironment(basePath, env, region string) *ValidationResult { + result := &ValidationResult{} + + envPath := filepath.Join(basePath, env) + regionPath := filepath.Join(envPath, region) + + // Check directory structure + requiredFiles := []string{ + filepath.Join(basePath, "host.hcl"), + filepath.Join(basePath, "host-registry.yaml"), + filepath.Join(envPath, "env.hcl"), + filepath.Join(regionPath, "region.hcl"), + filepath.Join(regionPath, "network", "terragrunt.hcl"), + } + + for _, f := range requiredFiles { + if _, err := os.Stat(f); os.IsNotExist(err) { + result.Errors = append(result.Errors, fmt.Sprintf("missing required file: %s", f)) + } + } + + // Check GOAD host directories + goadHosts := []string{"dc01", "dc02", "dc03", "srv02", "srv03"} + for _, host := range goadHosts { + hclPath := filepath.Join(regionPath, "goad", host, "terragrunt.hcl") + if _, err := os.Stat(hclPath); os.IsNotExist(err) { + result.Warnings = append(result.Warnings, fmt.Sprintf("GOAD host config missing: %s", hclPath)) + } + } + + // Validate env.hcl content + envHCL := filepath.Join(envPath, "env.hcl") + if content, err := os.ReadFile(envHCL); err == nil { + s := string(content) + validateHCLField(s, "deployment_name", result) + validateHCLField(s, "aws_account_id", result) + validateHCLField(s, "env", result) + + // Check for CHANGE_ME placeholders + if strings.Contains(s, "CHANGE_ME") { + result.Errors = append(result.Errors, "env.hcl contains CHANGE_ME placeholder(s) - update with your values") + } + + // Validate env name matches + envPattern := regexp.MustCompile(`env\s*=\s*"([^"]*)"`) + if matches := envPattern.FindStringSubmatch(s); len(matches) > 1 { + if matches[1] != env { + result.Errors = append(result.Errors, fmt.Sprintf("env.hcl env=%q does not match --env=%q", matches[1], env)) + } + } + } + + // Validate region.hcl content + regionHCL := filepath.Join(regionPath, "region.hcl") + if content, err := os.ReadFile(regionHCL); err == nil { + s := string(content) + regionPattern := regexp.MustCompile(`aws_region\s*=\s*"([^"]*)"`) + if matches := regionPattern.FindStringSubmatch(s); len(matches) > 1 { + if matches[1] != region { + result.Errors = append(result.Errors, fmt.Sprintf("region.hcl aws_region=%q does not match --region=%q", matches[1], region)) + } + } + } + + // Check for CHANGE_ME in GOAD host configs + for _, host := range goadHosts { + hclPath := filepath.Join(regionPath, "goad", host, "terragrunt.hcl") + if content, err := os.ReadFile(hclPath); err == nil { + if strings.Contains(string(content), "CHANGE_ME") { + result.Warnings = append(result.Warnings, + fmt.Sprintf("goad/%s/terragrunt.hcl has CHANGE_ME placeholder(s) - update AMI IDs and passwords", host)) + } + } + } + + return result +} + +// PrintValidationResult prints the result with colored output. +func PrintValidationResult(result *ValidationResult, env, region string) { + fmt.Printf("Validating environment: %s (%s)\n\n", env, region) + + if len(result.Errors) > 0 { + color.Red("Errors:") + for _, e := range result.Errors { + fmt.Printf(" %s %s\n", color.RedString("x"), e) + } + fmt.Println() + } + + if len(result.Warnings) > 0 { + color.Yellow("Warnings:") + for _, w := range result.Warnings { + fmt.Printf(" %s %s\n", color.YellowString("!"), w) + } + fmt.Println() + } + + switch { + case result.OK() && len(result.Warnings) == 0: + color.Green("Environment validation passed.") + case result.OK(): + color.Green("Environment validation passed with %d warning(s).", len(result.Warnings)) + default: + color.Red("Environment validation failed with %d error(s).", len(result.Errors)) + } +} + +func validateHCLField(content, field string, result *ValidationResult) { + pattern := regexp.MustCompile(field + `\s*=\s*"[^"]*"`) + if !pattern.MatchString(content) { + result.Errors = append(result.Errors, fmt.Sprintf("env.hcl missing required field: %s", field)) + } +} diff --git a/providers/elk/aws/linux.tf b/extensions/elk/aws/linux.tf similarity index 100% rename from providers/elk/aws/linux.tf rename to extensions/elk/aws/linux.tf diff --git a/providers/elk/azure/linux.tf b/extensions/elk/azure/linux.tf similarity index 100% rename from providers/elk/azure/linux.tf rename to extensions/elk/azure/linux.tf diff --git a/providers/elk/ludus/config.yml b/extensions/elk/ludus/config.yml similarity index 100% rename from providers/elk/ludus/config.yml rename to extensions/elk/ludus/config.yml diff --git a/providers/elk/virtualbox/Vagrantfile b/extensions/elk/virtualbox/Vagrantfile similarity index 100% rename from providers/elk/virtualbox/Vagrantfile rename to extensions/elk/virtualbox/Vagrantfile diff --git a/providers/elk/vmware/Vagrantfile b/extensions/elk/vmware/Vagrantfile similarity index 100% rename from providers/elk/vmware/Vagrantfile rename to extensions/elk/vmware/Vagrantfile diff --git a/providers/exchange/aws/windows.tf b/extensions/exchange/aws/windows.tf similarity index 100% rename from providers/exchange/aws/windows.tf rename to extensions/exchange/aws/windows.tf diff --git a/providers/exchange/azure/windows.tf b/extensions/exchange/azure/windows.tf similarity index 100% rename from providers/exchange/azure/windows.tf rename to extensions/exchange/azure/windows.tf diff --git a/providers/exchange/ludus/config.yml b/extensions/exchange/ludus/config.yml similarity index 100% rename from providers/exchange/ludus/config.yml rename to extensions/exchange/ludus/config.yml diff --git a/providers/exchange/proxmox/windows.tf b/extensions/exchange/proxmox/windows.tf similarity index 100% rename from providers/exchange/proxmox/windows.tf rename to extensions/exchange/proxmox/windows.tf diff --git a/providers/exchange/virtualbox/Vagrantfile b/extensions/exchange/virtualbox/Vagrantfile similarity index 100% rename from providers/exchange/virtualbox/Vagrantfile rename to extensions/exchange/virtualbox/Vagrantfile diff --git a/providers/exchange/vmware/Vagrantfile b/extensions/exchange/vmware/Vagrantfile similarity index 100% rename from providers/exchange/vmware/Vagrantfile rename to extensions/exchange/vmware/Vagrantfile diff --git a/providers/guacamole/aws/linux.tf b/extensions/guacamole/aws/linux.tf similarity index 100% rename from providers/guacamole/aws/linux.tf rename to extensions/guacamole/aws/linux.tf diff --git a/providers/guacamole/azure/linux.tf b/extensions/guacamole/azure/linux.tf similarity index 100% rename from providers/guacamole/azure/linux.tf rename to extensions/guacamole/azure/linux.tf diff --git a/providers/guacamole/ludus/config.yml b/extensions/guacamole/ludus/config.yml similarity index 100% rename from providers/guacamole/ludus/config.yml rename to extensions/guacamole/ludus/config.yml diff --git a/providers/guacamole/proxmox/linux.tf b/extensions/guacamole/proxmox/linux.tf similarity index 100% rename from providers/guacamole/proxmox/linux.tf rename to extensions/guacamole/proxmox/linux.tf diff --git a/providers/guacamole/virtualbox/Vagrantfile b/extensions/guacamole/virtualbox/Vagrantfile similarity index 100% rename from providers/guacamole/virtualbox/Vagrantfile rename to extensions/guacamole/virtualbox/Vagrantfile diff --git a/providers/guacamole/vmware/Vagrantfile b/extensions/guacamole/vmware/Vagrantfile similarity index 100% rename from providers/guacamole/vmware/Vagrantfile rename to extensions/guacamole/vmware/Vagrantfile diff --git a/providers/wazuh/aws/linux.tf b/extensions/wazuh/aws/linux.tf similarity index 100% rename from providers/wazuh/aws/linux.tf rename to extensions/wazuh/aws/linux.tf diff --git a/providers/wazuh/azure/linux.tf b/extensions/wazuh/azure/linux.tf similarity index 100% rename from providers/wazuh/azure/linux.tf rename to extensions/wazuh/azure/linux.tf diff --git a/providers/wazuh/ludus/config.yml b/extensions/wazuh/ludus/config.yml similarity index 100% rename from providers/wazuh/ludus/config.yml rename to extensions/wazuh/ludus/config.yml diff --git a/providers/wazuh/virtualbox/Vagrantfile b/extensions/wazuh/virtualbox/Vagrantfile similarity index 100% rename from providers/wazuh/virtualbox/Vagrantfile rename to extensions/wazuh/virtualbox/Vagrantfile diff --git a/providers/wazuh/vmware/Vagrantfile b/extensions/wazuh/vmware/Vagrantfile similarity index 100% rename from providers/wazuh/vmware/Vagrantfile rename to extensions/wazuh/vmware/Vagrantfile diff --git a/providers/ws01/aws/windows.tf b/extensions/ws01/aws/windows.tf similarity index 100% rename from providers/ws01/aws/windows.tf rename to extensions/ws01/aws/windows.tf diff --git a/providers/ws01/azure/windows.tf b/extensions/ws01/azure/windows.tf similarity index 100% rename from providers/ws01/azure/windows.tf rename to extensions/ws01/azure/windows.tf diff --git a/providers/ws01/ludus/config.yml b/extensions/ws01/ludus/config.yml similarity index 100% rename from providers/ws01/ludus/config.yml rename to extensions/ws01/ludus/config.yml diff --git a/providers/ws01/proxmox/windows.tf b/extensions/ws01/proxmox/windows.tf similarity index 100% rename from providers/ws01/proxmox/windows.tf rename to extensions/ws01/proxmox/windows.tf diff --git a/providers/ws01/proxmox/ws01.tf b/extensions/ws01/proxmox/ws01.tf similarity index 100% rename from providers/ws01/proxmox/ws01.tf rename to extensions/ws01/proxmox/ws01.tf diff --git a/providers/ws01/virtualbox/Vagrantfile b/extensions/ws01/virtualbox/Vagrantfile similarity index 100% rename from providers/ws01/virtualbox/Vagrantfile rename to extensions/ws01/virtualbox/Vagrantfile diff --git a/providers/ws01/vmware/Vagrantfile b/extensions/ws01/vmware/Vagrantfile similarity index 100% rename from providers/ws01/vmware/Vagrantfile rename to extensions/ws01/vmware/Vagrantfile diff --git a/infra/goad-deployment/host-registry.yaml b/infra/goad-deployment/host-registry.yaml new file mode 100644 index 00000000..240dd663 --- /dev/null +++ b/infra/goad-deployment/host-registry.yaml @@ -0,0 +1,111 @@ +--- +# DreadGOAD Host Registry +# Single source of truth for GOAD host metadata. +# +# This file defines all hosts in the GOAD deployment and serves as the +# authoritative source for infrastructure inventory and Terragrunt configuration. + +version: "1.0" +deployment: "goad" +environments: ["dev", "staging"] +default_region: "us-west-1" + +hosts: + # ============================================================================ + # GOAD (Game of Active Directory) Hosts + # Provisioned via DreadGOAD and configured with AD vulnerabilities + # ============================================================================ + + kingslanding: + hostname: "kingslanding" + computer_name: "DC01" + goad_id: "dc01" + friendly_name: "GOAD DC01" + role: "Domain Controller" + os: "windows" + os_version: "2019" + domain: "sevenkingdoms.local" + tier: "critical" + terragrunt_path: "goad/dc01" + description: "Primary domain controller for Seven Kingdoms domain" + groups: ["goad", "domain_controllers", "windows"] + + winterfell: + hostname: "winterfell" + computer_name: "DC02" + goad_id: "dc02" + friendly_name: "GOAD DC02" + role: "Domain Controller" + os: "windows" + os_version: "2019" + domain: "north.sevenkingdoms.local" + tier: "critical" + terragrunt_path: "goad/dc02" + description: "Domain controller for North subdomain" + groups: ["goad", "domain_controllers", "windows"] + + meereen: + hostname: "meereen" + computer_name: "DC03" + goad_id: "dc03" + friendly_name: "GOAD DC03" + role: "Domain Controller" + os: "windows" + os_version: "2016" + domain: "essos.local" + tier: "critical" + terragrunt_path: "goad/dc03" + description: "Domain controller for Essos domain" + groups: ["goad", "domain_controllers", "windows"] + + castelblack: + hostname: "castelblack" + computer_name: "SRV02" + goad_id: "srv02" + friendly_name: "GOAD SRV02" + role: "Server" + os: "windows" + os_version: "2019" + domain: "north.sevenkingdoms.local" + tier: "high" + terragrunt_path: "goad/srv02" + description: "Member server in North subdomain (MSSQL)" + groups: ["goad", "servers", "windows"] + + braavos: + hostname: "braavos" + computer_name: "SRV03" + goad_id: "srv03" + friendly_name: "GOAD SRV03" + role: "Server" + os: "windows" + os_version: "2016" + domain: "essos.local" + tier: "high" + terragrunt_path: "goad/srv03" + description: "Member server in Essos domain" + groups: ["goad", "servers", "windows"] + +# Host groups for logical organization +groups: + goad: + description: "Game of Active Directory lab hosts" + type: "ad-lab" + provisioner: "dreadgoad" + + domain_controllers: + description: "Active Directory domain controllers" + type: "role-based" + + servers: + description: "Member servers (non-DC)" + type: "role-based" + + windows: + description: "Windows-based systems" + type: "os-based" + +metadata: + created: "2026-04-02" + last_updated: "2026-04-02" + maintainers: ["dreadgoad-community"] diff --git a/infra/goad-deployment/host.hcl b/infra/goad-deployment/host.hcl new file mode 100644 index 00000000..ebe22cbe --- /dev/null +++ b/infra/goad-deployment/host.hcl @@ -0,0 +1,118 @@ +locals { + registry_path = "${get_terragrunt_dir()}/../host-registry.yaml" + host_registry = yamldecode(file(local.registry_path)) + + terragrunt_dir = get_terragrunt_dir() + terragrunt_dir_parts = split("/", local.terragrunt_dir) + + # Find the goad-deployment directory in the path to determine relative position + deployment_index = try( + index(local.terragrunt_dir_parts, "goad-deployment"), + -1 + ) + + path_structure_validation = local.deployment_index == -1 ? ( + <<-EOT + + ============================================================================ + ERROR: Not running from within goad-deployment directory structure! + ============================================================================ + + Current path: ${local.terragrunt_dir} + + This host.hcl file is designed to be used within the goad-deployment + structure. Please ensure you are running Terragrunt from a directory within: + + infra/goad-deployment/{env}/{region}/{module}/ + + ============================================================================ + + EOT + ) : "valid" + + _path_structure_check = regex("^valid$", local.path_structure_validation) + + # Extract the module path after {env}/{region}/ + path_after_region = slice( + local.terragrunt_dir_parts, + local.deployment_index + 3, + length(local.terragrunt_dir_parts) + ) + + relative_path = join("/", local.path_after_region) + + host_metadata_map = { + for hostname, metadata in local.host_registry.hosts : + metadata.terragrunt_path => merge(metadata, { + hostname = hostname + }) + } + + host_lookup = lookup(local.host_metadata_map, local.relative_path, null) + + validation_message = local.host_lookup == null ? ( + <<-EOT + + ============================================================================ + ERROR: Host not found in registry! + ============================================================================ + + Current path: ${local.relative_path} + + Available hosts in registry: + ${join("\n", [for path in keys(local.host_metadata_map) : " - ${path}"])} + + Please ensure: + 1. The host is defined in host-registry.yaml + 2. The terragrunt_path matches your directory structure + 3. You are in the correct directory + + Registry location: ${local.registry_path} + ============================================================================ + + EOT + ) : "valid" + + _ = regex("^valid$", local.validation_message) + + host = local.host_lookup + + hostname = local.host.hostname + computer_name = try(local.host.computer_name, local.host.hostname) + goad_id = try(local.host.goad_id, "") + + friendly_name = local.host.friendly_name + role = local.host.role + description = try(local.host.description, "") + + os = local.host.os + os_type = local.host.os + os_version = try(local.host.os_version, "") + os_distribution = try(local.host.os_distribution, "") + + domain = try(local.host.domain, "nodomain.local") + + tier = local.host.tier + groups = local.host.groups + + owner = try(local.host.owner, null) + notes = try(local.host.notes, "") + features = try(local.host.features, []) + services = try(local.host.services, []) + + terragrunt_path = local.host.terragrunt_path + + is_windows = local.os == "windows" + is_linux = local.os == "linux" + is_goad = contains(local.groups, "goad") + is_domain_controller = contains(local.groups, "domain_controllers") + windows_os_version = local.is_windows ? try(local.host.os_version, "2019") : "" + + debug_info = { + terragrunt_dir = local.terragrunt_dir + relative_path = local.relative_path + registry_path = local.registry_path + hostname = local.hostname + available_hosts = keys(local.host_metadata_map) + } +} diff --git a/infra/goad-deployment/staging/env.hcl b/infra/goad-deployment/staging/env.hcl new file mode 100644 index 00000000..a3909311 --- /dev/null +++ b/infra/goad-deployment/staging/env.hcl @@ -0,0 +1,8 @@ +# Set common variables for the environment. +# This is automatically pulled in by the root terragrunt.hcl configuration. +locals { + deployment_name = "goad" # Change to your deployment name + aws_account_id = "CHANGE_ME" # Your AWS account ID + env = "staging" # Environment name (dev, staging, prod) + vpc_cidr = "10.1.0.0/16" # VPC CIDR block for this environment +} diff --git a/infra/goad-deployment/staging/us-west-1/goad/dc01/templates/user_data.ps1.tpl b/infra/goad-deployment/staging/us-west-1/goad/dc01/templates/user_data.ps1.tpl new file mode 100644 index 00000000..bbc0ec7c --- /dev/null +++ b/infra/goad-deployment/staging/us-west-1/goad/dc01/templates/user_data.ps1.tpl @@ -0,0 +1,44 @@ +# Add registry keys to enable TLS 1.2 at the OS level +New-Item 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server' -Force | Out-Null +New-Item 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client' -Force | Out-Null +New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server' -name 'Enabled' -value '1' -PropertyType 'DWord' -Force | Out-Null +New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server' -name 'DisabledByDefault' -value 0 -PropertyType 'DWord' -Force | Out-Null +New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client' -name 'Enabled' -value 1 -PropertyType 'DWord' -Force | Out-Null +New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client' -name 'DisabledByDefault' -value 0 -PropertyType 'DWord' -Force | Out-Null + +# Enable strong cryptography on .NET Framework +Set-ItemProperty -Path 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\.NetFramework\v4.0.30319' -Name 'SchUseStrongCrypto' -Value 1 -Type DWord -Force +Set-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\.NetFramework\v4.0.30319' -Name 'SchUseStrongCrypto' -Value 1 -Type DWord -Force + +# Force TLS 1.2 in current PowerShell session and create system-wide PowerShell profile +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + +# Create AllSigned profile for all users +$allUsersAllHosts = "$env:windir\System32\WindowsPowerShell\v1.0\profile.ps1" +New-Item -Path $allUsersAllHosts -ItemType File -Force | Out-Null +Set-Content -Path $allUsersAllHosts -Value "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12" -Force + +# Set the local Administrator password +net user Administrator ${admin_password} /expires:never /y + +# Create ansible user, add to administrators group, and set password +net user ansible ${admin_password} /add /expires:never /y +net localgroup administrators ansible /add + +# Setup SSM Agent +$progressPreference = 'silentlyContinue' + +# Use WebClient instead of Invoke-WebRequest for SSM agent download too +$ssmUrl = "https://amazon-ssm-${aws_region}.s3.amazonaws.com/latest/windows_amd64/AmazonSSMAgentSetup.exe" +$ssmOutput = "$env:TEMP\SSMAgent_latest.exe" +$webClient = New-Object System.Net.WebClient +$webClient.DownloadFile($ssmUrl, $ssmOutput) + +# Install SSM agent +Start-Process -FilePath $env:TEMP\SSMAgent_latest.exe -ArgumentList "/S" -Wait +Remove-Item -Force $env:TEMP\SSMAgent_latest.exe +Restart-Service AmazonSSMAgent + +# Rename computer and restart +Rename-Computer -NewName "${hostname}" -Force +Restart-Computer -Force diff --git a/infra/goad-deployment/staging/us-west-1/goad/dc01/templates/user_data_wrapper.ps1.tpl b/infra/goad-deployment/staging/us-west-1/goad/dc01/templates/user_data_wrapper.ps1.tpl new file mode 100644 index 00000000..4f83016f --- /dev/null +++ b/infra/goad-deployment/staging/us-west-1/goad/dc01/templates/user_data_wrapper.ps1.tpl @@ -0,0 +1,5 @@ + +$EncodedUserData = "${compressed_user_data}" +$DecodedUserData = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($EncodedUserData)) +Invoke-Expression $DecodedUserData + diff --git a/infra/goad-deployment/staging/us-west-1/goad/dc01/terragrunt.hcl b/infra/goad-deployment/staging/us-west-1/goad/dc01/terragrunt.hcl new file mode 100644 index 00000000..f820cd8f --- /dev/null +++ b/infra/goad-deployment/staging/us-west-1/goad/dc01/terragrunt.hcl @@ -0,0 +1,114 @@ +# ============================================================================= +# DC01 - Primary Domain Controller for Seven Kingdoms +# AMI: Built from warpgate-templates/goad-dc-base (Windows Server 2019) +# ============================================================================= + +include "host" { + path = find_in_parent_folders("host.hcl") + expose = true +} + +locals { + env_vars = read_terragrunt_config(find_in_parent_folders("env.hcl")) + region_vars = read_terragrunt_config(find_in_parent_folders("region.hcl")) + + env = local.env_vars.locals.env + aws_region = local.region_vars.locals.aws_region + deployment_name = local.env_vars.locals.deployment_name + + # Host metadata from host-registry.yaml via host.hcl + hostname = include.host.locals.computer_name + friendly_name = include.host.locals.hostname + domain = include.host.locals.domain + os_type = include.host.locals.os_type + role = include.host.locals.role + goad_id = include.host.locals.goad_id + + # Set admin password via terraform.tfvars or TF_VAR_admin_password env var + admin_password = get_env("TF_VAR_goad_dc01_password", "CHANGE_ME") +} + +terraform { + source = "${get_repo_root()}/modules//terraform-aws-instance-factory" +} + +dependency "network" { + config_path = "../../network" +} + +include { + path = find_in_parent_folders("root.hcl") +} + +inputs = { + env = local.env + instance_name = "${local.deployment_name}-dreadgoad-${local.hostname}" + instance_type = "t3.medium" + os_type = local.os_type + enable_asg = false + subnet_id = dependency.network.outputs.private_subnet_ids[0] + vpc_id = dependency.network.outputs.vpc_id + + enable_ssm = true + + additional_iam_policies = { + cloudwatch_agent = "arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy" + s3_full_access = "arn:aws:iam::aws:policy/AmazonS3FullAccess" + } + + # Windows AMI - replace with your AMI built from warpgate-templates/goad-dc-base + windows_os = "Windows_Server" + windows_os_version = "2019-English-Full-Base" + windows_ami_owners = ["self"] + + additional_windows_ami_filters = [ + { + name = "image-id" + values = ["CHANGE_ME"] # Your goad-dc-base AMI ID + } + ] + + ingress_rules = [ + { + description = "Allow all traffic from VPC CIDR" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = [dependency.network.outputs.vpc_cidr] + }, + ] + + egress_rules = [ + { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + ] + + enable_monitoring = true + enable_metadata = true + require_imdsv2 = true + encrypt_volumes = true + root_volume_size = 100 + volume_type = "gp3" + + user_data = templatefile("${get_terragrunt_dir()}/templates/user_data_wrapper.ps1.tpl", { + compressed_user_data = base64encode(templatefile("${get_terragrunt_dir()}/templates/user_data.ps1.tpl", { + aws_region = local.aws_region, + hostname = local.hostname, + admin_password = local.admin_password + })) + }) + + tags = { + Environment = local.env + Project = "DreadGOAD" + Role = "DomainController" + Lab = "${local.deployment_name}-goad" + Name = "${local.deployment_name}-dreadgoad-${local.hostname}" + Domain = local.domain + ComputerName = local.hostname + } +} diff --git a/infra/goad-deployment/staging/us-west-1/goad/dc02/templates/user_data.ps1.tpl b/infra/goad-deployment/staging/us-west-1/goad/dc02/templates/user_data.ps1.tpl new file mode 100644 index 00000000..bbc0ec7c --- /dev/null +++ b/infra/goad-deployment/staging/us-west-1/goad/dc02/templates/user_data.ps1.tpl @@ -0,0 +1,44 @@ +# Add registry keys to enable TLS 1.2 at the OS level +New-Item 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server' -Force | Out-Null +New-Item 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client' -Force | Out-Null +New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server' -name 'Enabled' -value '1' -PropertyType 'DWord' -Force | Out-Null +New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server' -name 'DisabledByDefault' -value 0 -PropertyType 'DWord' -Force | Out-Null +New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client' -name 'Enabled' -value 1 -PropertyType 'DWord' -Force | Out-Null +New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client' -name 'DisabledByDefault' -value 0 -PropertyType 'DWord' -Force | Out-Null + +# Enable strong cryptography on .NET Framework +Set-ItemProperty -Path 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\.NetFramework\v4.0.30319' -Name 'SchUseStrongCrypto' -Value 1 -Type DWord -Force +Set-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\.NetFramework\v4.0.30319' -Name 'SchUseStrongCrypto' -Value 1 -Type DWord -Force + +# Force TLS 1.2 in current PowerShell session and create system-wide PowerShell profile +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + +# Create AllSigned profile for all users +$allUsersAllHosts = "$env:windir\System32\WindowsPowerShell\v1.0\profile.ps1" +New-Item -Path $allUsersAllHosts -ItemType File -Force | Out-Null +Set-Content -Path $allUsersAllHosts -Value "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12" -Force + +# Set the local Administrator password +net user Administrator ${admin_password} /expires:never /y + +# Create ansible user, add to administrators group, and set password +net user ansible ${admin_password} /add /expires:never /y +net localgroup administrators ansible /add + +# Setup SSM Agent +$progressPreference = 'silentlyContinue' + +# Use WebClient instead of Invoke-WebRequest for SSM agent download too +$ssmUrl = "https://amazon-ssm-${aws_region}.s3.amazonaws.com/latest/windows_amd64/AmazonSSMAgentSetup.exe" +$ssmOutput = "$env:TEMP\SSMAgent_latest.exe" +$webClient = New-Object System.Net.WebClient +$webClient.DownloadFile($ssmUrl, $ssmOutput) + +# Install SSM agent +Start-Process -FilePath $env:TEMP\SSMAgent_latest.exe -ArgumentList "/S" -Wait +Remove-Item -Force $env:TEMP\SSMAgent_latest.exe +Restart-Service AmazonSSMAgent + +# Rename computer and restart +Rename-Computer -NewName "${hostname}" -Force +Restart-Computer -Force diff --git a/infra/goad-deployment/staging/us-west-1/goad/dc02/templates/user_data_wrapper.ps1.tpl b/infra/goad-deployment/staging/us-west-1/goad/dc02/templates/user_data_wrapper.ps1.tpl new file mode 100644 index 00000000..4f83016f --- /dev/null +++ b/infra/goad-deployment/staging/us-west-1/goad/dc02/templates/user_data_wrapper.ps1.tpl @@ -0,0 +1,5 @@ + +$EncodedUserData = "${compressed_user_data}" +$DecodedUserData = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($EncodedUserData)) +Invoke-Expression $DecodedUserData + diff --git a/infra/goad-deployment/staging/us-west-1/goad/dc02/terragrunt.hcl b/infra/goad-deployment/staging/us-west-1/goad/dc02/terragrunt.hcl new file mode 100644 index 00000000..e686b854 --- /dev/null +++ b/infra/goad-deployment/staging/us-west-1/goad/dc02/terragrunt.hcl @@ -0,0 +1,112 @@ +# ============================================================================= +# DC02 - Domain Controller for North Subdomain +# AMI: Built from warpgate-templates/goad-dc-base (Windows Server 2019) +# ============================================================================= + +include "host" { + path = find_in_parent_folders("host.hcl") + expose = true +} + +locals { + env_vars = read_terragrunt_config(find_in_parent_folders("env.hcl")) + region_vars = read_terragrunt_config(find_in_parent_folders("region.hcl")) + + env = local.env_vars.locals.env + aws_region = local.region_vars.locals.aws_region + deployment_name = local.env_vars.locals.deployment_name + + hostname = include.host.locals.computer_name + friendly_name = include.host.locals.hostname + domain = include.host.locals.domain + os_type = include.host.locals.os_type + role = include.host.locals.role + goad_id = include.host.locals.goad_id + + admin_password = get_env("TF_VAR_goad_dc02_password", "CHANGE_ME") +} + +terraform { + source = "${get_repo_root()}/modules//terraform-aws-instance-factory" +} + +dependency "network" { + config_path = "../../network" +} + +include { + path = find_in_parent_folders("root.hcl") +} + +inputs = { + env = local.env + instance_name = "${local.deployment_name}-dreadgoad-${local.hostname}" + instance_type = "t3.medium" + os_type = local.os_type + enable_asg = false + subnet_id = dependency.network.outputs.private_subnet_ids[0] + vpc_id = dependency.network.outputs.vpc_id + + enable_ssm = true + + additional_iam_policies = { + cloudwatch_agent = "arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy" + s3_full_access = "arn:aws:iam::aws:policy/AmazonS3FullAccess" + } + + # Windows AMI - replace with your AMI built from warpgate-templates/goad-dc-base + windows_os = "Windows_Server" + windows_os_version = "2019-English-Full-Base" + windows_ami_owners = ["self"] + + additional_windows_ami_filters = [ + { + name = "image-id" + values = ["CHANGE_ME"] # Your goad-dc-base AMI ID + } + ] + + ingress_rules = [ + { + description = "Allow all traffic from VPC CIDR" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = [dependency.network.outputs.vpc_cidr] + }, + ] + + egress_rules = [ + { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + ] + + enable_monitoring = true + enable_metadata = true + require_imdsv2 = true + encrypt_volumes = true + root_volume_size = 100 + volume_type = "gp3" + + user_data = templatefile("${get_terragrunt_dir()}/templates/user_data_wrapper.ps1.tpl", { + compressed_user_data = base64encode(templatefile("${get_terragrunt_dir()}/templates/user_data.ps1.tpl", { + aws_region = local.aws_region, + hostname = local.hostname, + admin_password = local.admin_password + })) + }) + + tags = { + Environment = local.env + Project = "DreadGOAD" + Role = "DomainController" + Lab = "${local.deployment_name}-goad" + Name = "${local.deployment_name}-dreadgoad-${local.hostname}" + Domain = local.domain + ComputerName = local.hostname + } +} diff --git a/infra/goad-deployment/staging/us-west-1/goad/dc03/templates/user_data.ps1.tpl b/infra/goad-deployment/staging/us-west-1/goad/dc03/templates/user_data.ps1.tpl new file mode 100644 index 00000000..bbc0ec7c --- /dev/null +++ b/infra/goad-deployment/staging/us-west-1/goad/dc03/templates/user_data.ps1.tpl @@ -0,0 +1,44 @@ +# Add registry keys to enable TLS 1.2 at the OS level +New-Item 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server' -Force | Out-Null +New-Item 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client' -Force | Out-Null +New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server' -name 'Enabled' -value '1' -PropertyType 'DWord' -Force | Out-Null +New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server' -name 'DisabledByDefault' -value 0 -PropertyType 'DWord' -Force | Out-Null +New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client' -name 'Enabled' -value 1 -PropertyType 'DWord' -Force | Out-Null +New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client' -name 'DisabledByDefault' -value 0 -PropertyType 'DWord' -Force | Out-Null + +# Enable strong cryptography on .NET Framework +Set-ItemProperty -Path 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\.NetFramework\v4.0.30319' -Name 'SchUseStrongCrypto' -Value 1 -Type DWord -Force +Set-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\.NetFramework\v4.0.30319' -Name 'SchUseStrongCrypto' -Value 1 -Type DWord -Force + +# Force TLS 1.2 in current PowerShell session and create system-wide PowerShell profile +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + +# Create AllSigned profile for all users +$allUsersAllHosts = "$env:windir\System32\WindowsPowerShell\v1.0\profile.ps1" +New-Item -Path $allUsersAllHosts -ItemType File -Force | Out-Null +Set-Content -Path $allUsersAllHosts -Value "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12" -Force + +# Set the local Administrator password +net user Administrator ${admin_password} /expires:never /y + +# Create ansible user, add to administrators group, and set password +net user ansible ${admin_password} /add /expires:never /y +net localgroup administrators ansible /add + +# Setup SSM Agent +$progressPreference = 'silentlyContinue' + +# Use WebClient instead of Invoke-WebRequest for SSM agent download too +$ssmUrl = "https://amazon-ssm-${aws_region}.s3.amazonaws.com/latest/windows_amd64/AmazonSSMAgentSetup.exe" +$ssmOutput = "$env:TEMP\SSMAgent_latest.exe" +$webClient = New-Object System.Net.WebClient +$webClient.DownloadFile($ssmUrl, $ssmOutput) + +# Install SSM agent +Start-Process -FilePath $env:TEMP\SSMAgent_latest.exe -ArgumentList "/S" -Wait +Remove-Item -Force $env:TEMP\SSMAgent_latest.exe +Restart-Service AmazonSSMAgent + +# Rename computer and restart +Rename-Computer -NewName "${hostname}" -Force +Restart-Computer -Force diff --git a/infra/goad-deployment/staging/us-west-1/goad/dc03/templates/user_data_wrapper.ps1.tpl b/infra/goad-deployment/staging/us-west-1/goad/dc03/templates/user_data_wrapper.ps1.tpl new file mode 100644 index 00000000..4f83016f --- /dev/null +++ b/infra/goad-deployment/staging/us-west-1/goad/dc03/templates/user_data_wrapper.ps1.tpl @@ -0,0 +1,5 @@ + +$EncodedUserData = "${compressed_user_data}" +$DecodedUserData = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($EncodedUserData)) +Invoke-Expression $DecodedUserData + diff --git a/infra/goad-deployment/staging/us-west-1/goad/dc03/terragrunt.hcl b/infra/goad-deployment/staging/us-west-1/goad/dc03/terragrunt.hcl new file mode 100644 index 00000000..3df4ec51 --- /dev/null +++ b/infra/goad-deployment/staging/us-west-1/goad/dc03/terragrunt.hcl @@ -0,0 +1,112 @@ +# ============================================================================= +# DC03 - Domain Controller for Essos +# AMI: Built from warpgate-templates/goad-dc-base-2016 (Windows Server 2016) +# ============================================================================= + +include "host" { + path = find_in_parent_folders("host.hcl") + expose = true +} + +locals { + env_vars = read_terragrunt_config(find_in_parent_folders("env.hcl")) + region_vars = read_terragrunt_config(find_in_parent_folders("region.hcl")) + + env = local.env_vars.locals.env + aws_region = local.region_vars.locals.aws_region + deployment_name = local.env_vars.locals.deployment_name + + hostname = include.host.locals.computer_name + friendly_name = include.host.locals.hostname + domain = include.host.locals.domain + os_type = include.host.locals.os_type + role = include.host.locals.role + goad_id = include.host.locals.goad_id + + admin_password = get_env("TF_VAR_goad_dc03_password", "CHANGE_ME") +} + +terraform { + source = "${get_repo_root()}/modules//terraform-aws-instance-factory" +} + +dependency "network" { + config_path = "../../network" +} + +include { + path = find_in_parent_folders("root.hcl") +} + +inputs = { + env = local.env + instance_name = "${local.deployment_name}-dreadgoad-${local.hostname}" + instance_type = "t3.medium" + os_type = local.os_type + enable_asg = false + subnet_id = dependency.network.outputs.private_subnet_ids[0] + vpc_id = dependency.network.outputs.vpc_id + + enable_ssm = true + + additional_iam_policies = { + cloudwatch_agent = "arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy" + s3_full_access = "arn:aws:iam::aws:policy/AmazonS3FullAccess" + } + + # Windows AMI - replace with your AMI built from warpgate-templates/goad-dc-base-2016 + windows_os = "Windows_Server" + windows_os_version = "2016-English-Full-Base" + windows_ami_owners = ["self"] + + additional_windows_ami_filters = [ + { + name = "image-id" + values = ["CHANGE_ME"] # Your goad-dc-base-2016 AMI ID + } + ] + + ingress_rules = [ + { + description = "Allow all traffic from VPC CIDR" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = [dependency.network.outputs.vpc_cidr] + }, + ] + + egress_rules = [ + { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + ] + + enable_monitoring = true + enable_metadata = true + require_imdsv2 = true + encrypt_volumes = true + root_volume_size = 100 + volume_type = "gp3" + + user_data = templatefile("${get_terragrunt_dir()}/templates/user_data_wrapper.ps1.tpl", { + compressed_user_data = base64encode(templatefile("${get_terragrunt_dir()}/templates/user_data.ps1.tpl", { + aws_region = local.aws_region, + hostname = local.hostname, + admin_password = local.admin_password + })) + }) + + tags = { + Environment = local.env + Project = "DreadGOAD" + Role = "DomainController" + Lab = "${local.deployment_name}-goad" + Name = "${local.deployment_name}-dreadgoad-${local.hostname}" + Domain = local.domain + ComputerName = local.hostname + } +} diff --git a/infra/goad-deployment/staging/us-west-1/goad/srv02/templates/user_data.ps1.tpl b/infra/goad-deployment/staging/us-west-1/goad/srv02/templates/user_data.ps1.tpl new file mode 100644 index 00000000..bbc0ec7c --- /dev/null +++ b/infra/goad-deployment/staging/us-west-1/goad/srv02/templates/user_data.ps1.tpl @@ -0,0 +1,44 @@ +# Add registry keys to enable TLS 1.2 at the OS level +New-Item 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server' -Force | Out-Null +New-Item 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client' -Force | Out-Null +New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server' -name 'Enabled' -value '1' -PropertyType 'DWord' -Force | Out-Null +New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server' -name 'DisabledByDefault' -value 0 -PropertyType 'DWord' -Force | Out-Null +New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client' -name 'Enabled' -value 1 -PropertyType 'DWord' -Force | Out-Null +New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client' -name 'DisabledByDefault' -value 0 -PropertyType 'DWord' -Force | Out-Null + +# Enable strong cryptography on .NET Framework +Set-ItemProperty -Path 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\.NetFramework\v4.0.30319' -Name 'SchUseStrongCrypto' -Value 1 -Type DWord -Force +Set-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\.NetFramework\v4.0.30319' -Name 'SchUseStrongCrypto' -Value 1 -Type DWord -Force + +# Force TLS 1.2 in current PowerShell session and create system-wide PowerShell profile +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + +# Create AllSigned profile for all users +$allUsersAllHosts = "$env:windir\System32\WindowsPowerShell\v1.0\profile.ps1" +New-Item -Path $allUsersAllHosts -ItemType File -Force | Out-Null +Set-Content -Path $allUsersAllHosts -Value "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12" -Force + +# Set the local Administrator password +net user Administrator ${admin_password} /expires:never /y + +# Create ansible user, add to administrators group, and set password +net user ansible ${admin_password} /add /expires:never /y +net localgroup administrators ansible /add + +# Setup SSM Agent +$progressPreference = 'silentlyContinue' + +# Use WebClient instead of Invoke-WebRequest for SSM agent download too +$ssmUrl = "https://amazon-ssm-${aws_region}.s3.amazonaws.com/latest/windows_amd64/AmazonSSMAgentSetup.exe" +$ssmOutput = "$env:TEMP\SSMAgent_latest.exe" +$webClient = New-Object System.Net.WebClient +$webClient.DownloadFile($ssmUrl, $ssmOutput) + +# Install SSM agent +Start-Process -FilePath $env:TEMP\SSMAgent_latest.exe -ArgumentList "/S" -Wait +Remove-Item -Force $env:TEMP\SSMAgent_latest.exe +Restart-Service AmazonSSMAgent + +# Rename computer and restart +Rename-Computer -NewName "${hostname}" -Force +Restart-Computer -Force diff --git a/infra/goad-deployment/staging/us-west-1/goad/srv02/templates/user_data_wrapper.ps1.tpl b/infra/goad-deployment/staging/us-west-1/goad/srv02/templates/user_data_wrapper.ps1.tpl new file mode 100644 index 00000000..4f83016f --- /dev/null +++ b/infra/goad-deployment/staging/us-west-1/goad/srv02/templates/user_data_wrapper.ps1.tpl @@ -0,0 +1,5 @@ + +$EncodedUserData = "${compressed_user_data}" +$DecodedUserData = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($EncodedUserData)) +Invoke-Expression $DecodedUserData + diff --git a/infra/goad-deployment/staging/us-west-1/goad/srv02/terragrunt.hcl b/infra/goad-deployment/staging/us-west-1/goad/srv02/terragrunt.hcl new file mode 100644 index 00000000..46005572 --- /dev/null +++ b/infra/goad-deployment/staging/us-west-1/goad/srv02/terragrunt.hcl @@ -0,0 +1,113 @@ +# ============================================================================= +# SRV02 - Member Server in North Subdomain (MSSQL) +# AMI: Built from warpgate-templates/goad-mssql-base (Windows Server 2019) +# ============================================================================= + +include "host" { + path = find_in_parent_folders("host.hcl") + expose = true +} + +locals { + env_vars = read_terragrunt_config(find_in_parent_folders("env.hcl")) + region_vars = read_terragrunt_config(find_in_parent_folders("region.hcl")) + + env = local.env_vars.locals.env + aws_region = local.region_vars.locals.aws_region + deployment_name = local.env_vars.locals.deployment_name + + hostname = include.host.locals.computer_name + friendly_name = include.host.locals.hostname + domain = include.host.locals.domain + os_type = include.host.locals.os_type + role = include.host.locals.role + goad_id = include.host.locals.goad_id + + # SRV02 uses DC02's password (it joins the north subdomain) + admin_password = get_env("TF_VAR_goad_dc02_password", "CHANGE_ME") +} + +terraform { + source = "${get_repo_root()}/modules//terraform-aws-instance-factory" +} + +dependency "network" { + config_path = "../../network" +} + +include { + path = find_in_parent_folders("root.hcl") +} + +inputs = { + env = local.env + instance_name = "${local.deployment_name}-dreadgoad-${local.hostname}" + instance_type = "t3.medium" + os_type = local.os_type + enable_asg = false + subnet_id = dependency.network.outputs.private_subnet_ids[0] + vpc_id = dependency.network.outputs.vpc_id + + enable_ssm = true + + additional_iam_policies = { + cloudwatch_agent = "arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy" + s3_full_access = "arn:aws:iam::aws:policy/AmazonS3FullAccess" + } + + # Windows AMI - replace with your AMI built from warpgate-templates/goad-mssql-base + windows_os = "Windows_Server" + windows_os_version = "2019-English-Full-Base" + windows_ami_owners = ["self"] + + additional_windows_ami_filters = [ + { + name = "image-id" + values = ["CHANGE_ME"] # Your goad-mssql-base AMI ID + } + ] + + ingress_rules = [ + { + description = "Allow all traffic from VPC CIDR" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = [dependency.network.outputs.vpc_cidr] + }, + ] + + egress_rules = [ + { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + ] + + enable_monitoring = true + enable_metadata = true + require_imdsv2 = true + encrypt_volumes = true + root_volume_size = 100 + volume_type = "gp3" + + user_data = templatefile("${get_terragrunt_dir()}/templates/user_data_wrapper.ps1.tpl", { + compressed_user_data = base64encode(templatefile("${get_terragrunt_dir()}/templates/user_data.ps1.tpl", { + aws_region = local.aws_region, + hostname = local.hostname, + admin_password = local.admin_password + })) + }) + + tags = { + Environment = local.env + Project = "DreadGOAD" + Role = "MemberServer" + Lab = "${local.deployment_name}-goad" + Name = "${local.deployment_name}-dreadgoad-${local.hostname}" + Domain = local.domain + ComputerName = local.hostname + } +} diff --git a/infra/goad-deployment/staging/us-west-1/goad/srv03/templates/user_data.ps1.tpl b/infra/goad-deployment/staging/us-west-1/goad/srv03/templates/user_data.ps1.tpl new file mode 100644 index 00000000..bbc0ec7c --- /dev/null +++ b/infra/goad-deployment/staging/us-west-1/goad/srv03/templates/user_data.ps1.tpl @@ -0,0 +1,44 @@ +# Add registry keys to enable TLS 1.2 at the OS level +New-Item 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server' -Force | Out-Null +New-Item 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client' -Force | Out-Null +New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server' -name 'Enabled' -value '1' -PropertyType 'DWord' -Force | Out-Null +New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server' -name 'DisabledByDefault' -value 0 -PropertyType 'DWord' -Force | Out-Null +New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client' -name 'Enabled' -value 1 -PropertyType 'DWord' -Force | Out-Null +New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client' -name 'DisabledByDefault' -value 0 -PropertyType 'DWord' -Force | Out-Null + +# Enable strong cryptography on .NET Framework +Set-ItemProperty -Path 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\.NetFramework\v4.0.30319' -Name 'SchUseStrongCrypto' -Value 1 -Type DWord -Force +Set-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\.NetFramework\v4.0.30319' -Name 'SchUseStrongCrypto' -Value 1 -Type DWord -Force + +# Force TLS 1.2 in current PowerShell session and create system-wide PowerShell profile +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + +# Create AllSigned profile for all users +$allUsersAllHosts = "$env:windir\System32\WindowsPowerShell\v1.0\profile.ps1" +New-Item -Path $allUsersAllHosts -ItemType File -Force | Out-Null +Set-Content -Path $allUsersAllHosts -Value "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12" -Force + +# Set the local Administrator password +net user Administrator ${admin_password} /expires:never /y + +# Create ansible user, add to administrators group, and set password +net user ansible ${admin_password} /add /expires:never /y +net localgroup administrators ansible /add + +# Setup SSM Agent +$progressPreference = 'silentlyContinue' + +# Use WebClient instead of Invoke-WebRequest for SSM agent download too +$ssmUrl = "https://amazon-ssm-${aws_region}.s3.amazonaws.com/latest/windows_amd64/AmazonSSMAgentSetup.exe" +$ssmOutput = "$env:TEMP\SSMAgent_latest.exe" +$webClient = New-Object System.Net.WebClient +$webClient.DownloadFile($ssmUrl, $ssmOutput) + +# Install SSM agent +Start-Process -FilePath $env:TEMP\SSMAgent_latest.exe -ArgumentList "/S" -Wait +Remove-Item -Force $env:TEMP\SSMAgent_latest.exe +Restart-Service AmazonSSMAgent + +# Rename computer and restart +Rename-Computer -NewName "${hostname}" -Force +Restart-Computer -Force diff --git a/infra/goad-deployment/staging/us-west-1/goad/srv03/templates/user_data_wrapper.ps1.tpl b/infra/goad-deployment/staging/us-west-1/goad/srv03/templates/user_data_wrapper.ps1.tpl new file mode 100644 index 00000000..4f83016f --- /dev/null +++ b/infra/goad-deployment/staging/us-west-1/goad/srv03/templates/user_data_wrapper.ps1.tpl @@ -0,0 +1,5 @@ + +$EncodedUserData = "${compressed_user_data}" +$DecodedUserData = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($EncodedUserData)) +Invoke-Expression $DecodedUserData + diff --git a/infra/goad-deployment/staging/us-west-1/goad/srv03/terragrunt.hcl b/infra/goad-deployment/staging/us-west-1/goad/srv03/terragrunt.hcl new file mode 100644 index 00000000..339dac88 --- /dev/null +++ b/infra/goad-deployment/staging/us-west-1/goad/srv03/terragrunt.hcl @@ -0,0 +1,113 @@ +# ============================================================================= +# SRV03 - Member Server in Essos Domain +# AMI: Built from warpgate-templates/goad-member-base-2016 (Windows Server 2016) +# ============================================================================= + +include "host" { + path = find_in_parent_folders("host.hcl") + expose = true +} + +locals { + env_vars = read_terragrunt_config(find_in_parent_folders("env.hcl")) + region_vars = read_terragrunt_config(find_in_parent_folders("region.hcl")) + + env = local.env_vars.locals.env + aws_region = local.region_vars.locals.aws_region + deployment_name = local.env_vars.locals.deployment_name + + hostname = include.host.locals.computer_name + friendly_name = include.host.locals.hostname + domain = include.host.locals.domain + os_type = include.host.locals.os_type + role = include.host.locals.role + goad_id = include.host.locals.goad_id + + # SRV03 uses DC03's password (it joins the essos domain) + admin_password = get_env("TF_VAR_goad_dc03_password", "CHANGE_ME") +} + +terraform { + source = "${get_repo_root()}/modules//terraform-aws-instance-factory" +} + +dependency "network" { + config_path = "../../network" +} + +include { + path = find_in_parent_folders("root.hcl") +} + +inputs = { + env = local.env + instance_name = "${local.deployment_name}-dreadgoad-${local.hostname}" + instance_type = "t3.medium" + os_type = local.os_type + enable_asg = false + subnet_id = dependency.network.outputs.private_subnet_ids[0] + vpc_id = dependency.network.outputs.vpc_id + + enable_ssm = true + + additional_iam_policies = { + cloudwatch_agent = "arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy" + s3_full_access = "arn:aws:iam::aws:policy/AmazonS3FullAccess" + } + + # Windows AMI - replace with your AMI built from warpgate-templates/goad-member-base-2016 + windows_os = "Windows_Server" + windows_os_version = "2016-English-Full-Base" + windows_ami_owners = ["self"] + + additional_windows_ami_filters = [ + { + name = "image-id" + values = ["CHANGE_ME"] # Your goad-member-base-2016 AMI ID + } + ] + + ingress_rules = [ + { + description = "Allow all traffic from VPC CIDR" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = [dependency.network.outputs.vpc_cidr] + }, + ] + + egress_rules = [ + { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + ] + + enable_monitoring = true + enable_metadata = true + require_imdsv2 = true + encrypt_volumes = true + root_volume_size = 100 + volume_type = "gp3" + + user_data = templatefile("${get_terragrunt_dir()}/templates/user_data_wrapper.ps1.tpl", { + compressed_user_data = base64encode(templatefile("${get_terragrunt_dir()}/templates/user_data.ps1.tpl", { + aws_region = local.aws_region, + hostname = local.hostname, + admin_password = local.admin_password + })) + }) + + tags = { + Environment = local.env + Project = "DreadGOAD" + Role = "MemberServer" + Lab = "${local.deployment_name}-goad" + Name = "${local.deployment_name}-dreadgoad-${local.hostname}" + Domain = local.domain + ComputerName = local.hostname + } +} diff --git a/infra/goad-deployment/staging/us-west-1/network/terragrunt.hcl b/infra/goad-deployment/staging/us-west-1/network/terragrunt.hcl new file mode 100644 index 00000000..43a8c989 --- /dev/null +++ b/infra/goad-deployment/staging/us-west-1/network/terragrunt.hcl @@ -0,0 +1,57 @@ +locals { + env_vars = read_terragrunt_config(find_in_parent_folders("env.hcl")) + region_vars = read_terragrunt_config(find_in_parent_folders("region.hcl")) + + env = local.env_vars.locals.env + aws_region = local.region_vars.locals.aws_region + deployment_name = local.env_vars.locals.deployment_name + vpc_cidr = local.env_vars.locals.vpc_cidr +} + +terraform { + source = "${get_repo_root()}/modules//terraform-aws-net" +} + +include { + path = find_in_parent_folders("root.hcl") +} + +inputs = { + additional_tags = { + Project = "DreadGOAD" + Environment = local.env + } + deployment_name = local.deployment_name + env = local.env + map_public_ip = true + vpc_cidr_block = local.vpc_cidr + + # Security group rules for VPC endpoints + vpce_security_group_rules = { + ingress_cidr_blocks = [local.vpc_cidr] + egress_cidr_blocks = ["0.0.0.0/0"] + } + + # VPC endpoints required for SSM-based instance management + vpc_endpoints = { + ssm = { + service = "ssm" + type = "Interface" + private_dns = true + } + ssmmessages = { + service = "ssmmessages" + type = "Interface" + private_dns = true + } + ec2messages = { + service = "ec2messages" + type = "Interface" + private_dns = true + } + s3 = { + service = "s3" + type = "Gateway" + } + } +} diff --git a/infra/goad-deployment/staging/us-west-1/region.hcl b/infra/goad-deployment/staging/us-west-1/region.hcl new file mode 100644 index 00000000..0e6cc4c1 --- /dev/null +++ b/infra/goad-deployment/staging/us-west-1/region.hcl @@ -0,0 +1,3 @@ +locals { + aws_region = "us-west-1" +} diff --git a/infra/root.hcl b/infra/root.hcl new file mode 100644 index 00000000..54191c0b --- /dev/null +++ b/infra/root.hcl @@ -0,0 +1,57 @@ +# --------------------------------------------------------------------------------------------------------------------- +# TERRAGRUNT CONFIGURATION +# Root configuration for DreadGOAD infrastructure deployments. +# Configures S3 remote state, AWS provider, and shared variables. +# --------------------------------------------------------------------------------------------------------------------- +locals { + env_vars = read_terragrunt_config(find_in_parent_folders("env.hcl")) + + aws_region = coalesce( + get_env("AWS_DEFAULT_REGION", ""), + can(read_terragrunt_config(find_in_parent_folders("region.hcl"))) ? read_terragrunt_config(find_in_parent_folders("region.hcl")).locals.aws_region : "us-east-1" + ) + + deployment_name = local.env_vars.locals.deployment_name + account_id = local.env_vars.locals.aws_account_id + env = local.env_vars.locals.env +} + +generate "versions" { + path = "versions_override.tf" + if_exists = "overwrite_terragrunt" + contents = < +Logo + +## Terraform module for flexible EC2 instance deployment ☁️ + +_... supporting Linux, Windows, and macOS with ASG capabilities_ 🚀 + + + +
+ +[![Terratest](https://github.com/dreadnode/terraform-module/actions/workflows/terratest.yaml/badge.svg)](https://github.com/dreadnode/terraform-module/actions/workflows/terratest.yaml) +[![Pre-Commit](https://github.com/dreadnode/terraform-module/actions/workflows/pre-commit.yaml/badge.svg)](https://github.com/dreadnode/terraform-module/actions/workflows/pre-commit.yaml) +[![Renovate](https://github.com/dreadnode/terraform-module/actions/workflows/renovate.yaml/badge.svg)](https://github.com/dreadnode/terraform-module/actions/workflows/renovate.yaml) + +
+ +--- + +## 📖 Overview + +This Terraform module provides a flexible way to deploy EC2 instances in AWS, +supporting multiple operating systems (Linux, Windows, and macOS) with the +option to deploy either single instances or Auto Scaling Groups (ASG). The +module includes comprehensive security features, monitoring capabilities, and +storage management. + +--- + +## Table of Contents + +- [Features](#features) +- [Usage](#usage) +- [Inputs](#inputs) +- [Outputs](#outputs) +- [Requirements](#requirements) +- [Development](#development) + +--- + +## Features + +This Terraform module deploys the following AWS resources: + +- EC2 instances with support for Linux, Windows, and macOS +- Auto Scaling Groups with customizable scaling policies +- Launch Templates for consistent instance configuration +- Security Groups with customizable rules +- EBS volumes with encryption support +- SSH Key Pairs for secure access +- Instance Metadata Service v2 (IMDSv2) support +- Detailed monitoring and health checks +- Custom tags and naming conventions + +--- + +## Usage + +Here are some examples of how to use this module: + +### Basic Linux Instance + +```hcl +module "linux_instance" { + source = "github.com/dreadnode/terraform-aws-instance-factory" + env = "dev" + workload_name = "web-server" + os_type = "linux" + instance_type = "t3.micro" + vpc_id = "vpc-1234567890" + subnet_id = "subnet-1234567890" + + ingress_rules = [ + { + description = "Allow HTTP" + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + ] +} +``` + +### Windows Instance with Additional EBS Volume + +```hcl +module "windows_instance" { + source = "github.com/dreadnode/terraform-aws-instance-factory" + env = "prod" + workload_name = "app-server" + os_type = "windows" + instance_type = "t3.large" + vpc_id = "vpc-1234567890" + subnet_id = "subnet-1234567890" + + additional_ebs_volumes = [ + { + device_name = "/dev/xvdf" + volume_size = 100 + volume_type = "gp3" + delete_on_termination = true + } + ] +} +``` + +### Auto Scaling Group Configuration + +```hcl +module "asg_deployment" { + source = "github.com/dreadnode/terraform-aws-instance-factory" + env = "staging" + workload_name = "web-cluster" + os_type = "linux" + instance_type = "t3.small" + vpc_id = "vpc-1234567890" + enable_asg = true + + asg_subnet_ids = ["subnet-1234", "subnet-5678"] + asg_min_size = 2 + asg_max_size = 4 + asg_desired_capacity = 2 +} +``` + +--- + + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | ~> 1.7 | +| [aws](#requirement\_aws) | ~> 6.36.0 | +| [http](#requirement\_http) | ~> 3.5.0 | +| [random](#requirement\_random) | ~> 3.8.0 | + +## Providers + +| Name | Version | +|------|---------| +| [aws](#provider\_aws) | 6.36.0 | +| [http](#provider\_http) | ~> 3.5.0 | + +## Modules + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [aws_autoscaling_group.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/autoscaling_group) | resource | +| [aws_iam_instance_profile.ssm](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_instance_profile) | resource | +| [aws_iam_role.ssm](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy_attachment.additional_policies](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_iam_role_policy_attachment.ssm](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_instance.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/instance) | resource | +| [aws_key_pair.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/key_pair) | resource | +| [aws_launch_template.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/launch_template) | resource | +| [aws_lb.nlb](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lb) | resource | +| [aws_lb.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lb) | resource | +| [aws_lb_listener.http](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lb_listener) | resource | +| [aws_lb_listener.https](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lb_listener) | resource | +| [aws_lb_listener.nlb](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lb_listener) | resource | +| [aws_lb_target_group.nlb](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lb_target_group) | resource | +| [aws_lb_target_group.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lb_target_group) | resource | +| [aws_security_group.alb](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group) | resource | +| [aws_security_group.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group) | resource | +| [aws_ami.linux](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/ami) | data source | +| [aws_ami.macos](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/ami) | data source | +| [aws_ami.windows](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/ami) | data source | +| [aws_instances.asg_instances](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/instances) | data source | +| [http_http.current_ip](https://registry.terraform.io/providers/hashicorp/http/latest/docs/data-sources/http) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [access\_logs\_bucket](#input\_access\_logs\_bucket) | S3 bucket for ALB access logs | `string` | `null` | no | +| [additional\_ebs\_volumes](#input\_additional\_ebs\_volumes) | Additional EBS volumes to attach |
list(object({
device_name = string
volume_size = number
volume_type = string
delete_on_termination = bool
}))
| `[]` | no | +| [additional\_iam\_policies](#input\_additional\_iam\_policies) | Map of additional IAM policies to attach to the instance role (if SSM is enabled). Example usage: additional\_iam\_policies = { s3\_full\_access = "arn:aws:iam::aws:policy/AmazonS3FullAccess" } | `map(string)` | `{}` | no | +| [additional\_linux\_ami\_filters](#input\_additional\_linux\_ami\_filters) | Additional filters for Linux AMI lookup |
list(object({
name = string
values = list(string)
}))
| `[]` | no | +| [additional\_macos\_ami\_filters](#input\_additional\_macos\_ami\_filters) | Additional filters for macOS AMI lookup |
list(object({
name = string
values = list(string)
}))
| `[]` | no | +| [additional\_security\_group\_ids](#input\_additional\_security\_group\_ids) | Additional security group IDs to attach | `list(string)` | `[]` | no | +| [additional\_windows\_ami\_filters](#input\_additional\_windows\_ami\_filters) | Additional filters for Windows AMI lookup |
list(object({
name = string
values = list(string)
}))
| `[]` | no | +| [alb\_additional\_security\_group\_rules](#input\_alb\_additional\_security\_group\_rules) | List of additional rules for the ALB security group | `list(any)` | `[]` | no | +| [alb\_idle\_timeout](#input\_alb\_idle\_timeout) | The time in seconds that the connection is allowed to be idle | `number` | `60` | no | +| [alb\_internal](#input\_alb\_internal) | Whether ALB should be internal | `bool` | `false` | no | +| [alb\_subnet\_ids](#input\_alb\_subnet\_ids) | List of subnet IDs where the ALB will be deployed | `list(string)` | `[]` | no | +| [alb\_target\_port](#input\_alb\_target\_port) | Port for ALB target group | `number` | `80` | no | +| [allowed\_cidr\_blocks](#input\_allowed\_cidr\_blocks) | List of CIDR blocks allowed to access the ALB | `list(string)` | `[]` | no | +| [ami\_architecture](#input\_ami\_architecture) | Architecture for AMI selection (x86\_64, arm64, etc.) | `string` | `"x86_64"` | no | +| [asg\_desired\_capacity](#input\_asg\_desired\_capacity) | Desired capacity of ASG | `number` | `1` | no | +| [asg\_force\_delete](#input\_asg\_force\_delete) | Force delete ASG | `bool` | `false` | no | +| [asg\_health\_check\_grace\_period](#input\_asg\_health\_check\_grace\_period) | Health check grace period for ASG | `number` | `300` | no | +| [asg\_health\_check\_type](#input\_asg\_health\_check\_type) | Health check type for ASG | `string` | `"EC2"` | no | +| [asg\_max\_size](#input\_asg\_max\_size) | Maximum size of ASG | `number` | `1` | no | +| [asg\_min\_size](#input\_asg\_min\_size) | Minimum size of ASG | `number` | `1` | no | +| [asg\_subnet\_ids](#input\_asg\_subnet\_ids) | Subnet IDs for ASG | `list(string)` | `[]` | no | +| [asg\_suspended\_processes](#input\_asg\_suspended\_processes) | List of processes to suspend for the ASG (e.g., ReplaceUnhealthy, Launch, Terminate) | `list(string)` | `[]` | no | +| [asg\_tags](#input\_asg\_tags) | Additional tags for ASG | `map(string)` | `{}` | no | +| [asg\_termination\_policies](#input\_asg\_termination\_policies) | Termination policies for ASG | `list(string)` |
[
"Default"
]
| no | +| [assign\_public\_ip](#input\_assign\_public\_ip) | Assign public IP address to the instance(s) | `bool` | `false` | no | +| [create\_alb](#input\_create\_alb) | Whether to create an Application Load Balancer | `bool` | `false` | no | +| [create\_nlb](#input\_create\_nlb) | Whether to create a Network Load Balancer | `bool` | `false` | no | +| [delete\_on\_termination](#input\_delete\_on\_termination) | Delete volumes on instance termination | `bool` | `true` | no | +| [deregistration\_delay](#input\_deregistration\_delay) | Amount of time to wait for in-flight requests before deregistering a target | `number` | `300` | no | +| [drop\_invalid\_header\_fields](#input\_drop\_invalid\_header\_fields) | Drop invalid header fields in HTTP(S) requests | `bool` | `true` | no | +| [ebs\_optimized](#input\_ebs\_optimized) | Enable EBS optimization | `bool` | `true` | no | +| [egress\_rules](#input\_egress\_rules) | List of egress rules to create | `list(any)` | `[]` | no | +| [enable\_access\_logs](#input\_enable\_access\_logs) | Enable ALB access logging to S3 | `bool` | `false` | no | +| [enable\_asg](#input\_enable\_asg) | Whether to create an Auto Scaling Group instead of a single instance | `bool` | `false` | no | +| [enable\_metadata](#input\_enable\_metadata) | Enable metadata service | `bool` | `true` | no | +| [enable\_monitoring](#input\_enable\_monitoring) | Enable detailed monitoring | `bool` | `true` | no | +| [enable\_nlb\_access\_logs](#input\_enable\_nlb\_access\_logs) | Enable NLB access logging to S3 | `bool` | `false` | no | +| [enable\_ssm](#input\_enable\_ssm) | Enable AWS Systems Manager Session Manager access | `bool` | `false` | no | +| [encrypt\_volumes](#input\_encrypt\_volumes) | Enable volume encryption | `bool` | `true` | no | +| [env](#input\_env) | Environment name (e.g., 'dev', 'staging', 'prod') | `string` | n/a | yes | +| [health\_check\_healthy\_threshold](#input\_health\_check\_healthy\_threshold) | Number of consecutive health check successes required | `number` | `2` | no | +| [health\_check\_interval](#input\_health\_check\_interval) | Health check interval in seconds | `number` | `30` | no | +| [health\_check\_timeout](#input\_health\_check\_timeout) | Health check timeout in seconds | `number` | `10` | no | +| [health\_check\_unhealthy\_threshold](#input\_health\_check\_unhealthy\_threshold) | Number of consecutive health check failures required | `number` | `5` | no | +| [include\_current\_ip](#input\_include\_current\_ip) | Whether to include the current IP address in the allowed CIDR blocks | `bool` | `false` | no | +| [ingress\_rules](#input\_ingress\_rules) | List of ingress rules to create | `list(any)` | `[]` | no | +| [instance\_name](#input\_instance\_name) | Name of the instance(s) | `string` | n/a | yes | +| [instance\_profile](#input\_instance\_profile) | IAM instance profile name. If empty and enable\_ssm is true, a new profile will be created | `string` | `""` | no | +| [instance\_type](#input\_instance\_type) | EC2 instance type | `string` | n/a | yes | +| [kms\_key\_arn](#input\_kms\_key\_arn) | KMS key ARN for volume encryption. If empty and encrypt\_volumes is true, AWS default encryption will be used | `string` | `""` | no | +| [linux\_ami\_owners](#input\_linux\_ami\_owners) | List of Linux AMI owners | `list(string)` |
[
"amazon"
]
| no | +| [linux\_os](#input\_linux\_os) | Linux OS name | `string` | `"ubuntu/images/hvm-ssd/ubuntu-jammy"` | no | +| [linux\_os\_version](#input\_linux\_os\_version) | Linux OS version pattern | `string` | `"*"` | no | +| [macos\_ami\_owners](#input\_macos\_ami\_owners) | List of macOS AMI owners | `list(string)` |
[
"amazon"
]
| no | +| [macos\_os](#input\_macos\_os) | macOS name | `string` | `"amzn-ec2-macos"` | no | +| [macos\_os\_version](#input\_macos\_os\_version) | macOS version | `string` | `"13"` | no | +| [metadata\_hop\_limit](#input\_metadata\_hop\_limit) | Metadata service hop limit | `number` | `1` | no | +| [nlb\_access\_logs\_prefix](#input\_nlb\_access\_logs\_prefix) | S3 prefix for NLB access logs | `string` | `"nlb-logs"` | no | +| [nlb\_cross\_zone\_enabled](#input\_nlb\_cross\_zone\_enabled) | Enable cross-zone load balancing for NLB | `bool` | `true` | no | +| [nlb\_internal](#input\_nlb\_internal) | Whether NLB should be internal | `bool` | `false` | no | +| [nlb\_listeners](#input\_nlb\_listeners) | Map of NLB listener configurations |
map(object({
port = number
protocol = string
target_group_key = string
certificate_arn = optional(string, null)
alpn_policy = optional(string, null)
ssl_policy = optional(string, null)
}))
| `{}` | no | +| [nlb\_subnet\_ids](#input\_nlb\_subnet\_ids) | List of subnet IDs where the NLB will be deployed | `list(string)` | `[]` | no | +| [nlb\_target\_groups](#input\_nlb\_target\_groups) | Map of NLB target group configurations |
map(object({
port = number
protocol = string
target_type = string
preserve_client_ip = optional(bool, true)
health_check = optional(object({
port = optional(string, "traffic-port")
protocol = optional(string, "TCP")
path = optional(string, "/")
healthy_threshold = optional(number, 3)
unhealthy_threshold = optional(number, 3)
timeout = optional(number, 10)
interval = optional(number, 30)
matcher = optional(string, "200-399")
}), {})
}))
| `{}` | no | +| [os\_type](#input\_os\_type) | Operating system type (linux, windows, or macos) | `string` | n/a | yes | +| [require\_imdsv2](#input\_require\_imdsv2) | Require IMDSv2 metadata | `bool` | `true` | no | +| [root\_volume\_size](#input\_root\_volume\_size) | Size of root volume in GB | `number` | `100` | no | +| [source\_dest\_check](#input\_source\_dest\_check) | Controls if traffic is routed to the instance when the destination address does not match the instance. Used for NAT or VPN instances. | `bool` | `true` | no | +| [ssh\_allowed\_cidr\_blocks](#input\_ssh\_allowed\_cidr\_blocks) | CIDR blocks allowed for SSH access when using SSH instead of SSM | `list(string)` | `[]` | no | +| [ssh\_public\_key](#input\_ssh\_public\_key) | Public SSH key. Cannot be set if SSM is enabled | `string` | `""` | no | +| [stickiness\_cookie\_duration](#input\_stickiness\_cookie\_duration) | Cookie duration in seconds for session stickiness | `number` | `86400` | no | +| [subnet\_id](#input\_subnet\_id) | Subnet ID for single instance deployment | `string` | `""` | no | +| [tags](#input\_tags) | Additional tags for resources | `map(string)` | `{}` | no | +| [target\_group\_arns](#input\_target\_group\_arns) | Target group ARNs for ASG | `list(string)` | `[]` | no | +| [target\_groups](#input\_target\_groups) | Map of target group configurations |
map(object({
name = string
port = number
protocol = string
target_type = string
health_check_path = optional(string, "/")
stickiness_enabled = optional(bool, false)
}))
| `{}` | no | +| [tls\_configuration](#input\_tls\_configuration) | TLS configuration for HTTPS listener |
object({
certificate_arn = string
ssl_policy = string
})
| `null` | no | +| [user\_data](#input\_user\_data) | User data script | `string` | `""` | no | +| [volume\_type](#input\_volume\_type) | EBS volume type | `string` | `"gp3"` | no | +| [vpc\_id](#input\_vpc\_id) | VPC ID where resources will be created | `string` | n/a | yes | +| [windows\_ami\_owners](#input\_windows\_ami\_owners) | List of Windows AMI owners | `list(string)` |
[
"amazon"
]
| no | +| [windows\_os](#input\_windows\_os) | Windows OS name | `string` | `"Windows_Server"` | no | +| [windows\_os\_version](#input\_windows\_os\_version) | Windows OS version | `string` | `"2022-English-Full-Base"` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| [alb\_arn](#output\_alb\_arn) | ARN of the Application Load Balancer | +| [alb\_dns\_name](#output\_alb\_dns\_name) | DNS name of the Application Load Balancer | +| [alb\_id](#output\_alb\_id) | ID of the Application Load Balancer | +| [alb\_security\_group\_id](#output\_alb\_security\_group\_id) | ID of the ALB security group | +| [alb\_zone\_id](#output\_alb\_zone\_id) | The canonical hosted zone ID of the ALB | +| [all\_instance\_details](#output\_all\_instance\_details) | Detailed information about all instances (both standalone and ASG) | +| [ami\_id](#output\_ami\_id) | ID of the AMI used | +| [asg\_arn](#output\_asg\_arn) | ARN of the created Auto Scaling Group | +| [asg\_id](#output\_asg\_id) | ID of the created Auto Scaling Group | +| [asg\_name](#output\_asg\_name) | Name of the created Auto Scaling Group | +| [instance\_arns](#output\_instance\_arns) | ARNs of created instances | +| [instance\_details](#output\_instance\_details) | Map of instance details | +| [instance\_ids](#output\_instance\_ids) | IDs of created instances (single instance or ASG instances) | +| [instance\_primary\_eni\_ids](#output\_instance\_primary\_eni\_ids) | Primary ENI IDs of created instances | +| [instance\_private\_ips](#output\_instance\_private\_ips) | Private IPs of created instances (single instance or ASG instances) | +| [instance\_public\_ips](#output\_instance\_public\_ips) | Public IPs of created instances | +| [launch\_template\_id](#output\_launch\_template\_id) | ID of the created Launch Template | +| [launch\_template\_latest\_version](#output\_launch\_template\_latest\_version) | Latest version of the Launch Template | +| [nlb\_arn](#output\_nlb\_arn) | ARN of the Network Load Balancer | +| [nlb\_dns\_name](#output\_nlb\_dns\_name) | DNS name of the Network Load Balancer | +| [nlb\_id](#output\_nlb\_id) | ID of the Network Load Balancer | +| [nlb\_target\_group\_arns](#output\_nlb\_target\_group\_arns) | ARNs of the NLB Target Groups | +| [nlb\_zone\_id](#output\_nlb\_zone\_id) | The canonical hosted zone ID of the NLB | +| [security\_group\_arn](#output\_security\_group\_arn) | ARN of the created security group | +| [security\_group\_id](#output\_security\_group\_id) | ID of the created security group | +| [target\_group\_arns](#output\_target\_group\_arns) | ARNs of the Target Groups | +| [windows\_password\_data](#output\_windows\_password\_data) | Password data for Windows instances (encrypted) | + + + +--- + +## Development + +### Prerequisites + +- [Terraform](https://www.terraform.io/downloads.html) (~> 1.7) +- [pre-commit](https://pre-commit.com/#install) +- [Terratest](https://terratest.gruntwork.io/docs/getting-started/install/) +- [terraform-docs](https://github.com/terraform-docs/terraform-docs) + +### Testing the Module + +To test the module without destroying the created test infrastructure: + +```bash +export TASK_X_REMOTE_TASKFILES=1 && \ +task terraform:run-terratest -y DESTROY=false +``` + +To run a complete test including infrastructure cleanup: + +```bash +export TASK_X_REMOTE_TASKFILES=1 && \ +task terraform:run-terratest -y +``` + +### Pre-Commit Hooks + +Install, update, and run pre-commit hooks: + +```bash +export TASK_X_REMOTE_TASKFILES=1 && \ +task run-pre-commit -y +``` diff --git a/modules/terraform-aws-instance-factory/alb.tf b/modules/terraform-aws-instance-factory/alb.tf new file mode 100644 index 00000000..e5f9c4aa --- /dev/null +++ b/modules/terraform-aws-instance-factory/alb.tf @@ -0,0 +1,114 @@ +locals { + create_alb = var.create_alb && var.enable_asg + + # Only truncate if deployment_name exceeds the limit + lb_name = length(local.deployment_name) > 32 ? substr(local.deployment_name, 0, 32) : local.deployment_name + + tg_name_prefix = local.deployment_name + get_tg_name = { for k, v in var.target_groups : k => + length("${local.tg_name_prefix}${k}") > 32 ? + "${trimsuffix(substr(local.tg_name_prefix, 0, 32 - length(k) - 1), "-")}-${k}" : + "${trimsuffix(local.tg_name_prefix, "-")}-${k}" + } +} + +resource "aws_lb" "this" { + # checkov:skip=CKV2_AWS_28: Ensure public facing ALB are protected by WAF + # checkov:skip=CKV_AWS_150: Deletion protection is conditionally enabled based on environment + count = local.create_alb ? 1 : 0 + + name = local.lb_name + internal = var.alb_internal + load_balancer_type = "application" + security_groups = [aws_security_group.alb[0].id] + subnets = var.alb_subnet_ids + + enable_deletion_protection = var.env == "prod" + drop_invalid_header_fields = var.drop_invalid_header_fields + idle_timeout = var.alb_idle_timeout + + dynamic "access_logs" { + for_each = var.enable_access_logs && var.access_logs_bucket != null ? [1] : [] + content { + bucket = var.access_logs_bucket + enabled = true + } + } + + tags = local.common_tags +} + +resource "aws_lb_target_group" "this" { + for_each = var.target_groups + name = local.get_tg_name[each.key] + port = each.value.port + protocol = each.value.protocol + vpc_id = var.vpc_id + target_type = each.value.target_type + deregistration_delay = var.deregistration_delay + + health_check { + enabled = true + path = each.value.health_check_path + port = each.value.port + protocol = each.value.protocol + healthy_threshold = var.health_check_healthy_threshold + unhealthy_threshold = var.health_check_unhealthy_threshold + timeout = var.health_check_timeout + interval = var.health_check_interval + matcher = "200-499" + } + + stickiness { + type = "lb_cookie" + enabled = each.value.stickiness_enabled + cookie_duration = var.stickiness_cookie_duration + } + + tags = local.common_tags +} + +resource "aws_lb_listener" "https" { + count = local.create_alb && var.tls_configuration != null ? 1 : 0 + + load_balancer_arn = aws_lb.this[0].arn + port = 443 + protocol = "HTTPS" + ssl_policy = var.tls_configuration.ssl_policy + certificate_arn = var.tls_configuration.certificate_arn + + default_action { + type = "forward" + target_group_arn = values(aws_lb_target_group.this)[0].arn + } +} + +resource "aws_lb_listener" "http" { + count = local.create_alb ? 1 : 0 + + load_balancer_arn = aws_lb.this[0].arn + port = 80 + protocol = "HTTP" + + default_action { + type = var.tls_configuration != null ? "redirect" : "forward" + + dynamic "redirect" { + for_each = var.tls_configuration != null ? [1] : [] + content { + port = "443" + protocol = "HTTPS" + status_code = "HTTP_301" + } + } + + dynamic "forward" { + for_each = var.tls_configuration == null ? [1] : [] + content { + target_group { + arn = one(values(aws_lb_target_group.this)).arn + } + } + } + } +} diff --git a/modules/terraform-aws-instance-factory/data.tf b/modules/terraform-aws-instance-factory/data.tf new file mode 100644 index 00000000..d30d726a --- /dev/null +++ b/modules/terraform-aws-instance-factory/data.tf @@ -0,0 +1,167 @@ +# Get the latest Linux AMI +data "aws_ami" "linux" { + count = var.os_type == "linux" ? 1 : 0 + most_recent = true + owners = var.linux_ami_owners + + filter { + name = "root-device-type" + values = ["ebs"] + } + + filter { + name = "virtualization-type" + values = ["hvm"] + } + + filter { + name = "state" + values = ["available"] + } + + # Apply any name filter from additional_linux_ami_filters first + dynamic "filter" { + for_each = var.additional_linux_ami_filters + content { + name = filter.value.name + values = filter.value.values + } + } + + # Only apply default name filter if no name filter exists in additional_linux_ami_filters + dynamic "filter" { + for_each = length([for f in var.additional_linux_ami_filters : f if f.name == "name" || f.name == "image-id"]) > 0 ? [] : [1] + content { + name = "name" + values = ["${var.linux_os}*${var.linux_os_version}*"] + } + } + + # Always apply architecture filter when using default instance types + dynamic "filter" { + for_each = var.ami_architecture != "" ? [1] : [] + content { + name = "architecture" + values = [var.ami_architecture] + } + } +} + +# Get the latest Windows AMI +data "aws_ami" "windows" { + count = var.os_type == "windows" ? 1 : 0 + most_recent = true + owners = var.windows_ami_owners + + filter { + name = "root-device-type" + values = ["ebs"] + } + + filter { + name = "virtualization-type" + values = ["hvm"] + } + + filter { + name = "state" + values = ["available"] + } + + # Apply any name filter from additional_windows_ami_filters first + dynamic "filter" { + for_each = var.additional_windows_ami_filters + content { + name = filter.value.name + values = filter.value.values + } + } + + # Only apply default name filter if no name filter exists in additional_windows_ami_filters + dynamic "filter" { + for_each = length([for f in var.additional_windows_ami_filters : f if f.name == "name" || f.name == "image-id"]) > 0 ? [] : [1] + content { + name = "name" + values = ["${var.windows_os}-${var.windows_os_version}*"] + } + } + + # Always apply architecture filter when using default instance types + dynamic "filter" { + for_each = var.ami_architecture != "" ? [1] : [] + content { + name = "architecture" + values = [var.ami_architecture] + } + } +} + +# Get the latest macOS AMI +data "aws_ami" "macos" { + count = var.os_type == "macos" ? 1 : 0 + most_recent = true + owners = var.macos_ami_owners + + filter { + name = "root-device-type" + values = ["ebs"] + } + + filter { + name = "virtualization-type" + values = ["hvm"] + } + + filter { + name = "state" + values = ["available"] + } + + # Apply any name filter from additional_macos_ami_filters first + dynamic "filter" { + for_each = var.additional_macos_ami_filters + content { + name = filter.value.name + values = filter.value.values + } + } + + # Only apply default name filter if no name filter exists in additional_macos_ami_filters + dynamic "filter" { + for_each = length([for f in var.additional_macos_ami_filters : f if f.name == "name" || f.name == "image-id"]) > 0 ? [] : [1] + content { + name = "name" + values = ["${var.macos_os}*${var.macos_os_version}*"] + } + } + + # Always apply architecture filter when using default instance types + dynamic "filter" { + for_each = var.ami_architecture != "" ? [1] : [] + content { + name = "architecture" + values = [var.ami_architecture] + } + } +} + +data "aws_instances" "asg_instances" { + count = local.create_asg ? 1 : 0 + + filter { + name = "instance-state-name" + values = ["running"] + } + + filter { + name = "tag:aws:autoscaling:groupName" + values = [aws_autoscaling_group.this[0].name] + } + + depends_on = [aws_autoscaling_group.this] +} + +data "http" "current_ip" { + count = var.create_alb && var.include_current_ip ? 1 : 0 + url = "https://api.ipify.org?format=text" +} diff --git a/modules/terraform-aws-instance-factory/iam.tf b/modules/terraform-aws-instance-factory/iam.tf new file mode 100644 index 00000000..dc41d403 --- /dev/null +++ b/modules/terraform-aws-instance-factory/iam.tf @@ -0,0 +1,41 @@ +# Create IAM role and instance profile for SSM access if enabled +resource "aws_iam_role" "ssm" { + count = var.enable_ssm && var.instance_profile == "" ? 1 : 0 + name = "${local.deployment_name}-ssm-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "ec2.amazonaws.com" + } + } + ] + }) + + tags = local.common_tags +} + +resource "aws_iam_role_policy_attachment" "ssm" { + count = var.enable_ssm && var.instance_profile == "" ? 1 : 0 + role = aws_iam_role.ssm[0].name + policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" +} + +# Add additional IAM policy attachments to the SSM role +resource "aws_iam_role_policy_attachment" "additional_policies" { + for_each = var.enable_ssm && var.instance_profile == "" ? var.additional_iam_policies : {} + + role = aws_iam_role.ssm[0].name + policy_arn = each.value +} + +resource "aws_iam_instance_profile" "ssm" { + count = var.enable_ssm && var.instance_profile == "" ? 1 : 0 + name = "${local.deployment_name}-ssm-profile" + role = aws_iam_role.ssm[0].name + tags = local.common_tags +} diff --git a/modules/terraform-aws-instance-factory/main.tf b/modules/terraform-aws-instance-factory/main.tf new file mode 100644 index 00000000..5e769167 --- /dev/null +++ b/modules/terraform-aws-instance-factory/main.tf @@ -0,0 +1,212 @@ +locals { + create_asg = var.enable_asg + is_windows = var.os_type == "windows" + is_macos = var.os_type == "macos" + is_linux = var.os_type == "linux" + create_key_pair = var.ssh_public_key != "" && !var.enable_ssm + + deployment_name = "${var.env}-${var.instance_name}" + + kms_key_id = var.encrypt_volumes && var.kms_key_arn != "" ? var.kms_key_arn : null + + common_tags = merge( + var.tags, + { + Environment = var.env + ManagedBy = "Terraform" + AccessMethod = var.enable_ssm ? "SSM" : "SSH" + } + ) +} + +resource "aws_instance" "this" { + #checkov:skip=CKV_AWS_8:Encryption is controlled by var.encrypt_volumes variable + count = local.create_asg ? 0 : 1 + + ami = local.is_windows && length(data.aws_ami.windows) > 0 ? data.aws_ami.windows[0].id : ( + local.is_macos && length(data.aws_ami.macos) > 0 ? data.aws_ami.macos[0].id : ( + local.is_linux && length(data.aws_ami.linux) > 0 ? data.aws_ami.linux[0].id : null + ) + ) + instance_type = var.instance_type + key_name = local.create_key_pair ? aws_key_pair.this[0].key_name : null + subnet_id = var.subnet_id + vpc_security_group_ids = concat([aws_security_group.this.id], var.additional_security_group_ids) + iam_instance_profile = var.enable_ssm && var.instance_profile == "" ? aws_iam_instance_profile.ssm[0].name : var.instance_profile + ebs_optimized = var.ebs_optimized + monitoring = var.enable_monitoring + user_data = var.user_data != "" ? var.user_data : null + user_data_replace_on_change = true + associate_public_ip_address = var.assign_public_ip && !var.enable_ssm + source_dest_check = var.source_dest_check + + root_block_device { + delete_on_termination = var.delete_on_termination + encrypted = var.encrypt_volumes + volume_size = var.root_volume_size + volume_type = var.volume_type + kms_key_id = local.kms_key_id + } + + dynamic "ebs_block_device" { + for_each = var.additional_ebs_volumes + content { + device_name = ebs_block_device.value.device_name + volume_size = ebs_block_device.value.volume_size + volume_type = ebs_block_device.value.volume_type + encrypted = var.encrypt_volumes + kms_key_id = local.kms_key_id + delete_on_termination = ebs_block_device.value.delete_on_termination + } + } + + metadata_options { + http_endpoint = var.enable_metadata ? "enabled" : "disabled" + http_tokens = var.require_imdsv2 ? "required" : "optional" + } + + lifecycle { + create_before_destroy = true + } + + tags = merge( + local.common_tags, + { + Name = local.deployment_name + } + ) +} + +resource "aws_launch_template" "this" { + count = local.create_asg ? 1 : 0 + + name_prefix = "${local.deployment_name}-template-" + image_id = local.is_windows && length(data.aws_ami.windows) > 0 ? data.aws_ami.windows[0].id : ( + local.is_macos && length(data.aws_ami.macos) > 0 ? data.aws_ami.macos[0].id : ( + local.is_linux && length(data.aws_ami.linux) > 0 ? data.aws_ami.linux[0].id : null + ) + ) + instance_type = var.instance_type + key_name = local.create_key_pair ? aws_key_pair.this[0].key_name : null + user_data = var.user_data != "" ? base64encode(var.user_data) : null + ebs_optimized = var.ebs_optimized + + network_interfaces { + associate_public_ip_address = var.assign_public_ip + security_groups = concat([aws_security_group.this.id], var.additional_security_group_ids) + delete_on_termination = true + } + + iam_instance_profile { + name = var.enable_ssm && var.instance_profile == "" ? aws_iam_instance_profile.ssm[0].name : var.instance_profile + } + + monitoring { + enabled = var.enable_monitoring + } + + metadata_options { + http_endpoint = var.enable_metadata ? "enabled" : "disabled" + http_tokens = var.require_imdsv2 ? "required" : "optional" + http_put_response_hop_limit = var.metadata_hop_limit + } + + block_device_mappings { + device_name = "/dev/sda1" + ebs { + delete_on_termination = var.delete_on_termination + encrypted = var.encrypt_volumes + volume_size = var.root_volume_size + volume_type = var.volume_type + kms_key_id = local.kms_key_id + } + } + + dynamic "block_device_mappings" { + for_each = var.additional_ebs_volumes + content { + device_name = block_device_mappings.value.device_name + ebs { + delete_on_termination = block_device_mappings.value.delete_on_termination + encrypted = var.encrypt_volumes + volume_size = block_device_mappings.value.volume_size + volume_type = block_device_mappings.value.volume_type + kms_key_id = local.kms_key_id + } + } + } + + tag_specifications { + resource_type = "instance" + tags = merge( + local.common_tags, + { + Name = local.deployment_name + } + ) + } + + tag_specifications { + resource_type = "volume" + tags = local.common_tags + } + + lifecycle { + create_before_destroy = true + } +} + +resource "aws_autoscaling_group" "this" { + count = local.create_asg ? 1 : 0 + + name_prefix = "${local.deployment_name}-asg-" + min_size = var.asg_min_size + max_size = var.asg_max_size + desired_capacity = var.asg_desired_capacity + vpc_zone_identifier = length(var.asg_subnet_ids) > 0 ? var.asg_subnet_ids : [var.subnet_id] + health_check_type = var.asg_health_check_type + health_check_grace_period = var.asg_health_check_grace_period + force_delete = var.asg_force_delete + termination_policies = var.asg_termination_policies + suspended_processes = var.asg_suspended_processes + + # Updated to include both ALB and NLB target groups + target_group_arns = concat( + local.create_alb ? [for tg in aws_lb_target_group.this : tg.arn] : [], + local.create_nlb ? [for tg in aws_lb_target_group.nlb : tg.arn] : [], + var.target_group_arns + ) + + launch_template { + id = aws_launch_template.this[0].id + version = "$Latest" + } + + dynamic "tag" { + for_each = merge( + local.common_tags, + var.asg_tags, + { + Name = local.deployment_name + } + ) + content { + key = tag.key + value = tag.value + propagate_at_launch = true + } + } + + lifecycle { + create_before_destroy = true + + replace_triggered_by = [ + aws_launch_template.this[0].latest_version + ] + } + + depends_on = [ + aws_launch_template.this, + aws_security_group.this, + ] +} diff --git a/modules/terraform-aws-instance-factory/network.tf b/modules/terraform-aws-instance-factory/network.tf new file mode 100644 index 00000000..9debf596 --- /dev/null +++ b/modules/terraform-aws-instance-factory/network.tf @@ -0,0 +1,6 @@ +resource "aws_key_pair" "this" { + count = local.create_key_pair ? 1 : 0 + key_name = "${local.deployment_name}-key-pair" + public_key = var.ssh_public_key + tags = local.common_tags +} diff --git a/modules/terraform-aws-instance-factory/nlb.tf b/modules/terraform-aws-instance-factory/nlb.tf new file mode 100644 index 00000000..b70b878a --- /dev/null +++ b/modules/terraform-aws-instance-factory/nlb.tf @@ -0,0 +1,100 @@ +locals { + create_nlb = var.create_nlb && var.enable_asg + + # Only truncate if deployment_name exceeds the limit for NLB name + nlb_name = length(local.deployment_name) > 32 ? substr(local.deployment_name, 0, 32) : local.deployment_name + + nlb_tg_name_prefix = local.deployment_name + get_nlb_tg_name = { for k, v in var.nlb_target_groups : k => + length("${local.nlb_tg_name_prefix}-${k}") > 32 ? + "${trimsuffix(substr(local.nlb_tg_name_prefix, 0, 32 - length(k) - 1), "-")}-${k}" : + "${trimsuffix(local.nlb_tg_name_prefix, "-")}-${k}" + } +} + +resource "aws_lb" "nlb" { + # checkov:skip=CKV_AWS_150: Deletion protection is conditionally enabled based on environment + count = local.create_nlb ? 1 : 0 + + name = local.nlb_name + internal = var.nlb_internal + load_balancer_type = "network" + subnets = var.nlb_subnet_ids + + enable_deletion_protection = var.env == "prod" + enable_cross_zone_load_balancing = var.nlb_cross_zone_enabled + + dynamic "access_logs" { + for_each = var.enable_nlb_access_logs && var.access_logs_bucket != null ? [1] : [] + content { + bucket = var.access_logs_bucket + enabled = true + prefix = var.nlb_access_logs_prefix + } + } + + tags = merge( + local.common_tags, + { + Name = local.deployment_name + } + ) +} + +resource "aws_lb_target_group" "nlb" { + for_each = var.nlb_target_groups + name = local.get_nlb_tg_name[each.key] + port = each.value.port + protocol = each.value.protocol + vpc_id = var.vpc_id + target_type = each.value.target_type + deregistration_delay = var.deregistration_delay + preserve_client_ip = each.value.preserve_client_ip + + dynamic "health_check" { + for_each = [each.value.health_check] + content { + enabled = true + port = health_check.value.port + protocol = health_check.value.protocol + path = health_check.value.protocol == "HTTP" || health_check.value.protocol == "HTTPS" ? health_check.value.path : null + healthy_threshold = health_check.value.healthy_threshold + unhealthy_threshold = health_check.value.unhealthy_threshold + timeout = health_check.value.timeout + interval = health_check.value.interval + matcher = health_check.value.protocol == "HTTP" || health_check.value.protocol == "HTTPS" ? health_check.value.matcher : null + } + } + + tags = merge( + local.common_tags, + { + Name = "${local.deployment_name}-${each.key}-tg" + } + ) +} + +resource "aws_lb_listener" "nlb" { + # checkov:skip=CKV_AWS_2: "NLB uses TCP protocol, not HTTP/HTTPS" + # checkov:skip=CKV_AWS_103: "TLS policy not applicable for TCP listeners" + for_each = var.nlb_listeners + + load_balancer_arn = aws_lb.nlb[0].arn + port = each.value.port + protocol = each.value.protocol + certificate_arn = each.value.certificate_arn + alpn_policy = each.value.alpn_policy + ssl_policy = each.value.ssl_policy + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.nlb[each.value.target_group_key].arn + } + + tags = merge( + local.common_tags, + { + Name = "${local.deployment_name}-nlb-listener-${each.key}" + } + ) +} diff --git a/modules/terraform-aws-instance-factory/outputs.tf b/modules/terraform-aws-instance-factory/outputs.tf new file mode 100644 index 00000000..043c48ec --- /dev/null +++ b/modules/terraform-aws-instance-factory/outputs.tf @@ -0,0 +1,186 @@ +output "alb_arn" { + description = "ARN of the Application Load Balancer" + value = local.create_alb ? aws_lb.this[0].arn : null +} + +output "alb_dns_name" { + description = "DNS name of the Application Load Balancer" + value = local.create_alb ? aws_lb.this[0].dns_name : null +} + +output "alb_id" { + description = "ID of the Application Load Balancer" + value = local.create_alb ? aws_lb.this[0].id : null +} + +output "alb_security_group_id" { + description = "ID of the ALB security group" + value = local.create_alb ? aws_security_group.alb[0].id : null +} + +output "alb_zone_id" { + description = "The canonical hosted zone ID of the ALB" + value = local.create_alb ? aws_lb.this[0].zone_id : null +} + +output "all_instance_details" { + description = "Detailed information about all instances (both standalone and ASG)" + value = { + deployment_type = local.create_asg ? "asg" : "standalone" + + asg = local.create_asg ? { + id = aws_autoscaling_group.this[0].id + name = aws_autoscaling_group.this[0].name + private_ips = length(data.aws_instances.asg_instances) > 0 ? data.aws_instances.asg_instances[0].private_ips : [] + instance_ids = length(data.aws_instances.asg_instances) > 0 ? data.aws_instances.asg_instances[0].ids : [] + } : null + + standalone_instances = !local.create_asg ? { + for instance in aws_instance.this : instance.id => { + name = instance.tags["Name"] + public_ip = instance.public_ip + private_ip = instance.private_ip + subnet_id = instance.subnet_id + os_type = local.is_windows ? "windows" : (local.is_macos ? "macos" : "linux") + } + } : {} + + load_balancers = { + alb = local.create_alb ? { + dns_name = aws_lb.this[0].dns_name + arn = aws_lb.this[0].arn + id = aws_lb.this[0].id + } : null + + nlb = local.create_nlb ? { + dns_name = aws_lb.nlb[0].dns_name + arn = aws_lb.nlb[0].arn + id = aws_lb.nlb[0].id + } : null + } + } +} + +output "ami_id" { + description = "ID of the AMI used" + value = local.is_windows && length(data.aws_ami.windows) > 0 ? data.aws_ami.windows[0].id : ( + local.is_macos && length(data.aws_ami.macos) > 0 ? data.aws_ami.macos[0].id : ( + local.is_linux && length(data.aws_ami.linux) > 0 ? data.aws_ami.linux[0].id : null + ) + ) +} + +output "asg_arn" { + description = "ARN of the created Auto Scaling Group" + value = local.create_asg ? aws_autoscaling_group.this[0].arn : null +} + +output "asg_id" { + description = "ID of the created Auto Scaling Group" + value = local.create_asg ? aws_autoscaling_group.this[0].id : null +} + +output "asg_name" { + description = "Name of the created Auto Scaling Group" + value = local.create_asg ? aws_autoscaling_group.this[0].name : null +} + +output "instance_arns" { + description = "ARNs of created instances" + value = local.create_asg ? [] : aws_instance.this[*].arn +} + +output "instance_details" { + description = "Map of instance details" + value = local.create_asg ? { + asg = { + id = aws_autoscaling_group.this[0].id + } + } : { + for instance in aws_instance.this : instance.id => { + name = instance.tags["Name"] + public_ip = instance.public_ip + private_ip = instance.private_ip + subnet_id = instance.subnet_id + os_type = local.is_windows ? "windows" : (local.is_macos ? "macos" : "linux") + } + } +} + +output "instance_ids" { + description = "IDs of created instances (single instance or ASG instances)" + value = local.create_asg ? [] : aws_instance.this[*].id +} + +output "instance_private_ips" { + description = "Private IPs of created instances (single instance or ASG instances)" + value = local.create_asg ? ( + length(data.aws_instances.asg_instances) > 0 ? data.aws_instances.asg_instances[0].private_ips : [] + ) : aws_instance.this[*].private_ip +} + +output "instance_public_ips" { + description = "Public IPs of created instances" + value = local.create_asg ? [] : aws_instance.this[*].public_ip +} + +output "instance_primary_eni_ids" { + description = "Primary ENI IDs of created instances" + value = local.create_asg ? [] : aws_instance.this[*].primary_network_interface_id +} + +output "launch_template_id" { + description = "ID of the created Launch Template" + value = local.create_asg ? aws_launch_template.this[0].id : null +} + +output "launch_template_latest_version" { + description = "Latest version of the Launch Template" + value = local.create_asg ? aws_launch_template.this[0].latest_version : null +} + +output "nlb_arn" { + description = "ARN of the Network Load Balancer" + value = var.create_nlb ? aws_lb.nlb[0].arn : null +} + +output "nlb_dns_name" { + description = "DNS name of the Network Load Balancer" + value = local.create_nlb ? aws_lb.nlb[0].dns_name : null +} + +output "nlb_id" { + description = "ID of the Network Load Balancer" + value = local.create_nlb ? aws_lb.nlb[0].id : null +} + +output "nlb_zone_id" { + description = "The canonical hosted zone ID of the NLB" + value = local.create_nlb ? aws_lb.nlb[0].zone_id : null +} + +output "nlb_target_group_arns" { + description = "ARNs of the NLB Target Groups" + value = local.create_nlb ? { for k, v in aws_lb_target_group.nlb : k => v.arn } : null +} + +output "security_group_arn" { + description = "ARN of the created security group" + value = aws_security_group.this.arn +} + +output "security_group_id" { + description = "ID of the created security group" + value = aws_security_group.this.id +} + +output "target_group_arns" { + description = "ARNs of the Target Groups" + value = local.create_alb ? { for k, v in aws_lb_target_group.this : k => v.arn } : null +} + +output "windows_password_data" { + description = "Password data for Windows instances (encrypted)" + value = local.is_windows && !local.create_asg ? aws_instance.this[*].password_data : [] + sensitive = true +} diff --git a/modules/terraform-aws-instance-factory/sg.tf b/modules/terraform-aws-instance-factory/sg.tf new file mode 100644 index 00000000..310fe38c --- /dev/null +++ b/modules/terraform-aws-instance-factory/sg.tf @@ -0,0 +1,149 @@ +locals { + alb_allowed_cidrs = var.include_current_ip ? concat(var.allowed_cidr_blocks, ["${chomp(data.http.current_ip[0].response_body)}/32"]) : var.allowed_cidr_blocks + + default_egress_rule = [{ + name = "Allow All Outbound" + description = "Allow all outbound traffic" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + }] + + # Combine SSH rules with other ingress rules if SSH is enabled + ssh_rule = (var.ssh_public_key != "" && !var.enable_ssm && length(var.ssh_allowed_cidr_blocks) > 0) ? [{ + name = "SSH Access" + description = "Allow SSH access" + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = var.ssh_allowed_cidr_blocks + }] : [] + + instance_base_ingress_rules = concat(local.ssh_rule, var.ingress_rules) + final_egress_rules = length(var.egress_rules) > 0 ? var.egress_rules : local.default_egress_rule +} + +resource "aws_security_group" "alb" { + count = local.create_alb ? 1 : 0 + + name = "${local.deployment_name}-alb-sg" + description = "Security group for ALB" + vpc_id = var.vpc_id + + # Allow HTTPS/HTTP from the specified CIDR blocks + dynamic "ingress" { + for_each = var.tls_configuration != null ? [443] : [80] + content { + description = "${local.deployment_name}-alb-${ingress.value == 443 ? "https" : "http"}-ingress" + from_port = ingress.value + to_port = ingress.value + protocol = "tcp" + cidr_blocks = local.alb_allowed_cidrs + } + } + + # This covers all private IP ranges used in AWS VPCs + dynamic "ingress" { + for_each = var.alb_internal ? (var.tls_configuration != null ? [443] : [80]) : [] + content { + description = "Allow internal network access to ALB ${ingress.value == 443 ? "HTTPS" : "HTTP"}" + from_port = ingress.value + to_port = ingress.value + protocol = "tcp" + cidr_blocks = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"] # Standard private IP ranges + } + } + + # Additional security group rules for ALB + dynamic "ingress" { + for_each = var.alb_additional_security_group_rules + content { + description = lookup(ingress.value, "description", null) + from_port = lookup(ingress.value, "from_port", null) + to_port = lookup(ingress.value, "to_port", null) + protocol = lookup(ingress.value, "protocol", null) + cidr_blocks = lookup(ingress.value, "cidr_blocks", null) + ipv6_cidr_blocks = lookup(ingress.value, "ipv6_cidr_blocks", null) + prefix_list_ids = lookup(ingress.value, "prefix_list_ids", null) + security_groups = lookup(ingress.value, "security_groups", null) + self = lookup(ingress.value, "self", null) + } + } + + egress { + description = "${local.deployment_name}-alb-to-instances" + from_port = var.alb_target_port + to_port = var.alb_target_port + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] # This will be restricted by the instance security group + } + + tags = merge( + local.common_tags, + { + Name = "${local.deployment_name}-alb-sg" + } + ) +} + +resource "aws_security_group" "this" { + name = "${local.deployment_name}-sg" + description = "Security group for ${var.os_type} instance(s)" + vpc_id = var.vpc_id + + # First, create all the base ingress rules + dynamic "ingress" { + for_each = local.instance_base_ingress_rules + content { + description = lookup(ingress.value, "description", "") + from_port = lookup(ingress.value, "from_port", null) + to_port = lookup(ingress.value, "to_port", null) + protocol = lookup(ingress.value, "protocol", null) + cidr_blocks = lookup(ingress.value, "cidr_blocks", null) + ipv6_cidr_blocks = lookup(ingress.value, "ipv6_cidr_blocks", null) + prefix_list_ids = lookup(ingress.value, "prefix_list_ids", null) + security_groups = lookup(ingress.value, "security_groups", null) + self = lookup(ingress.value, "self", null) + } + } + + # Add the ALB rule separately if ALB is enabled + dynamic "ingress" { + for_each = local.create_alb ? [1] : [] + content { + description = "Allow traffic from ALB to instances" + from_port = var.alb_target_port + to_port = var.alb_target_port + protocol = "tcp" + security_groups = [aws_security_group.alb[0].id] + } + } + + # Egress rules + dynamic "egress" { + for_each = local.final_egress_rules + content { + description = lookup(egress.value, "description", "") + from_port = lookup(egress.value, "from_port", null) + to_port = lookup(egress.value, "to_port", null) + protocol = lookup(egress.value, "protocol", null) + cidr_blocks = lookup(egress.value, "cidr_blocks", null) + ipv6_cidr_blocks = lookup(egress.value, "ipv6_cidr_blocks", null) + prefix_list_ids = lookup(egress.value, "prefix_list_ids", null) + security_groups = lookup(egress.value, "security_groups", null) + self = lookup(egress.value, "self", null) + } + } + + lifecycle { + create_before_destroy = true + } + + tags = merge( + local.common_tags, + { + Name = "${local.deployment_name}-sg" + } + ) +} diff --git a/modules/terraform-aws-instance-factory/variables.tf b/modules/terraform-aws-instance-factory/variables.tf new file mode 100644 index 00000000..16dc9ffd --- /dev/null +++ b/modules/terraform-aws-instance-factory/variables.tf @@ -0,0 +1,553 @@ +variable "access_logs_bucket" { + description = "S3 bucket for ALB access logs" + type = string + default = null +} + +variable "additional_ebs_volumes" { + description = "Additional EBS volumes to attach" + type = list(object({ + device_name = string + volume_size = number + volume_type = string + delete_on_termination = bool + })) + default = [] +} + +variable "additional_iam_policies" { + description = "Map of additional IAM policies to attach to the instance role (if SSM is enabled). Example usage: additional_iam_policies = { s3_full_access = \"arn:aws:iam::aws:policy/AmazonS3FullAccess\" }" + type = map(string) + default = {} +} + +variable "additional_linux_ami_filters" { + description = "Additional filters for Linux AMI lookup" + type = list(object({ + name = string + values = list(string) + })) + default = [] +} + +variable "additional_macos_ami_filters" { + description = "Additional filters for macOS AMI lookup" + type = list(object({ + name = string + values = list(string) + })) + default = [] +} + +variable "additional_security_group_ids" { + type = list(string) + description = "Additional security group IDs to attach" + default = [] +} + +variable "additional_windows_ami_filters" { + description = "Additional filters for Windows AMI lookup" + type = list(object({ + name = string + values = list(string) + })) + default = [] +} + +variable "alb_additional_security_group_rules" { + description = "List of additional rules for the ALB security group" + type = list(any) + default = [] +} + +variable "alb_idle_timeout" { + description = "The time in seconds that the connection is allowed to be idle" + type = number + default = 60 +} + +variable "alb_internal" { + description = "Whether ALB should be internal" + type = bool + default = false +} + +variable "alb_subnet_ids" { + description = "List of subnet IDs where the ALB will be deployed" + type = list(string) + default = [] +} + +variable "alb_target_port" { + description = "Port for ALB target group" + type = number + default = 80 +} + +variable "allowed_cidr_blocks" { + description = "List of CIDR blocks allowed to access the ALB" + type = list(string) + default = [] +} + +variable "ami_architecture" { + description = "Architecture for AMI selection (x86_64, arm64, etc.)" + type = string + default = "x86_64" +} + +variable "source_dest_check" { + description = "Controls if traffic is routed to the instance when the destination address does not match the instance. Used for NAT or VPN instances." + type = bool + default = true +} + +variable "assign_public_ip" { + type = bool + description = "Assign public IP address to the instance(s)" + default = false +} + +variable "asg_desired_capacity" { + type = number + description = "Desired capacity of ASG" + default = 1 +} + +variable "asg_force_delete" { + type = bool + description = "Force delete ASG" + default = false +} + +variable "asg_health_check_grace_period" { + type = number + description = "Health check grace period for ASG" + default = 300 +} + +variable "asg_health_check_type" { + type = string + description = "Health check type for ASG" + default = "EC2" +} + +variable "asg_max_size" { + type = number + description = "Maximum size of ASG" + default = 1 +} + +variable "asg_min_size" { + type = number + description = "Minimum size of ASG" + default = 1 +} + +variable "asg_subnet_ids" { + type = list(string) + description = "Subnet IDs for ASG" + default = [] +} + +variable "asg_tags" { + description = "Additional tags for ASG" + type = map(string) + default = {} +} + +variable "asg_termination_policies" { + type = list(string) + description = "Termination policies for ASG" + default = ["Default"] +} + +variable "asg_suspended_processes" { + type = list(string) + description = "List of processes to suspend for the ASG (e.g., ReplaceUnhealthy, Launch, Terminate)" + default = [] +} + +variable "create_alb" { + description = "Whether to create an Application Load Balancer" + type = bool + default = false +} + +variable "create_nlb" { + description = "Whether to create a Network Load Balancer" + type = bool + default = false +} + +variable "delete_on_termination" { + type = bool + description = "Delete volumes on instance termination" + default = true +} + +variable "deregistration_delay" { + description = "Amount of time to wait for in-flight requests before deregistering a target" + type = number + default = 300 +} + +variable "drop_invalid_header_fields" { + description = "Drop invalid header fields in HTTP(S) requests" + type = bool + default = true +} + +variable "ebs_optimized" { + type = bool + description = "Enable EBS optimization" + default = true +} + +variable "egress_rules" { + description = "List of egress rules to create" + type = list(any) + default = [] +} + +variable "enable_access_logs" { + description = "Enable ALB access logging to S3" + type = bool + default = false +} + +variable "enable_asg" { + description = "Whether to create an Auto Scaling Group instead of a single instance" + type = bool + default = false +} + +variable "enable_metadata" { + type = bool + description = "Enable metadata service" + default = true +} + +variable "enable_monitoring" { + type = bool + description = "Enable detailed monitoring" + default = true +} + +variable "enable_ssm" { + type = bool + description = "Enable AWS Systems Manager Session Manager access" + default = false +} + +variable "encrypt_volumes" { + type = bool + description = "Enable volume encryption" + default = true +} + +variable "env" { + type = string + description = "Environment name (e.g., 'dev', 'staging', 'prod')" +} + +variable "health_check_healthy_threshold" { + description = "Number of consecutive health check successes required" + type = number + default = 2 +} + +variable "health_check_interval" { + description = "Health check interval in seconds" + type = number + default = 30 +} + +variable "health_check_timeout" { + description = "Health check timeout in seconds" + type = number + default = 10 +} + +variable "health_check_unhealthy_threshold" { + description = "Number of consecutive health check failures required" + type = number + default = 5 +} + +variable "include_current_ip" { + type = bool + description = "Whether to include the current IP address in the allowed CIDR blocks" + default = false +} + +variable "ingress_rules" { + description = "List of ingress rules to create" + type = list(any) + default = [] +} + +variable "instance_name" { + type = string + description = "Name of the instance(s)" +} + +variable "instance_profile" { + type = string + description = "IAM instance profile name. If empty and enable_ssm is true, a new profile will be created" + default = "" +} + +variable "instance_type" { + description = "EC2 instance type" + type = string +} + +variable "kms_key_arn" { + description = "KMS key ARN for volume encryption. If empty and encrypt_volumes is true, AWS default encryption will be used" + type = string + default = "" + + validation { + condition = var.kms_key_arn == "" || can(regex("^arn:aws:kms:[a-z0-9-]+:[0-9]{12}:key/[a-f0-9-]+$", var.kms_key_arn)) + error_message = "The kms_key_arn must be a valid KMS key ARN or empty string." + } +} + +variable "linux_ami_owners" { + type = list(string) + description = "List of Linux AMI owners" + default = ["amazon"] +} + +variable "linux_os" { + type = string + description = "Linux OS name" + default = "ubuntu/images/hvm-ssd/ubuntu-jammy" +} + +variable "linux_os_version" { + type = string + description = "Linux OS version pattern" + default = "*" +} + +variable "macos_ami_owners" { + type = list(string) + description = "List of macOS AMI owners" + default = ["amazon"] +} + +variable "macos_os" { + type = string + description = "macOS name" + default = "amzn-ec2-macos" +} + +variable "macos_os_version" { + type = string + description = "macOS version" + default = "13" +} + +variable "metadata_hop_limit" { + type = number + description = "Metadata service hop limit" + default = 1 +} + +# NLB-specific variables +variable "nlb_internal" { + description = "Whether NLB should be internal" + type = bool + default = false +} + +variable "nlb_subnet_ids" { + description = "List of subnet IDs where the NLB will be deployed" + type = list(string) + default = [] +} + +variable "nlb_target_groups" { + description = "Map of NLB target group configurations" + type = map(object({ + port = number + protocol = string + target_type = string + preserve_client_ip = optional(bool, true) + health_check = optional(object({ + port = optional(string, "traffic-port") + protocol = optional(string, "TCP") + path = optional(string, "/") + healthy_threshold = optional(number, 3) + unhealthy_threshold = optional(number, 3) + timeout = optional(number, 10) + interval = optional(number, 30) + matcher = optional(string, "200-399") + }), {}) + })) + default = {} + + validation { + condition = alltrue([ + for key in keys(var.nlb_target_groups) : can(regex("^[a-zA-Z0-9-]+$", key)) + ]) + error_message = "Target group keys must contain only alphanumeric characters and hyphens (no underscores). AWS NLB target group names don't allow underscores." + } +} + +variable "nlb_listeners" { + description = "Map of NLB listener configurations" + type = map(object({ + port = number + protocol = string + target_group_key = string + certificate_arn = optional(string, null) + alpn_policy = optional(string, null) + ssl_policy = optional(string, null) + })) + default = {} +} + +variable "nlb_cross_zone_enabled" { + description = "Enable cross-zone load balancing for NLB" + type = bool + default = true +} + +variable "enable_nlb_access_logs" { + description = "Enable NLB access logging to S3" + type = bool + default = false +} + +variable "nlb_access_logs_prefix" { + description = "S3 prefix for NLB access logs" + type = string + default = "nlb-logs" +} + +variable "os_type" { + description = "Operating system type (linux, windows, or macos)" + type = string + validation { + condition = contains(["linux", "windows", "macos"], var.os_type) + error_message = "Valid values for os_type are: linux, windows, macos." + } +} + +variable "require_imdsv2" { + type = bool + description = "Require IMDSv2 metadata" + default = true +} + +variable "root_volume_size" { + type = number + description = "Size of root volume in GB" + default = 100 +} + +variable "ssh_allowed_cidr_blocks" { + type = list(string) + description = "CIDR blocks allowed for SSH access when using SSH instead of SSM" + default = [] + + validation { + condition = length(var.ssh_allowed_cidr_blocks) == 0 || !var.enable_ssm + error_message = "SSH CIDR blocks should not be specified when using SSM for access." + } +} + +variable "ssh_public_key" { + type = string + description = "Public SSH key. Cannot be set if SSM is enabled" + default = "" + + validation { + condition = var.ssh_public_key == "" || !var.enable_ssm + error_message = "SSH access should not be configured when using SSM. Use either SSM or SSH, not both." + } +} + +variable "stickiness_cookie_duration" { + description = "Cookie duration in seconds for session stickiness" + type = number + default = 86400 +} + +variable "subnet_id" { + description = "Subnet ID for single instance deployment" + type = string + default = "" +} + +variable "tags" { + description = "Additional tags for resources" + type = map(string) + default = {} +} + +variable "target_group_arns" { + type = list(string) + description = "Target group ARNs for ASG" + default = [] +} + +variable "target_groups" { + description = "Map of target group configurations" + type = map(object({ + name = string + port = number + protocol = string + target_type = string + health_check_path = optional(string, "/") + stickiness_enabled = optional(bool, false) + })) + default = {} +} + +variable "tls_configuration" { + description = "TLS configuration for HTTPS listener" + type = object({ + certificate_arn = string + ssl_policy = string + }) + default = null +} + +variable "user_data" { + description = "User data script" + type = string + default = "" +} + +variable "volume_type" { + type = string + description = "EBS volume type" + default = "gp3" +} + +variable "vpc_id" { + description = "VPC ID where resources will be created" + type = string +} + +variable "windows_ami_owners" { + type = list(string) + description = "List of Windows AMI owners" + default = ["amazon"] +} + +variable "windows_os" { + type = string + description = "Windows OS name" + default = "Windows_Server" +} + +variable "windows_os_version" { + type = string + description = "Windows OS version" + default = "2022-English-Full-Base" +} diff --git a/modules/terraform-aws-instance-factory/versions.tf b/modules/terraform-aws-instance-factory/versions.tf new file mode 100644 index 00000000..5d898ca4 --- /dev/null +++ b/modules/terraform-aws-instance-factory/versions.tf @@ -0,0 +1,18 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.36.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.8.0" + } + http = { + source = "hashicorp/http" + version = "~> 3.5.0" + } + } + + required_version = "~> 1.7" +} diff --git a/modules/terraform-aws-net/README.md b/modules/terraform-aws-net/README.md new file mode 100644 index 00000000..90b3ab3a --- /dev/null +++ b/modules/terraform-aws-net/README.md @@ -0,0 +1,389 @@ +# AWS Network Terraform Module + +
+ +Logo + +## Terraform module for AWS VPC and Network Infrastructure ☁️ + +_... managed with Terraform, Terratest, and GitHub Actions_ 🤖 + +
+ +
+ +[![Terratest](https://github.com/dreadnode/terraform-aws-net/actions/workflows/terratest.yaml/badge.svg)](https://github.com/dreadnode/terraform-aws-net/actions/workflows/terratest.yaml) +[![Pre-Commit](https://github.com/dreadnode/terraform-aws-net/actions/workflows/pre-commit.yaml/badge.svg)](https://github.com/dreadnode/terraform-aws-net/actions/workflows/pre-commit.yaml) +[![Renovate](https://github.com/dreadnode/terraform-aws-net/actions/workflows/renovate.yaml/badge.svg)](https://github.com/dreadnode/terraform-aws-net/actions/workflows/renovate.yaml) + +
+ +--- + +## 📖 Overview + +This Terraform module creates a complete AWS networking foundation with public +and private subnets across multiple Availability Zones. It includes: + +- A VPC with DNS support and hostnames enabled +- Public subnets with route to Internet Gateway +- Private subnets with route to NAT Gateway +- NAT Gateway with Elastic IP for outbound internet access +- Configurable VPC endpoints for AWS services (Interface and Gateway types) +- Security groups for VPC endpoints with customizable ingress/egress rules +- Automatic AZ distribution for subnet placement +- Route tables for both public and private subnets +- Optional Kubernetes integration with cluster-specific resource tagging + +The module is designed to provide a secure and scalable network architecture +that follows AWS best practices. All resources are tagged consistently, and the +configuration can be customized through variables to support different +environments and requirements. + +Key features: + +- Multi-AZ deployment for high availability +- Separate public and private subnet tiers +- Configurable CIDR blocks and subnet sizes +- Optional VPC endpoints for AWS service access +- Flexible security group rules for endpoint access +- Kubernetes integration for EKS clusters +- Terragrunt-compatible structure + +--- + +## Table of Contents + +- [Features](#features) +- [Usage](#usage) +- [Inputs](#inputs) +- [Outputs](#outputs) +- [Requirements](#requirements) +- [Development](#development) + +--- + +## Features + +This Terraform module deploys the following AWS resources: + +### VPC Endpoints (Optional) + +- Support for both Interface and Gateway endpoint types +- Automatic security group creation and management for Interface endpoints +- Private DNS configuration for Interface endpoints +- Route table associations for Gateway endpoints +- Customizable endpoint access through security group rules + +### Security & Access + +- Configurable public IP mapping for instances in public subnets +- Default security group with customizable name +- VPC endpoint security groups with configurable ingress/egress rules +- Separation of public and private resources across subnet tiers + +### Kubernetes Integration (Optional) + +- Automatic tagging of VPC and subnet resources for Kubernetes integration +- Support for EKS load balancer controller with appropriate subnet role tags +- Configurable cluster name for resource tagging +- Public subnet tagging for external load balancers +- Private subnet tagging for internal load balancers + +### Infrastructure Management + +- Multi-AZ deployment for high availability +- Automatic AZ distribution for subnet placement +- Flexible tagging system for all resources +- Terragrunt-compatible structure +- Lifecycle management with create_before_destroy support + +### Customization Options + +- Configurable CIDR blocks for VPC and subnets +- Adjustable subnet sizes and distribution +- Optional VPC endpoint deployment +- Customizable security group rules +- Flexible tagging system for resource management + +--- + +## Usage + +### Basic Network Setup + +```hcl +module "network" { + source = "github.com/dreadnode/terraform-aws-network" + env = "dev" + deployment_name = "my-crucible" + vpc_cidr_block = "10.0.0.0/16" + public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"] + private_subnet_cidrs = ["10.0.3.0/24", "10.0.4.0/24", "10.0.32.0/21", "10.0.40.0/21"] + map_public_ip = true + additional_tags = { + Project = "MyProject" + } +} +``` + +### With VPC Endpoints + +This example shows how to enable VPC endpoints for common AWS services with +custom security group rules: + +```hcl +module "network" { + source = "github.com/dreadnode/terraform-aws-network" + env = "dev" + deployment_name = "my-crucible" + vpc_cidr_block = "10.0.0.0/16" + public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"] + private_subnet_cidrs = ["10.0.3.0/24", "10.0.4.0/24", "10.0.32.0/21", "10.0.40.0/21"] + env = "dev" + deployment_name = "my-crucible" + vpc_cidr_block = "10.0.0.0/16" + public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"] + private_subnet_cidrs = ["10.0.3.0/24", "10.0.4.0/24", "10.0.32.0/21", "10.0.40.0/21"] + + # VPC Endpoint configurations + vpc_endpoints = { + secretsmanager = { + service = "secretsmanager" + type = "Interface" + security_group_ids = [] # Module will create and manage security group + } + ecr_dkr = { + service = "ecr.dkr" + type = "Interface" + security_group_ids = [] + } + ecr_api = { + service = "ecr.api" + type = "Interface" + security_group_ids = [] + } + cloudwatch = { + service = "logs" + type = "Interface" + security_group_ids = [] + } + s3 = { + service = "s3" + type = "Gateway" + security_group_ids = [] # Not used for Gateway endpoints + } + } + + # Security group rules for VPC endpoints + vpce_security_group_rules = { + ingress_security_group_ids = [] # Add SG IDs that need access to endpoints + ingress_cidr_blocks = ["10.0.0.0/16"] # Allow access from within VPC + egress_cidr_blocks = ["0.0.0.0/0"] + } + + additional_tags = { + Project = "MyProject" + } +} +``` + +### With Kubernetes Integration + +This example shows how to enable Kubernetes-specific tagging for EKS integration: + +```hcl +module "network" { + source = "github.com/dreadnode/terraform-aws-network" + env = "dev" + deployment_name = "my-crucible" + vpc_cidr_block = "10.0.0.0/16" + public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"] + private_subnet_cidrs = ["10.0.3.0/24", "10.0.4.0/24", "10.0.32.0/21", "10.0.40.0/21"] + + # Enable Kubernetes tagging with custom cluster name + kubernetes_tags = { + enabled = true + cluster_name = "my-eks-cluster" # Optional - defaults to {env}-{deployment_name} + } + + additional_tags = { + Project = "MyProject" + } +} +``` + +### Outputs + +The module provides several useful outputs: + +```hcl +# VPC Information +output "vpc_id" { + value = module.network.vpc_id +} + +# Subnet IDs +output "private_subnet_ids" { + value = module.network.private_subnet_ids +} + +output "public_subnet_ids" { + value = module.network.public_subnet_ids +} + +# VPC Endpoints +output "vpc_endpoints" { + value = module.network.vpc_endpoints +} + +# Security Group for VPC Endpoints +output "vpce_security_group" { + value = module.network.vpce_security_group +} +``` + +### Notes + +- VPC endpoints are optional - the module will only create them if the + `vpc_endpoints` variable is populated +- The module automatically creates and manages security groups for Interface + endpoints +- For Gateway endpoints (like S3), security groups are not required or used +- You can control access to the endpoints through the + `vpce_security_group_rules` variable +- All endpoints are placed in private subnets by default +- When Kubernetes tagging is enabled, the following tags are applied: + - To all resources: `kubernetes.io/cluster/` = "owned" + - To public subnets: `kubernetes.io/role/elb` = "1" + - To private subnets: `kubernetes.io/role/internal-elb` = "1" +- These tags allow AWS load balancer controller to automatically discover + and use the appropriate subnets + +--- + + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | ~> 1.7 | +| [aws](#requirement\_aws) | ~> 6.32.0 | + +## Providers + +| Name | Version | +|------|---------| +| [aws](#provider\_aws) | 6.32.1 | + +## Modules + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [aws_default_security_group.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/default_security_group) | resource | +| [aws_eip.nat](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/eip) | resource | +| [aws_internet_gateway.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/internet_gateway) | resource | +| [aws_nat_gateway.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/nat_gateway) | resource | +| [aws_route.additional_private](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route) | resource | +| [aws_route.private_nat_gateway](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route) | resource | +| [aws_route_table.private](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route_table) | resource | +| [aws_route_table.public](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route_table) | resource | +| [aws_route_table_association.pod](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route_table_association) | resource | +| [aws_route_table_association.private](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route_table_association) | resource | +| [aws_route_table_association.public](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route_table_association) | resource | +| [aws_security_group.vpce](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group) | resource | +| [aws_subnet.pod](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/subnet) | resource | +| [aws_subnet.private](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/subnet) | resource | +| [aws_subnet.public](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/subnet) | resource | +| [aws_vpc.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc) | resource | +| [aws_vpc_endpoint.endpoints](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_endpoint) | resource | +| [aws_vpc_ipv4_cidr_block_association.secondary](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_ipv4_cidr_block_association) | resource | +| [aws_availability_zones.available](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/availability_zones) | data source | +| [aws_region.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/region) | data source | +| [aws_route_tables.private](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/route_tables) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [additional\_private\_routes](#input\_additional\_private\_routes) | Additional routes to add to the private route table |
map(object({
destination_cidr_block = string
network_interface_id = optional(string, null)
gateway_id = optional(string, null)
nat_gateway_id = optional(string, null)
transit_gateway_id = optional(string, null)
vpc_peering_connection_id = optional(string, null)
}))
| `{}` | no | +| [additional\_tags](#input\_additional\_tags) | Additional tags to apply to resources | `map(string)` | `{}` | no | +| [deployment\_name](#input\_deployment\_name) | Name of the deployment (ex: "crucible") | `string` | n/a | yes | +| [env](#input\_env) | The environment name (e.g., dev, staging, prod, global) | `string` | n/a | yes | +| [kubernetes\_tags](#input\_kubernetes\_tags) | Configuration for Kubernetes integration tags |
object({
enabled = bool
cluster_name = optional(string, "") # Will default to {env}-{deployment_name} if empty
enable_karpenter_discovery = optional(bool, false)
})
|
{
"enable_karpenter_discovery": false,
"enabled": false
}
| no | +| [map\_public\_ip](#input\_map\_public\_ip) | Map public IP addresses to new instances. | `bool` | `true` | no | +| [pod\_subnet\_newbits](#input\_pod\_subnet\_newbits) | Number of bits to add to the secondary CIDR for pod subnets (e.g., 4 for /20 subnets from /16) | `number` | `4` | no | +| [secondary\_cidr\_block](#input\_secondary\_cidr\_block) | Secondary CIDR block for pod networking (e.g., 100.64.0.0/16). Uses CG-NAT space to avoid conflicts. | `string` | `""` | no | +| [vpc\_cidr\_block](#input\_vpc\_cidr\_block) | Top-level CIDR block for the VPC | `string` | `"10.0.0.0/16"` | no | +| [vpc\_endpoints](#input\_vpc\_endpoints) | Map of VPC endpoint configurations |
map(object({
service = string
type = string
private_dns = optional(bool, false) # Make private_dns optional with default false
}))
|
{
"cloudwatch": {
"private_dns": true,
"service": "logs",
"type": "Interface"
},
"ecr_api": {
"private_dns": true,
"service": "ecr.api",
"type": "Interface"
},
"ecr_dkr": {
"private_dns": true,
"service": "ecr.dkr",
"type": "Interface"
},
"s3": {
"service": "s3",
"type": "Gateway"
},
"secretsmanager": {
"private_dns": true,
"service": "secretsmanager",
"type": "Interface"
},
"sns": {
"private_dns": false,
"service": "sns",
"type": "Interface"
}
}
| no | +| [vpce\_security\_group\_rules](#input\_vpce\_security\_group\_rules) | Security group rules for VPC endpoints |
object({
ingress_security_group_ids = optional(list(string), [])
ingress_cidr_blocks = optional(list(string), [])
egress_cidr_blocks = optional(list(string), ["0.0.0.0/0"])
})
|
{
"egress_cidr_blocks": [
"0.0.0.0/0"
],
"ingress_cidr_blocks": [],
"ingress_security_group_ids": []
}
| no | + +## Outputs + +| Name | Description | +|------|-------------| +| [nat\_eip](#output\_nat\_eip) | The public IP of the NAT gateway | +| [pod\_subnet\_cidrs](#output\_pod\_subnet\_cidrs) | The CIDR blocks of the pod subnets | +| [pod\_subnet\_ids](#output\_pod\_subnet\_ids) | The IDs of the pod subnets (from secondary CIDR) | +| [pod\_subnets\_by\_az](#output\_pod\_subnets\_by\_az) | Map of pod subnets by availability zone | +| [private\_route\_table\_id](#output\_private\_route\_table\_id) | The ID of the private route table | +| [private\_subnet\_cidrs](#output\_private\_subnet\_cidrs) | The CIDR blocks of the private subnets | +| [private\_subnet\_ids](#output\_private\_subnet\_ids) | The IDs of the private subnets | +| [public\_subnet\_cidrs](#output\_public\_subnet\_cidrs) | The CIDR blocks of the public subnets | +| [public\_subnet\_ids](#output\_public\_subnet\_ids) | The IDs of the public subnets | +| [secondary\_cidr\_block](#output\_secondary\_cidr\_block) | The secondary CIDR block for pod networking | +| [vpc\_cidr](#output\_vpc\_cidr) | The CIDR block used by the VPC | +| [vpc\_endpoints](#output\_vpc\_endpoints) | Map of VPC endpoint configurations | +| [vpc\_id](#output\_vpc\_id) | The ID of the VPC | +| [vpce\_security\_group](#output\_vpce\_security\_group) | Security group for VPC endpoints | + + + +--- + +## Development + +### Prerequisites + +- [Terraform](https://www.terraform.io/downloads.html) +- [pre-commit](https://pre-commit.com/#install) +- [Terratest](https://terratest.gruntwork.io/docs/getting-started/install/) +- [terraform-docs](https://github.com/terraform-docs/terraform-docs) + (used by pre-commit hook) + +### Testing the Module + +To test the module without destroying the created test infrastructure, run the +following commands: + +```bash +export TASK_X_REMOTE_TASKFILES=1 && \ +task terraform:run-terratest -y DESTROY=false +``` + +To do a complete testing run, including destroying the test infrastructure, run: + +```bash +export TASK_X_REMOTE_TASKFILES=1 && \ +task terraform:run-terratest -y +``` + +### Pre-Commit Hooks + +Install, update, and run pre-commit hooks: + +```bash +export TASK_X_REMOTE_TASKFILES=1 && \ +task run-pre-commit -y +``` diff --git a/modules/terraform-aws-net/data.tf b/modules/terraform-aws-net/data.tf new file mode 100644 index 00000000..db56a389 --- /dev/null +++ b/modules/terraform-aws-net/data.tf @@ -0,0 +1,15 @@ +# Grab the list of availability zones +data "aws_availability_zones" "available" { + state = "available" +} + +data "aws_region" "current" {} + +# Get all route tables associated with private subnets +data "aws_route_tables" "private" { + vpc_id = aws_vpc.main.id + filter { + name = "association.subnet-id" + values = [for subnet in aws_subnet.private : subnet.id] + } +} diff --git a/modules/terraform-aws-net/endpoints.tf b/modules/terraform-aws-net/endpoints.tf new file mode 100644 index 00000000..5be00bf5 --- /dev/null +++ b/modules/terraform-aws-net/endpoints.tf @@ -0,0 +1,22 @@ +resource "aws_vpc_endpoint" "endpoints" { + for_each = local.vpc_endpoints + + vpc_id = aws_vpc.main.id + service_name = "com.amazonaws.${data.aws_region.current.id}.${each.value.service}" + vpc_endpoint_type = each.value.type + + # Set subnet_ids and security_group_ids only for Interface endpoints + subnet_ids = each.value.type == "Interface" ? local.unique_az_subnets : null + security_group_ids = each.value.type == "Interface" ? [aws_security_group.vpce[0].id] : null + + # Only set route table for Gateway endpoints + route_table_ids = each.value.type == "Gateway" ? data.aws_route_tables.private.ids : null + private_dns_enabled = each.value.private_dns + + lifecycle { + create_before_destroy = true + ignore_changes = [tags, tags_all] + } + + tags = local.tags +} diff --git a/modules/terraform-aws-net/main.tf b/modules/terraform-aws-net/main.tf new file mode 100644 index 00000000..70b89bf6 --- /dev/null +++ b/modules/terraform-aws-net/main.tf @@ -0,0 +1,299 @@ +locals { + name_prefix = "${var.env}-${var.deployment_name}" + az_names = data.aws_availability_zones.available.names + + eip_name = "${local.name_prefix}-nat-eip" + igw_name = local.name_prefix + + # Cluster name determination for k8s tags + k8s_cluster_name = var.kubernetes_tags.enabled ? ( + coalesce(var.kubernetes_tags.cluster_name, "${var.env}-${var.deployment_name}") + ) : "" + + # K8s-specific tags to apply conditionally + k8s_tags = var.kubernetes_tags.enabled ? { + "kubernetes.io/cluster/${local.k8s_cluster_name}" = "owned" + } : {} + + # Karpenter discovery tags for private subnets + karpenter_tags = var.kubernetes_tags.enabled && var.kubernetes_tags.enable_karpenter_discovery ? { + "karpenter.sh/discovery" = local.k8s_cluster_name + } : {} + nat_name = local.name_prefix + private_route_table_name = local.name_prefix + public_route_table_name = local.name_prefix + private_subnet_name_prefix = "${local.name_prefix}-private" + public_subnet_name_prefix = "${local.name_prefix}-public" + + subnet_newbits = 8 # This will create /24 subnets from a /16 VPC + + ## Create subnet to AZ mapping with calculated CIDRs + public_subnet_configs = { + for idx in range(length(local.az_names)) : idx => { + cidr = cidrsubnet(var.vpc_cidr_block, local.subnet_newbits, idx) + az = local.az_names[idx] + } + } + + private_subnet_configs = { + for idx in range(length(local.az_names)) : idx => { + cidr = cidrsubnet(var.vpc_cidr_block, local.subnet_newbits, idx + length(local.az_names)) + az = local.az_names[idx] + } + } + + # Pod subnets from secondary CIDR (for VPC CNI custom networking) + pod_subnet_configs = var.secondary_cidr_block != "" ? { + for idx in range(length(local.az_names)) : idx => { + cidr = cidrsubnet(var.secondary_cidr_block, var.pod_subnet_newbits, idx) + az = local.az_names[idx] + } + } : {} + + pod_subnet_name_prefix = "${local.name_prefix}-pod" + + subnet_by_az = { + for subnet in aws_subnet.private : + subnet.availability_zone => subnet.id... + } + + unique_az_subnets = [ + for az, subnets in local.subnet_by_az : + subnets[0] + ] + + vpc_name = local.name_prefix + vpc_endpoints = var.vpc_endpoints + vpce_sg_name = local.name_prefix + + base_tags = { + Module = "terraform-aws-network" + Name = local.name_prefix + } + + # Merge all tags together + tags = merge( + local.base_tags, + local.k8s_tags, + var.additional_tags + ) +} + +# Rest of your resources remain the same, but update the tags references... + +resource "aws_internet_gateway" "main" { + vpc_id = aws_vpc.main.id + + lifecycle { + create_before_destroy = true + ignore_changes = [tags, tags_all] + } + + tags = merge( + local.tags, + { Name = local.igw_name } + ) +} + +resource "aws_eip" "nat" { + domain = "vpc" + + lifecycle { + create_before_destroy = true + ignore_changes = [tags, tags_all] + } + + tags = merge( + local.tags, + { Name = local.eip_name } + ) +} + +resource "aws_nat_gateway" "main" { + allocation_id = aws_eip.nat.id + subnet_id = aws_subnet.public["0"].id + + lifecycle { + create_before_destroy = true + ignore_changes = [tags, tags_all] + } + + tags = merge( + local.tags, + { Name = local.nat_name } + ) +} + +resource "aws_route_table" "public" { + vpc_id = aws_vpc.main.id + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.main.id + } + + lifecycle { + create_before_destroy = true + ignore_changes = [tags, tags_all] + } + + tags = merge( + local.tags, + { Name = local.public_route_table_name } + ) +} + +resource "aws_route_table_association" "public" { + for_each = aws_subnet.public + + route_table_id = aws_route_table.public.id + subnet_id = each.value.id +} + +resource "aws_route_table" "private" { + vpc_id = aws_vpc.main.id + + lifecycle { + create_before_destroy = true + ignore_changes = [tags, tags_all, route] + } + + tags = merge( + local.tags, + { Name = local.private_route_table_name } + ) +} + +# Default NAT gateway route for private subnets +resource "aws_route" "private_nat_gateway" { + route_table_id = aws_route_table.private.id + destination_cidr_block = "0.0.0.0/0" + nat_gateway_id = aws_nat_gateway.main.id +} + +resource "aws_route_table_association" "private" { + for_each = aws_subnet.private + + subnet_id = each.value.id + route_table_id = aws_route_table.private.id +} + +resource "aws_route" "additional_private" { + for_each = var.additional_private_routes + + route_table_id = aws_route_table.private.id + destination_cidr_block = each.value.destination_cidr_block + network_interface_id = each.value.network_interface_id + gateway_id = each.value.gateway_id + nat_gateway_id = each.value.nat_gateway_id + transit_gateway_id = each.value.transit_gateway_id + vpc_peering_connection_id = each.value.vpc_peering_connection_id +} + +resource "aws_subnet" "public" { + # checkov:skip=CKV_AWS_130: "Public IP mappings for public subnets dictated by variable" + for_each = local.public_subnet_configs + availability_zone = each.value.az + cidr_block = each.value.cidr + vpc_id = aws_vpc.main.id + map_public_ip_on_launch = var.map_public_ip + + lifecycle { + create_before_destroy = true + ignore_changes = [tags, tags_all] + } + + tags = merge( + local.tags, + { + Name = "${local.public_subnet_name_prefix}-${each.key}" + Type = "public" + }, + var.kubernetes_tags.enabled ? { "kubernetes.io/role/elb" = "1" } : {} + ) +} + +resource "aws_subnet" "private" { + for_each = local.private_subnet_configs + availability_zone = each.value.az + cidr_block = each.value.cidr + map_public_ip_on_launch = false + vpc_id = aws_vpc.main.id + + lifecycle { + create_before_destroy = true + ignore_changes = [tags, tags_all] + } + + tags = merge( + local.tags, + { + Name = "${local.private_subnet_name_prefix}-${each.key}" + Type = "private" + }, + var.kubernetes_tags.enabled ? { "kubernetes.io/role/internal-elb" = "1" } : {}, + local.karpenter_tags + ) +} + +resource "aws_vpc" "main" { + # checkov:skip=CKV2_AWS_11: "Opting out of VPC flow logging as a requirement cause it gets expensive and lacks flexibility" + cidr_block = var.vpc_cidr_block + enable_dns_hostnames = true + enable_dns_support = true + + lifecycle { + create_before_destroy = true + ignore_changes = [tags, tags_all] + } + + tags = merge( + local.tags, + { Name = local.vpc_name } + ) +} + +################################################################################ +# Secondary CIDR and Pod Subnets (for VPC CNI Custom Networking) +################################################################################ + +resource "aws_vpc_ipv4_cidr_block_association" "secondary" { + count = var.secondary_cidr_block != "" ? 1 : 0 + + vpc_id = aws_vpc.main.id + cidr_block = var.secondary_cidr_block +} + +resource "aws_subnet" "pod" { + for_each = local.pod_subnet_configs + + availability_zone = each.value.az + cidr_block = each.value.cidr + map_public_ip_on_launch = false + vpc_id = aws_vpc.main.id + + # Ensure secondary CIDR is associated before creating subnets + depends_on = [aws_vpc_ipv4_cidr_block_association.secondary] + + lifecycle { + create_before_destroy = true + ignore_changes = [tags, tags_all] + } + + # NOTE: Pod subnets intentionally do NOT have karpenter.sh/discovery tags + # Karpenter should launch nodes in PRIMARY private subnets, not pod subnets + # The VPC CNI uses ENIConfig to assign pod IPs from these subnets + tags = merge( + local.tags, + { + Name = "${local.pod_subnet_name_prefix}-${each.key}" + Type = "pod" + } + ) +} + +resource "aws_route_table_association" "pod" { + for_each = aws_subnet.pod + + subnet_id = each.value.id + route_table_id = aws_route_table.private.id +} diff --git a/modules/terraform-aws-net/outputs.tf b/modules/terraform-aws-net/outputs.tf new file mode 100644 index 00000000..c9a6b57b --- /dev/null +++ b/modules/terraform-aws-net/outputs.tf @@ -0,0 +1,88 @@ +output "nat_eip" { + value = aws_eip.nat.public_ip + description = "The public IP of the NAT gateway" +} + +output "public_subnet_ids" { + value = [for subnet in aws_subnet.public : subnet.id] + description = "The IDs of the public subnets" +} + +output "private_subnet_ids" { + value = [for subnet in aws_subnet.private : subnet.id] + description = "The IDs of the private subnets" +} + +output "private_subnet_cidrs" { + value = [for subnet in aws_subnet.private : subnet.cidr_block] + description = "The CIDR blocks of the private subnets" +} + +output "public_subnet_cidrs" { + value = [for subnet in aws_subnet.public : subnet.cidr_block] + description = "The CIDR blocks of the public subnets" +} + +output "vpc_cidr" { + value = aws_vpc.main.cidr_block + description = "The CIDR block used by the VPC" +} + +output "vpc_endpoints" { + description = "Map of VPC endpoint configurations" + value = { + for k, v in aws_vpc_endpoint.endpoints : k => { + service = var.vpc_endpoints[k].service + type = var.vpc_endpoints[k].type + private_dns = var.vpc_endpoints[k].private_dns + id = v.id + dns_entry = v.dns_entry + } + } +} + +output "vpc_id" { + value = aws_vpc.main.id + description = "The ID of the VPC" +} + +output "vpce_security_group" { + value = length(aws_security_group.vpce) > 0 ? { + id = aws_security_group.vpce[0].id + } : null + description = "Security group for VPC endpoints" +} + +output "private_route_table_id" { + value = aws_route_table.private.id + description = "The ID of the private route table" +} + +################################################################################ +# Pod Subnet Outputs (for VPC CNI Custom Networking) +################################################################################ + +output "pod_subnet_ids" { + value = [for subnet in aws_subnet.pod : subnet.id] + description = "The IDs of the pod subnets (from secondary CIDR)" +} + +output "pod_subnet_cidrs" { + value = [for subnet in aws_subnet.pod : subnet.cidr_block] + description = "The CIDR blocks of the pod subnets" +} + +output "pod_subnets_by_az" { + value = { + for subnet in aws_subnet.pod : subnet.availability_zone => { + id = subnet.id + cidr = subnet.cidr_block + } + } + description = "Map of pod subnets by availability zone" +} + +output "secondary_cidr_block" { + value = var.secondary_cidr_block + description = "The secondary CIDR block for pod networking" +} diff --git a/modules/terraform-aws-net/sg.tf b/modules/terraform-aws-net/sg.tf new file mode 100644 index 00000000..9abc6ac7 --- /dev/null +++ b/modules/terraform-aws-net/sg.tf @@ -0,0 +1,59 @@ +# Ensure that the default security group does not allow unrestricted ingress or egress traffic +resource "aws_default_security_group" "default" { + vpc_id = aws_vpc.main.id +} + +resource "aws_security_group" "vpce" { + # checkov:skip=CKV2_AWS_5: Ensure that Security Groups are attached to another resource + # checkov:skip=CKV_AWS_382: Ensure no security groups allow egress from 0.0.0.0:0 to port -1 + count = length(var.vpc_endpoints) > 0 ? 1 : 0 + name = local.vpce_sg_name + description = "Security group for VPC endpoints" + vpc_id = aws_vpc.main.id + + dynamic "ingress" { + for_each = var.vpce_security_group_rules.ingress_security_group_ids + content { + description = "HTTPS from allowed security groups" + from_port = 443 + to_port = 443 + protocol = "tcp" + security_groups = [ingress.value] + } + } + + dynamic "ingress" { + for_each = length(var.vpce_security_group_rules.ingress_cidr_blocks) > 0 ? [1] : [] + content { + description = "HTTPS from CIDR blocks" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = var.vpce_security_group_rules.ingress_cidr_blocks + } + } + + # Add self-referential ingress rule + ingress { + description = "Allow traffic between endpoints" + from_port = 443 + to_port = 443 + protocol = "tcp" + self = true + } + + egress { + description = "Allow all outbound traffic" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = var.vpce_security_group_rules.egress_cidr_blocks + } + + lifecycle { + create_before_destroy = true + ignore_changes = [tags, tags_all] + } + + tags = merge({ Name = local.vpce_sg_name }, local.tags) +} diff --git a/modules/terraform-aws-net/variables.tf b/modules/terraform-aws-net/variables.tf new file mode 100644 index 00000000..81bc97ea --- /dev/null +++ b/modules/terraform-aws-net/variables.tf @@ -0,0 +1,126 @@ +variable "additional_tags" { + type = map(string) + description = "Additional tags to apply to resources" + default = {} +} + +variable "deployment_name" { + type = string + description = "Name of the deployment (ex: \"crucible\")" +} + +variable "env" { + description = "The environment name (e.g., dev, staging, prod, global)" + type = string +} + +variable "kubernetes_tags" { + description = "Configuration for Kubernetes integration tags" + type = object({ + enabled = bool + cluster_name = optional(string, "") # Will default to {env}-{deployment_name} if empty + enable_karpenter_discovery = optional(bool, false) + }) + default = { + enabled = false + enable_karpenter_discovery = false + } +} + +variable "map_public_ip" { + type = bool + description = "Map public IP addresses to new instances." + default = true +} + +variable "vpc_cidr_block" { + type = string + description = "Top-level CIDR block for the VPC" + default = "10.0.0.0/16" +} + +variable "vpc_endpoints" { + description = "Map of VPC endpoint configurations" + type = map(object({ + service = string + type = string + private_dns = optional(bool, false) # Make private_dns optional with default false + })) + default = { + secretsmanager = { + service = "secretsmanager" + type = "Interface" + private_dns = true + } + ecr_dkr = { + service = "ecr.dkr" + type = "Interface" + private_dns = true + } + ecr_api = { + service = "ecr.api" + type = "Interface" + private_dns = true + } + cloudwatch = { + service = "logs" + type = "Interface" + private_dns = true + } + sns = { + service = "sns" + type = "Interface" + private_dns = false + } + s3 = { + service = "s3" + type = "Gateway" + # private_dns not required for Gateway endpoints - will default to false + } + } + + validation { + condition = alltrue([for v in var.vpc_endpoints : contains(["Interface", "Gateway"], v.type)]) + error_message = "VPC endpoint type must be either 'Interface' or 'Gateway'." + } +} + +variable "vpce_security_group_rules" { + description = "Security group rules for VPC endpoints" + type = object({ + ingress_security_group_ids = optional(list(string), []) + ingress_cidr_blocks = optional(list(string), []) + egress_cidr_blocks = optional(list(string), ["0.0.0.0/0"]) + }) + + default = { + ingress_security_group_ids = [] + ingress_cidr_blocks = [] + egress_cidr_blocks = ["0.0.0.0/0"] + } +} + +variable "additional_private_routes" { + description = "Additional routes to add to the private route table" + type = map(object({ + destination_cidr_block = string + network_interface_id = optional(string, null) + gateway_id = optional(string, null) + nat_gateway_id = optional(string, null) + transit_gateway_id = optional(string, null) + vpc_peering_connection_id = optional(string, null) + })) + default = {} +} + +variable "secondary_cidr_block" { + type = string + description = "Secondary CIDR block for pod networking (e.g., 100.64.0.0/16). Uses CG-NAT space to avoid conflicts." + default = "" +} + +variable "pod_subnet_newbits" { + type = number + description = "Number of bits to add to the secondary CIDR for pod subnets (e.g., 4 for /20 subnets from /16)" + default = 4 +} diff --git a/modules/terraform-aws-net/versions.tf b/modules/terraform-aws-net/versions.tf new file mode 100644 index 00000000..f4a00d14 --- /dev/null +++ b/modules/terraform-aws-net/versions.tf @@ -0,0 +1,9 @@ +terraform { + required_version = "~> 1.7" + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.32.0" + } + } +} diff --git a/warpgate-templates/goad-dc-base-2016/README.md b/warpgate-templates/goad-dc-base-2016/README.md new file mode 100644 index 00000000..97ce8918 --- /dev/null +++ b/warpgate-templates/goad-dc-base-2016/README.md @@ -0,0 +1,55 @@ +# goad-dc-base-2016 + +Pre-baked Windows Server 2016 AMI with AD DS role and Windows Updates pre-installed for GOAD domain controllers. + +## Purpose + +This template creates a "golden" AMI for **Windows Server 2016** that significantly reduces GOAD deployment time by pre-installing: + +- Windows Updates (saves ~15 minutes per instance) +- AD-Domain-Services role (NOT promoted - promotion happens at runtime) +- DNS Server role +- RSAT tools (RSAT-AD-Tools, RSAT-DNS-Server, RSAT-ADDS) +- Group Policy Management Console (GPMC) +- Required PowerShell DSC modules +- SSM agent configuration for post-DC-promotion survival + +**Note**: The AD DS role is installed but NOT promoted to a domain controller. Domain promotion with domain-specific settings happens at runtime via Ansible. + +## Usage + +### Build the AMI + +```bash +warpgate build goad-dc-base-2016 --target ami +``` + +### Update Terragrunt + +For DC03 (uses 2016): + +```hcl +inputs = { + windows_os = "Windows_Server" + windows_os_version = "2016-English-Full-Base" + + additional_windows_ami_filters = [ + { + name = "image-id" + values = ["ami-xxxxxxxxxxxx"] # Your goad-dc-base-2016 AMI ID + } + ] + + windows_ami_owners = ["self"] +} +``` + +## Tags + +The AMI is tagged with: + +- `Name`: goad-dc-base-2016 +- `Lab`: GOAD +- `Role`: DomainController +- `ManagedBy`: warpgate +- `BaseOS`: WindowsServer2016 diff --git a/warpgate-templates/goad-dc-base-2016/scripts/01-install-modules.ps1 b/warpgate-templates/goad-dc-base-2016/scripts/01-install-modules.ps1 new file mode 100644 index 00000000..85e7ad72 --- /dev/null +++ b/warpgate-templates/goad-dc-base-2016/scripts/01-install-modules.ps1 @@ -0,0 +1,18 @@ +$ProgressPreference = 'SilentlyContinue' +$ErrorActionPreference = 'Stop' + +Write-Host "Installing NuGet provider..." +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +Install-PackageProvider -Name NuGet -Force -Confirm:$false + +Write-Host "Installing PowerShellGet module..." +Install-Module PowerShellGet -Force -Confirm:$false + +Write-Host "Installing required DSC modules..." +$modules = @('ComputerManagementDsc', 'ActiveDirectoryDsc', 'xNetworking', 'NetworkingDsc') +foreach ($module in $modules) { + Write-Host "Installing module: $module" + Install-Module -Name $module -Force -Confirm:$false -SkipPublisherCheck -AcceptLicense +} + +Write-Host "PowerShell modules installed successfully" diff --git a/warpgate-templates/goad-dc-base-2016/scripts/02-install-adds-role.ps1 b/warpgate-templates/goad-dc-base-2016/scripts/02-install-adds-role.ps1 new file mode 100644 index 00000000..4e620c38 --- /dev/null +++ b/warpgate-templates/goad-dc-base-2016/scripts/02-install-adds-role.ps1 @@ -0,0 +1,16 @@ +$ProgressPreference = 'SilentlyContinue' +$ErrorActionPreference = 'Stop' + +Write-Host "Installing AD Domain Services role..." +Install-WindowsFeature -Name AD-Domain-Services -IncludeManagementTools + +Write-Host "Installing DNS Server role..." +Install-WindowsFeature -Name DNS -IncludeManagementTools + +Write-Host "Installing RSAT tools..." +Install-WindowsFeature -Name RSAT-AD-Tools, RSAT-DNS-Server, RSAT-ADDS + +Write-Host "Installing Group Policy Management..." +Install-WindowsFeature -Name GPMC + +Write-Host "AD DS role installation complete" diff --git a/warpgate-templates/goad-dc-base-2016/scripts/03-enable-rdp.ps1 b/warpgate-templates/goad-dc-base-2016/scripts/03-enable-rdp.ps1 new file mode 100644 index 00000000..35400465 --- /dev/null +++ b/warpgate-templates/goad-dc-base-2016/scripts/03-enable-rdp.ps1 @@ -0,0 +1,5 @@ +Write-Host "Enabling Remote Desktop..." +Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name "fDenyTSConnections" -Value 0 +Enable-NetFirewallRule -DisplayGroup "Remote Desktop" + +Write-Host "RDP enabled" diff --git a/warpgate-templates/goad-dc-base-2016/scripts/04-windows-updates.ps1 b/warpgate-templates/goad-dc-base-2016/scripts/04-windows-updates.ps1 new file mode 100644 index 00000000..618a5771 --- /dev/null +++ b/warpgate-templates/goad-dc-base-2016/scripts/04-windows-updates.ps1 @@ -0,0 +1,23 @@ +$ProgressPreference = 'SilentlyContinue' +$ErrorActionPreference = 'Stop' + +Write-Host "Starting Windows Update service..." +Set-Service -Name wuauserv -StartupType Automatic +Start-Service -Name wuauserv + +Write-Host "Installing PSWindowsUpdate module..." +Install-Module -Name PSWindowsUpdate -Force -Confirm:$false + +Write-Host "Checking for Windows Updates..." +Import-Module PSWindowsUpdate + +Write-Host "Installing Windows Updates (this may take 15-30 minutes)..." +$updates = Get-WindowsUpdate -AcceptAll -Install -AutoReboot:$false -IgnoreReboot + +if ($updates) { + Write-Host "Installed $($updates.Count) updates" +} else { + Write-Host "No updates available" +} + +Write-Host "Windows Updates complete" diff --git a/warpgate-templates/goad-dc-base-2016/scripts/05-configure-ssm.ps1 b/warpgate-templates/goad-dc-base-2016/scripts/05-configure-ssm.ps1 new file mode 100644 index 00000000..e1446905 --- /dev/null +++ b/warpgate-templates/goad-dc-base-2016/scripts/05-configure-ssm.ps1 @@ -0,0 +1,27 @@ +$ProgressPreference = 'SilentlyContinue' + +Write-Host "Configuring SSM Agent for post-DC-promotion operation..." + +# SSM agent needs special configuration to survive DC promotion +# Create a scheduled task to restart SSM agent after DC promotion +$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-Command "Start-Sleep -Seconds 60; Restart-Service AmazonSSMAgent"' +$trigger = New-ScheduledTaskTrigger -AtStartup +$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest +$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable + +Register-ScheduledTask -TaskName "RestartSSMAfterBoot" -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force + +Write-Host "SSM Agent configuration complete" + +# Ensure SSM agent is running now +$ssmService = Get-Service -Name "AmazonSSMAgent" -ErrorAction SilentlyContinue +if ($ssmService) { + if ($ssmService.Status -ne 'Running') { + Start-Service -Name "AmazonSSMAgent" + Write-Host "SSM Agent started" + } else { + Write-Host "SSM Agent already running" + } +} else { + Write-Host "Warning: SSM Agent not found" +} diff --git a/warpgate-templates/goad-dc-base-2016/scripts/06-cleanup.ps1 b/warpgate-templates/goad-dc-base-2016/scripts/06-cleanup.ps1 new file mode 100644 index 00000000..9f7a8c1c --- /dev/null +++ b/warpgate-templates/goad-dc-base-2016/scripts/06-cleanup.ps1 @@ -0,0 +1,17 @@ +Write-Host "Cleaning up for AMI creation..." + +# Clear Windows Update download cache +Stop-Service -Name wuauserv -Force -ErrorAction SilentlyContinue +Remove-Item -Path "C:\Windows\SoftwareDistribution\Download\*" -Recurse -Force -ErrorAction SilentlyContinue +Start-Service -Name wuauserv + +# Clear temp files +Remove-Item -Path "$env:TEMP\*" -Recurse -Force -ErrorAction SilentlyContinue +Remove-Item -Path "C:\Windows\Temp\*" -Recurse -Force -ErrorAction SilentlyContinue + +# Clear event logs +wevtutil cl Application +wevtutil cl Security +wevtutil cl System + +Write-Host "Cleanup complete" diff --git a/warpgate-templates/goad-dc-base-2016/warpgate.yaml b/warpgate-templates/goad-dc-base-2016/warpgate.yaml new file mode 100644 index 00000000..99dd070e --- /dev/null +++ b/warpgate-templates/goad-dc-base-2016/warpgate.yaml @@ -0,0 +1,50 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/cowdogmoo/warpgate/main/schema/warpgate-template.json +metadata: + name: goad-dc-base-2016 + version: 1.0.0 + description: Windows Server 2016 with updates and AD DS role pre-installed for GOAD domain controllers + author: Dreadnode + license: MIT + tags: + - goad + - windows + - windows-2016 + - domain-controller + - active-directory + requires: + warpgate: ">=1.0.0" + +name: goad-dc-base-2016 +version: latest + +base: + image: "arn:aws:ssm:us-west-1::parameter/aws/service/ami-windows-latest/Windows_Server-2016-English-Full-Base" + +variables: + aws_region: us-west-1 + instance_type: t3.medium + ami_owner: "801119661308" + ami_name_filter: "Windows_Server-2016-English-Full-Base-*" + +provisioners: + # Provision with Ansible via AWS SSM + - type: ansible + playbook_path: ${PROVISION_REPO_PATH}/ansible/playbooks/base/dc_base.yml + galaxy_file: ${PROVISION_REPO_PATH}/ansible/requirements.yml + extra_vars: + ansible_connection: aws_ssm + ansible_shell_type: powershell + ansible_aws_ssm_bucket_name: "" + ansible_aws_ssm_region: "${aws_region}" +targets: + - type: ami + region: "${aws_region}" + instance_type: "${instance_type}" + ami_name: "goad-dc-base-2016-{{timestamp}}" + volume_size: 100 + ami_tags: + Name: goad-dc-base-2016 + Lab: GOAD + Role: DomainController + ManagedBy: warpgate + BaseOS: WindowsServer2016 diff --git a/warpgate-templates/goad-dc-base/README.md b/warpgate-templates/goad-dc-base/README.md new file mode 100644 index 00000000..214e1837 --- /dev/null +++ b/warpgate-templates/goad-dc-base/README.md @@ -0,0 +1,108 @@ +# goad-dc-base + +Pre-baked Windows Server 2019 AMI with AD DS role and Windows Updates pre-installed for GOAD domain controllers. + +## Purpose + +This template creates a "golden" AMI that significantly reduces GOAD deployment time by pre-installing: + +- Windows Updates (saves ~15 minutes per instance) +- AD-Domain-Services role (NOT promoted - promotion happens at runtime) +- DNS Server role +- RSAT tools (RSAT-AD-Tools, RSAT-DNS-Server, RSAT-ADDS) +- Group Policy Management Console (GPMC) +- Required PowerShell DSC modules +- SSM agent configuration for post-DC-promotion survival + +**Note**: The AD DS role is installed but NOT promoted to a domain controller. Domain promotion with domain-specific settings happens at runtime via Ansible. + +## Time Savings + +| Component | Vanilla AMI | Pre-baked AMI | +| --------- | ----------- | ------------- | +| Windows Updates | ~15 min | 0 min | +| AD DS Role Install | ~5 min | 0 min | +| DNS Role Install | ~2 min | 0 min | +| DSC Modules | ~3 min | 0 min | +| **Total per DC** | **~25 min** | **0 min** | + +With 3 domain controllers in GOAD, this saves approximately **75 minutes** per deployment. + +## Usage + +### Build the AMI + +```bash +# Build using warpgate CLI +warpgate build goad-dc-base --target ami + +# Or with custom region +warpgate build goad-dc-base --target ami --vars aws_region=us-east-1 +``` + +### Use in Terragrunt + +Update your GOAD terragrunt.hcl for DC01/DC02/DC03: + +```hcl +inputs = { + # Windows AMI configuration - using pre-baked goad-dc-base AMI + windows_os = "Windows_Server" + windows_os_version = "2019-English-Full-Base" + + additional_windows_ami_filters = [ + { + name = "image-id" + values = ["ami-xxxxxxxxxxxx"] # Your goad-dc-base AMI ID + } + ] + + windows_ami_owners = ["self"] +} +``` + +## What's Pre-installed + +### Windows Features + +- AD-Domain-Services (not promoted) +- DNS +- RSAT-AD-Tools +- RSAT-DNS-Server +- RSAT-ADDS +- GPMC (Group Policy Management Console) + +### PowerShell Modules + +- ComputerManagementDsc +- ActiveDirectoryDsc +- xNetworking +- NetworkingDsc +- PSWindowsUpdate + +### Configuration + +- RDP enabled +- Windows Updates applied +- SSM agent configured with scheduled task for post-DC-promotion restart + +## What Still Needs to Run at Deployment + +The following must still be configured at deployment time (domain-specific): + +1. AD domain promotion (`ad-parent_domain.yml`, `ad-child_domain.yml`) +2. User/group creation (`ad-data.yml`) +3. Trust relationships (`ad-trusts.yml`) +4. GPO configuration +5. LAPS installation +6. Vulnerability injection + +## Tags + +The AMI is tagged with: + +- `Name`: goad-dc-base +- `Lab`: GOAD +- `Role`: DomainController +- `ManagedBy`: warpgate +- `BaseOS`: WindowsServer2019 diff --git a/warpgate-templates/goad-dc-base/scripts/01-install-modules.ps1 b/warpgate-templates/goad-dc-base/scripts/01-install-modules.ps1 new file mode 100644 index 00000000..85e7ad72 --- /dev/null +++ b/warpgate-templates/goad-dc-base/scripts/01-install-modules.ps1 @@ -0,0 +1,18 @@ +$ProgressPreference = 'SilentlyContinue' +$ErrorActionPreference = 'Stop' + +Write-Host "Installing NuGet provider..." +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +Install-PackageProvider -Name NuGet -Force -Confirm:$false + +Write-Host "Installing PowerShellGet module..." +Install-Module PowerShellGet -Force -Confirm:$false + +Write-Host "Installing required DSC modules..." +$modules = @('ComputerManagementDsc', 'ActiveDirectoryDsc', 'xNetworking', 'NetworkingDsc') +foreach ($module in $modules) { + Write-Host "Installing module: $module" + Install-Module -Name $module -Force -Confirm:$false -SkipPublisherCheck -AcceptLicense +} + +Write-Host "PowerShell modules installed successfully" diff --git a/warpgate-templates/goad-dc-base/scripts/02-install-adds-role.ps1 b/warpgate-templates/goad-dc-base/scripts/02-install-adds-role.ps1 new file mode 100644 index 00000000..4e620c38 --- /dev/null +++ b/warpgate-templates/goad-dc-base/scripts/02-install-adds-role.ps1 @@ -0,0 +1,16 @@ +$ProgressPreference = 'SilentlyContinue' +$ErrorActionPreference = 'Stop' + +Write-Host "Installing AD Domain Services role..." +Install-WindowsFeature -Name AD-Domain-Services -IncludeManagementTools + +Write-Host "Installing DNS Server role..." +Install-WindowsFeature -Name DNS -IncludeManagementTools + +Write-Host "Installing RSAT tools..." +Install-WindowsFeature -Name RSAT-AD-Tools, RSAT-DNS-Server, RSAT-ADDS + +Write-Host "Installing Group Policy Management..." +Install-WindowsFeature -Name GPMC + +Write-Host "AD DS role installation complete" diff --git a/warpgate-templates/goad-dc-base/scripts/03-enable-rdp.ps1 b/warpgate-templates/goad-dc-base/scripts/03-enable-rdp.ps1 new file mode 100644 index 00000000..35400465 --- /dev/null +++ b/warpgate-templates/goad-dc-base/scripts/03-enable-rdp.ps1 @@ -0,0 +1,5 @@ +Write-Host "Enabling Remote Desktop..." +Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name "fDenyTSConnections" -Value 0 +Enable-NetFirewallRule -DisplayGroup "Remote Desktop" + +Write-Host "RDP enabled" diff --git a/warpgate-templates/goad-dc-base/scripts/04-windows-updates.ps1 b/warpgate-templates/goad-dc-base/scripts/04-windows-updates.ps1 new file mode 100644 index 00000000..618a5771 --- /dev/null +++ b/warpgate-templates/goad-dc-base/scripts/04-windows-updates.ps1 @@ -0,0 +1,23 @@ +$ProgressPreference = 'SilentlyContinue' +$ErrorActionPreference = 'Stop' + +Write-Host "Starting Windows Update service..." +Set-Service -Name wuauserv -StartupType Automatic +Start-Service -Name wuauserv + +Write-Host "Installing PSWindowsUpdate module..." +Install-Module -Name PSWindowsUpdate -Force -Confirm:$false + +Write-Host "Checking for Windows Updates..." +Import-Module PSWindowsUpdate + +Write-Host "Installing Windows Updates (this may take 15-30 minutes)..." +$updates = Get-WindowsUpdate -AcceptAll -Install -AutoReboot:$false -IgnoreReboot + +if ($updates) { + Write-Host "Installed $($updates.Count) updates" +} else { + Write-Host "No updates available" +} + +Write-Host "Windows Updates complete" diff --git a/warpgate-templates/goad-dc-base/scripts/05-configure-ssm.ps1 b/warpgate-templates/goad-dc-base/scripts/05-configure-ssm.ps1 new file mode 100644 index 00000000..e1446905 --- /dev/null +++ b/warpgate-templates/goad-dc-base/scripts/05-configure-ssm.ps1 @@ -0,0 +1,27 @@ +$ProgressPreference = 'SilentlyContinue' + +Write-Host "Configuring SSM Agent for post-DC-promotion operation..." + +# SSM agent needs special configuration to survive DC promotion +# Create a scheduled task to restart SSM agent after DC promotion +$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-Command "Start-Sleep -Seconds 60; Restart-Service AmazonSSMAgent"' +$trigger = New-ScheduledTaskTrigger -AtStartup +$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest +$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable + +Register-ScheduledTask -TaskName "RestartSSMAfterBoot" -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force + +Write-Host "SSM Agent configuration complete" + +# Ensure SSM agent is running now +$ssmService = Get-Service -Name "AmazonSSMAgent" -ErrorAction SilentlyContinue +if ($ssmService) { + if ($ssmService.Status -ne 'Running') { + Start-Service -Name "AmazonSSMAgent" + Write-Host "SSM Agent started" + } else { + Write-Host "SSM Agent already running" + } +} else { + Write-Host "Warning: SSM Agent not found" +} diff --git a/warpgate-templates/goad-dc-base/scripts/06-cleanup.ps1 b/warpgate-templates/goad-dc-base/scripts/06-cleanup.ps1 new file mode 100644 index 00000000..9f7a8c1c --- /dev/null +++ b/warpgate-templates/goad-dc-base/scripts/06-cleanup.ps1 @@ -0,0 +1,17 @@ +Write-Host "Cleaning up for AMI creation..." + +# Clear Windows Update download cache +Stop-Service -Name wuauserv -Force -ErrorAction SilentlyContinue +Remove-Item -Path "C:\Windows\SoftwareDistribution\Download\*" -Recurse -Force -ErrorAction SilentlyContinue +Start-Service -Name wuauserv + +# Clear temp files +Remove-Item -Path "$env:TEMP\*" -Recurse -Force -ErrorAction SilentlyContinue +Remove-Item -Path "C:\Windows\Temp\*" -Recurse -Force -ErrorAction SilentlyContinue + +# Clear event logs +wevtutil cl Application +wevtutil cl Security +wevtutil cl System + +Write-Host "Cleanup complete" diff --git a/warpgate-templates/goad-dc-base/warpgate.yaml b/warpgate-templates/goad-dc-base/warpgate.yaml new file mode 100644 index 00000000..b8f3316d --- /dev/null +++ b/warpgate-templates/goad-dc-base/warpgate.yaml @@ -0,0 +1,49 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/cowdogmoo/warpgate/main/schema/warpgate-template.json +metadata: + name: goad-dc-base + version: 1.0.0 + description: Windows Server 2019 with updates and AD DS role pre-installed for GOAD domain controllers + author: Dreadnode + license: MIT + tags: + - goad + - windows + - domain-controller + - active-directory + requires: + warpgate: ">=1.0.0" + +name: goad-dc-base +version: latest + +base: + image: "arn:aws:ssm:us-west-1::parameter/aws/service/ami-windows-latest/Windows_Server-2019-English-Full-Base" + +variables: + aws_region: us-west-1 + instance_type: t3.medium + ami_owner: "801119661308" + ami_name_filter: "Windows_Server-2019-English-Full-Base-*" + +provisioners: + # Provision with Ansible via AWS SSM + - type: ansible + playbook_path: ${PROVISION_REPO_PATH}/ansible/playbooks/base/dc_base.yml + galaxy_file: ${PROVISION_REPO_PATH}/ansible/requirements.yml + extra_vars: + ansible_connection: aws_ssm + ansible_shell_type: powershell + ansible_aws_ssm_bucket_name: "" + ansible_aws_ssm_region: "${aws_region}" +targets: + - type: ami + region: "${aws_region}" + instance_type: "${instance_type}" + ami_name: "goad-dc-base-{{timestamp}}" + volume_size: 100 + ami_tags: + Name: goad-dc-base + Lab: GOAD + Role: DomainController + ManagedBy: warpgate + BaseOS: WindowsServer2019 diff --git a/warpgate-templates/goad-member-base-2016/scripts/01-install-modules.ps1 b/warpgate-templates/goad-member-base-2016/scripts/01-install-modules.ps1 new file mode 100644 index 00000000..85e7ad72 --- /dev/null +++ b/warpgate-templates/goad-member-base-2016/scripts/01-install-modules.ps1 @@ -0,0 +1,18 @@ +$ProgressPreference = 'SilentlyContinue' +$ErrorActionPreference = 'Stop' + +Write-Host "Installing NuGet provider..." +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +Install-PackageProvider -Name NuGet -Force -Confirm:$false + +Write-Host "Installing PowerShellGet module..." +Install-Module PowerShellGet -Force -Confirm:$false + +Write-Host "Installing required DSC modules..." +$modules = @('ComputerManagementDsc', 'ActiveDirectoryDsc', 'xNetworking', 'NetworkingDsc') +foreach ($module in $modules) { + Write-Host "Installing module: $module" + Install-Module -Name $module -Force -Confirm:$false -SkipPublisherCheck -AcceptLicense +} + +Write-Host "PowerShell modules installed successfully" diff --git a/warpgate-templates/goad-member-base-2016/scripts/02-install-iis.ps1 b/warpgate-templates/goad-member-base-2016/scripts/02-install-iis.ps1 new file mode 100644 index 00000000..2c73305a --- /dev/null +++ b/warpgate-templates/goad-member-base-2016/scripts/02-install-iis.ps1 @@ -0,0 +1,10 @@ +$ProgressPreference = 'SilentlyContinue' +$ErrorActionPreference = 'Stop' + +Write-Host "Installing IIS..." +Install-WindowsFeature -Name Web-Server -IncludeManagementTools -IncludeAllSubFeature + +Write-Host "Installing WebDAV..." +Install-WindowsFeature -Name Web-DAV-Publishing + +Write-Host "IIS installation complete" diff --git a/warpgate-templates/goad-member-base-2016/scripts/03-enable-rdp.ps1 b/warpgate-templates/goad-member-base-2016/scripts/03-enable-rdp.ps1 new file mode 100644 index 00000000..35400465 --- /dev/null +++ b/warpgate-templates/goad-member-base-2016/scripts/03-enable-rdp.ps1 @@ -0,0 +1,5 @@ +Write-Host "Enabling Remote Desktop..." +Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name "fDenyTSConnections" -Value 0 +Enable-NetFirewallRule -DisplayGroup "Remote Desktop" + +Write-Host "RDP enabled" diff --git a/warpgate-templates/goad-member-base-2016/scripts/04-windows-updates.ps1 b/warpgate-templates/goad-member-base-2016/scripts/04-windows-updates.ps1 new file mode 100644 index 00000000..618a5771 --- /dev/null +++ b/warpgate-templates/goad-member-base-2016/scripts/04-windows-updates.ps1 @@ -0,0 +1,23 @@ +$ProgressPreference = 'SilentlyContinue' +$ErrorActionPreference = 'Stop' + +Write-Host "Starting Windows Update service..." +Set-Service -Name wuauserv -StartupType Automatic +Start-Service -Name wuauserv + +Write-Host "Installing PSWindowsUpdate module..." +Install-Module -Name PSWindowsUpdate -Force -Confirm:$false + +Write-Host "Checking for Windows Updates..." +Import-Module PSWindowsUpdate + +Write-Host "Installing Windows Updates (this may take 15-30 minutes)..." +$updates = Get-WindowsUpdate -AcceptAll -Install -AutoReboot:$false -IgnoreReboot + +if ($updates) { + Write-Host "Installed $($updates.Count) updates" +} else { + Write-Host "No updates available" +} + +Write-Host "Windows Updates complete" diff --git a/warpgate-templates/goad-member-base-2016/scripts/05-cleanup.ps1 b/warpgate-templates/goad-member-base-2016/scripts/05-cleanup.ps1 new file mode 100644 index 00000000..9f7a8c1c --- /dev/null +++ b/warpgate-templates/goad-member-base-2016/scripts/05-cleanup.ps1 @@ -0,0 +1,17 @@ +Write-Host "Cleaning up for AMI creation..." + +# Clear Windows Update download cache +Stop-Service -Name wuauserv -Force -ErrorAction SilentlyContinue +Remove-Item -Path "C:\Windows\SoftwareDistribution\Download\*" -Recurse -Force -ErrorAction SilentlyContinue +Start-Service -Name wuauserv + +# Clear temp files +Remove-Item -Path "$env:TEMP\*" -Recurse -Force -ErrorAction SilentlyContinue +Remove-Item -Path "C:\Windows\Temp\*" -Recurse -Force -ErrorAction SilentlyContinue + +# Clear event logs +wevtutil cl Application +wevtutil cl Security +wevtutil cl System + +Write-Host "Cleanup complete" diff --git a/warpgate-templates/goad-member-base-2016/warpgate.yaml b/warpgate-templates/goad-member-base-2016/warpgate.yaml new file mode 100644 index 00000000..7ac478d4 --- /dev/null +++ b/warpgate-templates/goad-member-base-2016/warpgate.yaml @@ -0,0 +1,50 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/cowdogmoo/warpgate/main/schema/warpgate-template.json +metadata: + name: goad-member-base-2016 + version: 1.0.0 + description: Windows Server 2016 with updates and IIS pre-installed for GOAD member servers + author: Dreadnode + license: MIT + tags: + - goad + - windows + - windows-2016 + - member-server + - iis + requires: + warpgate: ">=1.0.0" + +name: goad-member-base-2016 +version: latest + +base: + image: "arn:aws:ssm:us-west-1::parameter/aws/service/ami-windows-latest/Windows_Server-2016-English-Full-Base" + +variables: + aws_region: us-west-1 + instance_type: t3.medium + ami_owner: "801119661308" + ami_name_filter: "Windows_Server-2016-English-Full-Base-*" + +provisioners: + # Provision with Ansible via AWS SSM + - type: ansible + playbook_path: ${PROVISION_REPO_PATH}/ansible/playbooks/base/member_base.yml + galaxy_file: ${PROVISION_REPO_PATH}/ansible/requirements.yml + extra_vars: + ansible_connection: aws_ssm + ansible_shell_type: powershell + ansible_aws_ssm_bucket_name: "" + ansible_aws_ssm_region: "${aws_region}" +targets: + - type: ami + region: "${aws_region}" + instance_type: "${instance_type}" + ami_name: "goad-member-base-2016-{{timestamp}}" + volume_size: 100 + ami_tags: + Name: goad-member-base-2016 + Lab: GOAD + Role: MemberServer + ManagedBy: warpgate + BaseOS: WindowsServer2016 diff --git a/warpgate-templates/goad-mssql-base/README.md b/warpgate-templates/goad-mssql-base/README.md new file mode 100644 index 00000000..7e7b1ab6 --- /dev/null +++ b/warpgate-templates/goad-mssql-base/README.md @@ -0,0 +1,124 @@ +# goad-mssql-base + +Pre-baked Windows Server 2019 AMI with MSSQL Express 2019, IIS, and Windows Updates pre-installed for GOAD member servers. + +## Purpose + +This template creates a "golden" AMI that significantly reduces GOAD deployment time by pre-installing: + +- Windows Updates (saves ~15 minutes per instance) +- MSSQL Express 2019 (saves ~25 minutes per instance) +- IIS with WebDAV (saves ~5 minutes per instance) +- Required PowerShell DSC modules +- SQL Server firewall rules + +**Note**: MSSQL is installed with a temporary sa password. Domain-specific configuration (sysadmins, linked servers, impersonation) happens at runtime. + +## Time Savings + +| Component | Vanilla AMI | Pre-baked AMI | +| --------- | ----------- | ------------- | +| Windows Updates | ~15 min | 0 min | +| MSSQL Express Install | ~25 min | 0 min | +| IIS Install | ~5 min | 0 min | +| DSC Modules | ~3 min | 0 min | +| **Total per server** | **~48 min** | **0 min** | + +With 2 member servers in GOAD running MSSQL, this saves approximately **96 minutes** per deployment. + +## Usage + +### Build the AMI + +```bash +# Build using warpgate CLI +warpgate build goad-mssql-base --target ami + +# Or with custom region +warpgate build goad-mssql-base --target ami --vars aws_region=us-east-1 +``` + +### Use in Terragrunt + +Update your GOAD terragrunt.hcl for SRV02/SRV03: + +```hcl +inputs = { + # Windows AMI configuration - using pre-baked goad-mssql-base AMI + windows_os = "Windows_Server" + windows_os_version = "2019-English-Full-Base" + + additional_windows_ami_filters = [ + { + name = "image-id" + values = ["ami-xxxxxxxxxxxx"] # Your goad-mssql-base AMI ID + } + ] + + windows_ami_owners = ["self"] +} +``` + +## What's Pre-installed + +### Software + +- MSSQL Express 2019 (SQLEXPRESS instance) +- IIS with all subfeatures +- WebDAV + +### Windows Features + +- Web-Server (IIS) +- Web-DAV-Publishing + +### PowerShell Modules + +- ComputerManagementDsc +- ActiveDirectoryDsc +- xNetworking +- NetworkingDsc +- PSWindowsUpdate + +### Configuration + +- MSSQL listening on TCP port 1433 +- SQL Browser service enabled +- Firewall rules for MSSQL (1433/TCP, 1434/UDP) +- RDP enabled +- Windows Updates applied + +## What Still Needs to Run at Deployment + +The following must still be configured at deployment time (domain-specific): + +1. Domain join (`ad-members.yml`) +2. MSSQL domain account configuration: + - Add domain sysadmins + - Configure linked servers + - Set up impersonation grants + - Reset sa password +3. ADCS installation (for SRV03) +4. Vulnerability injection + +## MSSQL Pre-configuration Details + +The MSSQL instance is configured with: + +- Instance name: `SQLEXPRESS` +- Service account: `NT AUTHORITY\NETWORK SERVICE` +- TCP enabled on port 1433 +- Named pipes enabled +- SQL and Windows authentication mode +- Temporary sa password (must be changed at deployment) + +## Tags + +The AMI is tagged with: + +- `Name`: goad-mssql-base +- `Lab`: GOAD +- `Role`: MemberServer +- `Software`: MSSQL-Express-2019 +- `ManagedBy`: warpgate +- `BaseOS`: WindowsServer2019 diff --git a/warpgate-templates/goad-mssql-base/scripts/01-install-modules.ps1 b/warpgate-templates/goad-mssql-base/scripts/01-install-modules.ps1 new file mode 100644 index 00000000..85e7ad72 --- /dev/null +++ b/warpgate-templates/goad-mssql-base/scripts/01-install-modules.ps1 @@ -0,0 +1,18 @@ +$ProgressPreference = 'SilentlyContinue' +$ErrorActionPreference = 'Stop' + +Write-Host "Installing NuGet provider..." +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +Install-PackageProvider -Name NuGet -Force -Confirm:$false + +Write-Host "Installing PowerShellGet module..." +Install-Module PowerShellGet -Force -Confirm:$false + +Write-Host "Installing required DSC modules..." +$modules = @('ComputerManagementDsc', 'ActiveDirectoryDsc', 'xNetworking', 'NetworkingDsc') +foreach ($module in $modules) { + Write-Host "Installing module: $module" + Install-Module -Name $module -Force -Confirm:$false -SkipPublisherCheck -AcceptLicense +} + +Write-Host "PowerShell modules installed successfully" diff --git a/warpgate-templates/goad-mssql-base/scripts/02-install-iis.ps1 b/warpgate-templates/goad-mssql-base/scripts/02-install-iis.ps1 new file mode 100644 index 00000000..2c73305a --- /dev/null +++ b/warpgate-templates/goad-mssql-base/scripts/02-install-iis.ps1 @@ -0,0 +1,10 @@ +$ProgressPreference = 'SilentlyContinue' +$ErrorActionPreference = 'Stop' + +Write-Host "Installing IIS..." +Install-WindowsFeature -Name Web-Server -IncludeManagementTools -IncludeAllSubFeature + +Write-Host "Installing WebDAV..." +Install-WindowsFeature -Name Web-DAV-Publishing + +Write-Host "IIS installation complete" diff --git a/warpgate-templates/goad-mssql-base/scripts/03-download-mssql.ps1 b/warpgate-templates/goad-mssql-base/scripts/03-download-mssql.ps1 new file mode 100644 index 00000000..97c3402e --- /dev/null +++ b/warpgate-templates/goad-mssql-base/scripts/03-download-mssql.ps1 @@ -0,0 +1,19 @@ +$ProgressPreference = 'SilentlyContinue' +$ErrorActionPreference = 'Stop' + +$downloadUrl = "https://download.microsoft.com/download/7/f/8/7f8a9c43-8c8a-4f7c-9f92-83c18d96b681/SQL2019-SSEI-Expr.exe" + +Write-Host "Creating installation directories..." +New-Item -Path "C:\setup\mssql\media" -ItemType Directory -Force | Out-Null +New-Item -Path "C:\setup\mssql\extraction" -ItemType Directory -Force | Out-Null + +Write-Host "Downloading SQL Server Express 2019 installer..." +Invoke-WebRequest -Uri $downloadUrl -OutFile "C:\setup\mssql\sql_installer.exe" -UseBasicParsing + +Write-Host "Downloading SQL Server installation media (this may take 5-10 minutes)..." +Start-Process -FilePath "C:\setup\mssql\sql_installer.exe" -ArgumentList "/ACTION=Download", "/MEDIAPATH=C:\setup\mssql\media", "/Q" -Wait -NoNewWindow + +Write-Host "Extracting SQL Server installation files..." +Start-Process -FilePath "C:\setup\mssql\media\SQLEXPR_x64_ENU.exe" -ArgumentList "/x:C:\setup\mssql\extraction", "/q" -Wait -NoNewWindow + +Write-Host "SQL Server media download complete" diff --git a/warpgate-templates/goad-mssql-base/scripts/04-install-mssql.ps1 b/warpgate-templates/goad-mssql-base/scripts/04-install-mssql.ps1 new file mode 100644 index 00000000..4fb4c25a --- /dev/null +++ b/warpgate-templates/goad-mssql-base/scripts/04-install-mssql.ps1 @@ -0,0 +1,49 @@ +$ProgressPreference = 'SilentlyContinue' +$ErrorActionPreference = 'Stop' + +$sqlInstanceName = "SQLEXPRESS" + +# Create configuration file +$configContent = @" +[OPTIONS] +ACTION="Install" +FEATURES=SQLENGINE +INSTANCENAME="$sqlInstanceName" +INSTANCEID="$sqlInstanceName" +SQLSVCACCOUNT="NT AUTHORITY\NETWORK SERVICE" +SQLSYSADMINACCOUNTS="BUILTIN\Administrators" +AGTSVCSTARTUPTYPE="Automatic" +SQLSVCSTARTUPTYPE="Automatic" +BROWSERSVCSTARTUPTYPE="Automatic" +SECURITYMODE="SQL" +SAPWD="TempSaPassword123!" +TCPENABLED="1" +NPENABLED="1" +IACCEPTSQLSERVERLICENSETERMS="True" +QUIET="True" +QUIETSIMPLE="False" +UpdateEnabled="False" +ERRORREPORTING="False" +SQMREPORTING="False" +"@ + +Write-Host "Creating SQL Server configuration file..." +$configContent | Out-File -FilePath "C:\setup\mssql\sql_conf.ini" -Encoding ASCII + +Write-Host "Installing SQL Server Express 2019 (this may take 15-25 minutes)..." + +$process = Start-Process -FilePath "C:\setup\mssql\extraction\SETUP.EXE" ` + -ArgumentList "/ConfigurationFile=C:\setup\mssql\sql_conf.ini" ` + -Wait -NoNewWindow -PassThru + +if ($process.ExitCode -eq 0 -or $process.ExitCode -eq 3010) { + Write-Host "SQL Server Express installation completed successfully" +} else { + # Check if SQL is actually installed despite exit code + $sqlService = Get-Service -Name "MSSQL`$SQLEXPRESS" -ErrorAction SilentlyContinue + if ($sqlService) { + Write-Host "SQL Server Express installation completed (service exists)" + } else { + Write-Error "SQL Server installation failed with exit code: $($process.ExitCode)" + } +} diff --git a/warpgate-templates/goad-mssql-base/scripts/05-configure-mssql.ps1 b/warpgate-templates/goad-mssql-base/scripts/05-configure-mssql.ps1 new file mode 100644 index 00000000..b58e3fd9 --- /dev/null +++ b/warpgate-templates/goad-mssql-base/scripts/05-configure-mssql.ps1 @@ -0,0 +1,48 @@ +$ProgressPreference = 'SilentlyContinue' + +Write-Host "Configuring SQL Server TCP port..." + +# Set TCP port to 1433 +$regPath = "HKLM:\Software\Microsoft\Microsoft SQL Server\MSSQL15.SQLEXPRESS\MSSQLServer\SuperSocketNetLib\Tcp\IPAll" +if (Test-Path $regPath) { + Set-ItemProperty -Path $regPath -Name "TcpPort" -Value "1433" + Set-ItemProperty -Path $regPath -Name "TcpDynamicPorts" -Value "" + Write-Host "TCP port configured to 1433" +} else { + Write-Host "Registry path not found - SQL Server may need a restart" +} + +Write-Host "Configuring firewall rules for SQL Server..." + +# Allow SQL Server through firewall +New-NetFirewallRule -DisplayName "MSSQL TCP 1433" -Direction Inbound -Protocol TCP -LocalPort 1433 -Action Allow -Profile Domain -ErrorAction SilentlyContinue +New-NetFirewallRule -DisplayName "MSSQL UDP 1434" -Direction Inbound -Protocol UDP -LocalPort 1434 -Action Allow -Profile Domain -ErrorAction SilentlyContinue + +Write-Host "Firewall rules configured" + +Write-Host "Verifying SQL Server installation..." + +$sqlService = Get-Service -Name "MSSQL`$SQLEXPRESS" -ErrorAction SilentlyContinue +if ($sqlService) { + Write-Host "SQL Server service found: $($sqlService.Status)" + + # Ensure service is running + if ($sqlService.Status -ne 'Running') { + Start-Service -Name "MSSQL`$SQLEXPRESS" + Write-Host "SQL Server service started" + } +} else { + Write-Error "SQL Server service not found!" +} + +# Configure SQL Browser service +$browserService = Get-Service -Name "SQLBrowser" -ErrorAction SilentlyContinue +if ($browserService) { + Set-Service -Name "SQLBrowser" -StartupType Automatic + if ($browserService.Status -ne 'Running') { + Start-Service -Name "SQLBrowser" + } + Write-Host "SQL Browser service running" +} + +Write-Host "SQL Server configuration complete" diff --git a/warpgate-templates/goad-mssql-base/scripts/06-enable-rdp.ps1 b/warpgate-templates/goad-mssql-base/scripts/06-enable-rdp.ps1 new file mode 100644 index 00000000..35400465 --- /dev/null +++ b/warpgate-templates/goad-mssql-base/scripts/06-enable-rdp.ps1 @@ -0,0 +1,5 @@ +Write-Host "Enabling Remote Desktop..." +Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name "fDenyTSConnections" -Value 0 +Enable-NetFirewallRule -DisplayGroup "Remote Desktop" + +Write-Host "RDP enabled" diff --git a/warpgate-templates/goad-mssql-base/scripts/07-windows-updates.ps1 b/warpgate-templates/goad-mssql-base/scripts/07-windows-updates.ps1 new file mode 100644 index 00000000..618a5771 --- /dev/null +++ b/warpgate-templates/goad-mssql-base/scripts/07-windows-updates.ps1 @@ -0,0 +1,23 @@ +$ProgressPreference = 'SilentlyContinue' +$ErrorActionPreference = 'Stop' + +Write-Host "Starting Windows Update service..." +Set-Service -Name wuauserv -StartupType Automatic +Start-Service -Name wuauserv + +Write-Host "Installing PSWindowsUpdate module..." +Install-Module -Name PSWindowsUpdate -Force -Confirm:$false + +Write-Host "Checking for Windows Updates..." +Import-Module PSWindowsUpdate + +Write-Host "Installing Windows Updates (this may take 15-30 minutes)..." +$updates = Get-WindowsUpdate -AcceptAll -Install -AutoReboot:$false -IgnoreReboot + +if ($updates) { + Write-Host "Installed $($updates.Count) updates" +} else { + Write-Host "No updates available" +} + +Write-Host "Windows Updates complete" diff --git a/warpgate-templates/goad-mssql-base/scripts/08-cleanup.ps1 b/warpgate-templates/goad-mssql-base/scripts/08-cleanup.ps1 new file mode 100644 index 00000000..00c98077 --- /dev/null +++ b/warpgate-templates/goad-mssql-base/scripts/08-cleanup.ps1 @@ -0,0 +1,20 @@ +Write-Host "Cleaning up for AMI creation..." + +# Clear Windows Update download cache +Stop-Service -Name wuauserv -Force -ErrorAction SilentlyContinue +Remove-Item -Path "C:\Windows\SoftwareDistribution\Download\*" -Recurse -Force -ErrorAction SilentlyContinue +Start-Service -Name wuauserv + +# Keep SQL installer files (in case reinstall needed) +# Remove-Item -Path "C:\setup" -Recurse -Force -ErrorAction SilentlyContinue + +# Clear temp files +Remove-Item -Path "$env:TEMP\*" -Recurse -Force -ErrorAction SilentlyContinue +Remove-Item -Path "C:\Windows\Temp\*" -Recurse -Force -ErrorAction SilentlyContinue + +# Clear event logs +wevtutil cl Application +wevtutil cl Security +wevtutil cl System + +Write-Host "Cleanup complete" diff --git a/warpgate-templates/goad-mssql-base/warpgate.yaml b/warpgate-templates/goad-mssql-base/warpgate.yaml new file mode 100644 index 00000000..29ee7824 --- /dev/null +++ b/warpgate-templates/goad-mssql-base/warpgate.yaml @@ -0,0 +1,50 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/cowdogmoo/warpgate/main/schema/warpgate-template.json +metadata: + name: goad-mssql-base + version: 1.0.0 + description: Windows Server 2019 with updates and MSSQL Express 2019 pre-installed for GOAD member servers + author: Dreadnode + license: MIT + tags: + - goad + - windows + - mssql + - sql-server + requires: + warpgate: ">=1.0.0" + +name: goad-mssql-base +version: latest + +base: + image: "arn:aws:ssm:us-west-1::parameter/aws/service/ami-windows-latest/Windows_Server-2019-English-Full-Base" + +variables: + aws_region: us-west-1 + instance_type: t3.medium + ami_owner: "801119661308" + ami_name_filter: "Windows_Server-2019-English-Full-Base-*" + +provisioners: + # Provision with Ansible via AWS SSM + - type: ansible + playbook_path: ${PROVISION_REPO_PATH}/ansible/playbooks/base/mssql_base.yml + galaxy_file: ${PROVISION_REPO_PATH}/ansible/requirements.yml + extra_vars: + ansible_connection: aws_ssm + ansible_shell_type: powershell + ansible_aws_ssm_bucket_name: "" + ansible_aws_ssm_region: "${aws_region}" +targets: + - type: ami + region: "${aws_region}" + instance_type: "${instance_type}" + ami_name: "goad-mssql-base-{{timestamp}}" + volume_size: 100 + ami_tags: + Name: goad-mssql-base + Lab: GOAD + Role: MemberServer + Software: MSSQL-Express-2019 + ManagedBy: warpgate + BaseOS: WindowsServer2019