Lars Talian commited on
Commit
f0faee1
·
1 Parent(s): d42b272

fix(runtime): preserve per-host service specs and role-aware routing

Browse files
src/open_range/builder/service_manifest.py CHANGED
@@ -235,14 +235,14 @@ def generate_service_specs(
235
  services dict (or the topology hosts list as fallback).
236
  """
237
  specs: list[ServiceSpec] = []
238
- seen_daemons: set[str] = set()
239
 
240
  services = compose.get("services", {}) if compose else {}
241
 
242
  if services:
243
- specs = _from_compose(services, seen_daemons)
244
  else:
245
- specs = _from_topology(topology, seen_daemons)
246
 
247
  return specs
248
 
@@ -322,7 +322,7 @@ def _build_service_spec(
322
 
323
  def _from_compose(
324
  services: dict[str, Any],
325
- seen_daemons: set[str],
326
  ) -> list[ServiceSpec]:
327
  """Generate specs from the compose services section."""
328
  specs: list[ServiceSpec] = []
@@ -348,9 +348,10 @@ def _from_compose(
348
  continue
349
 
350
  daemon = hint[0]
351
- if daemon in seen_daemons:
 
352
  continue
353
- seen_daemons.add(daemon)
354
 
355
  env_vars = _env_from_compose_service(svc_def)
356
  spec = _build_service_spec(
@@ -365,7 +366,7 @@ def _from_compose(
365
 
366
  def _from_topology(
367
  topology: dict[str, Any],
368
- seen_daemons: set[str],
369
  ) -> list[ServiceSpec]:
370
  """Generate specs from the topology hosts list (fallback path)."""
371
  specs: list[ServiceSpec] = []
@@ -385,9 +386,10 @@ def _from_topology(
385
  continue
386
 
387
  daemon = hint[0]
388
- if daemon in seen_daemons:
 
389
  continue
390
- seen_daemons.add(daemon)
391
 
392
  spec = _build_service_spec(host=host_name, hint=hint)
393
  specs.append(spec)
 
235
  services dict (or the topology hosts list as fallback).
236
  """
237
  specs: list[ServiceSpec] = []
238
+ seen_identities: set[tuple[str, str]] = set()
239
 
240
  services = compose.get("services", {}) if compose else {}
241
 
242
  if services:
243
+ specs = _from_compose(services, seen_identities)
244
  else:
245
+ specs = _from_topology(topology, seen_identities)
246
 
247
  return specs
248
 
 
322
 
323
  def _from_compose(
324
  services: dict[str, Any],
325
+ seen_identities: set[tuple[str, str]],
326
  ) -> list[ServiceSpec]:
327
  """Generate specs from the compose services section."""
328
  specs: list[ServiceSpec] = []
 
348
  continue
349
 
350
  daemon = hint[0]
351
+ identity = (svc_name, daemon)
352
+ if identity in seen_identities:
353
  continue
354
+ seen_identities.add(identity)
355
 
356
  env_vars = _env_from_compose_service(svc_def)
357
  spec = _build_service_spec(
 
366
 
367
  def _from_topology(
368
  topology: dict[str, Any],
369
+ seen_identities: set[tuple[str, str]],
370
  ) -> list[ServiceSpec]:
371
  """Generate specs from the topology hosts list (fallback path)."""
372
  specs: list[ServiceSpec] = []
 
386
  continue
387
 
388
  daemon = hint[0]
389
+ identity = (host_name, daemon)
390
+ if identity in seen_identities:
391
  continue
392
+ seen_identities.add(identity)
393
 
394
  spec = _build_service_spec(host=host_name, hint=hint)
395
  specs.append(spec)
src/open_range/server/environment.py CHANGED
@@ -1111,23 +1111,36 @@ class RangeEnvironment(Environment[RangeAction, RangeObservation, RangeState]):
1111
  """Determine which container to route the command to.
1112
 
1113
  Reads from the snapshot topology to find the appropriate host:
1114
- - Red: host with role=attacker or zone=external.
1115
- - Blue: host with role=siem or zone=management.
1116
-
1117
- The snapshot topology must define hosts with roles or zones.
1118
- For string-only host lists, matches by name then falls back to
1119
- positional convention (first host for Red, last for Blue).
 
 
 
1120
  """
1121
  if not self._snapshot or not isinstance(self._snapshot.topology, dict):
1122
  raise RuntimeError("Cannot resolve target — no snapshot topology loaded")
1123
 
1124
- hosts = self._snapshot.topology.get("hosts", [])
 
1125
  if not hosts:
1126
  raise RuntimeError("Cannot resolve target — snapshot topology has no hosts")
1127
 
1128
  target_role = "attacker" if action.mode == "red" else "siem"
1129
  target_zone = "external" if action.mode == "red" else "management"
1130
 
 
 
 
 
 
 
 
 
 
1131
  # Look for a host with matching role or zone
1132
  for h in hosts:
1133
  if isinstance(h, dict):
@@ -1142,6 +1155,15 @@ class RangeEnvironment(Environment[RangeAction, RangeObservation, RangeState]):
1142
  if name == target_role:
1143
  return self._container_name(name)
1144
 
 
 
 
 
 
 
 
 
 
1145
  # Use positional convention: first host for Red, last for Blue
1146
  fallback = hosts[0] if action.mode == "red" else hosts[-1]
1147
  name = fallback if isinstance(fallback, str) else fallback.get("name", fallback.get("hostname", ""))
 
1111
  """Determine which container to route the command to.
1112
 
1113
  Reads from the snapshot topology to find the appropriate host:
1114
+ - Red: host with role=attacker or zone=external
1115
+ - Blue: host with role=siem or zone=management
1116
+
1117
+ Resolution priority:
1118
+ 1. host_catalog metadata (compiled from manifest)
1119
+ 2. dict entries in topology["hosts"] with role/zone
1120
+ 3. literal host-name match ("attacker"/"siem")
1121
+ 4. zone membership fallback via topology["zones"]
1122
+ 5. positional fallback (first host for Red, last for Blue)
1123
  """
1124
  if not self._snapshot or not isinstance(self._snapshot.topology, dict):
1125
  raise RuntimeError("Cannot resolve target — no snapshot topology loaded")
1126
 
1127
+ topology = self._snapshot.topology
1128
+ hosts = topology.get("hosts", [])
1129
  if not hosts:
1130
  raise RuntimeError("Cannot resolve target — snapshot topology has no hosts")
1131
 
1132
  target_role = "attacker" if action.mode == "red" else "siem"
1133
  target_zone = "external" if action.mode == "red" else "management"
1134
 
1135
+ host_catalog = topology.get("host_catalog", {})
1136
+ if isinstance(host_catalog, dict):
1137
+ for host_name, meta in host_catalog.items():
1138
+ if not isinstance(meta, dict):
1139
+ continue
1140
+ if meta.get("role") == target_role or meta.get("zone") == target_zone:
1141
+ if host_name:
1142
+ return self._container_name(str(host_name))
1143
+
1144
  # Look for a host with matching role or zone
1145
  for h in hosts:
1146
  if isinstance(h, dict):
 
1155
  if name == target_role:
1156
  return self._container_name(name)
1157
 
1158
+ zones = topology.get("zones", {})
1159
+ if isinstance(zones, dict):
1160
+ candidates = zones.get(target_zone, [])
1161
+ if isinstance(candidates, list):
1162
+ for candidate in candidates:
1163
+ host_name = str(candidate).strip()
1164
+ if host_name:
1165
+ return self._container_name(host_name)
1166
+
1167
  # Use positional convention: first host for Red, last for Blue
1168
  fallback = hosts[0] if action.mode == "red" else hosts[-1]
1169
  name = fallback if isinstance(fallback, str) else fallback.get("name", fallback.get("hostname", ""))
tests/test_environment.py CHANGED
@@ -67,6 +67,46 @@ class TestReset:
67
  assert env.snapshot is not None
68
 
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  class TestRedStep:
71
  """Red agent actions."""
72
 
 
67
  assert env.snapshot is not None
68
 
69
 
70
+ class TestTargetResolution:
71
+ """Target selection should honor manifest-compiled metadata."""
72
+
73
+ def test_resolve_target_uses_host_catalog_roles(self):
74
+ env = RangeEnvironment(docker_available=False)
75
+ env.reset(
76
+ snapshot=SnapshotSpec(
77
+ topology={
78
+ "hosts": ["web", "kali1", "socbox"],
79
+ "host_catalog": {
80
+ "web": {"role": "web", "zone": "dmz"},
81
+ "kali1": {"role": "attacker", "zone": "external"},
82
+ "socbox": {"role": "siem", "zone": "management"},
83
+ },
84
+ },
85
+ task=TaskSpec(red_briefing="Go.", blue_briefing="Watch."),
86
+ )
87
+ )
88
+ assert env._resolve_target(RangeAction(command="id", mode="red")) == "kali1"
89
+ assert env._resolve_target(RangeAction(command="id", mode="blue")) == "socbox"
90
+
91
+ def test_resolve_target_uses_zone_mapping_for_string_hosts(self):
92
+ env = RangeEnvironment(docker_available=False)
93
+ env.reset(
94
+ snapshot=SnapshotSpec(
95
+ topology={
96
+ "hosts": ["web", "kali1", "socbox"],
97
+ "zones": {
98
+ "dmz": ["web"],
99
+ "external": ["kali1"],
100
+ "management": ["socbox"],
101
+ },
102
+ },
103
+ task=TaskSpec(red_briefing="Go.", blue_briefing="Watch."),
104
+ )
105
+ )
106
+ assert env._resolve_target(RangeAction(command="id", mode="red")) == "kali1"
107
+ assert env._resolve_target(RangeAction(command="id", mode="blue")) == "socbox"
108
+
109
+
110
  class TestRedStep:
111
  """Red agent actions."""
112
 
tests/test_service_spec.py CHANGED
@@ -281,8 +281,8 @@ class TestGenerateFromCompose:
281
  assert specs[0].env_vars["MYSQL_ROOT_PASSWORD"] == "secret"
282
  assert specs[0].env_vars["MYSQL_DATABASE"] == "app"
283
 
284
- def test_no_duplicate_daemons(self):
285
- """If two compose services map to the same daemon, only one spec is produced."""
286
  compose = {
287
  "services": {
288
  "siem": {"image": "rsyslog:latest"},
@@ -290,8 +290,21 @@ class TestGenerateFromCompose:
290
  }
291
  }
292
  specs = generate_service_specs(compose, {"hosts": []})
293
- assert len(specs) == 1
294
- assert specs[0].daemon == "rsyslogd"
 
 
 
 
 
 
 
 
 
 
 
 
 
295
 
296
 
297
  # ---------------------------------------------------------------------------
 
281
  assert specs[0].env_vars["MYSQL_ROOT_PASSWORD"] == "secret"
282
  assert specs[0].env_vars["MYSQL_DATABASE"] == "app"
283
 
284
+ def test_repeated_daemons_on_different_hosts_are_preserved(self):
285
+ """Two hosts may intentionally run the same daemon family."""
286
  compose = {
287
  "services": {
288
  "siem": {"image": "rsyslog:latest"},
 
290
  }
291
  }
292
  specs = generate_service_specs(compose, {"hosts": []})
293
+ assert len(specs) == 2
294
+ assert {spec.host for spec in specs} == {"siem", "firewall"}
295
+ assert all(spec.daemon == "rsyslogd" for spec in specs)
296
+
297
+ def test_same_daemon_across_multiple_web_hosts(self):
298
+ compose = {
299
+ "services": {
300
+ "web1": {"image": "nginx:1.25"},
301
+ "web2": {"image": "nginx:1.25"},
302
+ }
303
+ }
304
+ specs = generate_service_specs(compose, {"hosts": ["web1", "web2"]})
305
+ assert len(specs) == 2
306
+ assert {spec.host for spec in specs} == {"web1", "web2"}
307
+ assert all(spec.daemon == "nginx" for spec in specs)
308
 
309
 
310
  # ---------------------------------------------------------------------------