Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 61 additions & 12 deletions cli/cmd/bastion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -198,30 +201,56 @@ 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 <path> -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 != "" {
Comment on lines +204 to +206

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refuted. resolveAzureHost (line 195) already called FindInstanceByHostname to resolve the VM ID moments earlier — the data is cached or at worst one extra API call (~200ms) before an interactive SSH session. The pre-existing controller code made the same unconditional lookup. Adding flag-changed guards would add complexity for negligible gain.

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
}
}

fmt.Printf("Bastion SSH to %s via %s...\n", args[0], host.Name)
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
}
Comment thread
mkultraWasHere marked this conversation as resolved.
}

// 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
Expand All @@ -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)
Expand Down
42 changes: 29 additions & 13 deletions cli/cmd/env_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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)
}

Expand Down
24 changes: 14 additions & 10 deletions cli/cmd/env_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
Expand Down
13 changes: 13 additions & 0 deletions cli/cmd/infra_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down Expand Up @@ -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
}
}
Comment thread
mkultraWasHere marked this conversation as resolved.
if withKali {
opts.ExtraEnv = append(opts.ExtraEnv, "DREADGOAD_ENABLE_AZURE_KALI=true")
}

if action == "apply" || action == "destroy" {
autoApprove, _ := cmd.Flags().GetBool("auto-approve")
Expand Down
3 changes: 3 additions & 0 deletions cli/cmd/runcmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions cli/cmd/ssm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions cli/internal/doctor/checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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",
}
}
Comment thread
mkultraWasHere marked this conversation as resolved.
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

Expand Down
78 changes: 78 additions & 0 deletions infra/azure/goad-deployment/test/centralus/kali/terragrunt.hcl
Original file line number Diff line number Diff line change
@@ -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"
}
}
5 changes: 5 additions & 0 deletions infra/azure/goad-deployment/test/env.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Loading
Loading