Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
b49a93c
feat: add azure windows vm terraform module and auth validation example
l50 Apr 29, 2026
b9c565e
feat: add Azure provider support and modularize Azure infra for GOAD …
l50 Apr 29, 2026
4fccf5a
feat: add Azure Bastion and in-VNet Ansible controller modules and CL…
l50 Apr 30, 2026
a769136
feat: add Azure Bastion SOCKS5 proxy support for WinRM provisioning
l50 Apr 30, 2026
9d6ed3a
fix: serialize azure run commands per-vm and improve dsc module insta…
l50 Apr 30, 2026
92ba133
feat: migrate Azure provider to Go SDK, add fast WinRM validation, an…
l50 Apr 30, 2026
3e50dea
feat: support both NTLM and Basic auth for WinRM based on host group
l50 Apr 30, 2026
bdc37b7
docs: update bastion module readme to reflect new image support
l50 Apr 30, 2026
d917aaa
Merge branch 'main' into feat/azure-provider
l50 Apr 30, 2026
c0c2673
build: add terraform-docs setup to pre-commit workflow
l50 Apr 30, 2026
0e6eae2
test: improve validator check output grouping test and clarify behavior
l50 Apr 30, 2026
332e4e0
feat: add terraform module for Azure VNet peering with per-VM WinRM r…
l50 Apr 30, 2026
d6f0126
ci: add terraform-docs setup to pre-commit workflow
l50 May 1, 2026
f24ae39
build: update pre-commit workflow to use latest terraform-docs version
l50 May 1, 2026
f51887a
build: add terraform provider lock files for module dependency manage…
l50 May 1, 2026
11be89c
build: update provider dependency hashes in terraform lock files
l50 May 1, 2026
34cfbe5
feat: parallelize DSC module installation and add async handling
l50 May 1, 2026
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
9 changes: 9 additions & 0 deletions .github/workflows/pre-commit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ permissions:
env:
GO_VERSION: "1.26.2"
PYTHON_VERSION: "3.14.3"
TFD_VERSION: "v0.22.0"

jobs:
pre-commit:
Expand Down Expand Up @@ -79,6 +80,14 @@ jobs:
- name: Init TFLint
run: tflint --init --config .hooks/linters/.tflint.hcl

- name: Set up terraform-docs
run: |
curl -fsSL -o /tmp/terraform-docs.tar.gz \
"https://github.com/terraform-docs/terraform-docs/releases/download/${TFD_VERSION}/terraform-docs-${TFD_VERSION}-linux-amd64.tar.gz"
tar -xzf /tmp/terraform-docs.tar.gz -C /tmp terraform-docs
sudo install -m 0755 /tmp/terraform-docs /usr/local/bin/terraform-docs
terraform-docs --version

- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
Expand Down
7 changes: 7 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ repos:
files: ^(modules|infra)/
args:
- --args=--config=__GIT_WORKING_DIR__/.hooks/linters/.tflint.hcl
- id: terraform_docs
files: ^modules/[^/]+/.+
exclude: ^modules/README\.md$
args:
- --hook-config=--path-to-file=README.md
- --hook-config=--add-to-existing-file=true
- --hook-config=--create-file-if-not-exist=true

- repo: local
hooks:
Expand Down
5 changes: 5 additions & 0 deletions ansible/roles/common/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Install-PackageProvider -Name NuGet -Force -Confirm:$false
Install-Module PowerShellGet -Force -Confirm:$false
# Newly-installed DLLs sometimes carry Mark-of-the-Web from the .nupkg
# download, which makes Microsoft.PackageManagement.dll fail to load on
# the next Install-Module call ("Catastrophic failure" / 0x8000FFFF).
Get-ChildItem -Path 'C:\Program Files\WindowsPowerShell\Modules' -Recurse -ErrorAction SilentlyContinue |
Unblock-File -ErrorAction SilentlyContinue
register: powershellget_install
retries: 3
delay: 10
Expand Down
293 changes: 293 additions & 0 deletions cli/cmd/bastion.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
package cmd

import (
"context"
"fmt"
"os"
"path/filepath"
"strings"

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

// bastionCmd is the Azure-only command tree for native-client access via the
// Azure Bastion module (modules/terraform-azure-bastion). Bastion is opt-in:
// the terragrunt module is excluded unless DREADGOAD_ENABLE_AZURE_BASTION=true
// (or `infra apply --with-bastion`) is set when the stack was applied. When
// no Bastion is deployed, these commands return an actionable error rather
// than silently falling back to Run Command.
var bastionCmd = &cobra.Command{
Use: "bastion",
Short: "Connect to lab VMs via Azure Bastion (SSH, RDP, port tunnel)",
Long: `Native-client access to lab VMs through the deployed Azure Bastion host.

Requires the Bastion module to be deployed (set DREADGOAD_ENABLE_AZURE_BASTION=true
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 {
cfg, err := config.Get()
if err != nil {
return err
}
if cfg.ResolvedProvider() != provider.NameAzure {
return fmt.Errorf("bastion is only available with the Azure provider (current: %s)", cfg.ResolvedProvider())
}
return nil
},
}

var bastionStatusCmd = &cobra.Command{
Use: "status",
Short: "Show the deployed Bastion host (or report none)",
RunE: runBastionStatus,
}

var bastionSSHCmd = &cobra.Command{
Use: "ssh <host>",
Short: "SSH to a lab VM via Bastion",
Long: `Open a native-client SSH session to a lab VM through Azure Bastion.

Defaults to password auth (matches the GOAD provisioning workflow). Pass
--auth-type ssh-key --ssh-key <path> for key auth, or --auth-type AAD for
Azure AD-joined VMs.`,
Args: cobra.ExactArgs(1),
RunE: runBastionSSH,
}

var bastionRDPCmd = &cobra.Command{
Use: "rdp <host>",
Short: "RDP to a lab VM via Bastion (Windows clients only)",
Args: cobra.ExactArgs(1),
RunE: runBastionRDP,
}

var bastionTunnelCmd = &cobra.Command{
Use: "tunnel <host>",
Short: "Forward a remote port from a lab VM to localhost via Bastion",
Long: `Open a port tunnel through Azure Bastion. Defaults to RDP (3389) so
non-Windows clients can point any RDP app at localhost. Override with
--remote-port and --local-port for SMB (445), WinRM (5985/5986), etc.

Requires tunneling_enabled = true on the Bastion (Standard or Premium SKU).`,
Args: cobra.ExactArgs(1),
RunE: runBastionTunnel,
}

func init() {
rootCmd.AddCommand(bastionCmd)
bastionCmd.AddCommand(bastionStatusCmd)
bastionCmd.AddCommand(bastionSSHCmd)
bastionCmd.AddCommand(bastionRDPCmd)
bastionCmd.AddCommand(bastionTunnelCmd)

bastionSSHCmd.Flags().StringP("user", "u", "", "Remote username")
bastionSSHCmd.Flags().String("auth-type", "password", "Auth type: password | ssh-key | AAD")
bastionSSHCmd.Flags().String("ssh-key", "", "Path to SSH private key (auth-type=ssh-key)")

bastionTunnelCmd.Flags().Int("remote-port", 3389, "Remote port on the target VM")
bastionTunnelCmd.Flags().Int("local-port", 3389, "Local port to bind")
}

// azureClientFromProvider extracts the underlying *azure.Client from the
// configured provider. We type-assert via the AzureProvider concrete type
// rather than adding a Bastion method to the provider.Provider interface,
// since Bastion is Azure-specific and shouldn't pollute the cross-provider
// abstraction.
func azureClientFromProvider(prov provider.Provider) (*azure.Client, error) {
ap, ok := prov.(*azure.AzureProvider)
if !ok {
return nil, fmt.Errorf("bastion requires the Azure provider; got %s", prov.Name())
}
return ap.Client(), nil
}

func bastionContext(ctx context.Context) (*azure.Client, *azure.BastionHost, *config.Config, error) {
cfg, err := config.Get()
if err != nil {
return nil, nil, nil, err
}
prov, err := cfg.NewProvider(ctx)
if err != nil {
return nil, nil, nil, err
}
client, err := azureClientFromProvider(prov)
if err != nil {
return nil, nil, nil, err
}
if _, err := client.VerifyCredentials(ctx); err != nil {
return nil, nil, nil, err
}
host, err := client.DiscoverBastion(ctx, cfg.Env)
if err != nil {
return nil, nil, nil, fmt.Errorf("discover bastion: %w", err)
}
if host == nil {
return nil, nil, nil, fmt.Errorf(
"no Azure Bastion host found for env=%s. Deploy with: "+
"DREADGOAD_ENABLE_AZURE_BASTION=true dreadgoad infra apply --module bastion "+
"(or use --with-bastion on infra apply)", cfg.Env)
}
return client, host, cfg, nil
}

func runBastionStatus(cmd *cobra.Command, args []string) error {
ctx := context.Background()
cfg, err := config.Get()
if err != nil {
return err
}
prov, err := cfg.NewProvider(ctx)
if err != nil {
return err
}
client, err := azureClientFromProvider(prov)
if err != nil {
return err
}
if _, err := client.VerifyCredentials(ctx); err != nil {
return err
}
host, err := client.DiscoverBastion(ctx, cfg.Env)
if err != nil {
return fmt.Errorf("discover bastion: %w", err)
}
if host == nil {
fmt.Printf("No Azure Bastion host deployed for env=%s.\n", cfg.Env)
fmt.Println("Deploy with: DREADGOAD_ENABLE_AZURE_BASTION=true dreadgoad infra apply --module bastion")
fmt.Println(" or: dreadgoad infra apply --with-bastion --module bastion")
return nil
}
fmt.Printf("Azure Bastion (%s)\n", cfg.Env)
fmt.Printf(" Name: %s\n", host.Name)
fmt.Printf(" Resource group: %s\n", host.ResourceGroup)
fmt.Printf(" Location: %s\n", host.Location)
fmt.Printf(" SKU: %s\n", host.SKU)
fmt.Printf(" Tunneling enabled: %t\n", host.TunnelingEnabled)
fmt.Printf(" IP connect: %t\n", host.IPConnectEnabled)
if !host.TunnelingEnabled {
fmt.Println("\nWarning: tunneling is disabled; ssh/rdp/tunnel subcommands require it.")
fmt.Println("Re-deploy with bastion_tunneling_enabled = true on Standard/Premium SKU.")
}
return nil
}

func runBastionSSH(cmd *cobra.Command, args []string) error {
ctx := context.Background()
client, host, cfg, err := bastionContext(ctx)
if err != nil {
return err
}
if !host.TunnelingEnabled {
return fmt.Errorf("bastion %s does not have tunneling enabled; ssh requires bastion_tunneling_enabled=true on a Standard/Premium SKU", host.Name)
}

prov, err := cfg.NewProvider(ctx)
if err != nil {
return err
}
vmID, err := resolveAzureHost(ctx, prov, cfg, args[0])
if err != nil {
return err
}

user, _ := cmd.Flags().GetString("user")
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" {
if !cmd.Flags().Changed("auth-type") {
authType = "ssh-key"
}
if !cmd.Flags().Changed("ssh-key") && authType == "ssh-key" {
if path := controllerKeyPath(cfg.Env, inst.Name); path != "" {
sshKey = path
}
}
if !cmd.Flags().Changed("user") {
user = "dreadadmin"
}
}

fmt.Printf("Bastion SSH to %s via %s...\n", args[0], host.Name)
return client.OpenBastionSSH(ctx, host, vmID, user, authType, sshKey)
}

// 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
// "~/.dreadgoad/keys/azure-{env}-{deployment}-controller". Returns "" if the
// VM name doesn't match the pattern or the key file isn't on disk.
func controllerKeyPath(env, vmName string) string {
deployment := strings.TrimSuffix(strings.TrimPrefix(vmName, env+"-"), "-controller-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-controller", 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)
if err != nil {
return err
}
if !host.TunnelingEnabled {
return fmt.Errorf("bastion %s does not have tunneling enabled; rdp requires bastion_tunneling_enabled=true on a Standard/Premium SKU", host.Name)
}
prov, err := cfg.NewProvider(ctx)
if err != nil {
return err
}
vmID, err := resolveAzureHost(ctx, prov, cfg, args[0])
if err != nil {
return err
}

fmt.Printf("Bastion RDP to %s via %s...\n", args[0], host.Name)
return client.OpenBastionRDP(ctx, host, vmID)
}

func runBastionTunnel(cmd *cobra.Command, args []string) error {
ctx := context.Background()
client, host, cfg, err := bastionContext(ctx)
if err != nil {
return err
}
if !host.TunnelingEnabled {
return fmt.Errorf("bastion %s does not have tunneling enabled; tunnel requires bastion_tunneling_enabled=true on a Standard/Premium SKU", host.Name)
}
prov, err := cfg.NewProvider(ctx)
if err != nil {
return err
}
vmID, err := resolveAzureHost(ctx, prov, cfg, args[0])
if err != nil {
return err
}

remotePort, _ := cmd.Flags().GetInt("remote-port")
localPort, _ := cmd.Flags().GetInt("local-port")

fmt.Printf("Bastion tunnel %s:%d → localhost:%d via %s\n",
args[0], remotePort, localPort, host.Name)
fmt.Println("Press Ctrl+C to close the tunnel.")
return client.OpenBastionTunnel(ctx, host, vmID, remotePort, localPort)
}
1 change: 1 addition & 0 deletions cli/cmd/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Common checks: ansible-core version, Python, jq, zip, Ansible collections, inven

Provider-specific:
aws (default) AWS CLI, AWS credentials, Terragrunt, Terraform/Tofu
azure Azure CLI, az login session, az network bastion, Terragrunt, Terraform/Tofu
ludus Ludus CLI (or SSH reachability when ludus.ssh_host is set), API key`,
RunE: func(cmd *cobra.Command, args []string) error {
if config.ConfigMissing() {
Expand Down
Loading
Loading