diff --git a/README.md b/README.md index f3b9bd0..fb38458 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,8 @@ See [docs/ci/README.md](docs/ci/README.md). Manual fallback: `npm run deploy:pag |---|---| | [docs/PRODUCT_BRIEF.md](docs/PRODUCT_BRIEF.md) | Vision / MVP roots | | [docs/GAME_DESIGN.md](docs/GAME_DESIGN.md) | Game loop, learning progression, campaign arcs, and implementation plan | +| [docs/MISSION_AUTHORING.md](docs/MISSION_AUTHORING.md) | Mode voice, anti-spoiler checklist, and mission authoring rules | | [docs/SCREEN_MAP.md](docs/SCREEN_MAP.md) | Screens & flows | | [docs/DATA_MODEL.md](docs/DATA_MODEL.md) | Ports, cables, intents | -| [docs/MVP_MISSIONS.md](docs/MVP_MISSIONS.md) | Mission catalog | +| [docs/MVP_MISSIONS.md](docs/MVP_MISSIONS.md) | Historical MVP mission notes (see learningDesign for current catalog) | | [docs/TECH_STACK.md](docs/TECH_STACK.md) | Stack & phases | diff --git a/docs/GAME_DESIGN.md b/docs/GAME_DESIGN.md index 3aa6d28..5400ef2 100644 --- a/docs/GAME_DESIGN.md +++ b/docs/GAME_DESIGN.md @@ -340,6 +340,8 @@ Catalog validation should enforce: - every introduced concept is revisited later, - difficulty changes by no more than two points between adjacent stages. +Authoring voice, anti-spoiler rules, and transfer guidance: [MISSION_AUTHORING.md](./MISSION_AUTHORING.md). Automated checks live in `src/missions/learningDesign.test.ts`. + --- ## 7. Target stage loop diff --git a/docs/MISSION_AUTHORING.md b/docs/MISSION_AUTHORING.md new file mode 100644 index 0000000..285233c --- /dev/null +++ b/docs/MISSION_AUTHORING.md @@ -0,0 +1,53 @@ +# PatchLab — Mission Authoring + +Short checklist for writing or revising campaign stages. Authoritative campaign order lives in `src/missions/learningDesign.ts` (`CAMPAIGN_MISSION_IDS`), not in each JSON file’s `order` field. + +## Mode voice + +| Mode | Player-facing brief | Ticket details | Visible objectives | +|---|---|---|---| +| Guided | Exact values and steps | Optional; may repeat the recipe | Exact checklist | +| Practice | Outcome + limited ticket facts | Exact values OK | Broad objectives | +| Challenge | Symptoms + service impact only | Exact values (Easy opens these) | Outcome only | +| Boss | Incident story + service targets | Exact repair values only here / hints | Service outcomes | + +**Campaign pace:** Easy may open ticket details and soften challenge/boss support. Standard must feel like real diagnosis — briefs stay symptom-first. + +## Anti-spoiler checklist + +For **practice / challenge / boss** (especially challenge and boss): + +1. Brief and constraints describe **symptoms and impact**, not the fix. +2. Do **not** put goal port IDs, CIDRs, ACL actions (`permit` / `deny`), next hops, or target host IPs in the brief/constraints when those values appear in `goals`. +3. Put exact repair values in `learning.ticketDetails` and the hint ladder (`prompt` → `evidence` → `action` → `solution`). +4. Visible objectives name outcomes (“restore WAN reachability”), not recipes (“set gateway 10.10.10.1”). + +Automated coverage: `src/missions/learningDesign.test.ts` fails if a challenge/boss brief/constraints surface contains goal literals. + +## Cadence (teach → practice → challenge → boss) + +- Each stage introduces **at most one** concept (`conceptsIntroduced` length ≤ 1). +- The arc’s **first** stage owns the introduction; later challenge/boss stages should mostly use `conceptsPracticed`. +- Every introduced concept (except the campaign finale) must appear later in `conceptsPracticed`. +- Prefer ~6+ challenge stages with faultful initial state so Standard pace practices diagnosis. +- Bosses combine known skills; they should not be the first place a primary novelty appears when that can be avoided. + +## Initial faults vs goals + +- Prefer a **broken initial state** the player can observe (wrong gateway, poisoned route, ACL deny, admin-down port). +- Engine `goals` are hidden truth used for completion — not the player checklist. +- Pre-seed healthy layers when teaching layered diagnosis (e.g. ACL already open, route missing) so the brief does not disclose both faults. + +## Transfers + +- Aim for **one changed-value transfer** per arc after the intro/practice parent clears. +- Transfer briefs stay symptom-first; values live in Easy ticket details / hints. +- Definitions live in `src/lib/transferVariants.ts`. + +## Files to touch + +1. `src/missions/.json` — topology, inventory, brief, constraints, goals, initial faults +2. `src/missions/learningDesign.ts` — mode, concepts, tools, objectives, ticket details, debrief, hints, campaign order +3. Tests — `learningDesign.test.ts`, engine fixtures, Playwright when flow/UI text changes + +See also [GAME_DESIGN.md](./GAME_DESIGN.md) for arcs and the player loop. diff --git a/docs/MVP_MISSIONS.md b/docs/MVP_MISSIONS.md index 4c2eb07..24f7e8b 100644 --- a/docs/MVP_MISSIONS.md +++ b/docs/MVP_MISSIONS.md @@ -1,8 +1,11 @@ # PatchLab — Mission Catalog (historical MVP notes) -> **Current product:** 32 stages across 15 chapters (`src/missions/`, `src/lib/chapters.ts`), -> plus sandbox free-play unlocked after Stage 5. This document retains early M1–M5 design -> notes for context; authoritative mission JSON lives under `src/missions/`. +> **Current product:** 32 stages across 10 operational arcs +> (`src/missions/learningDesign.ts` campaign order + `src/lib/chapters.ts` chapter titles), +> plus sandbox free-play unlocked after the first boss and optional transfer variants +> (`src/lib/transferVariants.ts`). This document retains early M1–M5 design notes for +> context; authoritative mission JSON lives under `src/missions/`. For authoring rules +> (symptom briefs, mode voice), see [MISSION_AUTHORING.md](./MISSION_AUTHORING.md). --- diff --git a/e2e/patchlab.spec.ts b/e2e/patchlab.spec.ts index a234ccd..c749db3 100644 --- a/e2e/patchlab.spec.ts +++ b/e2e/patchlab.spec.ts @@ -650,11 +650,20 @@ test.describe('logic / security missions', () => { test('Mission 32 traceroute path', async ({ page }) => { await clearApp(page); + await page.getByRole('button', { name: /Pace: Easy/i }).click(); await unlockThrough(page, 32); - await startMission(page, /Traceroute Path/i); + await page.getByRole('button', { name: /Traceroute Path/i }).click(); + await expect(page.getByRole('button', { name: /Start stage/i })).toBeVisible(); + const incident = page.locator('.incident-report'); + await expect(incident).toContainText(/BRANCH is dark/i); + await expect(incident).not.toContainText(/198\.51\.100\.0\/24/); + await expect( + page.locator('details').filter({ hasText: /Ticket details/i }), + ).not.toHaveAttribute('open', ''); + await page.getByRole('button', { name: /Start stage/i }).click(); + await expect(page.locator('svg.rack-svg')).toBeVisible(); await focusDevice(page, 'FW-EDGE'); await page.getByRole('button', { name: 'Apply route' }).click(); - await insertCustomAcl(page, 'permit', '10.10.10.0/24', '198.51.100.0/24'); await focusDevice(page, 'SERVER-01'); const diagnostics = page.locator('.config-block', { has: page.getByRole('heading', { name: /Traceroute/ }), diff --git a/src/engine/reducer.test.ts b/src/engine/reducer.test.ts index 4354fbd..2c742f5 100644 --- a/src/engine/reducer.test.ts +++ b/src/engine/reducer.test.ts @@ -1113,29 +1113,22 @@ describe('NetPractice-inspired routing lessons', () => { expect(state.snapshot.complete).toBe(true); }); - it('M32: traceroute succeeds after route + permit', () => { + it('M32: traceroute succeeds after route when ACL is already open', () => { const mission = getMission('m32-traceroute')!; let state = createEngineState(mission, baseRack); + expect(mission.brief).toMatch(/traceroute/i); + expect(mission.brief).not.toMatch(/198\.51\.100\.0\/24/); + expect(mission.brief).not.toMatch(/203\.0\.113\.2/); expect( evaluateTraceroute(state.snapshot.rack, 'server-01', 'branch-01').ok, ).toBe(false); + // ACL toward BRANCH is pre-seeded; only the route is missing. state = reduce(state, { type: 'SET_ROUTE', deviceId: 'fw-1', destCidr: '198.51.100.0/24', nextHop: '203.0.113.2', }); - state = reduce(state, { - type: 'UPSERT_FIREWALL_RULE', - deviceId: 'fw-1', - rule: { - id: 'permit-branch', - action: 'permit', - srcCidr: '10.10.10.0/24', - dstCidr: '198.51.100.0/24', - enabled: true, - }, - }); expect(state.snapshot.complete).toBe(false); state = reduce(state, { type: 'TRACEROUTE', diff --git a/src/lib/transferVariants.test.ts b/src/lib/transferVariants.test.ts index 8425c6d..f8a3299 100644 --- a/src/lib/transferVariants.test.ts +++ b/src/lib/transferVariants.test.ts @@ -1,11 +1,25 @@ import { describe, expect, it } from 'vitest'; import { missions } from '../missions'; import { + TRANSFER_DEFS, allTransferMissions, getTransferMission, transfersForParent, } from './transferVariants'; +const ARC_TRANSFER_PARENTS = [ + 'm1-first-lights', + 'm11-subnet-ping', + 'm13-access-vlan', + 'm19-broken-address', + 'm18-deny-host', + 'm24-wrong-gateway', + 'm25-host-route', + 'm31-pat-overload', + 'm30-floating-static', + 'm32-traceroute', +] as const; + describe('transferVariants', () => { it('builds transfer missions from cleared parents', () => { const transfers = transfersForParent('m1-first-lights', missions); @@ -17,11 +31,33 @@ describe('transferVariants', () => { }); }); + it('covers one transfer parent per late arc plus early arcs', () => { + for (const parentId of ARC_TRANSFER_PARENTS) { + expect( + TRANSFER_DEFS.some((def) => def.parentId === parentId), + `missing transfer for ${parentId}`, + ).toBe(true); + } + expect(allTransferMissions(missions).length).toBeGreaterThanOrEqual( + ARC_TRANSFER_PARENTS.length, + ); + }); + + it('keeps transfer briefs symptom-first with values in ticket details', () => { + const transfer = getTransferMission('m11-subnet-ping-t1', missions); + expect(transfer).toBeTruthy(); + expect(transfer!.brief).not.toMatch(/10\.10\.10\.40/); + expect(transfer!.learning.ticketDetails?.join(' ')).toMatch(/10\.10\.10\.40/); + + const deny = getTransferMission('m18-deny-host-t1', missions); + expect(deny!.brief).not.toMatch(/10\.10\.10\.10\/32/); + expect(deny!.learning.ticketDetails?.join(' ')).toMatch(/10\.10\.10\.10\/32/); + }); + it('resolves transfer missions by id', () => { expect(getTransferMission('missing', missions)).toBeUndefined(); - expect(getTransferMission('m11-subnet-ping-t1', missions)?.brief).toMatch( - /10\.10\.10\.40/, + expect(getTransferMission('m32-traceroute-t1', missions)?.id).toBe( + 'm32-traceroute-t1', ); - expect(allTransferMissions(missions).length).toBeGreaterThanOrEqual(4); }); }); diff --git a/src/lib/transferVariants.ts b/src/lib/transferVariants.ts index e84bed1..6a87518 100644 --- a/src/lib/transferVariants.ts +++ b/src/lib/transferVariants.ts @@ -16,13 +16,13 @@ export const TRANSFER_DEFS: TransferVariantDef[] = [ parentId: 'm1-first-lights', titleSuffix: 'Transfer', brief: - 'Same copper bring-up, new labels: use A-02 → Gi1/0/3 and Gi1/0/6 → SERVER-01 eth0.', + 'Same copper bring-up with reassigned panel and switch labels. Bring SERVER-01 online on the new documented path.', apply: (mission) => ({ ...mission, id: 'm1-first-lights-t1', title: `${mission.title} · Transfer`, brief: - 'Same copper bring-up, new labels: use A-02 → Gi1/0/3 and Gi1/0/6 → SERVER-01 eth0.', + 'Same copper bring-up with reassigned panel and switch labels. Bring SERVER-01 online on the new documented path.', goals: [ { type: 'link_up', @@ -60,13 +60,14 @@ export const TRANSFER_DEFS: TransferVariantDef[] = [ id: 'm11-subnet-ping-t1', parentId: 'm11-subnet-ping', titleSuffix: 'Transfer', - brief: 'Address SERVER-01 as 10.10.10.40/24 and prove same-subnet reachability.', + brief: + 'SERVER-01 needs a reissued host address on the same LAN. Restore same-subnet reachability to FW-EDGE.', apply: (mission) => ({ ...mission, id: 'm11-subnet-ping-t1', title: `${mission.title} · Transfer`, brief: - 'Address SERVER-01 as 10.10.10.40/24 and prove same-subnet reachability.', + 'SERVER-01 needs a reissued host address on the same LAN. Restore same-subnet reachability to FW-EDGE.', goals: mission.goals.map((goal) => goal.type === 'iface_ip' ? { ...goal, address: '10.10.10.40' } @@ -76,10 +77,12 @@ export const TRANSFER_DEFS: TransferVariantDef[] = [ ...mission.learning, mode: 'challenge', visibleObjectives: [ - 'Assign 10.10.10.40/24 on SERVER-01.', + 'Assign the reissued host address on SERVER-01.', 'Confirm same-subnet ping succeeds.', ], - ticketDetails: ['Use 10.10.10.40/24 with gateway 10.10.10.1 if required.'], + ticketDetails: [ + 'Use 10.10.10.40/24 with gateway 10.10.10.1 if required.', + ], }, }), }, @@ -87,12 +90,14 @@ export const TRANSFER_DEFS: TransferVariantDef[] = [ id: 'm13-access-vlan-t1', parentId: 'm13-access-vlan', titleSuffix: 'Transfer', - brief: 'Move Gi1/0/3 to VLAN 20 and land SERVER-07 there instead.', + brief: + 'A different access port and VLAN were assigned for SERVER-07. Place the host on the reissued circuit.', apply: (mission) => ({ ...mission, id: 'm13-access-vlan-t1', title: `${mission.title} · Transfer`, - brief: 'Move Gi1/0/3 to VLAN 20 and land SERVER-07 there instead.', + brief: + 'A different access port and VLAN were assigned for SERVER-07. Place the host on the reissued circuit.', goals: [ { type: 'port_vlan', @@ -109,10 +114,10 @@ export const TRANSFER_DEFS: TransferVariantDef[] = [ ...mission.learning, mode: 'challenge', visibleObjectives: [ - 'Set Gi1/0/3 access VLAN to 20.', - 'Patch SERVER-07 onto Gi1/0/3.', + 'Place the reassigned switchport in the tenant VLAN.', + 'Patch SERVER-07 onto that access port.', ], - ticketDetails: ['Use Gi1/0/3 (not Gi1/0/6) for this transfer ticket.'], + ticketDetails: ['Use Gi1/0/3 access VLAN 20 for SERVER-07.'], }, }), }, @@ -120,12 +125,14 @@ export const TRANSFER_DEFS: TransferVariantDef[] = [ id: 'm19-broken-address-t1', parentId: 'm19-broken-address', titleSuffix: 'Transfer', - brief: 'Repair SERVER-01 addressing using 10.10.10.55/24 via 10.10.10.1.', + brief: + 'SERVER-01 still cannot reach the firewall after a rushed address change. Repair host addressing for this transfer ticket.', apply: (mission) => ({ ...mission, id: 'm19-broken-address-t1', title: `${mission.title} · Transfer`, - brief: 'Repair SERVER-01 addressing using 10.10.10.55/24 via 10.10.10.1.', + brief: + 'SERVER-01 still cannot reach the firewall after a rushed address change. Repair host addressing for this transfer ticket.', goals: mission.goals.map((goal) => goal.type === 'iface_ip' ? { ...goal, address: '10.10.10.55', gateway: '10.10.10.1' } @@ -134,8 +141,286 @@ export const TRANSFER_DEFS: TransferVariantDef[] = [ learning: { ...mission.learning, mode: 'challenge', - visibleObjectives: ['Correct the host address to 10.10.10.55/24.'], - ticketDetails: ['Gateway remains 10.10.10.1.'], + visibleObjectives: [ + 'Diagnose the addressing fault and restore LAN reachability.', + ], + ticketDetails: ['Correct the host address to 10.10.10.55/24 via 10.10.10.1.'], + }, + }), + }, + { + id: 'm18-deny-host-t1', + parentId: 'm18-deny-host', + titleSuffix: 'Transfer', + brief: + 'Policy review: one approved host must keep WAN access while the other is cut off. Apply a selective deny without breaking the remaining service.', + apply: (mission) => ({ + ...mission, + id: 'm18-deny-host-t1', + title: `${mission.title} · Transfer`, + brief: + 'Policy review: one approved host must keep WAN access while the other is cut off. Apply a selective deny without breaking the remaining service.', + goals: [ + { + type: 'firewall_rule', + action: 'deny', + srcCidr: '10.10.10.10/32', + dstCidr: '203.0.113.0/30', + }, + { + type: 'ping_fail', + fromDeviceId: 'server-01', + toDeviceId: 'wan-peer', + }, + { + type: 'ping', + fromDeviceId: 'server-07', + toDeviceId: 'wan-peer', + }, + ], + learning: { + ...mission.learning, + mode: 'challenge', + visibleObjectives: [ + 'Block only the unauthorized host from the WAN.', + 'Preserve approved WAN reachability for the other server.', + ], + ticketDetails: [ + 'Deny 10.10.10.10/32 to 203.0.113.0/30 above the broad permit.', + 'SERVER-01 must fail to ISP-PEER; SERVER-07 must still succeed.', + ], + }, + }), + }, + { + id: 'm24-wrong-gateway-t1', + parentId: 'm24-wrong-gateway', + titleSuffix: 'Transfer', + brief: + 'Off-subnet probes fail again after a host rebuild. Local fabric looks fine — restore WAN reachability.', + apply: (mission) => { + const devices = mission.initial.devices.map((device) => { + if (device.id !== 'server-01') return device; + return { + ...device, + ports: device.ports.map((port) => + port.id === 'nic-1' && port.ip + ? { + ...port, + ip: { + ...port.ip, + address: '10.10.10.40', + prefix: 24, + gateway: '10.10.20.1', + }, + } + : port, + ), + }; + }); + return { + ...mission, + id: 'm24-wrong-gateway-t1', + title: `${mission.title} · Transfer`, + brief: + 'Off-subnet probes fail again after a host rebuild. Local fabric looks fine — restore WAN reachability.', + initial: { ...mission.initial, devices }, + goals: mission.goals.map((goal) => + goal.type === 'iface_ip' + ? { + ...goal, + address: '10.10.10.40', + prefix: 24, + gateway: '10.10.10.1', + } + : goal, + ), + learning: { + ...mission.learning, + mode: 'challenge', + visibleObjectives: [ + 'Diagnose why local configuration cannot reach the WAN.', + 'Repair only the incorrect forwarding setting and verify service.', + ], + ticketDetails: [ + 'Keep 10.10.10.40/24 and set the default gateway to 10.10.10.1.', + ], + }, + }; + }, + }, + { + id: 'm25-host-route-t1', + parentId: 'm25-host-route', + titleSuffix: 'Transfer', + brief: + 'BRANCH answers for other destinations, but one host path is black-holed. Fix the more-specific route poisoning that destination.', + apply: (mission) => { + const devices = mission.initial.devices.map((device) => { + if (device.id !== 'fw-1') return device; + return { + ...device, + routes: (device.routes ?? []).map((route) => + route.destCidr === '198.51.100.10/32' + ? { + ...route, + destCidr: '198.51.100.10/32', + nextHop: '203.0.113.1', + note: 'Poisoned host route — override next hop toward ISP-PEER', + } + : route, + ), + }; + }); + return { + ...mission, + id: 'm25-host-route-t1', + title: `${mission.title} · Transfer`, + brief: + 'BRANCH answers for other destinations, but one host path is black-holed. Fix the more-specific route poisoning that destination.', + initial: { ...mission.initial, devices }, + goals: mission.goals.map((goal) => + goal.type === 'route_entry' + ? { + ...goal, + destCidr: '198.51.100.10/32', + nextHop: '203.0.113.2', + } + : goal, + ), + learning: { + ...mission.learning, + mode: 'challenge', + visibleObjectives: [ + 'Identify why one branch host ignores the working summary path.', + 'Restore that destination without changing the WAN or ACL.', + ], + ticketDetails: [ + 'Override the /32 for BRANCH-01 so its next hop is 203.0.113.2.', + ], + }, + }; + }, + }, + { + id: 'm31-pat-overload-t1', + parentId: 'm31-pat-overload', + titleSuffix: 'Transfer', + brief: + 'Egress from the private LAN still fails despite healthy route and ACL. Restore many-to-one translation for outbound access.', + apply: (mission) => { + const devices = mission.initial.devices.map((device) => { + if (device.id !== 'server-01') return device; + return { + ...device, + ports: device.ports.map((port) => + port.id === 'nic-1' && port.ip + ? { + ...port, + ip: { + ...port.ip, + address: '10.10.10.40', + prefix: 24, + gateway: '10.10.10.1', + }, + } + : port, + ), + }; + }); + return { + ...mission, + id: 'm31-pat-overload-t1', + title: `${mission.title} · Transfer`, + brief: + 'Egress from the private LAN still fails despite healthy route and ACL. Restore many-to-one translation for outbound access.', + initial: { ...mission.initial, devices }, + goals: mission.goals.map((goal) => + goal.type === 'nat_pat' + ? { + ...goal, + insideCidr: '10.10.10.0/24', + outsideIp: '203.0.113.1', + } + : goal, + ), + learning: { + ...mission.learning, + mode: 'challenge', + visibleObjectives: [ + 'Restore outbound translation for private LAN hosts.', + 'Prove WAN reachability from the rebuilt server.', + ], + ticketDetails: [ + 'Apply PAT overload for 10.10.10.0/24 using outside 203.0.113.1.', + 'SERVER-01 is at 10.10.10.40/24 via 10.10.10.1.', + ], + }, + }; + }, + }, + { + id: 'm30-floating-static-t1', + parentId: 'm30-floating-static', + titleSuffix: 'Transfer', + brief: + 'Preferred BRANCH path is still withdrawn. Install a higher-AD backup that restores reachability without removing the tracked primary.', + apply: (mission) => ({ + ...mission, + id: 'm30-floating-static-t1', + title: `${mission.title} · Transfer`, + brief: + 'Preferred BRANCH path is still withdrawn. Install a higher-AD backup that restores reachability without removing the tracked primary.', + goals: mission.goals.map((goal) => + goal.type === 'route_entry' && goal.adminDistance === 10 + ? { ...goal, adminDistance: 20, nextHop: '203.0.113.2' } + : goal, + ), + learning: { + ...mission.learning, + mode: 'challenge', + visibleObjectives: [ + 'Keep the tracked-down primary configured.', + 'Install a higher-AD backup that restores BRANCH reachability.', + ], + ticketDetails: [ + 'Add floating backup 198.51.100.0/24 via 203.0.113.2 with AD 20.', + ], + }, + }), + }, + { + id: 'm32-traceroute-t1', + parentId: 'm32-traceroute', + titleSuffix: 'Transfer', + brief: + 'BRANCH traceroute dies mid-path again after a change window. Restore forwarding and prove the hop chain completes.', + apply: (mission) => ({ + ...mission, + id: 'm32-traceroute-t1', + title: `${mission.title} · Transfer`, + brief: + 'BRANCH traceroute dies mid-path again after a change window. Restore forwarding and prove the hop chain completes.', + goals: mission.goals.map((goal) => + goal.type === 'route_entry' + ? { + ...goal, + destCidr: '198.51.100.0/24', + nextHop: '203.0.113.2', + } + : goal, + ), + learning: { + ...mission.learning, + mode: 'challenge', + visibleObjectives: [ + 'Restore the broken forwarding layer.', + 'Prove the hop path with traceroute.', + ], + ticketDetails: [ + 'Policy toward BRANCH is already open.', + 'Add route 198.51.100.0/24 via 203.0.113.2, then traceroute SERVER-01 → BRANCH-01.', + ], }, }), }, diff --git a/src/missions/learningDesign.test.ts b/src/missions/learningDesign.test.ts index e02a090..e3d3e15 100644 --- a/src/missions/learningDesign.test.ts +++ b/src/missions/learningDesign.test.ts @@ -1,11 +1,68 @@ import { describe, expect, it } from 'vitest'; +import type { Goal } from '../types/schema'; import { CAMPAIGN_MISSION_IDS, LEARNING_DESIGN_BY_ID, isCampaignMissionId, + type CampaignMissionId, } from './learningDesign'; import { missions } from './index'; +/** Goal literals that must not appear in challenge/boss briefs or constraints. */ +function goalSpoilers(goals: Goal[]): string[] { + const spoilers = new Set(); + for (const goal of goals) { + switch (goal.type) { + case 'link_up': + case 'path_up': + case 'port_in_path': + case 'cable_color_between': + case 'cable_media_between': + case 'console_link': + if ('a' in goal) spoilers.add(goal.a.portId); + if ('b' in goal) spoilers.add(goal.b.portId); + if ('port' in goal) spoilers.add(goal.port.portId); + if ('from' in goal) spoilers.add(goal.from.portId); + if ('to' in goal) spoilers.add(goal.to.portId); + break; + case 'no_cables_on': + for (const port of goal.ports) spoilers.add(port.portId); + break; + case 'iface_ip': + spoilers.add(goal.port.portId); + spoilers.add(goal.address); + spoilers.add(`${goal.address}/${goal.prefix}`); + if (goal.gateway) spoilers.add(goal.gateway); + break; + case 'firewall_rule': + spoilers.add(goal.action); + spoilers.add(goal.srcCidr); + spoilers.add(goal.dstCidr); + break; + case 'port_vlan': + case 'port_mode': + case 'trunk_vlans': + spoilers.add(goal.port.portId); + break; + case 'nat_static': + spoilers.add(goal.insideIp); + spoilers.add(goal.outsideIp); + break; + case 'nat_pat': + spoilers.add(goal.insideCidr); + spoilers.add(goal.outsideIp); + break; + case 'route_entry': + spoilers.add(goal.destCidr); + spoilers.add(goal.nextHop); + break; + default: + break; + } + } + return [...spoilers].filter((value) => value.length > 0); +} + describe('learningDesign', () => { it('recognizes campaign mission ids', () => { expect(isCampaignMissionId('m1-first-lights')).toBe(true); @@ -37,4 +94,65 @@ describe('learningDesign', () => { expect(mission.learning.difficulty).toBe(design.difficulty); } }); + + it('introduces at most one concept per stage', () => { + for (const id of CAMPAIGN_MISSION_IDS) { + const design = LEARNING_DESIGN_BY_ID[id]; + expect( + design.conceptsIntroduced.length, + `${id} introduces too many concepts`, + ).toBeLessThanOrEqual(1); + } + }); + + it('keeps challenge count at a diagnosis-friendly floor', () => { + const challenges = CAMPAIGN_MISSION_IDS.filter( + (id) => LEARNING_DESIGN_BY_ID[id].mode === 'challenge', + ); + expect(challenges.length).toBeGreaterThanOrEqual(6); + }); + + it('revisits every introduced concept later (except campaign finale)', () => { + const finalId = CAMPAIGN_MISSION_IDS[CAMPAIGN_MISSION_IDS.length - 1]!; + const introducedAt = new Map(); + + for (const id of CAMPAIGN_MISSION_IDS) { + for (const concept of LEARNING_DESIGN_BY_ID[id].conceptsIntroduced) { + if (!introducedAt.has(concept)) introducedAt.set(concept, id); + } + } + + for (const [concept, introId] of introducedAt) { + if (introId === finalId) continue; + const introIndex = CAMPAIGN_MISSION_IDS.indexOf(introId); + const practicedLater = CAMPAIGN_MISSION_IDS.slice(introIndex + 1).some( + (id) => + (LEARNING_DESIGN_BY_ID[id].conceptsPracticed as readonly string[]).includes( + concept, + ), + ); + expect( + practicedLater, + `"${concept}" introduced on ${introId} never appears in later conceptsPracticed`, + ).toBe(true); + } + }); + + it('keeps challenge/boss briefs free of goal literals', () => { + for (const mission of missions) { + if ( + mission.learning.mode !== 'challenge' && + mission.learning.mode !== 'boss' + ) { + continue; + } + const surface = [mission.brief, ...(mission.constraints ?? [])].join('\n'); + for (const literal of goalSpoilers(mission.goals)) { + expect( + surface.includes(literal), + `${mission.id} ${mission.learning.mode} surface spoils goal literal "${literal}"`, + ).toBe(false); + } + } + }); }); diff --git a/src/missions/learningDesign.ts b/src/missions/learningDesign.ts index 6a6b121..8934860 100644 --- a/src/missions/learningDesign.ts +++ b/src/missions/learningDesign.ts @@ -195,7 +195,7 @@ export const LEARNING_DESIGN_BY_ID = { conceptsPracticed: ['Administrative-down link symptoms', 'Physical endpoints and link-state evidence'], enabledTools: ['switchport'], visibleObjectives: ['Restore the documented cross-connect without repatching it.', 'Keep the existing server-facing connection online.'], - ticketDetails: ['The A-04 to Gi1/0/4 cable is correct.', 'The switchport is administratively disabled.'], + ticketDetails: ['The A-04 to Gi1/0/4 cable is already correct.', 'Enable Gi1/0/4; leave the server-facing link on Gi1/0/5 unchanged.'], debrief: { outcome: 'The documented Gi1/0/4 circuit is restored in place.', explanation: 'Re-enabling the intended interface fixes the root cause and avoids an undocumented spare-port workaround.', @@ -204,8 +204,8 @@ export const LEARNING_DESIGN_BY_ID = { }, hints: { prompt: 'Decide whether the physical path or the interface state conflicts with the ticket.', - evidence: 'The cable already lands on the required endpoints, but Gi1/0/4 reports admin down.', - action: 'Use the switchport control to enable the existing interface.', + evidence: 'The cable already lands on A-04 and Gi1/0/4, but Gi1/0/4 reports admin down.', + action: 'Use the switchport control to enable Gi1/0/4.', solution: 'Select Gi1/0/4 and toggle its administrative state to up; do not move either cable.', }, }, @@ -275,13 +275,16 @@ export const LEARNING_DESIGN_BY_ID = { }, }, 'm19-broken-address': { - mode: 'practice', + mode: 'challenge', difficulty: 2, conceptsIntroduced: [], conceptsPracticed: ['Same-subnet IPv4 reachability', 'IPv4 interface addressing', 'Ping-based fault isolation'], enabledTools: ['ip', 'ping'], visibleObjectives: ['Diagnose why a healthy local link cannot reach the firewall.', 'Repair only the server addressing fault and verify service.'], - ticketDetails: ['The physical path is already working.', 'Compare SERVER-01 eth0 with the firewall LAN subnet.'], + ticketDetails: [ + 'The physical path is already working; only host addressing is wrong.', + 'Set SERVER-01 eth0 to 10.10.10.10/24 with gateway 10.10.10.1.', + ], debrief: { outcome: 'Correct server addressing restores local reachability without disturbing cabling.', explanation: 'The original 10.10.99.10/24 address placed the server outside the firewall LAN network despite a healthy physical link.', @@ -291,7 +294,7 @@ export const LEARNING_DESIGN_BY_ID = { hints: { prompt: 'Compare network portions rather than changing the known-good cable.', evidence: 'SERVER-01 is in 10.10.99.0/24 while FW-EDGE LAN is in 10.10.10.0/24.', - action: 'Correct the server address to the firewall LAN and retest.', + action: 'Correct the server address to 10.10.10.10/24 on the firewall LAN and retest.', solution: 'Set eth0 to 10.10.10.10/24 with gateway 10.10.10.1, then ping FW-EDGE.', }, }, @@ -302,6 +305,10 @@ export const LEARNING_DESIGN_BY_ID = { conceptsPracticed: ['Same-subnet IPv4 reachability', 'Ping-based fault isolation'], enabledTools: ['ip', 'ping'], visibleObjectives: ['Find the addressing detail that disagrees with the local network.', 'Restore and verify firewall reachability without repatching.'], + ticketDetails: [ + 'The host address text looks familiar, but same-subnet reachability still fails.', + 'Keep 10.10.10.10 and set the prefix to /24 with gateway 10.10.10.1.', + ], debrief: { outcome: 'SERVER-01 again interprets the LAN with the correct subnet boundary and reaches the firewall.', explanation: 'An IP address and prefix are a pair; the prefix determines which bits identify the network and which destinations are considered local.', @@ -310,8 +317,9 @@ export const LEARNING_DESIGN_BY_ID = { }, hints: { prompt: 'The address looks familiar; inspect the other half of the IPv4 configuration.', - evidence: 'The server and firewall do not describe the LAN with the same prefix length.', - action: 'Align the server prefix with the documented LAN, then use ping as evidence.', + evidence: 'SERVER-01 is 10.10.10.10/16 while FW-EDGE LAN is 10.10.10.1/24.', + action: 'Change only the server prefix length to /24, then use ping as evidence.', + solution: 'Set eth0 to 10.10.10.10/24 with gateway 10.10.10.1, then ping FW-EDGE.', }, }, 'm29-spare-pdu': { @@ -360,11 +368,18 @@ export const LEARNING_DESIGN_BY_ID = { 'm18-deny-host': { mode: 'boss', difficulty: 3, - conceptsIntroduced: ['First-match ACL order and host-specific /32 rules'], - conceptsPracticed: ['ACL permit rules and implicit deny', 'Ping-based fault isolation'], + conceptsIntroduced: [], + conceptsPracticed: [ + 'ACL permit rules and implicit deny', + 'First-match ACL order and host-specific /32 rules', + 'Ping-based fault isolation', + ], enabledTools: ['acl', 'ping'], visibleObjectives: ['Block only the unauthorized server from the WAN.', 'Preserve approved WAN reachability for the other server.'], - ticketDetails: ['SERVER-07 is 10.10.10.20.', 'A broad permit currently allows both servers.'], + ticketDetails: [ + 'One LAN host must lose WAN access while the approved server keeps it.', + 'Deny 10.10.10.20/32 to 203.0.113.0/30 above the broad permit.', + ], debrief: { outcome: 'SERVER-07 is blocked while SERVER-01 retains WAN access.', explanation: 'A /32 identifies one host, and placing its deny before the broad permit makes first-match processing selective.', @@ -373,8 +388,8 @@ export const LEARNING_DESIGN_BY_ID = { }, hints: { prompt: 'Design a rule that distinguishes one source without changing the permitted destination service.', - evidence: 'The existing any-source permit matches both hosts before a later rule could act.', - action: 'Place a host-specific deny ahead of the broad permit and test both outcomes.', + evidence: 'The existing any-source permit matches 10.10.10.20 before a later deny could act.', + action: 'Place a deny for 10.10.10.20/32 to 203.0.113.0/30 ahead of the broad permit and test both outcomes.', solution: 'Deny 10.10.10.20/32 to 203.0.113.0/30 before the permit; verify SERVER-07 fails and SERVER-01 succeeds.', }, }, @@ -407,7 +422,10 @@ export const LEARNING_DESIGN_BY_ID = { conceptsPracticed: ['Access-port VLAN assignment', 'VLAN mismatch diagnosis', 'Cable removal and clean final state'], enabledTools: ['patch'], visibleObjectives: ['Move SERVER-07 onto a port in its assigned tenant.', 'Clear the incorrect access port when service returns.'], - ticketDetails: ['SERVER-07 belongs to VLAN 20.', 'Inspect the VLAN assignments of the current and candidate ports.'], + ticketDetails: [ + 'SERVER-07 belongs to VLAN 20; the currently attached access port does not.', + 'Use Gi1/0/7 for SERVER-07 eth0 and leave Gi1/0/2 empty.', + ], debrief: { outcome: 'SERVER-07 uses the VLAN 20 access path and the incorrect VLAN 10 port is clear.', explanation: 'A healthy copper cable does not fix a VLAN mismatch; endpoint and access-port segmentation must agree.', @@ -417,7 +435,7 @@ export const LEARNING_DESIGN_BY_ID = { hints: { prompt: 'Look beyond cable condition and compare logical port assignments.', evidence: 'Gi1/0/2 is VLAN 10, while SERVER-07 requires VLAN 20 and Gi1/0/7 provides it.', - action: 'Move the server cable to the matching access port and clear the old one.', + action: 'Move the server cable to Gi1/0/7 and clear Gi1/0/2.', solution: 'Connect SERVER-07 eth0 to Gi1/0/7 and leave Gi1/0/2 empty.', }, }, @@ -427,7 +445,11 @@ export const LEARNING_DESIGN_BY_ID = { conceptsIntroduced: ['VLANs as Layer-2 isolation boundaries'], conceptsPracticed: ['Access-port VLAN assignment', 'Same-subnet IPv4 reachability', 'Negative probe evidence'], enabledTools: ['patch', 'ip', 'ping'], - visibleObjectives: ['Bring both tenant servers online in their assigned VLANs.', 'Demonstrate that the tenant boundary prevents direct communication.'], + visibleObjectives: ['Bring both tenant servers online in their assigned segments.', 'Prove that the tenant boundary blocks direct communication.'], + ticketDetails: [ + 'Both servers must stay linked while cross-tenant ping fails.', + 'SERVER-01: 10.10.10.10/24 on VLAN 10 (Gi1/0/5); SERVER-07: 10.10.10.20/24 on VLAN 20 (Gi1/0/7).', + ], debrief: { outcome: 'Both server links are healthy while the cross-tenant probe remains blocked.', explanation: 'Separate access VLANs create distinct Layer-2 broadcast domains, so a switch does not bridge traffic directly between them.', @@ -436,8 +458,9 @@ export const LEARNING_DESIGN_BY_ID = { }, hints: { prompt: 'Judge physical health and policy isolation as separate outcomes.', - evidence: 'Each server has a valid link in a different access VLAN; direct switching should not cross that boundary.', - action: 'Complete both tenant links and addresses, then verify the expected negative probe.', + evidence: 'Gi1/0/5 is VLAN 10 and Gi1/0/7 is VLAN 20; direct switching should not cross that boundary.', + action: 'Patch SERVER-01 to Gi1/0/5 and SERVER-07 to Gi1/0/7, set 10.10.10.10/24 and 10.10.10.20/24, then confirm the negative probe.', + solution: 'Link SERVER-01 on Gi1/0/5 at 10.10.10.10/24 and SERVER-07 on Gi1/0/7 at 10.10.10.20/24; ping between them must fail.', }, }, 'm8-dual-servers': { @@ -483,13 +506,21 @@ export const LEARNING_DESIGN_BY_ID = { }, }, 'm24-wrong-gateway': { - mode: 'practice', + mode: 'challenge', difficulty: 2, conceptsIntroduced: [], - conceptsPracticed: ['Default gateway for off-subnet forwarding', 'IPv4 interface addressing', 'Ping-based fault isolation'], + conceptsPracticed: [ + 'Default gateway for off-subnet forwarding', + 'IPv4 interface addressing', + 'Ping-based fault isolation', + 'Prefix length determines network membership', + ], enabledTools: ['ip', 'ping'], visibleObjectives: ['Diagnose why local configuration cannot reach the WAN.', 'Repair only the incorrect forwarding setting and verify service.'], - ticketDetails: ['The address, prefix, links, and firewall policy are already valid.', 'Compare the configured gateway with FW-EDGE LAN.'], + ticketDetails: [ + 'Address, prefix, links, and firewall policy are already valid; only off-subnet forwarding fails.', + 'Keep 10.10.10.10/24 and set the default gateway to 10.10.10.1.', + ], debrief: { outcome: 'Correcting the gateway restores WAN reachability without changing healthy components.', explanation: 'A host can communicate locally with a wrong gateway, but off-subnet packets must be sent to a router address on its own LAN.', @@ -498,8 +529,8 @@ export const LEARNING_DESIGN_BY_ID = { }, hints: { prompt: 'Focus on the setting used only for off-subnet destinations.', - evidence: 'SERVER-01 is in 10.10.10.0/24, but its gateway points into VLAN 20.', - action: 'Set the gateway to the firewall address on the server’s own LAN and retest.', + evidence: 'SERVER-01 is in 10.10.10.0/24, but its gateway is 10.10.20.1 instead of FW-EDGE LAN.', + action: 'Set the gateway to 10.10.10.1 and retest WAN reachability.', solution: 'Keep 10.10.10.10/24, change the gateway to 10.10.10.1, and ping ISP-PEER.', }, }, @@ -529,10 +560,20 @@ export const LEARNING_DESIGN_BY_ID = { mode: 'boss', difficulty: 4, conceptsIntroduced: ['Layer-3 routing between VLANs'], - conceptsPracticed: ['Access-port VLAN assignment', 'Default gateway for off-subnet forwarding', 'Same-subnet IPv4 reachability'], + conceptsPracticed: [ + 'Access-port VLAN assignment', + 'VLANs as Layer-2 isolation boundaries', + 'Default gateway for off-subnet forwarding', + 'Same-subnet IPv4 reachability', + '802.1Q trunk uplinks', + ], enabledTools: ['patch', 'ip', 'ping'], visibleObjectives: ['Bring both tenant networks to their firewall interfaces.', 'Configure each server for its local gateway and prove routed service.'], - ticketDetails: ['VLAN 10 uses 10.10.10.1/24; VLAN 20 uses 10.10.20.1/24.', 'Each firewall LAN interface has a separate switch connection.'], + ticketDetails: [ + 'Each tenant needs a firewall interface on its own LAN; trunks alone do not route.', + 'SERVER-01: 10.10.10.10/24 gw 10.10.10.1 on VLAN 10; SERVER-07: 10.10.20.10/24 gw 10.10.20.1 on VLAN 20.', + 'Patch VLAN 10 through LAN0/Gi1/0/2 and VLAN 20 through LAN20/Gi1/0/8.', + ], debrief: { outcome: 'SERVER-01 and SERVER-07 communicate through the firewall across their VLAN boundary.', explanation: 'Each server forwards off-subnet traffic to a firewall interface in its own VLAN; the Layer-3 device routes between the two connected networks.', @@ -541,8 +582,8 @@ export const LEARNING_DESIGN_BY_ID = { }, hints: { prompt: 'Trace each server to a gateway in its own VLAN before considering the end-to-end probe.', - evidence: 'The firewall provides separate LAN0 and LAN20 interfaces, one for each subnet.', - action: 'Complete both server and firewall links, configure each host in its subnet, then test across VLANs.', + evidence: 'The firewall provides separate LAN0 and LAN20 interfaces for 10.10.10.0/24 and 10.10.20.0/24.', + action: 'Complete both server and firewall links, set each host gateway to its VLAN .1 address, then test across VLANs.', solution: 'Patch VLAN 10 through LAN0/Gi1/0/2 and VLAN 20 through LAN20/Gi1/0/8; address both servers with their .1 gateways.', }, }, @@ -572,10 +613,18 @@ export const LEARNING_DESIGN_BY_ID = { mode: 'boss', difficulty: 4, conceptsIntroduced: ['Many-to-one PAT overload'], - conceptsPracticed: ['Default gateway for off-subnet forwarding', 'ACL permit rules and implicit deny', 'Address translation'], + conceptsPracticed: [ + 'One-to-one static NAT publication', + 'Default gateway for off-subnet forwarding', + 'ACL permit rules and implicit deny', + 'Address translation', + ], enabledTools: ['pat', 'ping'], visibleObjectives: ['Restore outbound translation for the private LAN.', 'Verify that a LAN host can use the shared WAN address.'], - ticketDetails: ['The inside pool is 10.10.10.0/24.', 'Use the FW-EDGE WAN address for overload.'], + ticketDetails: [ + 'Private LAN hosts need shared egress through the firewall outside address.', + 'Overload 10.10.10.0/24 to FW-EDGE WAN 203.0.113.1.', + ], debrief: { outcome: 'Private LAN traffic reaches the ISP through the firewall’s shared outside address.', explanation: 'PAT translates many inside sessions to one outside IP by distinguishing flows, allowing RFC1918 hosts to share WAN egress.', @@ -584,8 +633,8 @@ export const LEARNING_DESIGN_BY_ID = { }, hints: { prompt: 'The route and policy work; inspect what the WAN requires from private source addresses.', - evidence: 'FW-EDGE requires outbound translation, but no overload rule covers 10.10.10.0/24.', - action: 'Apply PAT from the inside subnet to the firewall WAN address, then test egress.', + evidence: 'FW-EDGE requires outbound translation, but no overload rule covers 10.10.10.0/24 to 203.0.113.1.', + action: 'Apply PAT from 10.10.10.0/24 to 203.0.113.1, then test egress.', solution: 'Configure PAT 10.10.10.0/24 to 203.0.113.1 and ping ISP-PEER from SERVER-01.', }, }, @@ -593,7 +642,12 @@ export const LEARNING_DESIGN_BY_ID = { mode: 'guided', difficulty: 3, conceptsIntroduced: ['Static routes to remote prefixes'], - conceptsPracticed: ['Default gateway for off-subnet forwarding', 'ACL permit rules and implicit deny', 'Ping-based verification'], + conceptsPracticed: [ + 'Default gateway for off-subnet forwarding', + 'ACL permit rules and implicit deny', + 'Ping-based verification', + 'Layer-3 routing between VLANs', + ], deviceUnlocks: ['Route table editor', 'Branch site'], enabledTools: ['route', 'acl', 'ping'], visibleObjectives: ['Teach FW-EDGE how to reach the branch network.', 'Authorize and verify the LAN-to-branch service path.'], @@ -612,13 +666,16 @@ export const LEARNING_DESIGN_BY_ID = { }, }, 'm25-host-route': { - mode: 'practice', + mode: 'challenge', difficulty: 4, conceptsIntroduced: ['Longest-prefix route selection'], conceptsPracticed: ['Static routes to remote prefixes', 'Ping-based fault isolation'], enabledTools: ['route', 'ping'], visibleObjectives: ['Identify why one branch host ignores the working summary path.', 'Restore that destination without changing the WAN or ACL.'], - ticketDetails: ['A /24 branch route exists.', 'Inspect the more-specific route for BRANCH-01.'], + ticketDetails: [ + 'A working /24 branch summary exists, but one host still black-holes.', + 'Set 198.51.100.10/32 next hop to 203.0.113.2 (ISP-PEER).', + ], debrief: { outcome: 'BRANCH-01 follows the corrected host route through ISP-PEER.', explanation: 'Routers prefer the longest matching prefix, so a /32 route controls this host even when a valid /24 route also matches.', @@ -627,8 +684,8 @@ export const LEARNING_DESIGN_BY_ID = { }, hints: { prompt: 'Compare every matching route by prefix specificity, not only by whether a summary exists.', - evidence: 'The 198.51.100.10/32 entry is more specific and points to a LAN host instead of ISP-PEER.', - action: 'Correct the host route next hop and retest that destination.', + evidence: 'The 198.51.100.10/32 entry is more specific and points to 10.10.10.10 instead of ISP-PEER.', + action: 'Change the 198.51.100.10/32 next hop to 203.0.113.2 and retest.', solution: 'Set 198.51.100.10/32 to next hop 203.0.113.2 and ping BRANCH-01.', }, }, @@ -636,10 +693,17 @@ export const LEARNING_DESIGN_BY_ID = { mode: 'boss', difficulty: 5, conceptsIntroduced: ['Administrative distance and floating backup routes'], - conceptsPracticed: ['Static routes to remote prefixes', 'Longest-prefix route selection', 'Ping-based fault isolation'], + conceptsPracticed: [ + 'Static routes to remote prefixes', + 'Longest-prefix route selection', + 'Ping-based fault isolation', + ], enabledTools: ['route', 'ping'], visibleObjectives: ['Preserve the configured primary route while adding branch resilience.', 'Recover branch service through the valid backup path.'], - ticketDetails: ['The configured AD1 primary is currently withdrawn by failed reachability tracking.', 'The backup must use ISP-PEER with administrative distance 10.'], + ticketDetails: [ + 'The preferred primary is withdrawn by failed reachability tracking; branch service needs a backup path.', + 'Keep 198.51.100.0/24 via 10.10.10.10 at AD1; add 198.51.100.0/24 via 203.0.113.2 at AD10.', + ], debrief: { outcome: 'The branch prefix has a retained primary route and a usable higher-distance backup.', explanation: 'For equal prefixes, lower administrative distance is preferred while tracked healthy; when tracking withdraws the primary, the floating static becomes eligible through its alternate next hop.', @@ -648,8 +712,8 @@ export const LEARNING_DESIGN_BY_ID = { }, hints: { prompt: 'Keep prefix specificity constant and compare route preference and next-hop viability.', - evidence: 'The tracked-down AD1 route remains configured, while ISP-PEER is the valid alternate next hop.', - action: 'Add an equal-prefix route through ISP-PEER with a higher administrative distance.', + evidence: 'The tracked-down AD1 route to 198.51.100.0/24 via 10.10.10.10 remains configured; ISP-PEER 203.0.113.2 is the valid alternate.', + action: 'Add 198.51.100.0/24 via 203.0.113.2 with administrative distance 10.', solution: 'Keep the AD1 route and add 198.51.100.0/24 via 203.0.113.2 at AD10, then ping BRANCH-01.', }, }, @@ -660,7 +724,10 @@ export const LEARNING_DESIGN_BY_ID = { conceptsPracticed: ['First-match ACL order and host-specific /32 rules', 'Static routes to remote prefixes', 'Negative probe evidence'], enabledTools: ['acl', 'ping'], visibleObjectives: ['Block one unauthorized branch client.', 'Keep branch access working for the approved server.'], - ticketDetails: ['SERVER-07 is 10.10.10.20.', 'The branch destination is 198.51.100.0/24 and a broad permit exists.'], + ticketDetails: [ + 'One LAN host must lose branch access while the approved server keeps it.', + 'Deny 10.10.10.20/32 to 198.51.100.0/24 above the existing LAN-to-branch permit.', + ], debrief: { outcome: 'SERVER-07 is denied to the routed branch while SERVER-01 remains authorized.', explanation: 'The earlier host-specific first-match policy pattern applies equally to a remote routed destination prefix.', @@ -669,8 +736,8 @@ export const LEARNING_DESIGN_BY_ID = { }, hints: { prompt: 'Reuse the selective-deny pattern without changing the known-good route.', - evidence: 'A broad LAN-to-branch permit currently matches SERVER-07 as well as SERVER-01.', - action: 'Insert a source-host deny before the broad branch permit and test both hosts.', + evidence: 'A broad permit for 10.10.10.0/24 to 198.51.100.0/24 currently matches SERVER-07 as well as SERVER-01.', + action: 'Insert deny 10.10.10.20/32 to 198.51.100.0/24 before the broad branch permit and test both hosts.', solution: 'Deny 10.10.10.20/32 to 198.51.100.0/24 first; verify SERVER-07 fails and SERVER-01 succeeds.', }, }, @@ -681,6 +748,10 @@ export const LEARNING_DESIGN_BY_ID = { conceptsPracticed: ['First-match ACL order and host-specific /32 rules', 'Out-of-band console access and management addressing', 'Negative probe evidence'], enabledTools: ['console', 'acl', 'ping'], visibleObjectives: ['Use the recovery path to make a least-privilege policy change.', 'Restore one approved branch flow without opening access for other hosts.'], + ticketDetails: [ + 'BRANCH is locked for the LAN; only one approved host-to-host flow may return.', + 'Console in, then permit 10.10.10.10/32 to 198.51.100.10/32 above the broad deny.', + ], debrief: { outcome: 'Only SERVER-01 can reach BRANCH-01, and the broader branch denial remains effective.', explanation: 'A narrowly scoped host-to-host permit placed before a broad deny creates the required exception without weakening policy for the rest of the LAN.', @@ -689,27 +760,49 @@ export const LEARNING_DESIGN_BY_ID = { }, hints: { prompt: 'Find the smallest rule that can precede the broad deny and satisfy the approved flow.', - evidence: 'The recovery console is available, and only one source-to-destination pair should pass.', - action: 'Connect out of band, add a more-specific permit before the deny, and test both allowed and blocked cases.', + evidence: 'TTY recovery console can reach the firewall; only 10.10.10.10 to 198.51.100.10 should pass.', + action: 'Connect out of band, add permit 10.10.10.10/32 to 198.51.100.10/32 before the deny, and test both cases.', + solution: 'Console TTY2 to FW CON; permit 10.10.10.10/32 to 198.51.100.10/32 above the deny; SERVER-01 succeeds and SERVER-07 fails.', }, }, 'm32-traceroute': { mode: 'boss', difficulty: 5, - conceptsIntroduced: ['Hop-by-hop traceroute fault isolation'], - conceptsPracticed: ['Static routes to remote prefixes', 'ACL permit rules and implicit deny', 'Default gateway for off-subnet forwarding'], - enabledTools: ['route', 'acl', 'traceroute'], - visibleObjectives: ['Use hop evidence to determine where the branch path stops.', 'Restore the layered service and prove the complete route with traceroute.'], + conceptsIntroduced: ['Traceroute as path evidence'], + conceptsPracticed: [ + 'Static routes to remote prefixes', + 'ACL permit rules and implicit deny', + 'Narrow ACL exceptions above broad denies', + 'Administrative distance and floating backup routes', + 'Many-to-one PAT overload', + ], + enabledTools: ['route', 'traceroute', 'ping'], + visibleObjectives: [ + 'Restore BRANCH path.', + 'Prove the hop path with traceroute.', + ], + ticketDetails: [ + 'Firewall policy toward BRANCH is already open — focus on forwarding.', + 'Add route 198.51.100.0/24 via 203.0.113.2, then traceroute SERVER-01 → BRANCH-01.', + ], debrief: { outcome: 'The end-to-end branch path completes and traceroute records each routed hop.', - explanation: 'A complete path requires both forwarding knowledge and policy permission; traceroute localizes progress through the host, gateway, next hop, and destination.', - question: 'How does traceroute narrow a layered fault more effectively than a final ping result?', - answer: 'It shows the last responding hop, revealing how far forwarding succeeds before routing or policy stops the path.', - }, - hints: { - prompt: 'Run the diagnostic and classify the stopping point as local, gateway, next-hop, route, or policy evidence.', - evidence: 'The LAN and WAN adjacency are present, but the branch path needs both forwarding knowledge and authorization.', - action: 'Repair the missing route or policy layer indicated by the hop evidence, then rerun traceroute.', + explanation: + 'With policy already open, missing forwarding still stops the path; traceroute shows how far packets travel before the break.', + question: + 'How does traceroute narrow a layered fault more effectively than a final ping result?', + answer: + 'It shows the last responding hop, revealing how far forwarding succeeds before the path stops.', + }, + hints: { + prompt: + 'Run traceroute and note the last hop that answers before BRANCH stays dark.', + evidence: + 'LAN and WAN adjacency respond; there is no installed route for the BRANCH prefix yet.', + action: + 'Install the BRANCH static via ISP-PEER, then rerun traceroute until the path completes.', + solution: + 'Route 198.51.100.0/24 via 203.0.113.2, then traceroute SERVER-01 to BRANCH-01.', }, }, } satisfies Record; diff --git a/src/missions/m14-vlan-isolation.json b/src/missions/m14-vlan-isolation.json index 50cf8f3..63484e2 100644 --- a/src/missions/m14-vlan-isolation.json +++ b/src/missions/m14-vlan-isolation.json @@ -3,12 +3,12 @@ "title": "VLAN Isolation", "order": 14, "track": "switching", - "lesson": "VLANs create broadcast-domain isolation. Two healthy hosts on different access VLANs should NOT ping each other at L2 — even on the same switch. That is a feature, not a bug.", - "brief": "Bring SERVER-01 (VLAN 10) and SERVER-07 (VLAN 20) online with addresses. Prove they cannot ping each other while both stay linked.", + "lesson": "VLANs create broadcast-domain isolation. Two healthy hosts on different access VLANs should NOT ping each other at L2 \u2014 even on the same switch. That is a feature, not a bug.", + "brief": "Bring both tenant servers online on their assigned segments. Prove they cannot talk to each other at Layer 2 while both stay linked.", "constraints": [ - "SERVER-01: 10.10.10.10/24 on VLAN 10", - "SERVER-07: 10.10.10.20/24 on VLAN 20", - "Ping between them must fail" + "Each server must remain on its own tenant VLAN", + "Cross-tenant ping must fail", + "Both links must stay up" ], "parTimeSec": 150, "hintAfterWrongAttempts": 2, @@ -18,27 +18,48 @@ "power_c13": 0, "console_rj45": 0 }, - "initial": { "devices": [], "cables": [] }, + "initial": { + "devices": [], + "cables": [] + }, "goals": [ { "type": "link_up", - "a": { "deviceId": "tor-1", "portId": "sw-5" }, - "b": { "deviceId": "server-01", "portId": "nic-1" } + "a": { + "deviceId": "tor-1", + "portId": "sw-5" + }, + "b": { + "deviceId": "server-01", + "portId": "nic-1" + } }, { "type": "link_up", - "a": { "deviceId": "tor-1", "portId": "sw-7" }, - "b": { "deviceId": "server-07", "portId": "nic-1" } + "a": { + "deviceId": "tor-1", + "portId": "sw-7" + }, + "b": { + "deviceId": "server-07", + "portId": "nic-1" + } }, { "type": "iface_ip", - "port": { "deviceId": "server-01", "portId": "nic-1" }, + "port": { + "deviceId": "server-01", + "portId": "nic-1" + }, "address": "10.10.10.10", "prefix": 24 }, { "type": "iface_ip", - "port": { "deviceId": "server-07", "portId": "nic-1" }, + "port": { + "deviceId": "server-07", + "portId": "nic-1" + }, "address": "10.10.10.20", "prefix": 24 }, diff --git a/src/missions/m18-deny-host.json b/src/missions/m18-deny-host.json index 85ec3b9..8aea638 100644 --- a/src/missions/m18-deny-host.json +++ b/src/missions/m18-deny-host.json @@ -3,12 +3,12 @@ "title": "Deny One Host", "order": 18, "track": "security", - "lesson": "ACLs are first-match. Put a specific deny above a broader permit. Wrong order means the wrong host is blocked — or nobody is.", - "brief": "Both servers can reach ISP-PEER today. Insert a deny for 10.10.10.20/32 → 203.0.113.0/30 so SERVER-07 is blocked, while SERVER-01 (10.10.10.10) still pings through.", + "lesson": "ACLs are first-match. Put a specific deny above a broader permit. Wrong order means the wrong host is blocked \u2014 or nobody is.", + "brief": "Both servers reach ISP-PEER today. Operations needs one unauthorized host blocked from the WAN while the approved server keeps access.", "constraints": [ - "Deny only host 10.10.10.20 (SERVER-07)", - "SERVER-01 must still reach ISP-PEER", - "Rule order matters — specific deny first" + "Block only the unauthorized host", + "Preserve WAN access for the approved server", + "Rule order matters \u2014 specific match first" ], "parTimeSec": 120, "hintAfterWrongAttempts": 2, @@ -28,16 +28,124 @@ "rackUnitStart": 40, "heightU": 1, "ports": [ - { "id": "sw-1", "deviceId": "tor-1", "index": 1, "label": "Gi1/0/1", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "network", "vlanId": 10 }, - { "id": "sw-2", "deviceId": "tor-1", "index": 2, "label": "Gi1/0/2", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "network", "vlanId": 10 }, - { "id": "sw-3", "deviceId": "tor-1", "index": 3, "label": "Gi1/0/3", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "network", "vlanId": 10 }, - { "id": "sw-4", "deviceId": "tor-1", "index": 4, "label": "Gi1/0/4", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "down", "role": "network", "vlanId": 10 }, - { "id": "sw-5", "deviceId": "tor-1", "index": 5, "label": "Gi1/0/5", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "network", "vlanId": 10 }, - { "id": "sw-6", "deviceId": "tor-1", "index": 6, "label": "Gi1/0/6", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "network", "vlanId": 10 }, - { "id": "sw-7", "deviceId": "tor-1", "index": 7, "label": "Gi1/0/7", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "network", "vlanId": 10 }, - { "id": "sw-8", "deviceId": "tor-1", "index": 8, "label": "Gi1/0/8", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "network", "vlanId": 10 }, - { "id": "sw-con", "deviceId": "tor-1", "index": 9, "label": "CON", "media": "console_rj45", "connector": "console", "kind": "console", "admin": "up", "role": "console" }, - { "id": "sw-psu", "deviceId": "tor-1", "index": 10, "label": "PSU", "media": "power_c13", "connector": "c13", "kind": "power", "admin": "up", "role": "power" } + { + "id": "sw-1", + "deviceId": "tor-1", + "index": 1, + "label": "Gi1/0/1", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "network", + "vlanId": 10 + }, + { + "id": "sw-2", + "deviceId": "tor-1", + "index": 2, + "label": "Gi1/0/2", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "network", + "vlanId": 10 + }, + { + "id": "sw-3", + "deviceId": "tor-1", + "index": 3, + "label": "Gi1/0/3", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "network", + "vlanId": 10 + }, + { + "id": "sw-4", + "deviceId": "tor-1", + "index": 4, + "label": "Gi1/0/4", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "down", + "role": "network", + "vlanId": 10 + }, + { + "id": "sw-5", + "deviceId": "tor-1", + "index": 5, + "label": "Gi1/0/5", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "network", + "vlanId": 10 + }, + { + "id": "sw-6", + "deviceId": "tor-1", + "index": 6, + "label": "Gi1/0/6", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "network", + "vlanId": 10 + }, + { + "id": "sw-7", + "deviceId": "tor-1", + "index": 7, + "label": "Gi1/0/7", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "network", + "vlanId": 10 + }, + { + "id": "sw-8", + "deviceId": "tor-1", + "index": 8, + "label": "Gi1/0/8", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "network", + "vlanId": 10 + }, + { + "id": "sw-con", + "deviceId": "tor-1", + "index": 9, + "label": "CON", + "media": "console_rj45", + "connector": "console", + "kind": "console", + "admin": "up", + "role": "console" + }, + { + "id": "sw-psu", + "deviceId": "tor-1", + "index": 10, + "label": "PSU", + "media": "power_c13", + "connector": "c13", + "kind": "power", + "admin": "up", + "role": "power" + } ] }, { @@ -53,15 +161,64 @@ "action": "permit", "srcCidr": "0.0.0.0/0", "dstCidr": "0.0.0.0/0", - "note": "Broad permit — put a specific deny above it", + "note": "Broad permit \u2014 put a specific deny above it", "enabled": true } ], "ports": [ - { "id": "fw-lan", "deviceId": "fw-1", "index": 1, "label": "LAN0", "media": "copper_cat6", "connector": "rj45", "kind": "lan", "admin": "up", "role": "lan", "vlanId": 10, "ip": { "address": "10.10.10.1", "prefix": 24 } }, - { "id": "fw-wan", "deviceId": "fw-1", "index": 2, "label": "WAN0", "media": "copper_cat6", "connector": "rj45", "kind": "wan", "admin": "up", "role": "wan", "ip": { "address": "203.0.113.1", "prefix": 30 } }, - { "id": "fw-con", "deviceId": "fw-1", "index": 3, "label": "CON", "media": "console_rj45", "connector": "console", "kind": "console", "admin": "up", "role": "console" }, - { "id": "fw-psu", "deviceId": "fw-1", "index": 4, "label": "PSU", "media": "power_c13", "connector": "c13", "kind": "power", "admin": "up", "role": "power" } + { + "id": "fw-lan", + "deviceId": "fw-1", + "index": 1, + "label": "LAN0", + "media": "copper_cat6", + "connector": "rj45", + "kind": "lan", + "admin": "up", + "role": "lan", + "vlanId": 10, + "ip": { + "address": "10.10.10.1", + "prefix": 24 + } + }, + { + "id": "fw-wan", + "deviceId": "fw-1", + "index": 2, + "label": "WAN0", + "media": "copper_cat6", + "connector": "rj45", + "kind": "wan", + "admin": "up", + "role": "wan", + "ip": { + "address": "203.0.113.1", + "prefix": 30 + } + }, + { + "id": "fw-con", + "deviceId": "fw-1", + "index": 3, + "label": "CON", + "media": "console_rj45", + "connector": "console", + "kind": "console", + "admin": "up", + "role": "console" + }, + { + "id": "fw-psu", + "deviceId": "fw-1", + "index": 4, + "label": "PSU", + "media": "power_c13", + "connector": "c13", + "kind": "power", + "admin": "up", + "role": "power" + } ] }, { @@ -72,9 +229,45 @@ "rackUnitStart": 20, "heightU": 1, "ports": [ - { "id": "nic-1", "deviceId": "server-01", "index": 1, "label": "eth0", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "nic", "accessVlan": 10, "ip": { "address": "10.10.10.10", "prefix": 24, "gateway": "10.10.10.1" } }, - { "id": "srv1-con", "deviceId": "server-01", "index": 2, "label": "CON", "media": "console_rj45", "connector": "console", "kind": "console", "admin": "up", "role": "console" }, - { "id": "srv1-psu", "deviceId": "server-01", "index": 3, "label": "PSU", "media": "power_c13", "connector": "c13", "kind": "power", "admin": "up", "role": "power" } + { + "id": "nic-1", + "deviceId": "server-01", + "index": 1, + "label": "eth0", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "nic", + "accessVlan": 10, + "ip": { + "address": "10.10.10.10", + "prefix": 24, + "gateway": "10.10.10.1" + } + }, + { + "id": "srv1-con", + "deviceId": "server-01", + "index": 2, + "label": "CON", + "media": "console_rj45", + "connector": "console", + "kind": "console", + "admin": "up", + "role": "console" + }, + { + "id": "srv1-psu", + "deviceId": "server-01", + "index": 3, + "label": "PSU", + "media": "power_c13", + "connector": "c13", + "kind": "power", + "admin": "up", + "role": "power" + } ] }, { @@ -85,8 +278,34 @@ "rackUnitStart": 14, "heightU": 1, "ports": [ - { "id": "nic-1", "deviceId": "server-07", "index": 1, "label": "eth0", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "nic", "accessVlan": 10, "ip": { "address": "10.10.10.20", "prefix": 24, "gateway": "10.10.10.1" } }, - { "id": "srv7-psu", "deviceId": "server-07", "index": 2, "label": "PSU", "media": "power_c13", "connector": "c13", "kind": "power", "admin": "up", "role": "power" } + { + "id": "nic-1", + "deviceId": "server-07", + "index": 1, + "label": "eth0", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "nic", + "accessVlan": 10, + "ip": { + "address": "10.10.10.20", + "prefix": 24, + "gateway": "10.10.10.1" + } + }, + { + "id": "srv7-psu", + "deviceId": "server-07", + "index": 2, + "label": "PSU", + "media": "power_c13", + "connector": "c13", + "kind": "power", + "admin": "up", + "role": "power" + } ] } ], @@ -97,8 +316,14 @@ "color": "yellow", "lengthM": 1, "ends": [ - { "deviceId": "fw-1", "portId": "fw-lan" }, - { "deviceId": "tor-1", "portId": "sw-2" } + { + "deviceId": "fw-1", + "portId": "fw-lan" + }, + { + "deviceId": "tor-1", + "portId": "sw-2" + } ] }, { @@ -107,8 +332,14 @@ "color": "blue", "lengthM": 2, "ends": [ - { "deviceId": "tor-1", "portId": "sw-5" }, - { "deviceId": "server-01", "portId": "nic-1" } + { + "deviceId": "tor-1", + "portId": "sw-5" + }, + { + "deviceId": "server-01", + "portId": "nic-1" + } ] }, { @@ -117,8 +348,14 @@ "color": "blue", "lengthM": 2, "ends": [ - { "deviceId": "tor-1", "portId": "sw-7" }, - { "deviceId": "server-07", "portId": "nic-1" } + { + "deviceId": "tor-1", + "portId": "sw-7" + }, + { + "deviceId": "server-07", + "portId": "nic-1" + } ] } ] diff --git a/src/missions/m19-broken-address.json b/src/missions/m19-broken-address.json index 352a8ef..a2841e2 100644 --- a/src/missions/m19-broken-address.json +++ b/src/missions/m19-broken-address.json @@ -3,11 +3,11 @@ "title": "Broken Address", "order": 19, "track": "routing", - "lesson": "Same-subnet reachability starts with a correct host IP. A perfect cable still fails when the address is on the wrong network — fix the IP, then prove it with ping. Better than a bare puzzle: the rack is already live, so the tip tells you exactly what broke.", - "brief": "SERVER-01 is patched and powered, but someone typed 10.10.99.10/24. Put eth0 on 10.10.10.10/24 (gateway 10.10.10.1) and ping FW-EDGE.", + "lesson": "Same-subnet reachability starts with a correct host IP. A perfect cable still fails when the address is on the wrong network \u2014 fix the IP, then prove it with ping. Better than a bare puzzle: the rack is already live, so the tip tells you exactly what broke.", + "brief": "SERVER-01 has healthy copper and power, but cannot reach FW-EDGE. Someone mis-addressed the host \u2014 restore LAN reachability and prove it with a ping.", "constraints": [ "Do not unpatch working copper", - "Target address: 10.10.10.10/24" + "Repair host addressing only" ], "parTimeSec": 90, "hintAfterWrongAttempts": 2, @@ -27,9 +27,45 @@ "rackUnitStart": 20, "heightU": 1, "ports": [ - { "id": "nic-1", "deviceId": "server-01", "index": 1, "label": "eth0", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "nic", "accessVlan": 10, "ip": { "address": "10.10.99.10", "prefix": 24, "gateway": "10.10.10.1" } }, - { "id": "srv1-con", "deviceId": "server-01", "index": 2, "label": "CON", "media": "console_rj45", "connector": "console", "kind": "console", "admin": "up", "role": "console" }, - { "id": "srv1-psu", "deviceId": "server-01", "index": 3, "label": "PSU", "media": "power_c13", "connector": "c13", "kind": "power", "admin": "up", "role": "power" } + { + "id": "nic-1", + "deviceId": "server-01", + "index": 1, + "label": "eth0", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "nic", + "accessVlan": 10, + "ip": { + "address": "10.10.99.10", + "prefix": 24, + "gateway": "10.10.10.1" + } + }, + { + "id": "srv1-con", + "deviceId": "server-01", + "index": 2, + "label": "CON", + "media": "console_rj45", + "connector": "console", + "kind": "console", + "admin": "up", + "role": "console" + }, + { + "id": "srv1-psu", + "deviceId": "server-01", + "index": 3, + "label": "PSU", + "media": "power_c13", + "connector": "c13", + "kind": "power", + "admin": "up", + "role": "power" + } ] } ], @@ -40,8 +76,14 @@ "color": "yellow", "lengthM": 1, "ends": [ - { "deviceId": "fw-1", "portId": "fw-lan" }, - { "deviceId": "tor-1", "portId": "sw-2" } + { + "deviceId": "fw-1", + "portId": "fw-lan" + }, + { + "deviceId": "tor-1", + "portId": "sw-2" + } ] }, { @@ -50,8 +92,14 @@ "color": "blue", "lengthM": 2, "ends": [ - { "deviceId": "tor-1", "portId": "sw-5" }, - { "deviceId": "server-01", "portId": "nic-1" } + { + "deviceId": "tor-1", + "portId": "sw-5" + }, + { + "deviceId": "server-01", + "portId": "nic-1" + } ] } ] @@ -59,7 +107,10 @@ "goals": [ { "type": "iface_ip", - "port": { "deviceId": "server-01", "portId": "nic-1" }, + "port": { + "deviceId": "server-01", + "portId": "nic-1" + }, "address": "10.10.10.10", "prefix": 24 }, diff --git a/src/missions/m20-mask-trap.json b/src/missions/m20-mask-trap.json index 5a0f551..d583472 100644 --- a/src/missions/m20-mask-trap.json +++ b/src/missions/m20-mask-trap.json @@ -3,11 +3,11 @@ "title": "Mask Trap", "order": 20, "track": "routing", - "lesson": "Address and mask are a pair. 10.10.10.10/16 is not the same network as the gateway 10.10.10.1/24 — prefixes must match for same-subnet logic. PatchLab refuses the lazy 'it looks close enough' answer.", - "brief": "SERVER-01 shows 10.10.10.10 with a /16 mask. Correct it to /24 (keep gateway 10.10.10.1) and ping FW-EDGE.", + "lesson": "Address and mask are a pair. 10.10.10.10/16 is not the same network as the gateway 10.10.10.1/24 \u2014 prefixes must match for same-subnet logic. PatchLab refuses the lazy 'it looks close enough' answer.", + "brief": "SERVER-01 has link and an address that looks familiar, but it cannot reach FW-EDGE on the LAN. Something in the host addressing is wrong \u2014 find it, fix it, and prove reachability with a ping.", "constraints": [ - "Keep address 10.10.10.10", - "Prefix must be 24" + "Do not change the host's IPv4 address itself", + "Restore same-subnet reachability to the firewall" ], "parTimeSec": 75, "hintAfterWrongAttempts": 2, @@ -27,9 +27,45 @@ "rackUnitStart": 20, "heightU": 1, "ports": [ - { "id": "nic-1", "deviceId": "server-01", "index": 1, "label": "eth0", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "nic", "accessVlan": 10, "ip": { "address": "10.10.10.10", "prefix": 16, "gateway": "10.10.10.1" } }, - { "id": "srv1-con", "deviceId": "server-01", "index": 2, "label": "CON", "media": "console_rj45", "connector": "console", "kind": "console", "admin": "up", "role": "console" }, - { "id": "srv1-psu", "deviceId": "server-01", "index": 3, "label": "PSU", "media": "power_c13", "connector": "c13", "kind": "power", "admin": "up", "role": "power" } + { + "id": "nic-1", + "deviceId": "server-01", + "index": 1, + "label": "eth0", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "nic", + "accessVlan": 10, + "ip": { + "address": "10.10.10.10", + "prefix": 16, + "gateway": "10.10.10.1" + } + }, + { + "id": "srv1-con", + "deviceId": "server-01", + "index": 2, + "label": "CON", + "media": "console_rj45", + "connector": "console", + "kind": "console", + "admin": "up", + "role": "console" + }, + { + "id": "srv1-psu", + "deviceId": "server-01", + "index": 3, + "label": "PSU", + "media": "power_c13", + "connector": "c13", + "kind": "power", + "admin": "up", + "role": "power" + } ] } ], @@ -40,8 +76,14 @@ "color": "yellow", "lengthM": 1, "ends": [ - { "deviceId": "fw-1", "portId": "fw-lan" }, - { "deviceId": "tor-1", "portId": "sw-2" } + { + "deviceId": "fw-1", + "portId": "fw-lan" + }, + { + "deviceId": "tor-1", + "portId": "sw-2" + } ] }, { @@ -50,8 +92,14 @@ "color": "blue", "lengthM": 2, "ends": [ - { "deviceId": "tor-1", "portId": "sw-5" }, - { "deviceId": "server-01", "portId": "nic-1" } + { + "deviceId": "tor-1", + "portId": "sw-5" + }, + { + "deviceId": "server-01", + "portId": "nic-1" + } ] } ] @@ -59,7 +107,10 @@ "goals": [ { "type": "iface_ip", - "port": { "deviceId": "server-01", "portId": "nic-1" }, + "port": { + "deviceId": "server-01", + "portId": "nic-1" + }, "address": "10.10.10.10", "prefix": 24 }, diff --git a/src/missions/m21-inter-vlan.json b/src/missions/m21-inter-vlan.json index aad23b6..a62a968 100644 --- a/src/missions/m21-inter-vlan.json +++ b/src/missions/m21-inter-vlan.json @@ -3,12 +3,12 @@ "title": "Inter-VLAN Router", "order": 21, "track": "routing", - "lesson": "Hosts in different VLANs need a router (or L3 firewall) with an interface in each network. This improves on flat IP puzzles: you feel the VLAN boundary, then bridge it with a second LAN IP — classic router-on-a-stick thinking without the CLI pain.", - "brief": "Bring SERVER-01 (VLAN 10 → 10.10.10.10/24 gw .1) and SERVER-07 (VLAN 20 → 10.10.20.10/24 gw .1) online. Patch both FW LAN interfaces (LAN0 + LAN20), then ping SERVER-01 → SERVER-07.", + "lesson": "Hosts in different VLANs need a router (or L3 firewall) with an interface in each network. This improves on flat IP puzzles: you feel the VLAN boundary, then bridge it with a second LAN IP \u2014 classic router-on-a-stick thinking without the CLI pain.", + "brief": "Two tenant servers sit on separate VLANs and cannot reach each other. Complete the Layer-3 path through FW-EDGE and prove SERVER-01 can reach SERVER-07.", "constraints": [ - "FW LAN0 = 10.10.10.1/24, LAN20 = 10.10.20.1/24 (pre-set)", - "Use Gi1/0/5 for SERVER-01 and Gi1/0/7 for SERVER-07", - "Gateways must match each VLAN" + "Each tenant needs a firewall interface on its own LAN", + "Hosts must use the gateway on their local VLAN", + "Do not rely on trunking alone to route" ], "parTimeSec": 180, "hintAfterWrongAttempts": 2, @@ -28,16 +28,124 @@ "rackUnitStart": 40, "heightU": 1, "ports": [ - { "id": "sw-1", "deviceId": "tor-1", "index": 1, "label": "Gi1/0/1", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "network", "vlanId": 10 }, - { "id": "sw-2", "deviceId": "tor-1", "index": 2, "label": "Gi1/0/2", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "network", "vlanId": 10 }, - { "id": "sw-3", "deviceId": "tor-1", "index": 3, "label": "Gi1/0/3", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "network", "vlanId": 10 }, - { "id": "sw-4", "deviceId": "tor-1", "index": 4, "label": "Gi1/0/4", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "down", "role": "network", "vlanId": 10 }, - { "id": "sw-5", "deviceId": "tor-1", "index": 5, "label": "Gi1/0/5", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "network", "vlanId": 10 }, - { "id": "sw-6", "deviceId": "tor-1", "index": 6, "label": "Gi1/0/6", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "network", "vlanId": 10 }, - { "id": "sw-7", "deviceId": "tor-1", "index": 7, "label": "Gi1/0/7", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "network", "vlanId": 20 }, - { "id": "sw-8", "deviceId": "tor-1", "index": 8, "label": "Gi1/0/8", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "network", "vlanId": 20 }, - { "id": "sw-con", "deviceId": "tor-1", "index": 9, "label": "CON", "media": "console_rj45", "connector": "console", "kind": "console", "admin": "up", "role": "console" }, - { "id": "sw-psu", "deviceId": "tor-1", "index": 10, "label": "PSU", "media": "power_c13", "connector": "c13", "kind": "power", "admin": "up", "role": "power" } + { + "id": "sw-1", + "deviceId": "tor-1", + "index": 1, + "label": "Gi1/0/1", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "network", + "vlanId": 10 + }, + { + "id": "sw-2", + "deviceId": "tor-1", + "index": 2, + "label": "Gi1/0/2", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "network", + "vlanId": 10 + }, + { + "id": "sw-3", + "deviceId": "tor-1", + "index": 3, + "label": "Gi1/0/3", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "network", + "vlanId": 10 + }, + { + "id": "sw-4", + "deviceId": "tor-1", + "index": 4, + "label": "Gi1/0/4", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "down", + "role": "network", + "vlanId": 10 + }, + { + "id": "sw-5", + "deviceId": "tor-1", + "index": 5, + "label": "Gi1/0/5", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "network", + "vlanId": 10 + }, + { + "id": "sw-6", + "deviceId": "tor-1", + "index": 6, + "label": "Gi1/0/6", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "network", + "vlanId": 10 + }, + { + "id": "sw-7", + "deviceId": "tor-1", + "index": 7, + "label": "Gi1/0/7", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "network", + "vlanId": 20 + }, + { + "id": "sw-8", + "deviceId": "tor-1", + "index": 8, + "label": "Gi1/0/8", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "network", + "vlanId": 20 + }, + { + "id": "sw-con", + "deviceId": "tor-1", + "index": 9, + "label": "CON", + "media": "console_rj45", + "connector": "console", + "kind": "console", + "admin": "up", + "role": "console" + }, + { + "id": "sw-psu", + "deviceId": "tor-1", + "index": 10, + "label": "PSU", + "media": "power_c13", + "connector": "c13", + "kind": "power", + "admin": "up", + "role": "power" + } ] }, { @@ -49,11 +157,75 @@ "heightU": 1, "firewallRules": [], "ports": [ - { "id": "fw-lan", "deviceId": "fw-1", "index": 1, "label": "LAN0", "media": "copper_cat6", "connector": "rj45", "kind": "lan", "admin": "up", "role": "lan", "vlanId": 10, "ip": { "address": "10.10.10.1", "prefix": 24 } }, - { "id": "fw-lan20", "deviceId": "fw-1", "index": 2, "label": "LAN20", "media": "copper_cat6", "connector": "rj45", "kind": "lan", "admin": "up", "role": "lan", "vlanId": 20, "ip": { "address": "10.10.20.1", "prefix": 24 } }, - { "id": "fw-wan", "deviceId": "fw-1", "index": 3, "label": "WAN0", "media": "copper_cat6", "connector": "rj45", "kind": "wan", "admin": "up", "role": "wan", "ip": { "address": "203.0.113.1", "prefix": 30 } }, - { "id": "fw-con", "deviceId": "fw-1", "index": 4, "label": "CON", "media": "console_rj45", "connector": "console", "kind": "console", "admin": "up", "role": "console" }, - { "id": "fw-psu", "deviceId": "fw-1", "index": 5, "label": "PSU", "media": "power_c13", "connector": "c13", "kind": "power", "admin": "up", "role": "power" } + { + "id": "fw-lan", + "deviceId": "fw-1", + "index": 1, + "label": "LAN0", + "media": "copper_cat6", + "connector": "rj45", + "kind": "lan", + "admin": "up", + "role": "lan", + "vlanId": 10, + "ip": { + "address": "10.10.10.1", + "prefix": 24 + } + }, + { + "id": "fw-lan20", + "deviceId": "fw-1", + "index": 2, + "label": "LAN20", + "media": "copper_cat6", + "connector": "rj45", + "kind": "lan", + "admin": "up", + "role": "lan", + "vlanId": 20, + "ip": { + "address": "10.10.20.1", + "prefix": 24 + } + }, + { + "id": "fw-wan", + "deviceId": "fw-1", + "index": 3, + "label": "WAN0", + "media": "copper_cat6", + "connector": "rj45", + "kind": "wan", + "admin": "up", + "role": "wan", + "ip": { + "address": "203.0.113.1", + "prefix": 30 + } + }, + { + "id": "fw-con", + "deviceId": "fw-1", + "index": 4, + "label": "CON", + "media": "console_rj45", + "connector": "console", + "kind": "console", + "admin": "up", + "role": "console" + }, + { + "id": "fw-psu", + "deviceId": "fw-1", + "index": 5, + "label": "PSU", + "media": "power_c13", + "connector": "c13", + "kind": "power", + "admin": "up", + "role": "power" + } ] } ], @@ -62,33 +234,63 @@ "goals": [ { "type": "link_up", - "a": { "deviceId": "tor-1", "portId": "sw-5" }, - "b": { "deviceId": "server-01", "portId": "nic-1" } + "a": { + "deviceId": "tor-1", + "portId": "sw-5" + }, + "b": { + "deviceId": "server-01", + "portId": "nic-1" + } }, { "type": "link_up", - "a": { "deviceId": "tor-1", "portId": "sw-7" }, - "b": { "deviceId": "server-07", "portId": "nic-1" } + "a": { + "deviceId": "tor-1", + "portId": "sw-7" + }, + "b": { + "deviceId": "server-07", + "portId": "nic-1" + } }, { "type": "link_up", - "a": { "deviceId": "fw-1", "portId": "fw-lan" }, - "b": { "deviceId": "tor-1", "portId": "sw-2" } + "a": { + "deviceId": "fw-1", + "portId": "fw-lan" + }, + "b": { + "deviceId": "tor-1", + "portId": "sw-2" + } }, { "type": "link_up", - "a": { "deviceId": "fw-1", "portId": "fw-lan20" }, - "b": { "deviceId": "tor-1", "portId": "sw-8" } + "a": { + "deviceId": "fw-1", + "portId": "fw-lan20" + }, + "b": { + "deviceId": "tor-1", + "portId": "sw-8" + } }, { "type": "iface_ip", - "port": { "deviceId": "server-01", "portId": "nic-1" }, + "port": { + "deviceId": "server-01", + "portId": "nic-1" + }, "address": "10.10.10.10", "prefix": 24 }, { "type": "iface_ip", - "port": { "deviceId": "server-07", "portId": "nic-1" }, + "port": { + "deviceId": "server-07", + "portId": "nic-1" + }, "address": "10.10.20.10", "prefix": 24 }, diff --git a/src/missions/m23-no-shutdown.json b/src/missions/m23-no-shutdown.json index 1cc939e..ac0f904 100644 --- a/src/missions/m23-no-shutdown.json +++ b/src/missions/m23-no-shutdown.json @@ -3,12 +3,11 @@ "title": "No Shutdown", "order": 23, "track": "copper", - "lesson": "Admin down is not a bad cable. Earlier you moved off Gi1/0/4 — now leave the patch where docs want it and administratively enable the port (no shutdown).", - "brief": "Panel A-04 is already patched to Gi1/0/4, and SERVER-01 is on Gi1/0/5. The cross-connect stays dark because Gi1/0/4 is admin down. Select Gi1/0/4 and Toggle admin (no shutdown) so the link comes up.", + "lesson": "Admin down is not a bad cable. Earlier you moved off Gi1/0/4 \u2014 now leave the patch where docs want it and administratively enable the port (no shutdown).", + "brief": "The documented panel cross-connect is already patched, but the path stays dark. Cabling is not the problem this time \u2014 restore the administratively disabled interface so the documented circuit lights.", "constraints": [ - "Do not move the A-04 ↔ Gi1/0/4 patch", - "Use Toggle admin on Gi1/0/4", - "Keep SERVER-01 on Gi1/0/5" + "Do not move the existing documented patch", + "Keep the server attachment in place" ], "parTimeSec": 75, "hintAfterWrongAttempts": 1, @@ -27,8 +26,14 @@ "color": "blue", "lengthM": 1, "ends": [ - { "deviceId": "panel-a", "portId": "panel-4" }, - { "deviceId": "tor-1", "portId": "sw-4" } + { + "deviceId": "panel-a", + "portId": "panel-4" + }, + { + "deviceId": "tor-1", + "portId": "sw-4" + } ] }, { @@ -37,8 +42,14 @@ "color": "blue", "lengthM": 2, "ends": [ - { "deviceId": "tor-1", "portId": "sw-5" }, - { "deviceId": "server-01", "portId": "nic-1" } + { + "deviceId": "tor-1", + "portId": "sw-5" + }, + { + "deviceId": "server-01", + "portId": "nic-1" + } ] } ] @@ -46,13 +57,25 @@ "goals": [ { "type": "link_up", - "a": { "deviceId": "panel-a", "portId": "panel-4" }, - "b": { "deviceId": "tor-1", "portId": "sw-4" } + "a": { + "deviceId": "panel-a", + "portId": "panel-4" + }, + "b": { + "deviceId": "tor-1", + "portId": "sw-4" + } }, { "type": "link_up", - "a": { "deviceId": "tor-1", "portId": "sw-5" }, - "b": { "deviceId": "server-01", "portId": "nic-1" } + "a": { + "deviceId": "tor-1", + "portId": "sw-5" + }, + "b": { + "deviceId": "server-01", + "portId": "nic-1" + } } ] } diff --git a/src/missions/m24-wrong-gateway.json b/src/missions/m24-wrong-gateway.json index 53c9be2..50e08f1 100644 --- a/src/missions/m24-wrong-gateway.json +++ b/src/missions/m24-wrong-gateway.json @@ -4,11 +4,10 @@ "order": 24, "track": "logic", "lesson": "Same-subnet address and mask can look fine while the default gateway is wrong. Off-subnet destinations fail until the gateway IP matches the router on your LAN.", - "brief": "SERVER-01 is patched and addressed 10.10.10.10/24, but its gateway is 10.10.20.1 (wrong VLAN). Fix the gateway to 10.10.10.1, then ping ISP-PEER. LAN→WAN is already permitted.", + "brief": "SERVER-01 can talk on the LAN fabric, but every off-subnet attempt toward ISP-PEER fails. Physical paths and ACL are already healthy \u2014 find the host routing mistake and restore WAN reachability.", "constraints": [ - "Keep address 10.10.10.10/24", - "Gateway must be 10.10.10.1 (FW LAN)", - "Do not unpatch the working cables" + "Do not unpatch working cables", + "Keep the host on its current address/mask" ], "parTimeSec": 90, "hintAfterWrongAttempts": 2, @@ -38,10 +37,59 @@ } ], "ports": [ - { "id": "fw-lan", "deviceId": "fw-1", "index": 1, "label": "LAN0", "media": "copper_cat6", "connector": "rj45", "kind": "lan", "admin": "up", "role": "lan", "vlanId": 10, "ip": { "address": "10.10.10.1", "prefix": 24 } }, - { "id": "fw-wan", "deviceId": "fw-1", "index": 2, "label": "WAN0", "media": "copper_cat6", "connector": "rj45", "kind": "wan", "admin": "up", "role": "wan", "ip": { "address": "203.0.113.1", "prefix": 30 } }, - { "id": "fw-con", "deviceId": "fw-1", "index": 3, "label": "CON", "media": "console_rj45", "connector": "console", "kind": "console", "admin": "up", "role": "console" }, - { "id": "fw-psu", "deviceId": "fw-1", "index": 4, "label": "PSU", "media": "power_c13", "connector": "c13", "kind": "power", "admin": "up", "role": "power" } + { + "id": "fw-lan", + "deviceId": "fw-1", + "index": 1, + "label": "LAN0", + "media": "copper_cat6", + "connector": "rj45", + "kind": "lan", + "admin": "up", + "role": "lan", + "vlanId": 10, + "ip": { + "address": "10.10.10.1", + "prefix": 24 + } + }, + { + "id": "fw-wan", + "deviceId": "fw-1", + "index": 2, + "label": "WAN0", + "media": "copper_cat6", + "connector": "rj45", + "kind": "wan", + "admin": "up", + "role": "wan", + "ip": { + "address": "203.0.113.1", + "prefix": 30 + } + }, + { + "id": "fw-con", + "deviceId": "fw-1", + "index": 3, + "label": "CON", + "media": "console_rj45", + "connector": "console", + "kind": "console", + "admin": "up", + "role": "console" + }, + { + "id": "fw-psu", + "deviceId": "fw-1", + "index": 4, + "label": "PSU", + "media": "power_c13", + "connector": "c13", + "kind": "power", + "admin": "up", + "role": "power" + } ] }, { @@ -52,9 +100,45 @@ "rackUnitStart": 20, "heightU": 1, "ports": [ - { "id": "nic-1", "deviceId": "server-01", "index": 1, "label": "eth0", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "nic", "accessVlan": 10, "ip": { "address": "10.10.10.10", "prefix": 24, "gateway": "10.10.20.1" } }, - { "id": "srv1-con", "deviceId": "server-01", "index": 2, "label": "CON", "media": "console_rj45", "connector": "console", "kind": "console", "admin": "up", "role": "console" }, - { "id": "srv1-psu", "deviceId": "server-01", "index": 3, "label": "PSU", "media": "power_c13", "connector": "c13", "kind": "power", "admin": "up", "role": "power" } + { + "id": "nic-1", + "deviceId": "server-01", + "index": 1, + "label": "eth0", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "nic", + "accessVlan": 10, + "ip": { + "address": "10.10.10.10", + "prefix": 24, + "gateway": "10.10.20.1" + } + }, + { + "id": "srv1-con", + "deviceId": "server-01", + "index": 2, + "label": "CON", + "media": "console_rj45", + "connector": "console", + "kind": "console", + "admin": "up", + "role": "console" + }, + { + "id": "srv1-psu", + "deviceId": "server-01", + "index": 3, + "label": "PSU", + "media": "power_c13", + "connector": "c13", + "kind": "power", + "admin": "up", + "role": "power" + } ] } ], @@ -65,8 +149,14 @@ "color": "yellow", "lengthM": 1, "ends": [ - { "deviceId": "fw-1", "portId": "fw-lan" }, - { "deviceId": "tor-1", "portId": "sw-2" } + { + "deviceId": "fw-1", + "portId": "fw-lan" + }, + { + "deviceId": "tor-1", + "portId": "sw-2" + } ] }, { @@ -75,8 +165,14 @@ "color": "blue", "lengthM": 2, "ends": [ - { "deviceId": "tor-1", "portId": "sw-5" }, - { "deviceId": "server-01", "portId": "nic-1" } + { + "deviceId": "tor-1", + "portId": "sw-5" + }, + { + "deviceId": "server-01", + "portId": "nic-1" + } ] } ] @@ -84,7 +180,10 @@ "goals": [ { "type": "iface_ip", - "port": { "deviceId": "server-01", "portId": "nic-1" }, + "port": { + "deviceId": "server-01", + "portId": "nic-1" + }, "address": "10.10.10.10", "prefix": 24 }, diff --git a/src/missions/m25-host-route.json b/src/missions/m25-host-route.json index 11031a4..0975fc4 100644 --- a/src/missions/m25-host-route.json +++ b/src/missions/m25-host-route.json @@ -3,12 +3,11 @@ "title": "Host Route", "order": 25, "track": "routing", - "lesson": "Longest-prefix match: a /32 host route beats a broader summary. A more-specific bad next hop can black-hole traffic even when a correct /24 exists — override the host route.", - "brief": "LAN and WAN are up. A poisoned host route 198.51.100.10/32 → 10.10.10.10 black-holes BRANCH. Override it with 198.51.100.10/32 via 203.0.113.2, then ping BRANCH-01. ACL is already permitted.", + "lesson": "Longest-prefix match: a /32 host route beats a broader summary. A more-specific bad next hop can black-hole traffic even when a correct /24 exists \u2014 override the host route.", + "brief": "LAN and WAN look healthy, yet BRANCH-01 is unreachable. A more-specific route on FW-EDGE is poisoning the path. Correct the host route so BRANCH service returns, then verify with a ping.", "constraints": [ - "Replace the host route next hop with ISP-PEER (203.0.113.2)", "Do not remove the WAN uplink", - "BRANCH-01 is a cloud host" + "Fix the poisoned more-specific route rather than rebuilding the whole rack" ], "parTimeSec": 120, "hintAfterWrongAttempts": 2, @@ -43,14 +42,63 @@ "destCidr": "198.51.100.10/32", "nextHop": "10.10.10.10", "enabled": true, - "note": "Poisoned host route — override next hop" + "note": "Poisoned host route \u2014 override next hop" } ], "ports": [ - { "id": "fw-lan", "deviceId": "fw-1", "index": 1, "label": "LAN0", "media": "copper_cat6", "connector": "rj45", "kind": "lan", "admin": "up", "role": "lan", "vlanId": 10, "ip": { "address": "10.10.10.1", "prefix": 24 } }, - { "id": "fw-wan", "deviceId": "fw-1", "index": 2, "label": "WAN0", "media": "copper_cat6", "connector": "rj45", "kind": "wan", "admin": "up", "role": "wan", "ip": { "address": "203.0.113.1", "prefix": 30 } }, - { "id": "fw-con", "deviceId": "fw-1", "index": 3, "label": "CON", "media": "console_rj45", "connector": "console", "kind": "console", "admin": "up", "role": "console" }, - { "id": "fw-psu", "deviceId": "fw-1", "index": 4, "label": "PSU", "media": "power_c13", "connector": "c13", "kind": "power", "admin": "up", "role": "power" } + { + "id": "fw-lan", + "deviceId": "fw-1", + "index": 1, + "label": "LAN0", + "media": "copper_cat6", + "connector": "rj45", + "kind": "lan", + "admin": "up", + "role": "lan", + "vlanId": 10, + "ip": { + "address": "10.10.10.1", + "prefix": 24 + } + }, + { + "id": "fw-wan", + "deviceId": "fw-1", + "index": 2, + "label": "WAN0", + "media": "copper_cat6", + "connector": "rj45", + "kind": "wan", + "admin": "up", + "role": "wan", + "ip": { + "address": "203.0.113.1", + "prefix": 30 + } + }, + { + "id": "fw-con", + "deviceId": "fw-1", + "index": 3, + "label": "CON", + "media": "console_rj45", + "connector": "console", + "kind": "console", + "admin": "up", + "role": "console" + }, + { + "id": "fw-psu", + "deviceId": "fw-1", + "index": 4, + "label": "PSU", + "media": "power_c13", + "connector": "c13", + "kind": "power", + "admin": "up", + "role": "power" + } ] }, { @@ -61,9 +109,45 @@ "rackUnitStart": 20, "heightU": 1, "ports": [ - { "id": "nic-1", "deviceId": "server-01", "index": 1, "label": "eth0", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "nic", "accessVlan": 10, "ip": { "address": "10.10.10.10", "prefix": 24, "gateway": "10.10.10.1" } }, - { "id": "srv1-con", "deviceId": "server-01", "index": 2, "label": "CON", "media": "console_rj45", "connector": "console", "kind": "console", "admin": "up", "role": "console" }, - { "id": "srv1-psu", "deviceId": "server-01", "index": 3, "label": "PSU", "media": "power_c13", "connector": "c13", "kind": "power", "admin": "up", "role": "power" } + { + "id": "nic-1", + "deviceId": "server-01", + "index": 1, + "label": "eth0", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "nic", + "accessVlan": 10, + "ip": { + "address": "10.10.10.10", + "prefix": 24, + "gateway": "10.10.10.1" + } + }, + { + "id": "srv1-con", + "deviceId": "server-01", + "index": 2, + "label": "CON", + "media": "console_rj45", + "connector": "console", + "kind": "console", + "admin": "up", + "role": "console" + }, + { + "id": "srv1-psu", + "deviceId": "server-01", + "index": 3, + "label": "PSU", + "media": "power_c13", + "connector": "c13", + "kind": "power", + "admin": "up", + "role": "power" + } ] } ], @@ -74,8 +158,14 @@ "color": "yellow", "lengthM": 1, "ends": [ - { "deviceId": "fw-1", "portId": "fw-lan" }, - { "deviceId": "tor-1", "portId": "sw-2" } + { + "deviceId": "fw-1", + "portId": "fw-lan" + }, + { + "deviceId": "tor-1", + "portId": "sw-2" + } ] }, { @@ -84,8 +174,14 @@ "color": "blue", "lengthM": 2, "ends": [ - { "deviceId": "tor-1", "portId": "sw-5" }, - { "deviceId": "server-01", "portId": "nic-1" } + { + "deviceId": "tor-1", + "portId": "sw-5" + }, + { + "deviceId": "server-01", + "portId": "nic-1" + } ] } ] diff --git a/src/missions/m26-deny-branch.json b/src/missions/m26-deny-branch.json index 93aa9a5..ca585eb 100644 --- a/src/missions/m26-deny-branch.json +++ b/src/missions/m26-deny-branch.json @@ -4,11 +4,11 @@ "order": 26, "track": "security", "lesson": "Same first-match ACL idea as Deny One Host, aimed at a routed BRANCH prefix. Specific deny above a broader permit keeps one host out while others still reach the cloud site.", - "brief": "Route to BRANCH is up and a broad LAN→BRANCH permit exists. Insert deny 10.10.10.20/32 → 198.51.100.0/24 so SERVER-07 cannot reach BRANCH-01, while SERVER-01 still can.", + "brief": "BRANCH is reachable from SERVER-01, but security wants SERVER-07 blocked from that cloud site without cutting off the rest of the LAN. Enforce the selective deny and prove both outcomes.", "constraints": [ - "Deny only host 10.10.10.20 (SERVER-07)", + "Only one host should lose BRANCH access", "SERVER-01 must still reach BRANCH-01", - "Rule order matters — specific deny first" + "Rule order matters" ], "parTimeSec": 120, "hintAfterWrongAttempts": 2, @@ -28,16 +28,124 @@ "rackUnitStart": 40, "heightU": 1, "ports": [ - { "id": "sw-1", "deviceId": "tor-1", "index": 1, "label": "Gi1/0/1", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "network", "vlanId": 10 }, - { "id": "sw-2", "deviceId": "tor-1", "index": 2, "label": "Gi1/0/2", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "network", "vlanId": 10 }, - { "id": "sw-3", "deviceId": "tor-1", "index": 3, "label": "Gi1/0/3", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "network", "vlanId": 10 }, - { "id": "sw-4", "deviceId": "tor-1", "index": 4, "label": "Gi1/0/4", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "down", "role": "network", "vlanId": 10 }, - { "id": "sw-5", "deviceId": "tor-1", "index": 5, "label": "Gi1/0/5", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "network", "vlanId": 10 }, - { "id": "sw-6", "deviceId": "tor-1", "index": 6, "label": "Gi1/0/6", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "network", "vlanId": 10 }, - { "id": "sw-7", "deviceId": "tor-1", "index": 7, "label": "Gi1/0/7", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "network", "vlanId": 10 }, - { "id": "sw-8", "deviceId": "tor-1", "index": 8, "label": "Gi1/0/8", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "network", "vlanId": 10 }, - { "id": "sw-con", "deviceId": "tor-1", "index": 9, "label": "CON", "media": "console_rj45", "connector": "console", "kind": "console", "admin": "up", "role": "console" }, - { "id": "sw-psu", "deviceId": "tor-1", "index": 10, "label": "PSU", "media": "power_c13", "connector": "c13", "kind": "power", "admin": "up", "role": "power" } + { + "id": "sw-1", + "deviceId": "tor-1", + "index": 1, + "label": "Gi1/0/1", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "network", + "vlanId": 10 + }, + { + "id": "sw-2", + "deviceId": "tor-1", + "index": 2, + "label": "Gi1/0/2", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "network", + "vlanId": 10 + }, + { + "id": "sw-3", + "deviceId": "tor-1", + "index": 3, + "label": "Gi1/0/3", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "network", + "vlanId": 10 + }, + { + "id": "sw-4", + "deviceId": "tor-1", + "index": 4, + "label": "Gi1/0/4", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "down", + "role": "network", + "vlanId": 10 + }, + { + "id": "sw-5", + "deviceId": "tor-1", + "index": 5, + "label": "Gi1/0/5", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "network", + "vlanId": 10 + }, + { + "id": "sw-6", + "deviceId": "tor-1", + "index": 6, + "label": "Gi1/0/6", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "network", + "vlanId": 10 + }, + { + "id": "sw-7", + "deviceId": "tor-1", + "index": 7, + "label": "Gi1/0/7", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "network", + "vlanId": 10 + }, + { + "id": "sw-8", + "deviceId": "tor-1", + "index": 8, + "label": "Gi1/0/8", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "network", + "vlanId": 10 + }, + { + "id": "sw-con", + "deviceId": "tor-1", + "index": 9, + "label": "CON", + "media": "console_rj45", + "connector": "console", + "kind": "console", + "admin": "up", + "role": "console" + }, + { + "id": "sw-psu", + "deviceId": "tor-1", + "index": 10, + "label": "PSU", + "media": "power_c13", + "connector": "c13", + "kind": "power", + "admin": "up", + "role": "power" + } ] }, { @@ -53,7 +161,7 @@ "action": "permit", "srcCidr": "10.10.10.0/24", "dstCidr": "198.51.100.0/24", - "note": "Broad permit — put a specific deny above it", + "note": "Broad permit \u2014 put a specific deny above it", "enabled": true } ], @@ -66,10 +174,59 @@ } ], "ports": [ - { "id": "fw-lan", "deviceId": "fw-1", "index": 1, "label": "LAN0", "media": "copper_cat6", "connector": "rj45", "kind": "lan", "admin": "up", "role": "lan", "vlanId": 10, "ip": { "address": "10.10.10.1", "prefix": 24 } }, - { "id": "fw-wan", "deviceId": "fw-1", "index": 2, "label": "WAN0", "media": "copper_cat6", "connector": "rj45", "kind": "wan", "admin": "up", "role": "wan", "ip": { "address": "203.0.113.1", "prefix": 30 } }, - { "id": "fw-con", "deviceId": "fw-1", "index": 3, "label": "CON", "media": "console_rj45", "connector": "console", "kind": "console", "admin": "up", "role": "console" }, - { "id": "fw-psu", "deviceId": "fw-1", "index": 4, "label": "PSU", "media": "power_c13", "connector": "c13", "kind": "power", "admin": "up", "role": "power" } + { + "id": "fw-lan", + "deviceId": "fw-1", + "index": 1, + "label": "LAN0", + "media": "copper_cat6", + "connector": "rj45", + "kind": "lan", + "admin": "up", + "role": "lan", + "vlanId": 10, + "ip": { + "address": "10.10.10.1", + "prefix": 24 + } + }, + { + "id": "fw-wan", + "deviceId": "fw-1", + "index": 2, + "label": "WAN0", + "media": "copper_cat6", + "connector": "rj45", + "kind": "wan", + "admin": "up", + "role": "wan", + "ip": { + "address": "203.0.113.1", + "prefix": 30 + } + }, + { + "id": "fw-con", + "deviceId": "fw-1", + "index": 3, + "label": "CON", + "media": "console_rj45", + "connector": "console", + "kind": "console", + "admin": "up", + "role": "console" + }, + { + "id": "fw-psu", + "deviceId": "fw-1", + "index": 4, + "label": "PSU", + "media": "power_c13", + "connector": "c13", + "kind": "power", + "admin": "up", + "role": "power" + } ] }, { @@ -80,9 +237,45 @@ "rackUnitStart": 20, "heightU": 1, "ports": [ - { "id": "nic-1", "deviceId": "server-01", "index": 1, "label": "eth0", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "nic", "accessVlan": 10, "ip": { "address": "10.10.10.10", "prefix": 24, "gateway": "10.10.10.1" } }, - { "id": "srv1-con", "deviceId": "server-01", "index": 2, "label": "CON", "media": "console_rj45", "connector": "console", "kind": "console", "admin": "up", "role": "console" }, - { "id": "srv1-psu", "deviceId": "server-01", "index": 3, "label": "PSU", "media": "power_c13", "connector": "c13", "kind": "power", "admin": "up", "role": "power" } + { + "id": "nic-1", + "deviceId": "server-01", + "index": 1, + "label": "eth0", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "nic", + "accessVlan": 10, + "ip": { + "address": "10.10.10.10", + "prefix": 24, + "gateway": "10.10.10.1" + } + }, + { + "id": "srv1-con", + "deviceId": "server-01", + "index": 2, + "label": "CON", + "media": "console_rj45", + "connector": "console", + "kind": "console", + "admin": "up", + "role": "console" + }, + { + "id": "srv1-psu", + "deviceId": "server-01", + "index": 3, + "label": "PSU", + "media": "power_c13", + "connector": "c13", + "kind": "power", + "admin": "up", + "role": "power" + } ] }, { @@ -93,8 +286,34 @@ "rackUnitStart": 14, "heightU": 1, "ports": [ - { "id": "nic-1", "deviceId": "server-07", "index": 1, "label": "eth0", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "nic", "accessVlan": 10, "ip": { "address": "10.10.10.20", "prefix": 24, "gateway": "10.10.10.1" } }, - { "id": "srv7-psu", "deviceId": "server-07", "index": 2, "label": "PSU", "media": "power_c13", "connector": "c13", "kind": "power", "admin": "up", "role": "power" } + { + "id": "nic-1", + "deviceId": "server-07", + "index": 1, + "label": "eth0", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "nic", + "accessVlan": 10, + "ip": { + "address": "10.10.10.20", + "prefix": 24, + "gateway": "10.10.10.1" + } + }, + { + "id": "srv7-psu", + "deviceId": "server-07", + "index": 2, + "label": "PSU", + "media": "power_c13", + "connector": "c13", + "kind": "power", + "admin": "up", + "role": "power" + } ] } ], @@ -105,8 +324,14 @@ "color": "yellow", "lengthM": 1, "ends": [ - { "deviceId": "fw-1", "portId": "fw-lan" }, - { "deviceId": "tor-1", "portId": "sw-2" } + { + "deviceId": "fw-1", + "portId": "fw-lan" + }, + { + "deviceId": "tor-1", + "portId": "sw-2" + } ] }, { @@ -115,8 +340,14 @@ "color": "blue", "lengthM": 2, "ends": [ - { "deviceId": "tor-1", "portId": "sw-5" }, - { "deviceId": "server-01", "portId": "nic-1" } + { + "deviceId": "tor-1", + "portId": "sw-5" + }, + { + "deviceId": "server-01", + "portId": "nic-1" + } ] }, { @@ -125,8 +356,14 @@ "color": "blue", "lengthM": 2, "ends": [ - { "deviceId": "tor-1", "portId": "sw-7" }, - { "deviceId": "server-07", "portId": "nic-1" } + { + "deviceId": "tor-1", + "portId": "sw-7" + }, + { + "deviceId": "server-07", + "portId": "nic-1" + } ] } ] diff --git a/src/missions/m27-branch-exception.json b/src/missions/m27-branch-exception.json index 4059ebc..1ac5923 100644 --- a/src/missions/m27-branch-exception.json +++ b/src/missions/m27-branch-exception.json @@ -4,11 +4,10 @@ "order": 27, "track": "security", "lesson": "When BRANCH is locked down, carve a host exception with a more-specific permit above the deny. Console into the firewall on a spare TTY when you treat ACL changes as out-of-band work.", - "brief": "BRANCH is denied for the whole LAN. Console into FW-EDGE from Console TTY2, then insert a custom permit 10.10.10.10/32 → 198.51.100.10/32 so only SERVER-01 reaches BRANCH-01. SERVER-07 must stay blocked.", + "brief": "BRANCH is locked down for the whole LAN after an incident. Operations needs a narrow exception so one approved server can reach a single BRANCH host again. Use out-of-band console access on the firewall, carve the exception, and prove the other host stays blocked.", "constraints": [ - "Use Console TTY2 → FW CON", - "Permit only host 10.10.10.10 to BRANCH host .10", - "Do not open the whole LAN" + "Treat the ACL change as an out-of-band console task", + "Do not reopen BRANCH for the entire LAN" ], "parTimeSec": 150, "hintAfterWrongAttempts": 2, @@ -28,16 +27,124 @@ "rackUnitStart": 40, "heightU": 1, "ports": [ - { "id": "sw-1", "deviceId": "tor-1", "index": 1, "label": "Gi1/0/1", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "network", "vlanId": 10 }, - { "id": "sw-2", "deviceId": "tor-1", "index": 2, "label": "Gi1/0/2", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "network", "vlanId": 10 }, - { "id": "sw-3", "deviceId": "tor-1", "index": 3, "label": "Gi1/0/3", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "network", "vlanId": 10 }, - { "id": "sw-4", "deviceId": "tor-1", "index": 4, "label": "Gi1/0/4", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "down", "role": "network", "vlanId": 10 }, - { "id": "sw-5", "deviceId": "tor-1", "index": 5, "label": "Gi1/0/5", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "network", "vlanId": 10 }, - { "id": "sw-6", "deviceId": "tor-1", "index": 6, "label": "Gi1/0/6", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "network", "vlanId": 10 }, - { "id": "sw-7", "deviceId": "tor-1", "index": 7, "label": "Gi1/0/7", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "network", "vlanId": 10 }, - { "id": "sw-8", "deviceId": "tor-1", "index": 8, "label": "Gi1/0/8", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "network", "vlanId": 10 }, - { "id": "sw-con", "deviceId": "tor-1", "index": 9, "label": "CON", "media": "console_rj45", "connector": "console", "kind": "console", "admin": "up", "role": "console" }, - { "id": "sw-psu", "deviceId": "tor-1", "index": 10, "label": "PSU", "media": "power_c13", "connector": "c13", "kind": "power", "admin": "up", "role": "power" } + { + "id": "sw-1", + "deviceId": "tor-1", + "index": 1, + "label": "Gi1/0/1", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "network", + "vlanId": 10 + }, + { + "id": "sw-2", + "deviceId": "tor-1", + "index": 2, + "label": "Gi1/0/2", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "network", + "vlanId": 10 + }, + { + "id": "sw-3", + "deviceId": "tor-1", + "index": 3, + "label": "Gi1/0/3", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "network", + "vlanId": 10 + }, + { + "id": "sw-4", + "deviceId": "tor-1", + "index": 4, + "label": "Gi1/0/4", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "down", + "role": "network", + "vlanId": 10 + }, + { + "id": "sw-5", + "deviceId": "tor-1", + "index": 5, + "label": "Gi1/0/5", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "network", + "vlanId": 10 + }, + { + "id": "sw-6", + "deviceId": "tor-1", + "index": 6, + "label": "Gi1/0/6", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "network", + "vlanId": 10 + }, + { + "id": "sw-7", + "deviceId": "tor-1", + "index": 7, + "label": "Gi1/0/7", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "network", + "vlanId": 10 + }, + { + "id": "sw-8", + "deviceId": "tor-1", + "index": 8, + "label": "Gi1/0/8", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "network", + "vlanId": 10 + }, + { + "id": "sw-con", + "deviceId": "tor-1", + "index": 9, + "label": "CON", + "media": "console_rj45", + "connector": "console", + "kind": "console", + "admin": "up", + "role": "console" + }, + { + "id": "sw-psu", + "deviceId": "tor-1", + "index": 10, + "label": "PSU", + "media": "power_c13", + "connector": "c13", + "kind": "power", + "admin": "up", + "role": "power" + } ] }, { @@ -53,7 +160,7 @@ "action": "deny", "srcCidr": "10.10.10.0/24", "dstCidr": "198.51.100.0/24", - "note": "Closed — carve a host exception above this", + "note": "Closed \u2014 carve a host exception above this", "enabled": true } ], @@ -66,10 +173,59 @@ } ], "ports": [ - { "id": "fw-lan", "deviceId": "fw-1", "index": 1, "label": "LAN0", "media": "copper_cat6", "connector": "rj45", "kind": "lan", "admin": "up", "role": "lan", "vlanId": 10, "ip": { "address": "10.10.10.1", "prefix": 24 } }, - { "id": "fw-wan", "deviceId": "fw-1", "index": 2, "label": "WAN0", "media": "copper_cat6", "connector": "rj45", "kind": "wan", "admin": "up", "role": "wan", "ip": { "address": "203.0.113.1", "prefix": 30 } }, - { "id": "fw-con", "deviceId": "fw-1", "index": 3, "label": "CON", "media": "console_rj45", "connector": "console", "kind": "console", "admin": "up", "role": "console" }, - { "id": "fw-psu", "deviceId": "fw-1", "index": 4, "label": "PSU", "media": "power_c13", "connector": "c13", "kind": "power", "admin": "up", "role": "power" } + { + "id": "fw-lan", + "deviceId": "fw-1", + "index": 1, + "label": "LAN0", + "media": "copper_cat6", + "connector": "rj45", + "kind": "lan", + "admin": "up", + "role": "lan", + "vlanId": 10, + "ip": { + "address": "10.10.10.1", + "prefix": 24 + } + }, + { + "id": "fw-wan", + "deviceId": "fw-1", + "index": 2, + "label": "WAN0", + "media": "copper_cat6", + "connector": "rj45", + "kind": "wan", + "admin": "up", + "role": "wan", + "ip": { + "address": "203.0.113.1", + "prefix": 30 + } + }, + { + "id": "fw-con", + "deviceId": "fw-1", + "index": 3, + "label": "CON", + "media": "console_rj45", + "connector": "console", + "kind": "console", + "admin": "up", + "role": "console" + }, + { + "id": "fw-psu", + "deviceId": "fw-1", + "index": 4, + "label": "PSU", + "media": "power_c13", + "connector": "c13", + "kind": "power", + "admin": "up", + "role": "power" + } ] }, { @@ -80,9 +236,45 @@ "rackUnitStart": 20, "heightU": 1, "ports": [ - { "id": "nic-1", "deviceId": "server-01", "index": 1, "label": "eth0", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "nic", "accessVlan": 10, "ip": { "address": "10.10.10.10", "prefix": 24, "gateway": "10.10.10.1" } }, - { "id": "srv1-con", "deviceId": "server-01", "index": 2, "label": "CON", "media": "console_rj45", "connector": "console", "kind": "console", "admin": "up", "role": "console" }, - { "id": "srv1-psu", "deviceId": "server-01", "index": 3, "label": "PSU", "media": "power_c13", "connector": "c13", "kind": "power", "admin": "up", "role": "power" } + { + "id": "nic-1", + "deviceId": "server-01", + "index": 1, + "label": "eth0", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "nic", + "accessVlan": 10, + "ip": { + "address": "10.10.10.10", + "prefix": 24, + "gateway": "10.10.10.1" + } + }, + { + "id": "srv1-con", + "deviceId": "server-01", + "index": 2, + "label": "CON", + "media": "console_rj45", + "connector": "console", + "kind": "console", + "admin": "up", + "role": "console" + }, + { + "id": "srv1-psu", + "deviceId": "server-01", + "index": 3, + "label": "PSU", + "media": "power_c13", + "connector": "c13", + "kind": "power", + "admin": "up", + "role": "power" + } ] }, { @@ -93,8 +285,34 @@ "rackUnitStart": 14, "heightU": 1, "ports": [ - { "id": "nic-1", "deviceId": "server-07", "index": 1, "label": "eth0", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "nic", "accessVlan": 10, "ip": { "address": "10.10.10.20", "prefix": 24, "gateway": "10.10.10.1" } }, - { "id": "srv7-psu", "deviceId": "server-07", "index": 2, "label": "PSU", "media": "power_c13", "connector": "c13", "kind": "power", "admin": "up", "role": "power" } + { + "id": "nic-1", + "deviceId": "server-07", + "index": 1, + "label": "eth0", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "nic", + "accessVlan": 10, + "ip": { + "address": "10.10.10.20", + "prefix": 24, + "gateway": "10.10.10.1" + } + }, + { + "id": "srv7-psu", + "deviceId": "server-07", + "index": 2, + "label": "PSU", + "media": "power_c13", + "connector": "c13", + "kind": "power", + "admin": "up", + "role": "power" + } ] } ], @@ -105,8 +323,14 @@ "color": "yellow", "lengthM": 1, "ends": [ - { "deviceId": "fw-1", "portId": "fw-lan" }, - { "deviceId": "tor-1", "portId": "sw-2" } + { + "deviceId": "fw-1", + "portId": "fw-lan" + }, + { + "deviceId": "tor-1", + "portId": "sw-2" + } ] }, { @@ -115,8 +339,14 @@ "color": "blue", "lengthM": 2, "ends": [ - { "deviceId": "tor-1", "portId": "sw-5" }, - { "deviceId": "server-01", "portId": "nic-1" } + { + "deviceId": "tor-1", + "portId": "sw-5" + }, + { + "deviceId": "server-01", + "portId": "nic-1" + } ] }, { @@ -125,8 +355,14 @@ "color": "blue", "lengthM": 2, "ends": [ - { "deviceId": "tor-1", "portId": "sw-7" }, - { "deviceId": "server-07", "portId": "nic-1" } + { + "deviceId": "tor-1", + "portId": "sw-7" + }, + { + "deviceId": "server-07", + "portId": "nic-1" + } ] } ] @@ -134,8 +370,14 @@ "goals": [ { "type": "console_link", - "a": { "deviceId": "con-srv", "portId": "tty2" }, - "b": { "deviceId": "fw-1", "portId": "fw-con" } + "a": { + "deviceId": "con-srv", + "portId": "tty2" + }, + "b": { + "deviceId": "fw-1", + "portId": "fw-con" + } }, { "type": "firewall_rule", diff --git a/src/missions/m3-vlan-trap.json b/src/missions/m3-vlan-trap.json index bb1c9e7..3251579 100644 --- a/src/missions/m3-vlan-trap.json +++ b/src/missions/m3-vlan-trap.json @@ -3,10 +3,10 @@ "title": "VLAN Trap", "order": 3, "track": "copper", - "brief": "SERVER-07 belongs on VLAN 20. It is patched to Gi1/0/2 (VLAN 10) \u2014 the cable looks fine, but the link will not pass. Move the server cable to Gi1/0/7 (VLAN 20).", + "brief": "SERVER-07 is online in the rack but cannot join its assigned tenant segment. The copper patch looks seated, yet traffic never arrives. Restore the server onto the correct VLAN path and leave the wrong switch port clear.", "constraints": [ - "SERVER-07 access VLAN is 20", - "Clear the wrong switch port when finished" + "Match the server to its documented VLAN segment", + "Leave no abandoned cross-connect on the wrong switch port" ], "parTimeSec": 120, "hintAfterWrongAttempts": 2, diff --git a/src/missions/m30-floating-static.json b/src/missions/m30-floating-static.json index c3cf709..903532d 100644 --- a/src/missions/m30-floating-static.json +++ b/src/missions/m30-floating-static.json @@ -4,10 +4,10 @@ "order": 30, "track": "routing", "lesson": "Floating statics share a prefix but differ by administrative distance. The lower-AD route is preferred while its tracked next hop is healthy; when tracking withdraws it, the higher-AD backup is installed.", - "brief": "The configured AD1 route to BRANCH has been withdrawn by failed reachability tracking. Add a floating backup via 203.0.113.2 with AD 10, keep the LAN→BRANCH permit, then verify SERVER-01 → BRANCH-01.", + "brief": "The preferred BRANCH route has been withdrawn by failed reachability tracking. Install a floating backup so SERVER-01 can reach BRANCH-01 again while leaving the tracked primary configured.", "constraints": [ - "Keep the tracked-down AD1 primary configured", - "Backup next hop must be ISP-PEER (203.0.113.2) with AD 10", + "Keep the tracked-down primary route configured", + "Backup must be a higher administrative distance static", "ACL is already permitted" ], "parTimeSec": 150, @@ -48,10 +48,59 @@ } ], "ports": [ - { "id": "fw-lan", "deviceId": "fw-1", "index": 1, "label": "LAN0", "media": "copper_cat6", "connector": "rj45", "kind": "lan", "admin": "up", "role": "lan", "vlanId": 10, "ip": { "address": "10.10.10.1", "prefix": 24 } }, - { "id": "fw-wan", "deviceId": "fw-1", "index": 2, "label": "WAN0", "media": "copper_cat6", "connector": "rj45", "kind": "wan", "admin": "up", "role": "wan", "ip": { "address": "203.0.113.1", "prefix": 30 } }, - { "id": "fw-con", "deviceId": "fw-1", "index": 3, "label": "CON", "media": "console_rj45", "connector": "console", "kind": "console", "admin": "up", "role": "console" }, - { "id": "fw-psu", "deviceId": "fw-1", "index": 4, "label": "PSU", "media": "power_c13", "connector": "c13", "kind": "power", "admin": "up", "role": "power" } + { + "id": "fw-lan", + "deviceId": "fw-1", + "index": 1, + "label": "LAN0", + "media": "copper_cat6", + "connector": "rj45", + "kind": "lan", + "admin": "up", + "role": "lan", + "vlanId": 10, + "ip": { + "address": "10.10.10.1", + "prefix": 24 + } + }, + { + "id": "fw-wan", + "deviceId": "fw-1", + "index": 2, + "label": "WAN0", + "media": "copper_cat6", + "connector": "rj45", + "kind": "wan", + "admin": "up", + "role": "wan", + "ip": { + "address": "203.0.113.1", + "prefix": 30 + } + }, + { + "id": "fw-con", + "deviceId": "fw-1", + "index": 3, + "label": "CON", + "media": "console_rj45", + "connector": "console", + "kind": "console", + "admin": "up", + "role": "console" + }, + { + "id": "fw-psu", + "deviceId": "fw-1", + "index": 4, + "label": "PSU", + "media": "power_c13", + "connector": "c13", + "kind": "power", + "admin": "up", + "role": "power" + } ] }, { @@ -62,9 +111,45 @@ "rackUnitStart": 20, "heightU": 1, "ports": [ - { "id": "nic-1", "deviceId": "server-01", "index": 1, "label": "eth0", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "nic", "accessVlan": 10, "ip": { "address": "10.10.10.10", "prefix": 24, "gateway": "10.10.10.1" } }, - { "id": "srv1-con", "deviceId": "server-01", "index": 2, "label": "CON", "media": "console_rj45", "connector": "console", "kind": "console", "admin": "up", "role": "console" }, - { "id": "srv1-psu", "deviceId": "server-01", "index": 3, "label": "PSU", "media": "power_c13", "connector": "c13", "kind": "power", "admin": "up", "role": "power" } + { + "id": "nic-1", + "deviceId": "server-01", + "index": 1, + "label": "eth0", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "nic", + "accessVlan": 10, + "ip": { + "address": "10.10.10.10", + "prefix": 24, + "gateway": "10.10.10.1" + } + }, + { + "id": "srv1-con", + "deviceId": "server-01", + "index": 2, + "label": "CON", + "media": "console_rj45", + "connector": "console", + "kind": "console", + "admin": "up", + "role": "console" + }, + { + "id": "srv1-psu", + "deviceId": "server-01", + "index": 3, + "label": "PSU", + "media": "power_c13", + "connector": "c13", + "kind": "power", + "admin": "up", + "role": "power" + } ] } ], @@ -75,8 +160,14 @@ "color": "yellow", "lengthM": 1, "ends": [ - { "deviceId": "fw-1", "portId": "fw-lan" }, - { "deviceId": "tor-1", "portId": "sw-2" } + { + "deviceId": "fw-1", + "portId": "fw-lan" + }, + { + "deviceId": "tor-1", + "portId": "sw-2" + } ] }, { @@ -85,8 +176,14 @@ "color": "blue", "lengthM": 2, "ends": [ - { "deviceId": "tor-1", "portId": "sw-5" }, - { "deviceId": "server-01", "portId": "nic-1" } + { + "deviceId": "tor-1", + "portId": "sw-5" + }, + { + "deviceId": "server-01", + "portId": "nic-1" + } ] } ] diff --git a/src/missions/m31-pat-overload.json b/src/missions/m31-pat-overload.json index b98c9df..f7e4296 100644 --- a/src/missions/m31-pat-overload.json +++ b/src/missions/m31-pat-overload.json @@ -3,12 +3,11 @@ "title": "PAT Overload", "order": 31, "track": "services", - "lesson": "Private LAN hosts often need PAT/overload to leave for the WAN — many insides share one outside IP. Without translation, egress fails even when the route and ACL look fine.", - "brief": "LAN is up and LAN→WAN is permitted, but FW-EDGE requires outbound NAT. Apply PAT 10.10.10.0/24 → 203.0.113.1 (overload), then ping SERVER-01 → ISP-PEER.", + "lesson": "Private LAN hosts often need PAT/overload to leave for the WAN \u2014 many insides share one outside IP. Without translation, egress fails even when the route and ACL look fine.", + "brief": "Route and ACL toward the WAN look fine, but SERVER-01 still cannot leave the private LAN. FW-EDGE is requiring translation for egress \u2014 restore outbound access and prove it with a ping to ISP-PEER.", "constraints": [ - "PAT inside pool 10.10.10.0/24", - "Outside IP 203.0.113.1 (FW WAN)", - "Do not remove the WAN uplink" + "Do not remove the WAN uplink", + "Private LAN hosts must share the firewall outside address for egress" ], "parTimeSec": 120, "hintAfterWrongAttempts": 2, @@ -39,10 +38,59 @@ ], "natRules": [], "ports": [ - { "id": "fw-lan", "deviceId": "fw-1", "index": 1, "label": "LAN0", "media": "copper_cat6", "connector": "rj45", "kind": "lan", "admin": "up", "role": "lan", "vlanId": 10, "ip": { "address": "10.10.10.1", "prefix": 24 } }, - { "id": "fw-wan", "deviceId": "fw-1", "index": 2, "label": "WAN0", "media": "copper_cat6", "connector": "rj45", "kind": "wan", "admin": "up", "role": "wan", "ip": { "address": "203.0.113.1", "prefix": 30 } }, - { "id": "fw-con", "deviceId": "fw-1", "index": 3, "label": "CON", "media": "console_rj45", "connector": "console", "kind": "console", "admin": "up", "role": "console" }, - { "id": "fw-psu", "deviceId": "fw-1", "index": 4, "label": "PSU", "media": "power_c13", "connector": "c13", "kind": "power", "admin": "up", "role": "power" } + { + "id": "fw-lan", + "deviceId": "fw-1", + "index": 1, + "label": "LAN0", + "media": "copper_cat6", + "connector": "rj45", + "kind": "lan", + "admin": "up", + "role": "lan", + "vlanId": 10, + "ip": { + "address": "10.10.10.1", + "prefix": 24 + } + }, + { + "id": "fw-wan", + "deviceId": "fw-1", + "index": 2, + "label": "WAN0", + "media": "copper_cat6", + "connector": "rj45", + "kind": "wan", + "admin": "up", + "role": "wan", + "ip": { + "address": "203.0.113.1", + "prefix": 30 + } + }, + { + "id": "fw-con", + "deviceId": "fw-1", + "index": 3, + "label": "CON", + "media": "console_rj45", + "connector": "console", + "kind": "console", + "admin": "up", + "role": "console" + }, + { + "id": "fw-psu", + "deviceId": "fw-1", + "index": 4, + "label": "PSU", + "media": "power_c13", + "connector": "c13", + "kind": "power", + "admin": "up", + "role": "power" + } ] }, { @@ -53,9 +101,45 @@ "rackUnitStart": 20, "heightU": 1, "ports": [ - { "id": "nic-1", "deviceId": "server-01", "index": 1, "label": "eth0", "media": "copper_cat6", "connector": "rj45", "kind": "data", "admin": "up", "role": "nic", "accessVlan": 10, "ip": { "address": "10.10.10.10", "prefix": 24, "gateway": "10.10.10.1" } }, - { "id": "srv1-con", "deviceId": "server-01", "index": 2, "label": "CON", "media": "console_rj45", "connector": "console", "kind": "console", "admin": "up", "role": "console" }, - { "id": "srv1-psu", "deviceId": "server-01", "index": 3, "label": "PSU", "media": "power_c13", "connector": "c13", "kind": "power", "admin": "up", "role": "power" } + { + "id": "nic-1", + "deviceId": "server-01", + "index": 1, + "label": "eth0", + "media": "copper_cat6", + "connector": "rj45", + "kind": "data", + "admin": "up", + "role": "nic", + "accessVlan": 10, + "ip": { + "address": "10.10.10.10", + "prefix": 24, + "gateway": "10.10.10.1" + } + }, + { + "id": "srv1-con", + "deviceId": "server-01", + "index": 2, + "label": "CON", + "media": "console_rj45", + "connector": "console", + "kind": "console", + "admin": "up", + "role": "console" + }, + { + "id": "srv1-psu", + "deviceId": "server-01", + "index": 3, + "label": "PSU", + "media": "power_c13", + "connector": "c13", + "kind": "power", + "admin": "up", + "role": "power" + } ] } ], @@ -66,8 +150,14 @@ "color": "yellow", "lengthM": 1, "ends": [ - { "deviceId": "fw-1", "portId": "fw-lan" }, - { "deviceId": "tor-1", "portId": "sw-2" } + { + "deviceId": "fw-1", + "portId": "fw-lan" + }, + { + "deviceId": "tor-1", + "portId": "sw-2" + } ] }, { @@ -76,8 +166,14 @@ "color": "blue", "lengthM": 2, "ends": [ - { "deviceId": "tor-1", "portId": "sw-5" }, - { "deviceId": "server-01", "portId": "nic-1" } + { + "deviceId": "tor-1", + "portId": "sw-5" + }, + { + "deviceId": "server-01", + "portId": "nic-1" + } ] } ] diff --git a/src/missions/m32-traceroute.json b/src/missions/m32-traceroute.json index 53939c7..f5f4a49 100644 --- a/src/missions/m32-traceroute.json +++ b/src/missions/m32-traceroute.json @@ -3,12 +3,12 @@ "title": "Traceroute Path", "order": 32, "track": "routing", - "lesson": "Traceroute shows the hop path: host → gateway → next hop → destination. Fix the route and ACL, then prove the path with Traceroute — not just a silent ping.", - "brief": "BRANCH is dark. Add route 198.51.100.0/24 via 203.0.113.2, permit LAN→BRANCH, then run Traceroute from SERVER-01 to BRANCH-01 until the path completes.", + "lesson": "Traceroute shows the hop path: host → gateway → next hop → destination. When policy is already open, a missing route still stops the path — prove the repair with traceroute, not just a silent ping.", + "brief": "BRANCH is dark from SERVER-01. Ping alone is not enough for this incident — restore the path and prove the hop chain with traceroute until BRANCH answers.", "constraints": [ - "Next hop ISP-PEER 203.0.113.2", - "Use Traceroute (not only Ping) to finish", - "BRANCH-01 is a cloud host" + "Use traceroute evidence to finish the ticket", + "BRANCH-01 is a cloud host beyond the WAN edge", + "Firewall policy toward BRANCH is already open — focus on forwarding" ], "parTimeSec": 150, "hintAfterWrongAttempts": 2, @@ -29,10 +29,11 @@ "heightU": 1, "firewallRules": [ { - "id": "deny-branch", - "action": "deny", + "id": "permit-branch", + "action": "permit", "srcCidr": "10.10.10.0/24", "dstCidr": "198.51.100.0/24", + "note": "Already open for this incident", "enabled": true } ], @@ -88,12 +89,6 @@ "destCidr": "198.51.100.0/24", "nextHop": "203.0.113.2" }, - { - "type": "firewall_rule", - "action": "permit", - "srcCidr": "10.10.10.0/24", - "dstCidr": "198.51.100.0/24" - }, { "type": "traceroute_ok", "fromDeviceId": "server-01", diff --git a/src/missions/m5-change-window.json b/src/missions/m5-change-window.json index 66e4434..c9af0c9 100644 --- a/src/missions/m5-change-window.json +++ b/src/missions/m5-change-window.json @@ -3,11 +3,10 @@ "title": "Change Window", "order": 5, "track": "copper", - "brief": "Migrate the documented panel cross-connect from A-01/Gi1/0/1 to A-08/Gi1/0/8. Keep SERVER-01 online on Gi1/0/5. Finish clean: old ports empty, new path lit.", + "brief": "Change window: migrate the panel cross-connect to the newly assigned circuit. Keep SERVER-01 online and leave no abandoned copper on the old path.", "constraints": [ - "Final panel port: A-08", - "Final switch cross-connect: Gi1/0/8", - "Do not strand SERVER-01" + "Keep SERVER-01 attached through the move", + "Finish with the retired cross-connect endpoints empty" ], "parTimeSec": 150, "hintAfterWrongAttempts": 3,