Skip to content

Commit

Permalink
Refactored the control panel and workspace reload (#489)
Browse files Browse the repository at this point in the history
* Refactored the control panel and workspace reload

I Used BroadcastChannel to trigger an event through browser tabs and/or windows if a challenge (re)started.
VSCode reloads the page.
noVNC changes the iframe src.

I added a description to the control panel and moved the notifications to the bottom of the control panel. I Limited the max-size to 50vh and made it scrollable. Therefor I changed the scrollbar to be more visible.

I added the description to the test of active_module.

* Improve reconnection / Control Panel

increased the wait time as we now fetch the iframe url async via api.

Refactor workspace API and update templates

- Added `service` parameter to `view_desktop` endpoint in `workspace.py`.
- Updated iframe source handling based on `service` parameter in `workspace.py`.
- Simplified `view_workspace` route in `pages/workspace.py`.
- Removed async URL loading and share URLs script from `iframe.html`.
- Created new `workspace.html` template for dynamic iframe loading.

Refactor workspace API and update templates

- Added `service` parameter to `view_desktop` endpoint in `workspace.py`.
- Updated iframe source handling based on `service` parameter in `workspace.py`.
- Simplified `view_workspace` route in `pages/workspace.py`.
- Removed async URL loading and share URLs script from `iframe.html`.
- Created new `workspace.html` template for dynamic iframe loading.

Improve reconnection on challenge (re)start

- Added API endpoint to retrieve the current iframe URL.
- Moved `container_password` to `utils`.
- Relocated `start_on_demand_service` to `utils/workspace`.
- Removed special route for workspace desktop.
- Added edge-case handling to the workspace `<service>` route.
- Updated `iframe.html` to support new functionality.
- Modified `navbar.js` to use the new API endpoint.

* Improve reconnection / Control Panel

increased the wait time as we now fetch the iframe url async via api.

Refactor workspace API and update templates

- Added `service` parameter to `view_desktop` endpoint in `workspace.py`.
- Updated iframe source handling based on `service` parameter in `workspace.py`.
- Simplified `view_workspace` route in `pages/workspace.py`.
- Removed async URL loading and share URLs script from `iframe.html`.
- Created new `workspace.html` template for dynamic iframe loading.

Refactor workspace API and update templates

- Added `service` parameter to `view_desktop` endpoint in `workspace.py`.
- Updated iframe source handling based on `service` parameter in `workspace.py`.
- Simplified `view_workspace` route in `pages/workspace.py`.
- Removed async URL loading and share URLs script from `iframe.html`.
- Created new `workspace.html` template for dynamic iframe loading.

Improve reconnection on challenge (re)start

- Added API endpoint to retrieve the current iframe URL.
- Moved `container_password` to `utils`.
- Relocated `start_on_demand_service` to `utils/workspace`.
- Removed special route for workspace desktop.
- Added edge-case handling to the workspace `<service>` route.
- Updated `iframe.html` to support new functionality.
- Modified `navbar.js` to use the new API endpoint.
  • Loading branch information
JensHouses authored Aug 27, 2024
1 parent 0c88bba commit 9009948
Show file tree
Hide file tree
Showing 12 changed files with 230 additions and 143 deletions.
2 changes: 2 additions & 0 deletions dojo_plugin/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from .v1.scoreboard import scoreboard_namespace
from .v1.ssh_key import ssh_key_namespace
from .v1.workspace_tokens import workspace_tokens_namespace
from .v1.workspace import workspace_namespace


api = Blueprint("pwncollege_api", __name__)
Expand All @@ -24,3 +25,4 @@
api_v1.add_namespace(scoreboard_namespace, "/scoreboard")
api_v1.add_namespace(ssh_key_namespace, "/ssh_key")
api_v1.add_namespace(workspace_tokens_namespace, "/workspace_tokens")
api_v1.add_namespace(workspace_namespace, "/workspace")
69 changes: 69 additions & 0 deletions dojo_plugin/api/v1/workspace.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from flask_restx import Namespace, Resource
from flask import request, render_template, url_for, abort
from CTFd.utils.user import get_current_user
from CTFd.utils.decorators import authed_only
from ...utils import get_current_container, container_password
from ...utils.workspace import exec_run, start_on_demand_service


workspace_namespace = Namespace(
"workspace", description="Endpoint to manage workspace iframe urls"
)


@workspace_namespace.route("")
class view_desktop(Resource):
@authed_only
def get(self):
user_id = request.args.get("user")
password = request.args.get("password")
service = request.args.get("service")

if not service:
return { "active": False }


if user_id and not password and not is_admin():
abort(403)

user = get_current_user() if not user_id else Users.query.filter_by(id=int(user_id)).first_or_404()
container = get_current_container(user)
if not container:
return { "active": False }


if service == "desktop":
interact_password = container_password(container, "desktop", "interact")
view_password = container_password(container, "desktop", "view")

if user_id and password:
if not hmac.compare_digest(password, interact_password) and not hmac.compare_digest(password, view_password):
abort(403)
password = password[:8]
else:
password = interact_password[:8]

view_only = user_id is not None
service_param = "~".join(("desktop", str(user.id), container_password(container, "desktop")))

vnc_params = {
"autoconnect": 1,
"reconnect": 1,
"reconnect_delay": 200,
"resize": "remote",
"path": url_for("pwncollege_workspace.forward_workspace", service=service_param, service_path="websockify"),
"view_only": int(view_only),
"password": password,
}
iframe_src = url_for("pwncollege_workspace.forward_workspace", service=service_param, service_path="vnc.html", **vnc_params)
else:
iframe_src = f"/workspace/{service}/"

if start_on_demand_service(user, service) is False:
return { "active": False }

return {
"iframe_src": iframe_src,
"service": service,
"active": True
}
1 change: 1 addition & 0 deletions dojo_plugin/pages/dojo.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ def active_module():
"challenge_id": challs['current'].challenge_id,
"challenge_name": challs['current'].name,
"challenge_reference_id": challs['current'].id,
"description": render_markdown(challs['current'].description).strip(),
},
"c_next": {
"module_name": challs['next'].module.name if challs['next'] else None,
Expand Down
120 changes: 3 additions & 117 deletions dojo_plugin/pages/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
from CTFd.plugins import bypass_csrf_protection

from ..models import Dojos
from ..utils import redirect_user_socket, get_current_container
from ..utils import redirect_user_socket, get_current_container, container_password
from ..utils.dojo import get_current_dojo_challenge
from ..utils.workspace import exec_run
from ..utils.workspace import exec_run, start_on_demand_service


workspace = Blueprint("pwncollege_workspace", __name__)
Expand All @@ -20,126 +20,12 @@
"desktop": 6080,
"desktop-windows": 6082,
}
on_demand_services = { "code", "desktop", "desktop-windows" }

def container_password(container, *args):
key = container.labels["dojo.auth_token"].encode()
message = "-".join(args).encode()
return hmac.HMAC(key, message, "sha256").hexdigest()

def start_on_demand_service(user, service_name):
if service_name not in on_demand_services:
return
try:
exec_run(
f"/run/dojo/bin/dojo-{service_name}",
workspace_user="hacker",
user_id=user.id,
assert_success=True,
)
except docker.errors.NotFound:
return False
return True

@workspace.route("/workspace/desktop")
@authed_only
def view_desktop():
user_id = request.args.get("user")
password = request.args.get("password")

if user_id and not password and not is_admin():
abort(403)

user = get_current_user() if not user_id else Users.query.filter_by(id=int(user_id)).first_or_404()
container = get_current_container(user)
if not container:
return render_template("iframe.html", active=False)

interact_password = container_password(container, "desktop", "interact")
view_password = container_password(container, "desktop", "view")

if user_id and password:
if not hmac.compare_digest(password, interact_password) and not hmac.compare_digest(password, view_password):
abort(403)
password = password[:8]
else:
password = interact_password[:8]

view_only = user_id is not None
service = "~".join(("desktop", str(user.id), container_password(container, "desktop")))

vnc_params = {
"autoconnect": 1,
"reconnect": 1,
"reconnect_delay": 200,
"resize": "remote",
"path": url_for("pwncollege_workspace.forward_workspace", service=service, service_path="websockify"),
"view_only": int(view_only),
"password": password,
}
iframe_src = url_for("pwncollege_workspace.forward_workspace", service=service, service_path="vnc.html", **vnc_params)

share_urls = {
"Desktop (Interact)": url_for("pwncollege_workspace.view_desktop", user=user.id, password=interact_password, _external=True),
"Desktop (View)": url_for("pwncollege_workspace.view_desktop", user=user.id, password=view_password, _external=True),
}

if start_on_demand_service(user, "desktop") is False:
return render_template("iframe.html", active=False)

return render_template("iframe.html",
iframe_name="workspace",
iframe_src=iframe_src,
share_urls=share_urls,
active=True)

@workspace.route("/workspace/desktop-windows")
@authed_only
def view_desktop_win():
user_id = request.args.get("user")

if user_id and not is_admin():
abort(403)

user = get_current_user() if not user_id else Users.query.filter_by(id=int(user_id)).first_or_404()
container = get_current_container(user)
if not container:
return render_template("iframe.html", active=False)

interact_password = container_password(container, "desktop", "interact")
view_password = container_password(container, "desktop", "view")

view_only = user_id is not None
service = "~".join(("desktop", str(user.id), container_password(container, "desktop")))

vnc_params = {
"autoconnect": 1,
"reconnect": 1,
"reconnect_delay": 200,
"resize": "remote",
"path": url_for("pwncollege_workspace.forward_workspace", service=service, service_path="websockify"),
"view_only": int(view_only),
"password": "abcd",
}
iframe_src = url_for("pwncollege_workspace.forward_workspace", service=service, service_path="vnc.html", **vnc_params)

if start_on_demand_service(user, "desktop") is False:
return render_template("iframe.html", active=False)

return render_template("iframe.html",
iframe_name="workspace",
iframe_src=iframe_src,
share_urls={},
active=True)

@workspace.route("/workspace/<service>")
@authed_only
def view_workspace(service):
user = get_current_user()
active = bool(get_current_dojo_challenge())
if start_on_demand_service(user, service) is False:
return render_template("iframe.html", active=False)
return render_template("iframe.html", iframe_name="workspace", iframe_src=f"/workspace/{service}/", active=active)
return render_template("workspace.html", iframe_name="workspace", service=service)

@workspace.route("/workspace/<service>/", websocket=True)
@workspace.route("/workspace/<service>/<path:service_path>", websocket=True)
Expand Down
7 changes: 7 additions & 0 deletions dojo_plugin/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import datetime
import hashlib
import hmac
import io
import logging
import os
Expand Down Expand Up @@ -39,6 +40,12 @@ def container_name(user):
return f"user_{user.id}"


def container_password(container, *args):
key = container.labels["dojo.auth_token"].encode()
message = "-".join(args).encode()
return hmac.HMAC(key, message, "sha256").hexdigest()


def get_current_container(user=None):
user = user or get_current_user()
if not user:
Expand Down
15 changes: 15 additions & 0 deletions dojo_plugin/utils/workspace.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
import docker

on_demand_services = { "code", "desktop", "desktop-windows" }

def start_on_demand_service(user, service_name):
if service_name not in on_demand_services:
return
try:
exec_run(
f"/run/current-system/sw/bin/dojo-{service_name}",
workspace_user="hacker",
user_id=user.id,
assert_success=True,
)
except docker.errors.NotFound:
return False
return True

def exec_run(cmd, *, shell=False, assert_success=True, workspace_user="root", user_id=None, container=None, **kwargs):
# TODO: Cleanup this interface
Expand Down
46 changes: 39 additions & 7 deletions dojo_theme/static/js/dojo/navbar.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,29 @@
const broadcast = new BroadcastChannel('broadcast');

broadcast.onmessage = (event) => {
if (event.data.msg === 'New challenge started') {
if (window.location.pathname === '/workspace/code') {
window.location.reload();
}
else if (window.location.pathname === '/workspace/desktop') {
get_and_set_iframe_url()
}
}
};
function get_and_set_iframe_url() {
// check if the window location pathname starts with /workspace/ and set the rest of the path as an variable service
const service = window.location.pathname.startsWith('/workspace/') ? window.location.pathname.substring(11) : '';
fetch("/pwncollege_api/v1/workspace?service=" + service)
.then(response => response.json())
.then(data => {
if (data.active) {
const iframe = $("#workspace_iframe")[0];
if (iframe.src !== window.location.origin + data.iframe_src) {
iframe.src = data.iframe_src;
}
}
});
}
async function fetch_current_module() {
const response = await fetch('/active-module/');
const data = await response.json();
Expand All @@ -20,6 +46,7 @@ async function updateNavbarDropdown() {
$("#current #module").val(data.c_current.module_id);
$("#current #challenge").val(data.c_current.challenge_reference_id);
$("#current #challenge-id").val(data.c_current.challenge_id);
$("#dropdown-description").html(data.c_current.description);

if ("dojo_name" in data.c_previous) {
$("#previous").removeClass("invisible");
Expand Down Expand Up @@ -114,13 +141,11 @@ function DropdownStartChallenge(event) {
await updateNavbarDropdown();
$(".challenge-active").removeClass("challenge-active");
$(`.accordion-item input[value=${params.challenge}]`).closest(".accordion-item").find("h4.challenge-name").addClass("challenge-active");
if (window.location.href.includes('/workspace/desktop')) {
let iframe_html = await fetch('/workspace/desktop').then(response => response.text());
let iframe_src = $(iframe_html).find("iframe").attr("src");
if (iframe_src) {
$("main iframe").attr("src", iframe_src);
}
}
const broadcast_send = new BroadcastChannel('broadcast');
broadcast_send.postMessage({
time: new Date().getTime(),
msg: 'New challenge started'
});
}
else {
let message = "Error:";
Expand Down Expand Up @@ -190,6 +215,13 @@ function submitFlag(event) {
}
updateNavbarDropdown();
$(() => {
$("#show_description").click((event) =>{
$("#dropdown-description").toggle();
event.stopPropagation();
});
$("#dropdown-description").click((event) =>{
event.stopPropagation();
});
$(".close-link").on("click", () => {
$(".navbar")
.addClass("navbar-hiding")
Expand Down
25 changes: 18 additions & 7 deletions dojo_theme/templates/components/navbar.html
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,17 @@
</div>
<div class="dropdown-divider"></div>
<div class="container-fluid">
<div class="row mt-3">
<div class="row d-none d-md-block">
<div class="col-12"><p class="text-light dropdown-toggle" id="show_description">Description</p></div>
<div class="col-12" style="max-height: 50vh;min-width: 50vw;overflow-y: scroll;">
<p id="dropdown-description" class="text-light" style="display: none">
This is a description of the challenge.
</p>
</div>
</div>
</div>
<div class="container-fluid">
<div class="row">
<div class="col-md-8 form-group">
<input id="dropdown-challenge-input" class="challenge-input form-control" type="text" name="answer" placeholder="Flag">
</div>
Expand All @@ -74,11 +84,6 @@
</div>
<div class="container-fluid">
<div id="dropdown-controls" class="row pb-2">
<div class="notification-row fixed-top">
<div id="result-notification" class="alert alert-dismissable text-center w-100" role="alert" style="display: none;">
<strong id="result-message"></strong>
</div>
</div>
<div id="previous" class="col overflow-hidden ">
<button id="challenge-start" class="btn btn-dark text-truncate w-100">
<i class="fas fa-backward"></i>
Expand Down Expand Up @@ -115,6 +120,12 @@
<input id="challenge" type="hidden" value="xx">
<input id="challenge-id" type="hidden" value="xx">
</div>
<div class="w-100"></div>
<div class="notification-row col-12 mt-2">
<div id="result-notification" class="alert alert-dismissable text-center w-100" role="alert" style="display: none;">
<strong id="result-message"></strong>
</div>
</div>
</div>
</div>
</div>
Expand All @@ -130,7 +141,7 @@
{% endif %}

<li class="nav-item">
<a class="nav-link close-link" href="javascript:void()">
<a class="nav-link close-link" href="javascript:void(0)">
<span class="d-block" data-toggle="tooltip" data-placement="bottom" title="Hide Navbar">
<i class="fas caret-up d-none d-md-block d-lg-none"></i>
</span>
Expand Down
Loading

0 comments on commit 9009948

Please sign in to comment.