From b2a3ad79e4898afb0adff22588d6b69ad2dd986b Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Thu, 2 Apr 2026 14:52:15 -0600 Subject: [PATCH 1/3] feat: add goad infra/terraform/warpgate automation, templates, and pre-commit hooks **Added:** - Introduced `infra/` directory with Terragrunt-based GOAD deployment structure, including staging environment, network, and GOAD host modules (dc01, dc02, dc03, srv02, srv03) and associated PowerShell user data templates for Windows hosts - Added `infra/goad-deployment/host-registry.yaml` as authoritative host metadata registry for GOAD infrastructure modules - Implemented generic `infra/root.hcl` and per-environment Terragrunt configs to manage S3 remote state, AWS provider, and variable inheritance - Added reusable Terraform modules: - `modules/terraform-aws-instance-factory` for flexible EC2/ASG deployments - `modules/terraform-aws-net` for VPC/subnet/network infrastructure - Added comprehensive module READMEs with usage examples and terraform-docs output - Introduced warpgate image build templates for GOAD DC/member base images and MSSQL base images, including scripts for Windows feature/role pre-installation, updates, and cleanup - Added `.hooks/linters/.tflint.hcl` to enforce Terraform linting standards - Registered pre-commit-terraform hooks for `terraform_fmt`, `terraform_validate`, and `terraform_tflint` in `.pre-commit-config.yaml` - Added Terraform patterns to `.gitignore` to prevent state/plans from being committed - Implemented new `infra` CLI command (`cli/cmd/infra_cmd.go`) to manage Terragrunt-based infra lifecycle (init, plan, apply, destroy, output, validate) - Exposed infra/terragrunt config sections and path helpers in internal config - Added Terragrunt runner and environment validation helpers under `cli/internal/terragrunt/` **Changed:** - Enhanced `.github/workflows/pre-commit.yaml` to install and initialize Terraform, TFLint, and Terragrunt as part of CI for pre-commit checks - Extended `.gitignore` for Terraform state, plan, and lock files - Registered pre-commit-terraform repo and hooks in `.pre-commit-config.yaml` - Updated internal config (`cli/internal/config/config.go`) to support infra deployment/terragrunt path resolution and environment defaults - Added Terragrunt and Terraform/Tofu checks to `cli/internal/doctor/checks.go` for `doctor` command to validate prerequisites - Set default infra config values in `cli/internal/config/defaults.go` **Removed:** - None (all additions are new functionality and structure) --- .github/workflows/pre-commit.yaml | 13 + .gitignore | 7 + .hooks/linters/.tflint.hcl | 21 + .pre-commit-config.yaml | 14 + cli/cmd/infra_cmd.go | 271 +++++++++ cli/internal/config/config.go | 28 + cli/internal/config/defaults.go | 5 + cli/internal/doctor/checks.go | 37 ++ cli/internal/terragrunt/runner.go | 225 +++++++ cli/internal/terragrunt/validate.go | 140 +++++ infra/goad-deployment/host-registry.yaml | 111 ++++ infra/goad-deployment/host.hcl | 118 ++++ infra/goad-deployment/staging/env.hcl | 8 + .../goad/dc01/templates/user_data.ps1.tpl | 44 ++ .../dc01/templates/user_data_wrapper.ps1.tpl | 5 + .../us-west-1/goad/dc01/terragrunt.hcl | 114 ++++ .../goad/dc02/templates/user_data.ps1.tpl | 44 ++ .../dc02/templates/user_data_wrapper.ps1.tpl | 5 + .../us-west-1/goad/dc02/terragrunt.hcl | 112 ++++ .../goad/dc03/templates/user_data.ps1.tpl | 44 ++ .../dc03/templates/user_data_wrapper.ps1.tpl | 5 + .../us-west-1/goad/dc03/terragrunt.hcl | 112 ++++ .../goad/srv02/templates/user_data.ps1.tpl | 44 ++ .../srv02/templates/user_data_wrapper.ps1.tpl | 5 + .../us-west-1/goad/srv02/terragrunt.hcl | 113 ++++ .../goad/srv03/templates/user_data.ps1.tpl | 44 ++ .../srv03/templates/user_data_wrapper.ps1.tpl | 5 + .../us-west-1/goad/srv03/terragrunt.hcl | 113 ++++ .../staging/us-west-1/network/terragrunt.hcl | 57 ++ .../staging/us-west-1/region.hcl | 3 + infra/root.hcl | 57 ++ .../terraform-aws-instance-factory/README.md | 340 +++++++++++ modules/terraform-aws-instance-factory/alb.tf | 114 ++++ .../terraform-aws-instance-factory/data.tf | 167 ++++++ modules/terraform-aws-instance-factory/iam.tf | 41 ++ .../terraform-aws-instance-factory/main.tf | 212 +++++++ .../terraform-aws-instance-factory/network.tf | 6 + modules/terraform-aws-instance-factory/nlb.tf | 100 ++++ .../terraform-aws-instance-factory/outputs.tf | 186 ++++++ modules/terraform-aws-instance-factory/sg.tf | 149 +++++ .../variables.tf | 553 ++++++++++++++++++ .../versions.tf | 18 + modules/terraform-aws-net/README.md | 389 ++++++++++++ modules/terraform-aws-net/data.tf | 15 + modules/terraform-aws-net/endpoints.tf | 22 + modules/terraform-aws-net/main.tf | 299 ++++++++++ modules/terraform-aws-net/outputs.tf | 88 +++ modules/terraform-aws-net/sg.tf | 59 ++ modules/terraform-aws-net/variables.tf | 126 ++++ modules/terraform-aws-net/versions.tf | 9 + .../goad-dc-base-2016/README.md | 55 ++ .../scripts/01-install-modules.ps1 | 18 + .../scripts/02-install-adds-role.ps1 | 16 + .../scripts/03-enable-rdp.ps1 | 5 + .../scripts/04-windows-updates.ps1 | 23 + .../scripts/05-configure-ssm.ps1 | 27 + .../goad-dc-base-2016/scripts/06-cleanup.ps1 | 17 + .../goad-dc-base-2016/warpgate.yaml | 50 ++ warpgate-templates/goad-dc-base/README.md | 108 ++++ .../scripts/01-install-modules.ps1 | 18 + .../scripts/02-install-adds-role.ps1 | 16 + .../goad-dc-base/scripts/03-enable-rdp.ps1 | 5 + .../scripts/04-windows-updates.ps1 | 23 + .../goad-dc-base/scripts/05-configure-ssm.ps1 | 27 + .../goad-dc-base/scripts/06-cleanup.ps1 | 17 + warpgate-templates/goad-dc-base/warpgate.yaml | 49 ++ .../scripts/01-install-modules.ps1 | 18 + .../scripts/02-install-iis.ps1 | 10 + .../scripts/03-enable-rdp.ps1 | 5 + .../scripts/04-windows-updates.ps1 | 23 + .../scripts/05-cleanup.ps1 | 17 + .../goad-member-base-2016/warpgate.yaml | 50 ++ warpgate-templates/goad-mssql-base/README.md | 124 ++++ .../scripts/01-install-modules.ps1 | 18 + .../scripts/02-install-iis.ps1 | 10 + .../scripts/03-download-mssql.ps1 | 19 + .../scripts/04-install-mssql.ps1 | 49 ++ .../scripts/05-configure-mssql.ps1 | 48 ++ .../goad-mssql-base/scripts/06-enable-rdp.ps1 | 5 + .../scripts/07-windows-updates.ps1 | 23 + .../goad-mssql-base/scripts/08-cleanup.ps1 | 20 + .../goad-mssql-base/warpgate.yaml | 50 ++ 82 files changed, 5780 insertions(+) create mode 100644 .hooks/linters/.tflint.hcl create mode 100644 cli/cmd/infra_cmd.go create mode 100644 cli/internal/terragrunt/runner.go create mode 100644 cli/internal/terragrunt/validate.go create mode 100644 infra/goad-deployment/host-registry.yaml create mode 100644 infra/goad-deployment/host.hcl create mode 100644 infra/goad-deployment/staging/env.hcl create mode 100644 infra/goad-deployment/staging/us-west-1/goad/dc01/templates/user_data.ps1.tpl create mode 100644 infra/goad-deployment/staging/us-west-1/goad/dc01/templates/user_data_wrapper.ps1.tpl create mode 100644 infra/goad-deployment/staging/us-west-1/goad/dc01/terragrunt.hcl create mode 100644 infra/goad-deployment/staging/us-west-1/goad/dc02/templates/user_data.ps1.tpl create mode 100644 infra/goad-deployment/staging/us-west-1/goad/dc02/templates/user_data_wrapper.ps1.tpl create mode 100644 infra/goad-deployment/staging/us-west-1/goad/dc02/terragrunt.hcl create mode 100644 infra/goad-deployment/staging/us-west-1/goad/dc03/templates/user_data.ps1.tpl create mode 100644 infra/goad-deployment/staging/us-west-1/goad/dc03/templates/user_data_wrapper.ps1.tpl create mode 100644 infra/goad-deployment/staging/us-west-1/goad/dc03/terragrunt.hcl create mode 100644 infra/goad-deployment/staging/us-west-1/goad/srv02/templates/user_data.ps1.tpl create mode 100644 infra/goad-deployment/staging/us-west-1/goad/srv02/templates/user_data_wrapper.ps1.tpl create mode 100644 infra/goad-deployment/staging/us-west-1/goad/srv02/terragrunt.hcl create mode 100644 infra/goad-deployment/staging/us-west-1/goad/srv03/templates/user_data.ps1.tpl create mode 100644 infra/goad-deployment/staging/us-west-1/goad/srv03/templates/user_data_wrapper.ps1.tpl create mode 100644 infra/goad-deployment/staging/us-west-1/goad/srv03/terragrunt.hcl create mode 100644 infra/goad-deployment/staging/us-west-1/network/terragrunt.hcl create mode 100644 infra/goad-deployment/staging/us-west-1/region.hcl create mode 100644 infra/root.hcl create mode 100644 modules/terraform-aws-instance-factory/README.md create mode 100644 modules/terraform-aws-instance-factory/alb.tf create mode 100644 modules/terraform-aws-instance-factory/data.tf create mode 100644 modules/terraform-aws-instance-factory/iam.tf create mode 100644 modules/terraform-aws-instance-factory/main.tf create mode 100644 modules/terraform-aws-instance-factory/network.tf create mode 100644 modules/terraform-aws-instance-factory/nlb.tf create mode 100644 modules/terraform-aws-instance-factory/outputs.tf create mode 100644 modules/terraform-aws-instance-factory/sg.tf create mode 100644 modules/terraform-aws-instance-factory/variables.tf create mode 100644 modules/terraform-aws-instance-factory/versions.tf create mode 100644 modules/terraform-aws-net/README.md create mode 100644 modules/terraform-aws-net/data.tf create mode 100644 modules/terraform-aws-net/endpoints.tf create mode 100644 modules/terraform-aws-net/main.tf create mode 100644 modules/terraform-aws-net/outputs.tf create mode 100644 modules/terraform-aws-net/sg.tf create mode 100644 modules/terraform-aws-net/variables.tf create mode 100644 modules/terraform-aws-net/versions.tf create mode 100644 warpgate-templates/goad-dc-base-2016/README.md create mode 100644 warpgate-templates/goad-dc-base-2016/scripts/01-install-modules.ps1 create mode 100644 warpgate-templates/goad-dc-base-2016/scripts/02-install-adds-role.ps1 create mode 100644 warpgate-templates/goad-dc-base-2016/scripts/03-enable-rdp.ps1 create mode 100644 warpgate-templates/goad-dc-base-2016/scripts/04-windows-updates.ps1 create mode 100644 warpgate-templates/goad-dc-base-2016/scripts/05-configure-ssm.ps1 create mode 100644 warpgate-templates/goad-dc-base-2016/scripts/06-cleanup.ps1 create mode 100644 warpgate-templates/goad-dc-base-2016/warpgate.yaml create mode 100644 warpgate-templates/goad-dc-base/README.md create mode 100644 warpgate-templates/goad-dc-base/scripts/01-install-modules.ps1 create mode 100644 warpgate-templates/goad-dc-base/scripts/02-install-adds-role.ps1 create mode 100644 warpgate-templates/goad-dc-base/scripts/03-enable-rdp.ps1 create mode 100644 warpgate-templates/goad-dc-base/scripts/04-windows-updates.ps1 create mode 100644 warpgate-templates/goad-dc-base/scripts/05-configure-ssm.ps1 create mode 100644 warpgate-templates/goad-dc-base/scripts/06-cleanup.ps1 create mode 100644 warpgate-templates/goad-dc-base/warpgate.yaml create mode 100644 warpgate-templates/goad-member-base-2016/scripts/01-install-modules.ps1 create mode 100644 warpgate-templates/goad-member-base-2016/scripts/02-install-iis.ps1 create mode 100644 warpgate-templates/goad-member-base-2016/scripts/03-enable-rdp.ps1 create mode 100644 warpgate-templates/goad-member-base-2016/scripts/04-windows-updates.ps1 create mode 100644 warpgate-templates/goad-member-base-2016/scripts/05-cleanup.ps1 create mode 100644 warpgate-templates/goad-member-base-2016/warpgate.yaml create mode 100644 warpgate-templates/goad-mssql-base/README.md create mode 100644 warpgate-templates/goad-mssql-base/scripts/01-install-modules.ps1 create mode 100644 warpgate-templates/goad-mssql-base/scripts/02-install-iis.ps1 create mode 100644 warpgate-templates/goad-mssql-base/scripts/03-download-mssql.ps1 create mode 100644 warpgate-templates/goad-mssql-base/scripts/04-install-mssql.ps1 create mode 100644 warpgate-templates/goad-mssql-base/scripts/05-configure-mssql.ps1 create mode 100644 warpgate-templates/goad-mssql-base/scripts/06-enable-rdp.ps1 create mode 100644 warpgate-templates/goad-mssql-base/scripts/07-windows-updates.ps1 create mode 100644 warpgate-templates/goad-mssql-base/scripts/08-cleanup.ps1 create mode 100644 warpgate-templates/goad-mssql-base/warpgate.yaml 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/internal/config/config.go b/cli/internal/config/config.go index 82e88dbb..0ddf80ce 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 ( @@ -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/terragrunt/runner.go b/cli/internal/terragrunt/runner.go new file mode 100644 index 00000000..58708fe0 --- /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/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 From 03aaa4f39efcada674c9d20093dbf8b05bfc72c3 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Thu, 2 Apr 2026 16:02:50 -0600 Subject: [PATCH 2/3] feat: add lab discovery utilities and granular VM control; move providers to extensions **Added:** - Implemented lab discovery and listing utilities, including a new `lab list` command for enumerating labs, providers, and hosts - Added the ability to control (start, stop, restart, destroy) individual lab VMs by hostname via new subcommands (`start-vm`, `stop-vm`, etc.) - Introduced new AWS client methods for discovering all instances (including stopped), finding by hostname in any state, and terminating VMs - Added functions to resolve playbooks for a lab, resume provisioning from a specific playbook, and ensure variant generation during provisioning **Changed:** - Refactored provisioning logic to support the `--from` flag for resuming from a specified playbook and to use lab-specific playbook resolution - Updated the extensions provider path logic to use `extensions/` directory instead of `providers/` for extension provider configs - Updated the logic for generating variants to be more robust and reusable **Removed:** - Removed the `providers/` directory and migrated all extension resources to `extensions/`, updating all references accordingly - Removed indirect dependency on `go.yaml.in/yaml/v3` from `go.mod` (now direct) --- cli/cmd/lab.go | 108 +++++++++++++++- cli/cmd/lab_list.go | 57 +++++++++ cli/cmd/provision.go | 87 ++++++++----- cli/go.mod | 2 +- cli/internal/aws/ec2.go | 58 +++++++++ cli/internal/config/config.go | 4 +- cli/internal/lab/discovery.go | 117 ++++++++++++++++++ {providers => extensions}/elk/aws/linux.tf | 0 {providers => extensions}/elk/azure/linux.tf | 0 .../elk/ludus/config.yml | 0 .../elk/virtualbox/Vagrantfile | 0 .../elk/vmware/Vagrantfile | 0 .../exchange/aws/windows.tf | 0 .../exchange/azure/windows.tf | 0 .../exchange/ludus/config.yml | 0 .../exchange/proxmox/windows.tf | 0 .../exchange/virtualbox/Vagrantfile | 0 .../exchange/vmware/Vagrantfile | 0 .../guacamole/aws/linux.tf | 0 .../guacamole/azure/linux.tf | 0 .../guacamole/ludus/config.yml | 0 .../guacamole/proxmox/linux.tf | 0 .../guacamole/virtualbox/Vagrantfile | 0 .../guacamole/vmware/Vagrantfile | 0 {providers => extensions}/wazuh/aws/linux.tf | 0 .../wazuh/azure/linux.tf | 0 .../wazuh/ludus/config.yml | 0 .../wazuh/virtualbox/Vagrantfile | 0 .../wazuh/vmware/Vagrantfile | 0 {providers => extensions}/ws01/aws/windows.tf | 0 .../ws01/azure/windows.tf | 0 .../ws01/ludus/config.yml | 0 .../ws01/proxmox/windows.tf | 0 .../ws01/proxmox/ws01.tf | 0 .../ws01/virtualbox/Vagrantfile | 0 .../ws01/vmware/Vagrantfile | 0 36 files changed, 398 insertions(+), 35 deletions(-) create mode 100644 cli/cmd/lab_list.go create mode 100644 cli/internal/lab/discovery.go rename {providers => extensions}/elk/aws/linux.tf (100%) rename {providers => extensions}/elk/azure/linux.tf (100%) rename {providers => extensions}/elk/ludus/config.yml (100%) rename {providers => extensions}/elk/virtualbox/Vagrantfile (100%) rename {providers => extensions}/elk/vmware/Vagrantfile (100%) rename {providers => extensions}/exchange/aws/windows.tf (100%) rename {providers => extensions}/exchange/azure/windows.tf (100%) rename {providers => extensions}/exchange/ludus/config.yml (100%) rename {providers => extensions}/exchange/proxmox/windows.tf (100%) rename {providers => extensions}/exchange/virtualbox/Vagrantfile (100%) rename {providers => extensions}/exchange/vmware/Vagrantfile (100%) rename {providers => extensions}/guacamole/aws/linux.tf (100%) rename {providers => extensions}/guacamole/azure/linux.tf (100%) rename {providers => extensions}/guacamole/ludus/config.yml (100%) rename {providers => extensions}/guacamole/proxmox/linux.tf (100%) rename {providers => extensions}/guacamole/virtualbox/Vagrantfile (100%) rename {providers => extensions}/guacamole/vmware/Vagrantfile (100%) rename {providers => extensions}/wazuh/aws/linux.tf (100%) rename {providers => extensions}/wazuh/azure/linux.tf (100%) rename {providers => extensions}/wazuh/ludus/config.yml (100%) rename {providers => extensions}/wazuh/virtualbox/Vagrantfile (100%) rename {providers => extensions}/wazuh/vmware/Vagrantfile (100%) rename {providers => extensions}/ws01/aws/windows.tf (100%) rename {providers => extensions}/ws01/azure/windows.tf (100%) rename {providers => extensions}/ws01/ludus/config.yml (100%) rename {providers => extensions}/ws01/proxmox/windows.tf (100%) rename {providers => extensions}/ws01/proxmox/ws01.tf (100%) rename {providers => extensions}/ws01/virtualbox/Vagrantfile (100%) rename {providers => extensions}/ws01/vmware/Vagrantfile (100%) 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 0ddf80ce..a2963dc0 100644 --- a/cli/internal/config/config.go +++ b/cli/internal/config/config.go @@ -194,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. 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/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 From 1cf5846a343bf5d2ffc69e3ae2be737b608630ad Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Thu, 2 Apr 2026 16:19:21 -0600 Subject: [PATCH 3/3] fix: handle file close error in output writer cleanup function **Changed:** - Suppress potential error from file close operation in the output writer's cleanup function by assigning the result to the blank identifier. This prevents unhandled error warnings and aligns with idiomatic Go practices when the error is not actionable. --- cli/internal/terragrunt/runner.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/internal/terragrunt/runner.go b/cli/internal/terragrunt/runner.go index 58708fe0..bee305c0 100644 --- a/cli/internal/terragrunt/runner.go +++ b/cli/internal/terragrunt/runner.go @@ -221,5 +221,5 @@ func outputWriter(logFile string) (io.Writer, func(), error) { } mw := io.MultiWriter(os.Stdout, f) - return mw, func() { f.Close() }, nil + return mw, func() { _ = f.Close() }, nil }