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
3 changes: 3 additions & 0 deletions cli/cmd/env_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,9 @@ locals {
aws_account_id = get_aws_account_id()
env = %q
vpc_cidr = %q

# Optional Kali attack box. Enable with --with-kali on infra commands.
kali_instance_type = "t3.medium"
}
`, envName, vpcCIDR)
return os.WriteFile(filepath.Join(envDir, "env.hcl"), []byte(content), 0o644)
Expand Down
21 changes: 17 additions & 4 deletions cli/cmd/infra_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,13 @@ func init() {
infraApplyCmd.Flags().Bool("individual", false, "Apply each subdirectory individually (for module groups like goad/)")
infraDestroyCmd.Flags().Bool("auto-approve", false, "Skip confirmation prompt")

// Azure-only opt-in flags. The matching DREADGOAD_ENABLE_* env vars are
// what the terragrunt exclude{} blocks check; these flags just set them
// for the child process so users don't have to.
// Optional-module flags. The matching DREADGOAD_ENABLE_* env vars are what
// the Terragrunt exclude{} blocks check; these flags set them for the child
// process so users don't have to.
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")
cmd.Flags().Bool("with-kali", false, "Include the optional Kali Linux attack box")
}

infraCmd.PersistentFlags().StringP("deployment", "d", "", "Deployment name (default: from config)")
Expand Down Expand Up @@ -280,6 +280,19 @@ func runInfraActionAWS(cmd *cobra.Command, cfg *config.Config, action string) er
Debug: cfg.Debug,
}

withKali, _ := cmd.Flags().GetBool("with-kali")
// On destroy, always include the optional unit so an existing attack box
// is not orphaned when the user omits --with-kali.
if !withKali && action == "destroy" {
kaliDir := filepath.Join(cfg.ProjectRoot, "infra", deployment, cfg.Env, region, "kali")
if _, err := os.Stat(kaliDir); err == nil {
withKali = true
}
}
if withKali {
opts.ExtraEnv = append(opts.ExtraEnv, "DREADGOAD_ENABLE_AWS_KALI=true")
}

if action == "apply" || action == "destroy" {
autoApprove, _ := cmd.Flags().GetBool("auto-approve")
opts.AutoApprove = autoApprove
Expand Down
45 changes: 40 additions & 5 deletions cli/cmd/score.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"github.com/dreadnode/dreadgoad/internal/azure"
"github.com/dreadnode/dreadgoad/internal/config"
"github.com/dreadnode/dreadgoad/internal/provider"
"github.com/dreadnode/dreadgoad/internal/scoreboard"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -147,20 +148,54 @@ func buildShellRunner(ctx context.Context, cmd *cobra.Command, cfg *config.Confi
return buildAWSRunner(ctx, cmd, cfg, attackBox)
}

// No --attack-box: auto-detect provider.
if cfg.Provider == "azure" {
// No --attack-box: auto-detect provider and locate the tagged attack box.
if cfg.ResolvedProvider() == provider.NameAzure {
return buildAzureRunner(ctx, cmd, cfg, "")
}
return nil, fmt.Errorf("--attack-box is required with --live-verify (or set -p azure for auto-discovery)")
if cfg.ResolvedProvider() == provider.NameAWS {
region, profile, err := resolveAWSConnectionConfig(cmd, cfg)
if err != nil {
return nil, err
}
prov, err := provider.New(ctx, provider.NameAWS, provider.ConstructorOpts{
Region: region,
AWSProfile: profile,
})
if err != nil {
return nil, fmt.Errorf("create AWS provider: %w", err)
}
instances, err := prov.DiscoverInstances(ctx, cfg.Env)
if err != nil {
return nil, fmt.Errorf("discover AWS instances: %w", err)
}
kali := provider.FindInstanceByRole(instances, "AttackBox")
if kali == nil {
return nil, fmt.Errorf("no running Kali attack box (Role=AttackBox) found for env=%s; pass --attack-box to override", cfg.Env)
}
return scoreboard.NewSSMShellRunner(ctx, kali.ID, region, profile)
}
return nil, fmt.Errorf("--attack-box is required with --live-verify for provider %s", cfg.ResolvedProvider())
}

func buildAWSRunner(ctx context.Context, cmd *cobra.Command, cfg *config.Config, instanceID string) (scoreboard.ShellRunner, error) {
region, profile, err := resolveAWSConnectionConfig(cmd, cfg)
if err != nil {
return nil, err
}
return scoreboard.NewSSMShellRunner(ctx, instanceID, region, profile)
}

func resolveAWSConnectionConfig(cmd *cobra.Command, cfg *config.Config) (string, string, error) {
region, _ := cmd.Flags().GetString("region")
if region == "" {
region = cfg.Region
var err error
region, err = cfg.ResolveRegion()
if err != nil {
return "", "", err
}
}
profile, _ := cmd.Flags().GetString("profile")
return scoreboard.NewSSMShellRunner(ctx, instanceID, region, profile)
return region, profile, nil
}

func buildAzureRunner(ctx context.Context, cmd *cobra.Command, cfg *config.Config, vmResourceID string) (scoreboard.ShellRunner, error) {
Expand Down
57 changes: 57 additions & 0 deletions cli/cmd/score_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package cmd

import (
"testing"

"github.com/dreadnode/dreadgoad/internal/config"
"github.com/spf13/cobra"
)

func TestResolveAWSConnectionConfigUsesEnvironmentRegionAndProfile(t *testing.T) {
cmd := &cobra.Command{}
cmd.Flags().String("region", "", "")
cmd.Flags().String("profile", "", "")
if err := cmd.Flags().Set("profile", "lab"); err != nil {
t.Fatal(err)
}

cfg := &config.Config{
Env: "staging",
Region: "us-east-1",
Environments: map[string]config.EnvironmentConfig{
"staging": {Region: "us-west-1"},
},
}

region, profile, err := resolveAWSConnectionConfig(cmd, cfg)
if err != nil {
t.Fatalf("resolveAWSConnectionConfig() error = %v", err)
}
if region != "us-west-1" || profile != "lab" {
t.Fatalf("resolveAWSConnectionConfig() = (%q, %q), want (%q, %q)", region, profile, "us-west-1", "lab")
}
}

func TestResolveAWSConnectionConfigPrefersFlagRegion(t *testing.T) {
cmd := &cobra.Command{}
cmd.Flags().String("region", "", "")
cmd.Flags().String("profile", "", "")
if err := cmd.Flags().Set("region", "eu-west-1"); err != nil {
t.Fatal(err)
}

cfg := &config.Config{
Env: "staging",
Environments: map[string]config.EnvironmentConfig{
"staging": {Region: "us-west-1"},
},
}

region, _, err := resolveAWSConnectionConfig(cmd, cfg)
if err != nil {
t.Fatalf("resolveAWSConnectionConfig() error = %v", err)
}
if region != "eu-west-1" {
t.Fatalf("resolveAWSConnectionConfig() region = %q, want eu-west-1", region)
}
}
74 changes: 64 additions & 10 deletions cli/cmd/ssm.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"os/exec"
"strings"
"time"

Expand Down Expand Up @@ -165,7 +166,18 @@ func runSSMConnect(cmd *cobra.Command, args []string) error {
}
ctx := context.Background()

prov, err := cfg.NewProvider(ctx)
// The optional attack box is intentionally absent from the Ansible
// inventory. Prefer inventory for the Windows hosts, then fall back to
// provider discovery for tagged or otherwise out-of-inventory instances.
inv, invErr := inventory.Parse(cfg.InventoryPath())
if invErr != nil {
slog.Warn("inventory unavailable; falling back to AWS discovery", "path", cfg.InventoryPath(), "error", invErr)
}
opts, err := resolveSSMProviderOptions(cfg, inv)
if err != nil {
return err
}
prov, err := provider.New(ctx, provider.NameAWS, opts)
if err != nil {
return err
}
Expand All @@ -174,23 +186,59 @@ func runSSMConnect(cmd *cobra.Command, args []string) error {
return fmt.Errorf("provider %s does not support interactive shells", prov.Name())
}

inv, err := inventory.Parse(cfg.InventoryPath())
target, err := resolveSSMHost(ctx, prov, cfg.Env, inv, args[0])
if err != nil {
return fmt.Errorf("parse inventory: %w", err)
return err
}

host := inv.HostByName(args[0])
if host == nil || host.InstanceID == "" {
return fmt.Errorf("host %q not found in inventory", args[0])
if _, err := exec.LookPath("session-manager-plugin"); err != nil {
return fmt.Errorf("AWS Session Manager plugin not found in PATH; install it before running ssm connect")
}

fmt.Printf("Starting SSM session to %s (%s) in %s...\n", target.Name, target.ID, opts.Region)

return shell.StartInteractiveShell(ctx, target.ID, opts.Region)
}

func resolveSSMProviderOptions(cfg *config.Config, inv *inventory.Inventory) (provider.ConstructorOpts, error) {
region, err := cfg.ResolveRegionWithInventory(inv)
if err != nil {
return err
return provider.ConstructorOpts{}, err
}
return provider.ConstructorOpts{Region: region}, nil
}

func resolveSSMHost(ctx context.Context, prov provider.Provider, env string, inv *inventory.Inventory, hostName string) (*provider.Instance, error) {
if inv != nil {
if host := inv.HostByName(hostName); host != nil && host.InstanceID != "" {
return &provider.Instance{ID: host.InstanceID, Name: host.Name}, nil
}
}

// Also accept the stable role name so callers do not need to know whether
// the attack box resource is named "kali", "attacker", or something else.
if strings.EqualFold(hostName, "attack-box") || strings.EqualFold(hostName, "attackbox") {
instances, err := prov.DiscoverInstances(ctx, env)
if err != nil {
return nil, fmt.Errorf("discover AWS attack box: %w", err)
}
if inst := provider.FindInstanceByRole(instances, "AttackBox"); inst != nil {
return inst, nil
}
return nil, fmt.Errorf("no running AWS attack box (Role=AttackBox) found for env=%s", env)
}
fmt.Printf("Starting SSM session to %s (%s) in %s...\n", host.Name, host.InstanceID, region)

return shell.StartInteractiveShell(ctx, host.InstanceID, region)
inst, err := prov.FindInstanceByHostname(ctx, env, hostName)
if err != nil {
return nil, fmt.Errorf("discover AWS host %q: %w", hostName, err)
}
if inst != nil && inst.ID != "" {
if inst.State != "" && !strings.EqualFold(inst.State, "running") {
return nil, fmt.Errorf("AWS host %q is %s; it must be running before ssm connect", hostName, inst.State)
}
return inst, nil
}

return nil, fmt.Errorf("host %q not found via AWS discovery or inventory", hostName)
}

func runSSMRun(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -248,6 +296,12 @@ func filterProviderInstances(instances []provider.Instance, hostsFlag string) ([
var ids, names []string
if hostsFlag == "all" {
for _, inst := range instances {
// The run verb executes PowerShell and is intended for the Windows
// lab hosts. A tagged Linux attack box is managed separately through
// score/SSM shell commands.
if strings.EqualFold(inst.Tags["Role"], "AttackBox") {
continue
}
ids = append(ids, inst.ID)
names = append(names, inst.Name)
}
Expand Down
Loading
Loading