Skip to content

Commit 7ea5f0c

Browse files
committed
Added a few checks on deployment for Windows VMs
1 parent 1338b89 commit 7ea5f0c

File tree

10 files changed

+168
-16
lines changed

10 files changed

+168
-16
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ echo 'eval "$(register-python-argcomplete ctf)"' >> ~/.bashrc && source ~/.bashr
7979
echo 'eval "$(register-python-argcomplete ctf)"' >> ~/.zshrc && source ~/.zshrc # If using zsh
8080
```
8181

82+
## Create a Windows Image in Incus
83+
84+
To create a Windows Image, you can either use
85+
8286
## Development
8387

8488
Install with [uv](https://docs.astral.sh/uv/guides/tools/) virtual environment:

ctf/deploy.py

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import shutil
44
import subprocess
55
import textwrap
6+
import time
67

78
import typer
89
from typing_extensions import Annotated
@@ -107,7 +108,16 @@ def deploy(
107108
destroy(tracks=tracks, production=production, remote=remote, force=True)
108109
exit(code=0)
109110

110-
for track in distinct_tracks:
111+
# Starting a timer for tracks with a virtual machine in them.
112+
start_timer: float = time.time()
113+
114+
for track in sorted(
115+
distinct_tracks,
116+
key=lambda t: (
117+
t.has_virtual_machine,
118+
t.name,
119+
), # Running ansible on containers first then virtual machines
120+
):
111121
if track.require_build_container:
112122
run_ansible_playbook(
113123
remote=remote,
@@ -177,6 +187,70 @@ def deploy(
177187
):
178188
continue
179189

190+
if track.has_virtual_machine:
191+
incus_list = json.loads(
192+
s=subprocess.run(
193+
args=["incus", "list", f"--project={track}", "--format", "json"],
194+
check=True,
195+
capture_output=True,
196+
env=ENV,
197+
).stdout.decode()
198+
)
199+
200+
# Waiting for virtual machine to be up and running
201+
# Starting with a minute
202+
if start_timer > time.time() - (seconds := 60.0):
203+
LOG.info(
204+
f"Waiting for the virtual machine to be ready. Remaining {(seconds - (time.time() - start_timer)):.1f} seconds..."
205+
)
206+
207+
for machine in incus_list:
208+
if machine["type"] != "virtual-machine":
209+
continue
210+
211+
rebooting: bool = False
212+
cmd: str = "whoami" # Should works on most OS
213+
while start_timer > time.time() - seconds:
214+
# Avoid spamming too much, sleeping for a second between each request.
215+
time.sleep(1)
216+
217+
s = subprocess.run(
218+
args=[
219+
"incus",
220+
"exec",
221+
f"--project={track}",
222+
"-T",
223+
machine["name"],
224+
"--",
225+
cmd,
226+
],
227+
capture_output=True,
228+
env=ENV,
229+
)
230+
231+
match s.returncode:
232+
case 127:
233+
# If "whoami" is not found by the OS, change the command to sleep as it is most likely Linux.
234+
LOG.debug(
235+
f'Command not found, changing it to "{(cmd := "sleep 0")}".'
236+
)
237+
start_timer = time.time()
238+
case 0:
239+
if not rebooting:
240+
LOG.debug(
241+
f"Remaining {(seconds - (time.time() - start_timer)):.1f} seconds..."
242+
)
243+
else:
244+
LOG.info("Agent is up and running!")
245+
break
246+
case _:
247+
# Once the virtual machine rebooted once, set the timer to 30 minutes.
248+
if not rebooting:
249+
LOG.info(
250+
"Virtual machine is most likely rebooting. Once the agent is back up, let's move on."
251+
)
252+
rebooting = True
253+
180254
run_ansible_playbook(
181255
remote=remote, production=production, track=track.name, path=path
182256
)
@@ -192,6 +266,8 @@ def deploy(
192266
)
193267
ipv6_to_container_name = {}
194268
for machine in incus_list:
269+
if machine["type"] == "virtual-machine":
270+
continue
195271
addresses = machine["state"]["network"]["eth0"]["addresses"]
196272
ipv6_address = list(
197273
filter(lambda address: address["family"] == "inet6", addresses)
@@ -205,7 +281,17 @@ def deploy(
205281
track_yaml = parse_track_yaml(track_name=track.name)
206282

207283
for service in track_yaml["services"]:
208-
if service.get("dev_port_mapping"):
284+
if (
285+
service.get("dev_port_mapping")
286+
and (
287+
service["address"]
288+
.replace(":0", ":")
289+
.replace(":0", ":")
290+
.replace(":0", ":")
291+
.replace(":0", ":")
292+
)
293+
in ipv6_to_container_name
294+
):
209295
LOG.debug(
210296
f"Adding incus proxy for service {track}-{service['name']}-port-{service['port']}"
211297
)
@@ -312,6 +398,10 @@ def run_ansible_playbook(
312398
args=ansible_args,
313399
cwd=path,
314400
check=True,
401+
env={
402+
"ANSIBLE_COLLECTIONS_PATH": "/home/mbergeron/Documents/nsec/ctf-script/.ansible/collections",
403+
**ENV,
404+
},
315405
)
316406

317407
artifacts_path = os.path.join(path, "artifacts")

ctf/generate.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from ctf.utils import (
1111
add_tracks_to_terraform_modules,
1212
create_terraform_modules_file,
13+
track_has_virtual_machine,
1314
does_track_require_build_container,
1415
find_ctf_root_directory,
1516
get_all_available_tracks,
@@ -70,6 +71,7 @@ def generate(
7071
remote=remote,
7172
production=production,
7273
require_build_container=does_track_require_build_container(track),
74+
has_virtual_machine=track_has_virtual_machine(track),
7375
)
7476
)
7577
distinct_tracks = tmp_tracks

ctf/models.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class Track(BaseModel):
1616
remote: str = "local"
1717
production: bool = False
1818
require_build_container: bool = False
19+
has_virtual_machine: bool = False
1920

2021
def __eq__(self, other: Any) -> bool:
2122
match other:
@@ -31,7 +32,7 @@ def __hash__(self) -> int:
3132
return self.name.__hash__()
3233

3334
def __repr__(self) -> str:
34-
return f'{self.__class__.__name__}(name="{self.name}", remote="{self.remote}", production={self.production}, require_build_container={self.require_build_container})'
35+
return f'{self.__class__.__name__}(name="{self.name}", remote="{self.remote}", production={self.production}, require_build_container={self.require_build_container}, has_virtual_machine={self.has_virtual_machine})'
3536

3637
def __str__(self) -> str:
3738
return self.name

ctf/new.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class Template(StrEnum):
2020
FILES_ONLY = "files-only"
2121
TRACK_YAML_ONLY = "track-yaml-only"
2222
RUST_WEBSERVICE = "rust-webservice"
23+
WINDOWS_VM = "windows-vm"
2324

2425

2526
@app.command(help="Create a new CTF track with a given name")
@@ -205,6 +206,7 @@ def new(
205206
"ipv6_subnet": ipv6_subnet,
206207
"full_ipv6_address": full_ipv6_address,
207208
"with_build": with_build_container,
209+
"is_windows": template == Template.WINDOWS_VM,
208210
}
209211
)
210212
with open(
@@ -278,7 +280,11 @@ def new(
278280

279281
track_template = env.get_template(name=os.path.join("common", "inventory.j2"))
280282
render = track_template.render(
281-
data={"name": name, "with_build": with_build_container}
283+
data={
284+
"name": name,
285+
"with_build": with_build_container,
286+
"is_windows": template == Template.WINDOWS_VM,
287+
}
282288
)
283289
with open(
284290
file=(p := os.path.join(ansible_directory, "inventory")),

ctf/templates/init/.deploy/cleanup.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
- name: Pre-deployment system cleanup
2-
hosts: all,!build
2+
hosts: all,!build,!windows
33
order: shuffle
44
gather_facts: false
55
any_errors_fatal: true

ctf/templates/init/.deploy/common.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
- name: Pre-deployment Common
2-
hosts: all,!build
2+
hosts: all,!build,!windows
33
order: shuffle
44
gather_facts: false
55
any_errors_fatal: true

ctf/templates/new/common/inventory.j2

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
# This YAML file defines all machines that Ansible needs to know about to run playbooks and configure machines.
22
all:
3-
hosts:
3+
hosts:{% if not data.is_windows %}
44
# The following line defines how this machine will be referred to in Ansible scripts.
55
{{ data.name }}:
66
# This one tells Ansible that this host is reached using incus, and the name of the machine in incus is `{{ data.name }}`.
77
ansible_incus_host: {{ data.name }}
8+
{% else %}
9+
# If you also need Linux containers, add them here.
10+
# linux-incus-container:
11+
# ansible_incus_host: linux-incus-container
12+
{% endif %}
813
# You can set variables here to use in your Ansible playbooks. For example, you can set the flags here to set them dynamically when setting up the challenge.
914
vars:
1015
# Do not change these.
@@ -24,4 +29,16 @@ build:
2429
build-container:
2530
# The name must be the same as the previous line.
2631
ansible_incus_host: build-container
32+
{% endif %}{% if data.is_windows %}
33+
# This section is needed if you need Windows virtual machines. It's a group of hosts regrouped under the name "windows" which MUST remain the same.
34+
# The group "windows" is removed from the "cleanup.yaml" and "common.yaml", which is why you should not change it.
35+
windows:
36+
hosts:
37+
# The following line defines how this machine will be referred to in Ansible scripts.
38+
{{ data.name }}:
39+
# This one tells Ansible that this host is reached using incus, and the name of the machine in incus is `{{ data.name }}`.
40+
ansible_incus_host: {{ data.name }}
41+
vars:
42+
# This variable is used to tell Ansible that the hosts are Windows hosts and require a PowerShell shell.
43+
ansible_shell_type: powershell
2744
{% endif %}

ctf/templates/new/common/main.tf.j2

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,18 @@ resource "incus_profile" "this" {
3333
remote = var.incus_remote
3434
project = incus_project.this.name
3535

36-
name = "containers"
37-
description = "Default profile for containers in the ${local.track.name} track"
36+
name = {% if data.is_windows %}"windows-vm"{% else %}"containers"{% endif %}
37+
description = "Default profile for {% if data.is_windows %}Windows virtual machine{% else %}containers{% endif %} in the ${local.track.name} track"
3838

3939
config = {
4040
# These limits should only be adjusted if you NEED more resources.
4141
"limits.cpu" = "2"
42-
"limits.memory" = "256MiB"
42+
"limits.memory" = {% if data.is_windows %}"3GiB"{% else %}"256MiB"
4343
"limits.processes" = "2000"
4444
"environment.http_proxy" = var.deploy == "production" ? "http://proxy.ctf-int.internal.nsec.io:3128" : null
45-
"environment.https_proxy" = var.deploy == "production" ? "http://proxy.ctf-int.internal.nsec.io:3128" : null
45+
"environment.https_proxy" = var.deploy == "production" ? "http://proxy.ctf-int.internal.nsec.io:3128" : null{% endif %}
4646
}
4747

48-
4948
device {
5049
name = "root"
5150
type = "disk"
@@ -54,7 +53,7 @@ resource "incus_profile" "this" {
5453
"pool" = "default"
5554
"path" = "/"
5655
# This limit should only be adjusted if you NEED more resources.
57-
"size" = "1GiB"
56+
"size" = {% if data.is_windows %}"32GiB"{% else %}"1GiB"{% endif %}
5857
}
5958
}
6059
}
@@ -87,8 +86,14 @@ resource "incus_instance" "this" {
8786

8887
name = each.key
8988

90-
image = "images:ubuntu/24.04"
91-
profiles = ["default", incus_profile.this.name]
89+
type = {% if data.is_windows %}"virtual-machine"{% else %}"container"{% endif %}
90+
91+
image = {% if data.is_windows %}"CHANGE_ME" # Change to the Windows image location. Refer to the ctf-script README to know how to create an image in Incus.{% else %}"images:ubuntu/24.04"{% endif %}
92+
profiles = [{% if not data.is_windows %}"default", {% endif %}incus_profile.this.name]
93+
94+
{% if data.is_windows %}config = {
95+
"security.secureboot" = "false"
96+
}{% endif %}
9297

9398
device {
9499
name = "eth0"
@@ -101,9 +106,22 @@ resource "incus_instance" "this" {
101106
}
102107
}
103108

109+
{% if data.is_windows %}device {
110+
name = "incusagent"
111+
type = "disk"
112+
113+
properties = {
114+
"source" = "agent:config"
115+
}
116+
}{% endif %}
117+
104118
lifecycle {
105119
ignore_changes = [running]
106120
}
121+
122+
{% if data.is_windows %}wait_for {
123+
type = "agent"
124+
}{% endif %}
107125
}
108126
{% if data.with_build %}
109127
# AUTOGENERATED - No need to change this section #
@@ -170,7 +188,6 @@ resource "incus_network_zone_record" "this" {
170188
}
171189
}
172190

173-
174191
# If you need to manually add DNS records, here is an example.
175192
#resource "incus_network_zone_record" "sub" {
176193
# remote = var.incus_remote

ctf/utils.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,20 @@ def does_track_require_build_container(track: Track) -> bool:
6161
) and bool(load_yaml_file(build_yaml_file_path))
6262

6363

64+
def track_has_virtual_machine(track: str | Track) -> bool:
65+
with open(
66+
os.path.join(
67+
find_ctf_root_directory(),
68+
"challenges",
69+
track.name if isinstance(track, Track) else track,
70+
"terraform",
71+
"main.tf",
72+
),
73+
"r",
74+
) as f:
75+
return re.search(r'type\s*=\s*"virtual-machine"', f.read()) is not None
76+
77+
6478
def validate_track_can_be_deployed(track: Track) -> bool:
6579
return (
6680
os.path.exists(
@@ -249,6 +263,7 @@ def get_terraform_tracks_from_modules() -> set[Track]:
249263
remote=remote,
250264
production=production,
251265
require_build_container=require_build_container,
266+
has_virtual_machine=track_has_virtual_machine(track=name),
252267
)
253268
)
254269
name = ""

0 commit comments

Comments
 (0)