diff --git a/cli/cmd/bastion.go b/cli/cmd/bastion.go index 8dd2f937..0f5fd76b 100644 --- a/cli/cmd/bastion.go +++ b/cli/cmd/bastion.go @@ -29,6 +29,9 @@ before 'infra apply', or use 'infra apply --with-bastion'). Tunneling-enabled Standard/Premium SKUs are required for ssh/rdp/tunnel; the Developer SKU only supports the browser console.`, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if err := config.Init(); err != nil { + return err + } cfg, err := config.Get() if err != nil { return err @@ -198,23 +201,17 @@ func runBastionSSH(cmd *cobra.Command, args []string) error { authType, _ := cmd.Flags().GetString("auth-type") sshKey, _ := cmd.Flags().GetString("ssh-key") - // Auto-pick the ephemeral key for the in-VNet Ansible controller. The - // terraform-azure-controller module writes its private key to a - // well-known path and stamps Role=AnsibleController on the VM, so we - // can reach it without making the operator type --auth-type ssh-key - // --ssh-key -u dreadadmin every time. A failed live lookup is - // non-fatal — we just fall back to the user-supplied flag values. - if inst, err := client.FindInstanceByHostname(ctx, cfg.Env, args[0]); err == nil && inst.Tags["Role"] == "AnsibleController" { + // Auto-pick the ephemeral key for known VM roles. A failed live lookup + // is non-fatal — we just fall back to the user-supplied flag values. + if defaults := resolveRoleDefaults(client, ctx, cfg.Env, args[0]); defaults != nil && defaults.sshKey != "" { if !cmd.Flags().Changed("auth-type") { - authType = "ssh-key" + authType = defaults.authType } if !cmd.Flags().Changed("ssh-key") && authType == "ssh-key" { - if path := controllerKeyPath(cfg.Env, inst.Name); path != "" { - sshKey = path - } + sshKey = defaults.sshKey } if !cmd.Flags().Changed("user") { - user = "dreadadmin" + user = defaults.user } } @@ -222,6 +219,38 @@ func runBastionSSH(cmd *cobra.Command, args []string) error { return client.OpenBastionSSH(ctx, host, vmID, user, authType, sshKey) } +// roleDefaults holds auto-detected SSH defaults for a known VM role. +type roleDefaults struct { + authType string + user string + sshKey string +} + +// resolveRoleDefaults looks up a VM by hostname and returns SSH defaults based +// on its Role tag. Returns nil if the VM is not found or has no known role. +func resolveRoleDefaults(client *azure.Client, ctx context.Context, env, hostname string) *roleDefaults { + inst, err := client.FindInstanceByHostname(ctx, env, hostname) + if err != nil { + return nil + } + switch inst.Tags["Role"] { + case "AnsibleController": + return &roleDefaults{ + authType: "ssh-key", + user: "dreadadmin", + sshKey: controllerKeyPath(env, inst.Name), + } + case "AttackBox": + return &roleDefaults{ + authType: "ssh-key", + user: "kali", + sshKey: kaliKeyPath(env, inst.Name), + } + default: + return nil + } +} + // controllerKeyPath derives the conventional ephemeral private-key path the // terraform-azure-controller module writes. VM names follow // "{env}-{deployment}-controller-vm"; the module writes to @@ -243,6 +272,26 @@ func controllerKeyPath(env, vmName string) string { return path } +// kaliKeyPath derives the conventional ephemeral private-key path the +// terraform-azure-kali module writes. VM names follow +// "{env}-{deployment}-kali-vm"; the module writes to +// "~/.dreadgoad/keys/azure-{env}-{deployment}-kali". +func kaliKeyPath(env, vmName string) string { + deployment := strings.TrimSuffix(strings.TrimPrefix(vmName, env+"-"), "-kali-vm") + if deployment == "" || deployment == vmName { + return "" + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + path := filepath.Join(home, ".dreadgoad", "keys", fmt.Sprintf("azure-%s-%s-kali", env, deployment)) + if _, err := os.Stat(path); err != nil { + return "" + } + return path +} + func runBastionRDP(cmd *cobra.Command, args []string) error { ctx := context.Background() client, host, cfg, err := bastionContext(ctx) diff --git a/cli/cmd/env_cmd.go b/cli/cmd/env_cmd.go index 321b3d20..baba713f 100644 --- a/cli/cmd/env_cmd.go +++ b/cli/cmd/env_cmd.go @@ -460,33 +460,43 @@ func generateVariantConfig(projectRoot, envName string) error { return gen.Run() } -// deriveAzureSubnets computes bastion and controller subnet CIDRs from a /16 -// VNet CIDR. Given "10.X.0.0/16" it produces: +// azureSubnets holds the computed subnet CIDRs for an Azure deployment. +type azureSubnets struct { + Bastion string + Controller string + Kali string +} + +// deriveAzureSubnets computes bastion, controller, and kali subnet CIDRs from +// a /16 VNet CIDR. Given "10.X.0.0/16" it produces: // // bastion: 10.X.2.0/26 (64 IPs, required by Azure Bastion) // controller: 10.X.3.0/28 (16 IPs, single Ansible controller) -func deriveAzureSubnets(vnetCIDR string) (bastionSubnet, controllerSubnet string, err error) { +// kali: 10.X.4.0/28 (16 IPs, optional attack box) +func deriveAzureSubnets(vnetCIDR string) (azureSubnets, error) { _, ipnet, err := net.ParseCIDR(vnetCIDR) if err != nil { - return "", "", fmt.Errorf("invalid VNet CIDR %q: %w", vnetCIDR, err) + return azureSubnets{}, fmt.Errorf("invalid VNet CIDR %q: %w", vnetCIDR, err) } ones, _ := ipnet.Mask.Size() if ones != 16 { - return "", "", fmt.Errorf("VNet CIDR must be a /16, got /%d", ones) + return azureSubnets{}, fmt.Errorf("VNet CIDR must be a /16, got /%d", ones) } base := ipnet.IP.To4() if base == nil { - return "", "", fmt.Errorf("VNet CIDR must be IPv4, got %q", vnetCIDR) + return azureSubnets{}, fmt.Errorf("VNet CIDR must be IPv4, got %q", vnetCIDR) } - bastionSubnet = fmt.Sprintf("%d.%d.2.0/26", base[0], base[1]) - controllerSubnet = fmt.Sprintf("%d.%d.3.0/28", base[0], base[1]) - return bastionSubnet, controllerSubnet, nil + return azureSubnets{ + Bastion: fmt.Sprintf("%d.%d.2.0/26", base[0], base[1]), + Controller: fmt.Sprintf("%d.%d.3.0/28", base[0], base[1]), + Kali: fmt.Sprintf("%d.%d.4.0/28", base[0], base[1]), + }, nil } -// createAzureEnvHCL writes an Azure-specific env.hcl with VNet, bastion, and -// controller subnet CIDRs auto-derived from the VNet CIDR. +// createAzureEnvHCL writes an Azure-specific env.hcl with VNet, bastion, +// controller, and kali subnet CIDRs auto-derived from the VNet CIDR. func createAzureEnvHCL(envDir, envName, vnetCIDR string) error { - bastionSubnet, controllerSubnet, err := deriveAzureSubnets(vnetCIDR) + subnets, err := deriveAzureSubnets(vnetCIDR) if err != nil { return err } @@ -505,8 +515,14 @@ func createAzureEnvHCL(envDir, envName, vnetCIDR string) error { controller_subnet_cidr = %q controller_ssh_source_address_prefix = %q controller_instance_size = "Standard_D2s_v3" + + # Optional Kali attack box. Enable with --with-kali on infra commands. + kali_subnet_cidr = %q + kali_ssh_source_address_prefix = %q + kali_instance_size = "Standard_D2s_v3" } -`, envName, vnetCIDR, bastionSubnet, controllerSubnet, bastionSubnet) +`, envName, vnetCIDR, subnets.Bastion, subnets.Controller, subnets.Bastion, + subnets.Kali, subnets.Bastion) return os.WriteFile(filepath.Join(envDir, "env.hcl"), []byte(content), 0o644) } diff --git a/cli/cmd/env_cmd_test.go b/cli/cmd/env_cmd_test.go index c8e0b28e..b20d0033 100644 --- a/cli/cmd/env_cmd_test.go +++ b/cli/cmd/env_cmd_test.go @@ -8,26 +8,30 @@ func TestDeriveAzureSubnets(t *testing.T) { vnetCIDR string wantBast string wantCtrl string + wantKali string wantErr bool }{ - {"standard", "10.8.0.0/16", "10.8.2.0/26", "10.8.3.0/28", false}, - {"different octet", "10.1.0.0/16", "10.1.2.0/26", "10.1.3.0/28", false}, - {"high octet", "10.200.0.0/16", "10.200.2.0/26", "10.200.3.0/28", false}, - {"not /16", "10.8.0.0/24", "", "", true}, - {"invalid CIDR", "not-a-cidr", "", "", true}, + {"standard", "10.8.0.0/16", "10.8.2.0/26", "10.8.3.0/28", "10.8.4.0/28", false}, + {"different octet", "10.1.0.0/16", "10.1.2.0/26", "10.1.3.0/28", "10.1.4.0/28", false}, + {"high octet", "10.200.0.0/16", "10.200.2.0/26", "10.200.3.0/28", "10.200.4.0/28", false}, + {"not /16", "10.8.0.0/24", "", "", "", true}, + {"invalid CIDR", "not-a-cidr", "", "", "", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - bast, ctrl, err := deriveAzureSubnets(tt.vnetCIDR) + subnets, err := deriveAzureSubnets(tt.vnetCIDR) if (err != nil) != tt.wantErr { t.Fatalf("error = %v, wantErr %v", err, tt.wantErr) } - if bast != tt.wantBast { - t.Errorf("bastion = %q, want %q", bast, tt.wantBast) + if subnets.Bastion != tt.wantBast { + t.Errorf("bastion = %q, want %q", subnets.Bastion, tt.wantBast) } - if ctrl != tt.wantCtrl { - t.Errorf("controller = %q, want %q", ctrl, tt.wantCtrl) + if subnets.Controller != tt.wantCtrl { + t.Errorf("controller = %q, want %q", subnets.Controller, tt.wantCtrl) + } + if subnets.Kali != tt.wantKali { + t.Errorf("kali = %q, want %q", subnets.Kali, tt.wantKali) } }) } diff --git a/cli/cmd/infra_cmd.go b/cli/cmd/infra_cmd.go index 1d2844b9..ff5eade3 100644 --- a/cli/cmd/infra_cmd.go +++ b/cli/cmd/infra_cmd.go @@ -93,6 +93,7 @@ func init() { for _, cmd := range []*cobra.Command{infraApplyCmd, infraDestroyCmd, infraPlanCmd} { cmd.Flags().Bool("with-bastion", false, "(Azure) Include the optional Azure Bastion module") cmd.Flags().Bool("with-controller", false, "(Azure) Include the optional in-VNet Ansible controller module") + cmd.Flags().Bool("with-kali", false, "(Azure) Include the optional Kali Linux attack box") } infraCmd.PersistentFlags().StringP("deployment", "d", "", "Deployment name (default: from config)") @@ -179,6 +180,18 @@ func runInfraActionAzure(cmd *cobra.Command, cfg *config.Config, action string) if withController, _ := cmd.Flags().GetBool("with-controller"); withController { opts.ExtraEnv = append(opts.ExtraEnv, "DREADGOAD_ENABLE_AZURE_CONTROLLER=true") } + withKali, _ := cmd.Flags().GetBool("with-kali") + // On destroy, always include the kali module so orphaned VMs are cleaned up + // even if the user forgets --with-kali. + if !withKali && action == "destroy" { + kaliDir := filepath.Join(cfg.ProjectRoot, "infra", "azure", deployment, cfg.Env, region, "kali") + if _, err := os.Stat(kaliDir); err == nil { + withKali = true + } + } + if withKali { + opts.ExtraEnv = append(opts.ExtraEnv, "DREADGOAD_ENABLE_AZURE_KALI=true") + } if action == "apply" || action == "destroy" { autoApprove, _ := cmd.Flags().GetBool("auto-approve") diff --git a/cli/cmd/runcmd.go b/cli/cmd/runcmd.go index 84ded1d6..018dee29 100644 --- a/cli/cmd/runcmd.go +++ b/cli/cmd/runcmd.go @@ -25,6 +25,9 @@ no inbound ports). Caveats vs. AWS SSM: each invocation is one-shot (~5-15s latency), output is capped at 4096 bytes per stream, and there are no persistent sessions to list or clean up.`, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if err := config.Init(); err != nil { + return err + } cfg, err := config.Get() if err != nil { return err diff --git a/cli/cmd/ssm.go b/cli/cmd/ssm.go index befb8f2f..a922e7c3 100644 --- a/cli/cmd/ssm.go +++ b/cli/cmd/ssm.go @@ -19,6 +19,9 @@ var ssmCmd = &cobra.Command{ Long: `SSM commands are AWS-specific. For Azure use 'dreadgoad runcmd' (Azure Run Command). For other providers see their respective verbs.`, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if err := config.Init(); err != nil { + return err + } cfg, err := config.Get() if err != nil { return err diff --git a/cli/internal/doctor/checks.go b/cli/internal/doctor/checks.go index c9442c2e..be2d56c8 100644 --- a/cli/internal/doctor/checks.go +++ b/cli/internal/doctor/checks.go @@ -85,6 +85,7 @@ func runAzureChecks() []CheckResult { results = append(results, checkTerragrunt()) results = append(results, checkTerraformOrTofu()) results = append(results, checkPyPSRP()) + results = append(results, checkKaliMarketplaceTerms()) return results } @@ -409,6 +410,36 @@ func checkPyPSRP() CheckResult { return CheckResult{Name: "pypsrp + PySocks", Status: "pass", Message: "installed"} } +func checkKaliMarketplaceTerms() CheckResult { + out, err := exec.Command( + "az", "vm", "image", "terms", "show", + "--publisher", "kali-linux", "--offer", "kali", "--plan", "kali-2026-2", + "-o", "tsv", "--query", "accepted", + ).CombinedOutput() + if err != nil { + return CheckResult{ + Name: "Kali marketplace terms", + Status: "warn", + Message: "could not check (run: az vm image terms accept " + + "--publisher kali-linux --offer kali --plan kali-2026-2). " + + "Required if using --with-kali", + } + } + if strings.EqualFold(strings.TrimSpace(string(out)), "true") { + return CheckResult{ + Name: "Kali marketplace terms", + Status: "pass", + Message: "accepted", + } + } + return CheckResult{ + Name: "Kali marketplace terms", + Status: "warn", + Message: "not accepted. Required if using --with-kali. Run: " + + "az vm image terms accept --publisher kali-linux --offer kali --plan kali-2026-2", + } +} + func checkLudusSSH(opts LudusOptions) []CheckResult { var results []CheckResult diff --git a/infra/azure/goad-deployment/test/centralus/kali/terragrunt.hcl b/infra/azure/goad-deployment/test/centralus/kali/terragrunt.hcl new file mode 100644 index 00000000..bdc0ac73 --- /dev/null +++ b/infra/azure/goad-deployment/test/centralus/kali/terragrunt.hcl @@ -0,0 +1,78 @@ +# ============================================================================= +# Optional Kali Linux Attack Box +# +# Deploys a headless Kali VM on its own subnet for attacking the lab from +# inside the VNet. Access is via Azure Bastion SSH — no public IP. +# +# Enable by setting DREADGOAD_ENABLE_AZURE_KALI=true before Terragrunt runs, +# or use `dreadgoad infra apply --with-kali`. Requires marketplace terms to be +# accepted: az vm image terms accept --publisher kali-linux --offer kali --plan kali-2026-2 +# ============================================================================= + +exclude { + if = lower(get_env("DREADGOAD_ENABLE_AZURE_KALI", "false")) != "true" + actions = ["all"] +} + +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 + deployment_name = local.env_vars.locals.deployment_name + location = local.region_vars.locals.location + + kali_subnet_cidr = local.env_vars.locals.kali_subnet_cidr + kali_ssh_source_address_prefix = local.env_vars.locals.kali_ssh_source_address_prefix + kali_instance_size = local.env_vars.locals.kali_instance_size + + # SSH key resolution — same 3-tier pattern as the controller module. + ssh_key_inline = get_env("DREADGOAD_AZURE_KALI_SSH_KEY", "") + ssh_key_path_var = get_env("DREADGOAD_AZURE_KALI_SSH_KEY_PATH", "") + ssh_key_from_path = local.ssh_key_path_var != "" && fileexists(local.ssh_key_path_var) ? trimspace(file(local.ssh_key_path_var)) : "" + admin_ssh_public_key = ( + local.ssh_key_inline != "" ? local.ssh_key_inline : + local.ssh_key_from_path != "" ? local.ssh_key_from_path : + null + ) + + ephemeral_key_path = pathexpand("~/.dreadgoad/keys/azure-${local.env}-${local.deployment_name}-kali") +} + +terraform { + source = "${get_repo_root()}/modules//terraform-azure-kali" +} + +dependency "network" { + config_path = "../network" + mock_outputs = { + resource_group_name = "mock-rg" + location = "centralus" + vnet_name = "mock-vnet" + } + mock_outputs_allowed_terraform_commands = ["init", "validate", "plan"] +} + +include "root" { + path = find_in_parent_folders("root.hcl") +} + +inputs = { + env = local.env + deployment_name = local.deployment_name + location = dependency.network.outputs.location + resource_group_name = dependency.network.outputs.resource_group_name + virtual_network_name = dependency.network.outputs.vnet_name + + kali_subnet_cidr = local.kali_subnet_cidr + ssh_source_address_prefix = local.kali_ssh_source_address_prefix + instance_size = local.kali_instance_size + + admin_ssh_public_key = local.admin_ssh_public_key + ephemeral_key_output_path = local.ephemeral_key_path + + additional_tags = { + Project = "DreadGOAD" + Lab = "${local.deployment_name}-goad" + } +} diff --git a/infra/azure/goad-deployment/test/env.hcl b/infra/azure/goad-deployment/test/env.hcl index ba7073b4..b6a3cb4b 100644 --- a/infra/azure/goad-deployment/test/env.hcl +++ b/infra/azure/goad-deployment/test/env.hcl @@ -17,4 +17,9 @@ locals { # the GOAD DCs run on, so capacity is proven. Cost trade is small for a # transient lab. controller_instance_size = "Standard_D2s_v3" + + # Optional Kali attack box. Enable with --with-kali on infra commands. + kali_subnet_cidr = "10.8.4.0/28" + kali_ssh_source_address_prefix = "10.8.2.0/26" + kali_instance_size = "Standard_D2s_v3" } diff --git a/modules/terraform-azure-kali/.terraform.lock.hcl b/modules/terraform-azure-kali/.terraform.lock.hcl new file mode 100644 index 00000000..904cb566 --- /dev/null +++ b/modules/terraform-azure-kali/.terraform.lock.hcl @@ -0,0 +1,96 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/azurerm" { + version = "4.80.0" + constraints = "~> 4.0" + hashes = [ + "h1:/HSKYi2kpEnIs2AVYmWSOCq98T8twsV+OIopjX8f8rQ=", + "h1:9w0W319/O9peNiJZIwBaWPYyvOrZ8RVr0le6ixXT66Q=", + "h1:Cv5TQOTEGTV806bN4cvyyuToG6V4LV+cLAlWNU9FmXU=", + "h1:IGLCHEb0I3CGzLdrzzD7E3aUDrUDQt3dT6FYL1qQtSQ=", + "h1:R8n0T2WWUxa65nw64W/Q+M2X9My84/lebmKwAzixlu8=", + "h1:VKFPEqVn6/84AoY3BkoF+dBsBWNp4xRYyENlnA6j6o4=", + "h1:W7kIVPIabNADsBiTKTW/vlQnG//YukDMhCh5njp02hU=", + "h1:d8YcvRsfL2rg8S3rkdl+rYd0TY8WGLzVqIEtv9vacAA=", + "h1:eTDAe+AoQmlj7LENZPWvlNIz2GiEPluu9b0nduAN1rk=", + "h1:kuLwWwnQ0b9o0YE6/k/SeMSBv0F/O1nebnUfpKujzds=", + "h1:sxndDh73F1YkGhSnUQ2IfvixItMewzCKaX5H9TPfTZs=", + "zh:1287b44676ced8f2d8131b1746a027f05edba2e5aa7e3ecdbaa6887aaad68431", + "zh:1ae7263cafd4f9ffff6a595190ee940af929bda026bddd7db2cd733596878597", + "zh:3ec5bdbce4ce98db2f850d98c75024d7267df9809ad8ef425ec21e727858d150", + "zh:4a33b42598fe7337d8e78b0ab57daff65a0689e0896c1232d380511e0a011dc7", + "zh:69348cf4aaa49869ccb66aedc1ff99aac59fccf7c1971b3829d5f372291d847a", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7cfbfca11cb85713fa0666f788b5c5778f38ff88b1956c61a638b1fb0a02773c", + "zh:7e889f4d9c5907e037f3ea486dd517e5481342625635b10a4cc7338a5571cdc0", + "zh:85bc8cf551491ffcdf841110922b69d040090f379924896b2ccbc337cc7cb41e", + "zh:99585400adafdc8aedd37cb1e715e986ce39612ba18bab34fd8749aae37593d7", + "zh:a1d4ccf5e1afb4e440119b3ff2e41c3192979ea9b1735cc9d91a7fb8684339e0", + "zh:ea3be732d424c36634dd105425879ea7610224cada0c59ed6172ce67b610289d", + ] +} + +provider "registry.terraform.io/hashicorp/local" { + version = "2.9.0" + constraints = "~> 2.5" + hashes = [ + "h1:0W7SDCcshaGz7gFLqRh+xSybU7CsiffGtYxLyGkd5v8=", + "h1:19szYap0j+IaX3fdQkVlT/xXEAkyXZGoabKsK7RdLWU=", + "h1:9rBZCMNpxKwMlRbWH2QpwD3kqUCAejdOZQ/aiiDObXQ=", + "h1:F8pWDG0pUb3qa0pWXEgjxXZuIVtRrcwnK4OkMKb/Gqc=", + "h1:JFNiGJRidyYACpbXswqoX4cjdVkvNdYA/e4YhvA/M/w=", + "h1:M4Ij0M7djWAH1hWcXCfUeyH8BioaiK+UzrM8elq6jTQ=", + "h1:SjeVipWRhcQvyypSYSEtn95sSbiqbV0XH8BnkGoIAtU=", + "h1:Vxi5xD5rBImMA83gSYnJmLCodG0kUqGQHVYBxpgYj+E=", + "h1:m24fjcInWvTVZ1XSo2MaNuKPe+X/gfG8SIi09rA7a7M=", + "h1:oF4vw1rikPMqQtMMBoUjc/pERp50DlWwO+C2I6/sNI8=", + "h1:px3Hpv/tL288wzu5knHywTTBcrydLnnGEiF/NIQBaRs=", + "h1:zi/RbdLCYxU9upG9loL7b+M694GkW6kACIYJMkVdQVs=", + "zh:0baa4566cf77f1ff52f4293d1c8536202dd23edc197c3196413a28343c3ac3a0", + "zh:16b5559c3c07088ddad11a9bb9e9c0799999363c2958e9a5be2bcbbf2cd9ca64", + "zh:197c79015a10d1cce904a8ea722cbc750c42aeae2da53f44a6a0751d9fd1aa90", + "zh:29d0b03e5343a80677ebfeb2e2c31cbe4b1f65e736e53417454a4277fec2544c", + "zh:4896bfa6cf1d2fd562b47ef2e87f47862ae92a04f8ad5d764380f0c6653473b8", + "zh:531f8529cbca49f681883e57761a05a8398afaef6d1ab0d205d26bf12f4428e8", + "zh:6aaf5011d83161c86d2bfb80c0923ec934e578288758da2f37acb7aec129004b", + "zh:7430275253d3d3c40aa6179e0ec0d63212874dbbc06c5a51b9d07ec590f9756c", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:be17dc611e95e26cdf6cad79dfccf1064f0e32032a2efeb939a9bbe7fb1cbfe9", + "zh:f0e3b0aa644202e1d79d2000dca91f6019425da71e9800fa23f27e51c034f195", + "zh:f62bae4519e4ead49182ddc8afe8cf61e2a4c3ba3973b0fbba967736a2696aa3", + "zh:fcafa360a5b0b96244f26f4e3a6d642b716a376557142c2442ff2fb12d11da18", + ] +} + +provider "registry.terraform.io/hashicorp/tls" { + version = "4.3.0" + constraints = "~> 4.0" + hashes = [ + "h1:5bCU/c+2HUh7GhclzNSH6gAuoCS4inW3obEtRAwu6WQ=", + "h1:5xblgWaLCk0/S5sSDMEBpUO31Kv9eyvo2e0qvKDvFKg=", + "h1:7QWrBlzkkFAFyDl9UsfC0tdfNFquFx03miHwZcta33Q=", + "h1:9O6c6A3Pa8DaW0UlTqjVy9wamwN8+2xG+wfukhnVouk=", + "h1:Nx1AbmRplFV9vyOBwlBth+MyD5fth6Snv8lIFOsWvu0=", + "h1:OeJhJw1X6OeRqt8n4xMxJ8SQGTudxKT0ehqC8HxWpTE=", + "h1:QO1mfwRWprT41JDGRUfpIYyFs8h63yeOsB8RfKOjNYU=", + "h1:QjGpJjmvOB973D0BM9rPv/h9ln+wM64IGY/tfuOginw=", + "h1:Ubkf73KvM44o9Gh5+n9v+EskNjqGGl4wT29mQjmyUQE=", + "h1:eDtm+6iX4ydUbWFwQEyHzR8ukb+o5784XjotMq0C9yQ=", + "h1:iVr2IDV+RRoxAra3YM6w4jVsF052ougqMjbfcTtdJsw=", + "h1:j/BqLS2N2AScZyotd9nZpHdieJ7e5S8y+A+ZfIu8kL8=", + "zh:0ab58d6f8991d436c7d2dbd89ed814709b949b07ac5a54ee53b0aec1fa772a8b", + "zh:60b347abcb56f45d97c56f14d895069cd15a83993f199777f571b79fea3642ee", + "zh:6889be32640349230de3f23856e6f04e0e9ced4a84a27d3f552fa54684448218", + "zh:73f8e1ecf7135033165fb14b7e8bf4d656f3ce13065ec35762ea0481975328c7", + "zh:94ce25ee253eca0b42cae9c856b36bca8103b6453012d1b279c3623c805f2d42", + "zh:96bc6de9fd67bc446fd11257872e1ffb1029a996ed1d65a3f6b43f6d408ad9ab", + "zh:97c609a310a51bfd504d704e036d72064a84bf0bdb36cc08cd4cc66098212b41", + "zh:a12c16e94533c5bd123f75032576b9dc91dd5d5ccd5f7cf331d0f2e1adc55cf8", + "zh:c4f014f876adf7af57188795050bda5b0029d8c7d7773031102b6c36dcf1fc21", + "zh:d9b0a21583aaa3df3a95394fb949a3c515ff71c2ff5a1fc4a73d364aa90bfca5", + "zh:da510d22f0c6d71ad19a76406f106b782448f512375787ecfabb338ed1e311a7", + "zh:f0e9447a9ce3a24cdaa113089e65663c836d8b9bfdb915a1c0284e0112cab5c0", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} diff --git a/modules/terraform-azure-kali/README.md b/modules/terraform-azure-kali/README.md new file mode 100644 index 00000000..74815ac7 --- /dev/null +++ b/modules/terraform-azure-kali/README.md @@ -0,0 +1,71 @@ +# terraform-azure-kali + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.7 | +| [azurerm](#requirement\_azurerm) | ~> 4.0 | +| [local](#requirement\_local) | ~> 2.5 | +| [tls](#requirement\_tls) | ~> 4.0 | + +## Providers + +| Name | Version | +| ---- | ------- | +| [azurerm](#provider\_azurerm) | 4.80.0 | +| [local](#provider\_local) | 2.9.0 | +| [tls](#provider\_tls) | 4.3.0 | + +## Modules + +No modules. + +## Resources + +| Name | Type | +| ---- | ---- | +| [azurerm_linux_virtual_machine.this](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/linux_virtual_machine) | resource | +| [azurerm_network_interface.this](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/network_interface) | resource | +| [azurerm_network_security_group.this](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/network_security_group) | resource | +| [azurerm_subnet.this](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/subnet) | resource | +| [azurerm_subnet_network_security_group_association.this](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/subnet_network_security_group_association) | resource | +| [local_sensitive_file.kali_key](https://registry.terraform.io/providers/hashicorp/local/latest/docs/resources/sensitive_file) | resource | +| [tls_private_key.kali](https://registry.terraform.io/providers/hashicorp/tls/latest/docs/resources/private_key) | resource | + +## Inputs + +| Name | Description | Type | Default | Required | +| ---- | ----------- | ---- | ------- | :------: | +| [additional\_tags](#input\_additional\_tags) | Tags applied to every resource. | `map(string)` | `{}` | no | +| [admin\_ssh\_public\_key](#input\_admin\_ssh\_public\_key) | SSH public key authorised on the Kali VM. When null, the module generates an ephemeral ed25519 keypair and writes the private key to ephemeral\_key\_output\_path. | `string` | `null` | no | +| [admin\_username](#input\_admin\_username) | Local admin username for the Kali VM. | `string` | `"kali"` | no | +| [deployment\_name](#input\_deployment\_name) | Name of the deployment (e.g. "goad"). | `string` | n/a | yes | +| [env](#input\_env) | Environment name (e.g. test, staging). | `string` | n/a | yes | +| [ephemeral\_key\_output\_path](#input\_ephemeral\_key\_output\_path) | Filesystem path to write the generated private key when admin\_ssh\_public\_key is null. Required in that case; ignored when an explicit public key is supplied. | `string` | `null` | no | +| [instance\_size](#input\_instance\_size) | Azure VM size. D2s\_v3 (2 vCPU, 8 GB) handles concurrent attack tooling comfortably. | `string` | `"Standard_D2s_v3"` | no | +| [kali\_subnet\_cidr](#input\_kali\_subnet\_cidr) | CIDR for the Kali attack box's dedicated subnet. /28 is plenty for one VM. | `string` | `"10.8.4.0/28"` | no | +| [location](#input\_location) | Azure region. | `string` | n/a | yes | +| [os\_disk\_size\_gb](#input\_os\_disk\_size\_gb) | Size of the OS disk in GB. 32 is enough for stock Kali tooling. | `number` | `32` | no | +| [os\_disk\_storage\_account\_type](#input\_os\_disk\_storage\_account\_type) | Storage account type for the OS disk. | `string` | `"StandardSSD_LRS"` | no | +| [plan](#input\_plan) | Marketplace plan terms for the Kali image. Must be accepted per-subscription via `az vm image terms accept --publisher kali-linux --offer kali --plan `. |
object({
name = string
product = string
publisher = string
})
|
{
"name": "kali-2026-2",
"product": "kali",
"publisher": "kali-linux"
}
| no | +| [resource\_group\_name](#input\_resource\_group\_name) | Resource group the Kali VM and its NIC/NSG/subnet are deployed into. | `string` | n/a | yes | +| [source\_image](#input\_source\_image) | Marketplace image reference. Defaults to the latest Kali Linux release. |
object({
publisher = string
offer = string
sku = string
version = string
})
|
{
"offer": "kali",
"publisher": "kali-linux",
"sku": "kali-2026-2",
"version": "latest"
}
| no | +| [ssh\_source\_address\_prefix](#input\_ssh\_source\_address\_prefix) | Source allowed to reach the Kali box on TCP 22. Defaults to the AzureBastionSubnet CIDR. | `string` | `"10.8.2.0/26"` | no | +| [virtual\_network\_name](#input\_virtual\_network\_name) | VNet name where the Kali subnet will be created. | `string` | n/a | yes | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [admin\_username](#output\_admin\_username) | Local admin username for the Kali VM. | +| [computer\_name](#output\_computer\_name) | Linux hostname assigned to the Kali attack box. | +| [nsg\_id](#output\_nsg\_id) | NSG ID gating the Kali subnet. | +| [private\_ip](#output\_private\_ip) | Private IP address of the Kali VM's NIC. | +| [ssh\_private\_key\_path](#output\_ssh\_private\_key\_path) | Filesystem path to the generated private key when the module created an ephemeral keypair; null when an explicit admin\_ssh\_public\_key was supplied. | +| [ssh\_public\_key\_openssh](#output\_ssh\_public\_key\_openssh) | OpenSSH-formatted public key authorised on the Kali VM. | +| [subnet\_id](#output\_subnet\_id) | Subnet ID created for the Kali attack box. | +| [vm\_id](#output\_vm\_id) | Azure VM resource ID for the Kali attack box. | +| [vm\_name](#output\_vm\_name) | Azure VM resource name for the Kali attack box. | + diff --git a/modules/terraform-azure-kali/main.tf b/modules/terraform-azure-kali/main.tf new file mode 100644 index 00000000..0147e59b --- /dev/null +++ b/modules/terraform-azure-kali/main.tf @@ -0,0 +1,151 @@ +locals { + name_prefix = "${var.env}-${var.deployment_name}-kali" + + base_tags = { + Module = "terraform-azure-kali" + Environment = var.env + ManagedBy = "Terraform" + AccessMethod = "BastionSSH" + Role = "AttackBox" + } + + tags = merge(local.base_tags, var.additional_tags) + + generate_ssh_key = var.admin_ssh_public_key == null + + effective_public_key = ( + local.generate_ssh_key + ? tls_private_key.kali[0].public_key_openssh + : var.admin_ssh_public_key + ) +} + +resource "tls_private_key" "kali" { + count = local.generate_ssh_key ? 1 : 0 + + algorithm = "ED25519" +} + +resource "local_sensitive_file" "kali_key" { + count = local.generate_ssh_key ? 1 : 0 + + content = tls_private_key.kali[0].private_key_openssh + filename = var.ephemeral_key_output_path + file_permission = "0600" + + lifecycle { + precondition { + condition = var.ephemeral_key_output_path != null + error_message = "ephemeral_key_output_path must be set when admin_ssh_public_key is null." + } + } +} + +resource "azurerm_subnet" "this" { + name = "${local.name_prefix}-subnet" + resource_group_name = var.resource_group_name + virtual_network_name = var.virtual_network_name + address_prefixes = [var.kali_subnet_cidr] +} + +resource "azurerm_network_security_group" "this" { + name = "${local.name_prefix}-nsg" + location = var.location + resource_group_name = var.resource_group_name + + security_rule { + name = "AllowSSHFromBastion" + priority = 100 + direction = "Inbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_range = "22" + source_address_prefix = var.ssh_source_address_prefix + destination_address_prefix = "*" + } + + security_rule { + name = "AllowAzureLoadBalancer" + priority = 110 + direction = "Inbound" + access = "Allow" + protocol = "*" + source_port_range = "*" + destination_port_range = "*" + source_address_prefix = "AzureLoadBalancer" + destination_address_prefix = "*" + } + + security_rule { + name = "DenyAllInbound" + priority = 4096 + direction = "Inbound" + access = "Deny" + protocol = "*" + source_port_range = "*" + destination_port_range = "*" + source_address_prefix = "*" + destination_address_prefix = "*" + } + + tags = merge(local.tags, { Name = "${local.name_prefix}-nsg" }) +} + +resource "azurerm_subnet_network_security_group_association" "this" { + subnet_id = azurerm_subnet.this.id + network_security_group_id = azurerm_network_security_group.this.id +} + +resource "azurerm_network_interface" "this" { + name = "${local.name_prefix}-nic" + location = var.location + resource_group_name = var.resource_group_name + + ip_configuration { + name = "internal" + subnet_id = azurerm_subnet.this.id + private_ip_address_allocation = "Dynamic" + } + + tags = merge(local.tags, { Name = "${local.name_prefix}-nic" }) +} + +resource "azurerm_linux_virtual_machine" "this" { + name = "${local.name_prefix}-vm" + computer_name = substr(local.name_prefix, 0, 63) + resource_group_name = var.resource_group_name + location = var.location + size = var.instance_size + admin_username = var.admin_username + + network_interface_ids = [azurerm_network_interface.this.id] + + disable_password_authentication = true + + admin_ssh_key { + username = var.admin_username + public_key = local.effective_public_key + } + + os_disk { + caching = "ReadWrite" + storage_account_type = var.os_disk_storage_account_type + disk_size_gb = var.os_disk_size_gb + } + + source_image_reference { + publisher = var.source_image.publisher + offer = var.source_image.offer + sku = var.source_image.sku + version = var.source_image.version + } + + plan { + name = var.plan.name + product = var.plan.product + publisher = var.plan.publisher + } + + tags = merge(local.tags, { Name = "${local.name_prefix}-vm" }) +} diff --git a/modules/terraform-azure-kali/outputs.tf b/modules/terraform-azure-kali/outputs.tf new file mode 100644 index 00000000..b84a38c4 --- /dev/null +++ b/modules/terraform-azure-kali/outputs.tf @@ -0,0 +1,44 @@ +output "vm_id" { + description = "Azure VM resource ID for the Kali attack box." + value = azurerm_linux_virtual_machine.this.id +} + +output "vm_name" { + description = "Azure VM resource name for the Kali attack box." + value = azurerm_linux_virtual_machine.this.name +} + +output "computer_name" { + description = "Linux hostname assigned to the Kali attack box." + value = azurerm_linux_virtual_machine.this.computer_name +} + +output "private_ip" { + description = "Private IP address of the Kali VM's NIC." + value = azurerm_network_interface.this.private_ip_address +} + +output "subnet_id" { + description = "Subnet ID created for the Kali attack box." + value = azurerm_subnet.this.id +} + +output "nsg_id" { + description = "NSG ID gating the Kali subnet." + value = azurerm_network_security_group.this.id +} + +output "admin_username" { + description = "Local admin username for the Kali VM." + value = var.admin_username +} + +output "ssh_private_key_path" { + description = "Filesystem path to the generated private key when the module created an ephemeral keypair; null when an explicit admin_ssh_public_key was supplied." + value = local.generate_ssh_key ? var.ephemeral_key_output_path : null +} + +output "ssh_public_key_openssh" { + description = "OpenSSH-formatted public key authorised on the Kali VM." + value = local.effective_public_key +} diff --git a/modules/terraform-azure-kali/variables.tf b/modules/terraform-azure-kali/variables.tf new file mode 100644 index 00000000..10c74174 --- /dev/null +++ b/modules/terraform-azure-kali/variables.tf @@ -0,0 +1,113 @@ +variable "deployment_name" { + description = "Name of the deployment (e.g. \"goad\")." + type = string +} + +variable "env" { + description = "Environment name (e.g. test, staging)." + type = string +} + +variable "location" { + description = "Azure region." + type = string +} + +variable "resource_group_name" { + description = "Resource group the Kali VM and its NIC/NSG/subnet are deployed into." + type = string +} + +variable "virtual_network_name" { + description = "VNet name where the Kali subnet will be created." + type = string +} + +variable "kali_subnet_cidr" { + description = "CIDR for the Kali attack box's dedicated subnet. /28 is plenty for one VM." + type = string + default = "10.8.4.0/28" + + validation { + condition = can(cidrhost(var.kali_subnet_cidr, 0)) + error_message = "kali_subnet_cidr must be a valid IPv4 CIDR block." + } +} + +variable "ssh_source_address_prefix" { + description = "Source allowed to reach the Kali box on TCP 22. Defaults to the AzureBastionSubnet CIDR." + type = string + default = "10.8.2.0/26" +} + +variable "instance_size" { + description = "Azure VM size. D2s_v3 (2 vCPU, 8 GB) handles concurrent attack tooling comfortably." + type = string + default = "Standard_D2s_v3" +} + +variable "admin_username" { + description = "Local admin username for the Kali VM." + type = string + default = "kali" +} + +variable "admin_ssh_public_key" { + description = "SSH public key authorised on the Kali VM. When null, the module generates an ephemeral ed25519 keypair and writes the private key to ephemeral_key_output_path." + type = string + default = null +} + +variable "ephemeral_key_output_path" { + description = "Filesystem path to write the generated private key when admin_ssh_public_key is null. Required in that case; ignored when an explicit public key is supplied." + type = string + default = null +} + +variable "os_disk_size_gb" { + description = "Size of the OS disk in GB. 32 is enough for stock Kali tooling." + type = number + default = 32 +} + +variable "os_disk_storage_account_type" { + description = "Storage account type for the OS disk." + type = string + default = "StandardSSD_LRS" +} + +variable "source_image" { + description = "Marketplace image reference. Defaults to the latest Kali Linux release." + type = object({ + publisher = string + offer = string + sku = string + version = string + }) + default = { + publisher = "kali-linux" + offer = "kali" + sku = "kali-2026-2" + version = "latest" + } +} + +variable "plan" { + description = "Marketplace plan terms for the Kali image. Must be accepted per-subscription via `az vm image terms accept --publisher kali-linux --offer kali --plan `." + type = object({ + name = string + product = string + publisher = string + }) + default = { + name = "kali-2026-2" + product = "kali" + publisher = "kali-linux" + } +} + +variable "additional_tags" { + description = "Tags applied to every resource." + type = map(string) + default = {} +} diff --git a/modules/terraform-azure-kali/versions.tf b/modules/terraform-azure-kali/versions.tf new file mode 100644 index 00000000..3c3d705b --- /dev/null +++ b/modules/terraform-azure-kali/versions.tf @@ -0,0 +1,18 @@ +terraform { + required_version = ">= 1.7" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + tls = { + source = "hashicorp/tls" + version = "~> 4.0" + } + local = { + source = "hashicorp/local" + version = "~> 2.5" + } + } +}