From 0c2512496704b52f35fc8c706429dbdefaacbe6f Mon Sep 17 00:00:00 2001 From: "Z. Cliffe Schreuders" Date: Fri, 28 Nov 2025 14:23:39 +0000 Subject: [PATCH] feat: Update VM console integration to use ActionCable for async file delivery --- .../vms_and_flags/IMPLEMENTATION_PLAN.md | 389 ++++++++++++++++-- 1 file changed, 352 insertions(+), 37 deletions(-) diff --git a/planning_notes/vms_and_flags/IMPLEMENTATION_PLAN.md b/planning_notes/vms_and_flags/IMPLEMENTATION_PLAN.md index bfce34a..cd2341e 100644 --- a/planning_notes/vms_and_flags/IMPLEMENTATION_PLAN.md +++ b/planning_notes/vms_and_flags/IMPLEMENTATION_PLAN.md @@ -1,6 +1,6 @@ # VM and CTF Flag Integration - Implementation Plan -**Last Updated**: After Hacktivity Compatibility Review (2025-11-28) +**Last Updated**: After Console Integration Deep-Dive (2025-11-28) **Review Documents**: - `planning_notes/vms_and_flags/review1/REVIEW.md` - `planning_notes/vms_and_flags/review2/REVIEW.md` @@ -262,12 +262,26 @@ def submit_to_hacktivity(flag_key) end ``` -### VM Console URL Construction +### VM Console Integration -To launch a VM's graphical console in Hacktivity, construct this URL: +To launch a VM's graphical console in Hacktivity, the user triggers the `ovirt_console` action. **This is an async operation** - the endpoint dispatches a background job that generates the SPICE file, then pushes it via ActionCable. + +#### How Hacktivity's Console Flow Works + +1. User clicks console button → POST to `ovirt_console` with `remote: true` +2. Controller checks authorization via `VmPolicy#ovirt_console?` +3. If authorized, dispatches async job: `DispatchVmCtrlService.ctrl_vm_async(@vm, "console", user.id)` +4. Controller returns immediately (JS response updates VM card UI) +5. Background job generates SPICE `.vv` file content +6. Job broadcasts file via ActionCable: `ActionCable.server.broadcast "file_push:#{user_id}", { base64: "...", filename: "..." }` +7. JavaScript subscription receives message and triggers browser download + +**Key insight**: The console file is NOT returned by the HTTP response. It arrives async via ActionCable. + +#### Endpoint Details ``` -/hacktivities/:event_id/challenges/:sec_gen_batch_id/vm_sets/:vm_set_id/vms/:vm_id/ovirt_console +POST /hacktivities/:event_id/challenges/:sec_gen_batch_id/vm_sets/:vm_set_id/vms/:vm_id/ovirt_console ``` **Required IDs (store in VM context during game creation):** @@ -276,21 +290,152 @@ To launch a VM's graphical console in Hacktivity, construct this URL: - `vm_set_id` - from `vm_set.id` - `vm_id` - from `vm.id` -**Client-side URL construction:** +#### Integration Approach: ActionCable + AJAX POST + +Since BreakEscape runs as an engine within Hacktivity (same origin, shared session), we can: +1. Subscribe to the `FilePushChannel` to receive console files +2. POST to the console endpoint to trigger file generation +3. Wait for the ActionCable message to automatically download the file + +**Step 1: Subscribe to FilePushChannel (on game page load)** + ```javascript -function getConsoleUrl(vm) { - if (!window.breakEscapeConfig?.hacktivityMode) return null; +// public/break_escape/js/systems/hacktivity-cable.js +// This must run when the game loads in Hacktivity mode + +function setupHacktivityActionCable() { + if (!window.breakEscapeConfig?.hacktivityMode) return; + if (!window.App?.cable) { + console.warn('[BreakEscape] ActionCable not available - console downloads will not work'); + return; + } - return `/hacktivities/${vm.event_id}/challenges/${vm.sec_gen_batch_id}` + - `/vm_sets/${vm.vm_set_id}/vms/${vm.id}/ovirt_console`; + // Subscribe to the same channel Hacktivity's pages use + window.breakEscapeFilePush = App.cable.subscriptions.create("FilePushChannel", { + connected() { + console.log('[BreakEscape] Connected to FilePushChannel'); + }, + + disconnected() { + console.log('[BreakEscape] Disconnected from FilePushChannel'); + }, + + received(data) { + // Hacktivity broadcasts: { base64: "...", filename: "hacktivity_xxx.vv" } + if (data.base64 && data.filename) { + console.log('[BreakEscape] Received console file:', data.filename); + + // Trigger download (same approach as Hacktivity's file_push.js) + const link = document.createElement('a'); + link.href = 'data:text/plain;base64,' + data.base64; + link.download = data.filename; + link.click(); + + // Notify the VM launcher minigame that download succeeded + if (window.breakEscapeVmLauncherCallback) { + window.breakEscapeVmLauncherCallback(true, data.filename); + } + } + } + }); +} + +// Auto-setup when script loads +if (document.readyState === 'complete') { + setupHacktivityActionCable(); +} else { + window.addEventListener('load', setupHacktivityActionCable); +} + +export { setupHacktivityActionCable }; +``` + +**Step 2: POST to ovirt_console endpoint (from VM launcher minigame)** + +```javascript +// In VM launcher minigame when user clicks "Launch Console" +async function launchVmConsole(vm) { + if (!window.breakEscapeConfig?.hacktivityMode) { + showStandaloneInstructions(vm); + return; + } + + const url = `/hacktivities/${vm.event_id}/challenges/${vm.sec_gen_batch_id}` + + `/vm_sets/${vm.vm_set_id}/vms/${vm.vm_id}/ovirt_console`; + + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || + window.breakEscapeConfig?.csrfToken; + + try { + // Set up callback to receive ActionCable notification + window.breakEscapeVmLauncherCallback = (success, filename) => { + if (success) { + updateVmStatus(`✓ Console file downloaded: ${filename}`); + } + window.breakEscapeVmLauncherCallback = null; + }; + + updateVmStatus('Requesting console access...'); + + // POST triggers async job - file will arrive via ActionCable + const response = await fetch(url, { + method: 'POST', + headers: { + 'X-CSRF-Token': csrfToken, + 'X-Requested-With': 'XMLHttpRequest', // Tells Rails this is an AJAX request + 'Accept': 'text/javascript' // Hacktivity returns JS for remote: true + }, + credentials: 'same-origin' // Include session cookie + }); + + if (!response.ok) { + throw new Error(`Console request failed: ${response.status}`); + } + + updateVmStatus('Console file generating... (download will start automatically)'); + + // Set timeout in case ActionCable message doesn't arrive + setTimeout(() => { + if (window.breakEscapeVmLauncherCallback) { + updateVmStatus('Console file should download shortly. If not, check VM is running.'); + window.breakEscapeVmLauncherCallback = null; + } + }, 15000); + + } catch (error) { + console.error('[BreakEscape] Console launch failed:', error); + updateVmStatus(`Error: ${error.message}`); + window.breakEscapeVmLauncherCallback = null; + } } ``` -**Notes:** -- This triggers async console file generation -- User must be authorized (own the VM set or be admin/VIP) -- VM must be in "up" state for console to work -- Console access may start timers for timed assessments +**Step 3: Include ActionCable in BreakEscape game view** + +The game's `show.html.erb` must ensure ActionCable is available: + +```erb +<%# app/views/break_escape/games/show.html.erb %> +<%# ActionCable should already be loaded by Hacktivity's application.js %> +<%# If not, include it: %> +<%= action_cable_meta_tag if defined?(action_cable_meta_tag) %> +``` + +#### Why This Approach Works + +- **Same origin**: BreakEscape engine runs on same domain as Hacktivity +- **Shared session**: Devise authentication cookie is included automatically +- **ActionCable reuse**: Uses Hacktivity's existing `FilePushChannel` subscription +- **No navigation**: User stays in game while console file downloads +- **Async handling**: Properly handles the async nature of console file generation + +#### Caveats & Considerations + +- **ActionCable dependency**: Requires Hacktivity's ActionCable consumer (`App.cable`) to be loaded +- **Background jobs**: Console file generation depends on Sidekiq workers being active +- **VM state**: VM must be in "up" state (activated and running) +- **Timed assessments**: Console access may start assessment timer (if configured) +- **Tab focus**: Hacktivity's `file_push.js` only downloads in active tab (`isTabActive` check) - our subscription doesn't have this limitation ### Authorization Rules @@ -1223,6 +1368,125 @@ Add link tags for the new minigame CSS files to `app/views/break_escape/games/sh ## Client-Side Implementation +### 0. Hacktivity ActionCable Integration + +**File**: `public/break_escape/js/systems/hacktivity-cable.js` + +This module subscribes to Hacktivity's `FilePushChannel` to receive console file downloads. The subscription must be set up when the game loads in Hacktivity mode. + +```javascript +/** + * Hacktivity ActionCable Integration + * + * Subscribes to FilePushChannel to receive VM console files. + * Console files are generated asynchronously by Hacktivity and + * pushed via ActionCable as Base64-encoded SPICE .vv files. + */ + +let filePushSubscription = null; + +function setupHacktivityActionCable() { + // Only run in Hacktivity mode + if (!window.breakEscapeConfig?.hacktivityMode) { + console.log('[BreakEscape] Standalone mode - skipping ActionCable setup'); + return; + } + + // Check ActionCable consumer is available (loaded by Hacktivity's application.js) + if (!window.App?.cable) { + console.warn('[BreakEscape] ActionCable not available - console downloads will not work'); + console.warn('[BreakEscape] Ensure App.cable is loaded before game initializes'); + return; + } + + // Avoid duplicate subscriptions + if (filePushSubscription) { + console.log('[BreakEscape] FilePushChannel already subscribed'); + return; + } + + // Subscribe to the same channel Hacktivity's pages use + filePushSubscription = App.cable.subscriptions.create("FilePushChannel", { + connected() { + console.log('[BreakEscape] Connected to FilePushChannel'); + }, + + disconnected() { + console.log('[BreakEscape] Disconnected from FilePushChannel'); + }, + + received(data) { + // Hacktivity broadcasts: { base64: "...", filename: "hacktivity_xxx.vv" } + if (data.base64 && data.filename) { + console.log('[BreakEscape] Received console file:', data.filename); + + // Trigger download (same approach as Hacktivity's file_push.js) + const link = document.createElement('a'); + link.href = 'data:text/plain;base64,' + data.base64; + link.download = data.filename; + link.click(); + + // Notify the VM launcher minigame that download succeeded + if (window.breakEscapeVmLauncherCallback) { + window.breakEscapeVmLauncherCallback(true, data.filename); + } + + // Emit game event for other systems to react to + if (window.game?.events) { + window.game.events.emit('vm:console_downloaded', { filename: data.filename }); + } + } + } + }); + + // Store reference for cleanup + window.breakEscapeFilePush = filePushSubscription; +} + +function teardownHacktivityActionCable() { + if (filePushSubscription) { + filePushSubscription.unsubscribe(); + filePushSubscription = null; + window.breakEscapeFilePush = null; + console.log('[BreakEscape] Unsubscribed from FilePushChannel'); + } +} + +// Auto-setup when script loads (after DOM ready) +if (document.readyState === 'complete' || document.readyState === 'interactive') { + // Use setTimeout to ensure App.cable is loaded + setTimeout(setupHacktivityActionCable, 100); +} else { + window.addEventListener('DOMContentLoaded', () => { + setTimeout(setupHacktivityActionCable, 100); + }); +} + +export { setupHacktivityActionCable, teardownHacktivityActionCable }; +``` + +**Loading this module:** + +Include in the game's main entry point or load via script tag: + +```html + +<% if BreakEscape::Mission.hacktivity_mode? %> + +<% end %> +``` + +Or import in `js/main.js`: + +```javascript +// Only import in Hacktivity mode +if (window.breakEscapeConfig?.hacktivityMode) { + import('./systems/hacktivity-cable.js').then(module => { + module.setupHacktivityActionCable(); + }); +} +``` + ### 1. VM Launcher Minigame **File**: `public/break_escape/js/minigames/vm-launcher/vm-launcher-minigame.js` @@ -1324,35 +1588,82 @@ export class VMLauncherMinigame extends MinigameScene { async launchVMHacktivity() { const vmId = this.vmData.vm_id; - const gameId = window.breakEscapeConfig?.gameId; + const vmSetId = this.vmData.vm_set_id; + const eventId = this.vmData.event_id; + const secGenBatchId = this.vmData.sec_gen_batch_id; - if (!vmId || !gameId) { - this.showFailure('VM data not available', true, 3000); + if (!vmId || !vmSetId || !eventId || !secGenBatchId) { + this.showFailure('VM data incomplete - missing required IDs', true, 3000); + return; + } + + // Check ActionCable is available + if (!window.App?.cable) { + this.showFailure('Console connection not available. Try refreshing the page.', true, 5000); return; } try { - // Call Hacktivity's ovirt_console endpoint - // This downloads the SPICE .vv file - const consoleUrl = `/events/hacktivities/challenges/vm_sets/${this.vmData.vm_set_id}/vms/${vmId}/ovirt_console`; + // Set up callback to receive ActionCable notification + const statusEl = document.getElementById('vm-status'); + window.breakEscapeVmLauncherCallback = (success, filename) => { + if (success) { + statusEl.innerHTML = ` + ✓ Console file downloaded: ${filename}
+ Open the .vv file with a SPICE viewer to access the VM. + `; + // Complete the minigame after successful download + setTimeout(() => this.complete(true), 2000); + } + window.breakEscapeVmLauncherCallback = null; + }; - // Trigger download - window.location.href = consoleUrl; + statusEl.innerHTML = 'Requesting console access...'; - // Show success message - document.getElementById('vm-status').innerHTML = ` - ✓ Console file downloaded!
- Open the .vv file to access the VM. + // Build console URL + const consoleUrl = `/hacktivities/${eventId}/challenges/${secGenBatchId}` + + `/vm_sets/${vmSetId}/vms/${vmId}/ovirt_console`; + + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || + window.breakEscapeConfig?.csrfToken; + + // POST triggers async job - file will arrive via ActionCable + const response = await fetch(consoleUrl, { + method: 'POST', + headers: { + 'X-CSRF-Token': csrfToken, + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'text/javascript' + }, + credentials: 'same-origin' + }); + + if (!response.ok) { + throw new Error(`Console request failed: ${response.status}`); + } + + statusEl.innerHTML = ` + Console file generating...
+ Download will start automatically when ready. `; - // Wait a moment then complete + // Set timeout in case ActionCable message doesn't arrive setTimeout(() => { - this.complete(true); - }, 2000); + if (window.breakEscapeVmLauncherCallback) { + statusEl.innerHTML = ` + Console file should download shortly.
+ If not, check VM is running and try again. + `; + window.breakEscapeVmLauncherCallback = null; + } + }, 15000); } catch (error) { - console.error('Failed to launch VM:', error); - this.showFailure('Failed to download console file', true, 3000); + console.error('[BreakEscape] Console launch failed:', error); + document.getElementById('vm-status').innerHTML = ` + Error: ${error.message} + `; + window.breakEscapeVmLauncherCallback = null; } } } @@ -2150,6 +2461,7 @@ end - [ ] 2.13 Write controller tests ### Phase 3: Client-Side Minigames +- [ ] 3.0 Create `public/break_escape/js/systems/hacktivity-cable.js` (ActionCable FilePush subscription) - [ ] 3.1 Create `public/break_escape/js/minigames/vm-launcher/vm-launcher-minigame.js` - [ ] 3.2 Create `public/break_escape/js/minigames/flag-station/flag-station-minigame.js` - [ ] 3.3 Update `public/break_escape/js/minigames/index.js`: Register new minigames @@ -2157,9 +2469,10 @@ end - [ ] 3.5 Create CSS: `public/break_escape/css/minigames/vm-launcher.css` - [ ] 3.6 Create CSS: `public/break_escape/css/minigames/flag-station.css` - [ ] 3.7 Add CSS link tags to `app/views/break_escape/games/show.html.erb` -- [ ] 3.8 Update `main.js` to populate `window.gameState.submittedFlags` from scenario response -- [ ] 3.9 Create test files: `test-vm-launcher-minigame.html`, `test-flag-station-minigame.html` -- [ ] 3.10 Test minigames standalone +- [ ] 3.8 Add hacktivity-cable.js script tag to game show view (conditional on Hacktivity mode) +- [ ] 3.9 Update `main.js` to populate `window.gameState.submittedFlags` from scenario response +- [ ] 3.10 Create test files: `test-vm-launcher-minigame.html`, `test-flag-station-minigame.html` +- [ ] 3.11 Test minigames standalone ### Phase 4: ERB Templates - [ ] 4.1 Create example scenario: `scenarios/enterprise_breach/scenario.json.erb` @@ -2202,9 +2515,10 @@ Based on Review 3 findings, the implementation order is revised to ensure contro 4. Add flag submission methods ### Phase 4: Frontend & Minigames -1. Create VM launcher and flag station minigames -2. Update show.html.erb with extended config -3. Update interactions.js for new object types +1. Create `hacktivity-cable.js` for ActionCable FilePush subscription +2. Create VM launcher and flag station minigames +3. Update show.html.erb with extended config and hacktivity-cable.js script tag +4. Update interactions.js for new object types ### Phase 5: ERB Templates & Testing 1. Create example scenarios @@ -2268,3 +2582,4 @@ This plan provides a complete, actionable roadmap for integrating VMs and CTF fl | 2025-11-27 | Review 2 (`review2/REVIEW.md`) | Clarified to EXTEND breakEscapeConfig not replace, simplified Hacktivity flag submission (direct model update), added MissionsController#show update for VM missions, fixed function signature mismatch, added gameState.submittedFlags population note, added CSS link tags to checklist | | 2025-11-27 | Review 3 (`review3/REVIEW.md`) | **Critical**: Discovered `games#create` action does NOT exist (routes declared it but controller didn't implement it). Updated plan to implement action from scratch. Added `games#new` action and view for VM set selection. Added MissionsController#show update. Validated callback timing approach is correct. Added revised implementation order prioritizing controller infrastructure. | | 2025-11-28 | Hacktivity Compatibility (`review3/HACKTIVITY_COMPATIBILITY.md`) | **Reviewed Hacktivity codebase** for compatibility. Key findings: (1) Use `FlagService.process_flag()` instead of direct model update for proper scoring/streaks/notifications, (2) VmSet uses `sec_gen_batch` (with underscore) not `secgen_batch`, (3) VmSet has no `display_name` method - use `sec_gen_batch.title` instead, (4) Added `event_id` and `sec_gen_batch_id` to VM context for console URLs, (5) Added case-insensitive flag matching to match Hacktivity behavior, (6) Added eager loading with `.includes(:vms, :sec_gen_batch)`, (7) Filter out pending/error build_status VM sets. | +| 2025-11-28 | Console Integration Update | **Deep-dive into Hacktivity's console mechanism**: Discovered console file delivery is async via ActionCable, not HTTP response. (1) Console endpoint dispatches background job that generates SPICE `.vv` file, (2) Job broadcasts file via `ActionCable.server.broadcast "file_push:#{user_id}"` with Base64-encoded content, (3) Updated plan to single approach: subscribe to `FilePushChannel`, POST to trigger job, receive file via ActionCable. (4) Created `hacktivity-cable.js` module specification, (5) Updated VM launcher minigame to use AJAX POST + ActionCable callback pattern, (6) Removed alternative "open Hacktivity page" approach - now single definitive approach. |