Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docker_challenges/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,8 +282,34 @@ def docker_secrets():
app.register_blueprint(admin_docker_secrets)


def _ensure_healthy_column(app) -> None:
"""Add the `healthy` column to docker_challenge_tracker if it doesn't exist.

Handles upgrades from plugin versions that predate the health-check feature.
Existing rows get server_default=1 (healthy=True) so running containers
remain visible after upgrade.
"""
try:
from sqlalchemy import inspect, text

inspector = inspect(app.db.engine)
columns = [col["name"] for col in inspector.get_columns("docker_challenge_tracker")]
if "healthy" not in columns:
with app.db.engine.begin() as conn:
conn.execute(
text(
"ALTER TABLE docker_challenge_tracker "
"ADD COLUMN healthy BOOLEAN NOT NULL DEFAULT 1"
)
)
logging.info("docker_challenge_tracker: added 'healthy' column")
except Exception as err:
logging.warning("Could not ensure 'healthy' column on docker_challenge_tracker: %s", err)


def load(app):
app.db.create_all()
_ensure_healthy_column(app)

CHALLENGE_CLASSES["docker"] = DockerChallengeType
CHALLENGE_CLASSES["docker_service"] = DockerServiceChallengeType
Expand Down
114 changes: 93 additions & 21 deletions docker_challenges/api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@
from ..functions.general import (
create_secret,
delete_secret,
get_container_states,
get_repositories,
get_required_ports,
get_secrets,
get_service_states,
get_unavailable_ports,
is_swarm_mode,
)
Expand Down Expand Up @@ -310,6 +312,7 @@ def _track_container(
instance_id=instance_id,
ports=",".join(ports),
host=str(docker.hostname).split(":")[0],
healthy=False,
)
db.session.add(entry)
db.session.commit()
Expand Down Expand Up @@ -365,40 +368,109 @@ def post(self):
}, 201


def _get_tracker_for_session() -> list:
"""Return tracker entries for the current authenticated session."""
if is_teams_mode():
session = get_current_team()
return list(DockerChallengeTracker.query.filter_by(team_id=session.id))
session = get_current_user()
return list(DockerChallengeTracker.query.filter_by(user_id=session.id))


def _is_service_challenge(challenge_id: int) -> bool:
"""Check if a challenge ID belongs to a DockerServiceChallenge."""
challenge = _get_challenge_by_id(challenge_id)
return challenge is not None and challenge.type == "docker_service"


@active_docker_namespace.route("", methods=["POST", "GET"])
class DockerStatus(Resource):
"""
The Purpose of this API is to retrieve a public JSON string of all docker containers
in use by the current team/user.

Entries with healthy=False are checked against Docker to determine if the
container/service has started yet. Connection info is only returned once healthy.
"""

@authed_only
def get(self):
docker = DockerConfig.query.filter_by(id=1).first()
if is_teams_mode():
session = get_current_team()
tracker = DockerChallengeTracker.query.filter_by(team_id=session.id)
else:
session = get_current_user()
tracker = DockerChallengeTracker.query.filter_by(user_id=session.id)
tracker = _get_tracker_for_session()

# State dicts are cached (5s TTL) — cheap to call unconditionally
container_states = get_container_states(docker)
service_states = get_service_states(docker)

db_dirty = False
data = []
for tracker_entry in tracker:
data.append(
{
"id": tracker_entry.id,
"team_id": tracker_entry.team_id,
"user_id": tracker_entry.user_id,
"challenge_id": tracker_entry.challenge_id,
"docker_image": tracker_entry.docker_image,
"timestamp": tracker_entry.timestamp,
"revert_time": tracker_entry.revert_time,
"instance_id": tracker_entry.instance_id,
"ports": tracker_entry.ports.split(","),
"host": str(docker.hostname).split(":")[0],
}
)

for entry in tracker:
if entry.healthy:
data.append(self._build_entry(entry, docker, status="running"))
else:
if self._process_unhealthy_entry(
entry, docker, container_states, service_states, data
):
db_dirty = True

if db_dirty:
db.session.commit()

return {"success": True, "data": data}

@staticmethod
def _process_unhealthy_entry(
entry: DockerChallengeTracker,
docker: DockerConfig,
container_states: dict[str, str],
service_states: dict[str, str],
data: list,
) -> bool:
"""Check Docker state for an unhealthy tracker entry and update accordingly.

Returns True if the database was modified (healthy flag set or entry deleted).
"""
is_service = _is_service_challenge(entry.challenge_id)
state_dict = service_states if is_service else container_states
docker_status = state_dict.get(entry.instance_id, "")

if docker_status == "running":
entry.healthy = True
data.append(DockerStatus._build_entry(entry, docker, status="running"))
return True
if docker_status in ("starting", ""):
# "starting": healthcheck not yet passed
# "": not yet visible in Docker (just created) — treat as starting
data.append(DockerStatus._build_entry(entry, docker, status="starting"))
return False
# "stopped", "unhealthy", or unknown explicit state — clean up dead tracker entry
DockerChallengeTracker.query.filter_by(id=entry.id).delete()
return True

@staticmethod
def _build_entry(entry: DockerChallengeTracker, docker: DockerConfig, status: str) -> dict:
"""Build a response dict for a tracker entry.

When status is 'starting', ports and host are omitted to prevent
showing connection info for a service not yet accepting connections.
"""
base = {
"id": entry.id,
"team_id": entry.team_id,
"user_id": entry.user_id,
"challenge_id": entry.challenge_id,
"docker_image": entry.docker_image,
"timestamp": entry.timestamp,
"revert_time": entry.revert_time,
"instance_id": entry.instance_id,
"status": status,
}
if status == "running":
base["ports"] = entry.ports.split(",")
base["host"] = str(docker.hostname).split(":")[0]
return base


@docker_namespace.route("", methods=["GET"])
class DockerAPI(Resource):
Expand Down
24 changes: 22 additions & 2 deletions docker_challenges/assets/view.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,33 @@
x-data="containerStatus('{{ challenge.docker_image }}', '{{ challenge.id }}')"
x-init="init()"
>
<!-- Start button - shown when no container is running -->
<div x-show="!containerRunning">
<!-- Start button - shown when no container is running or starting -->
<div x-show="!containerRunning && !containerStarting">
<a @click="startContainer()" class="btn btn-dark" style="cursor: pointer">
<small style="color: white"><i class="fas fa-play"></i> Start Docker Instance</small>
</a>
</div>

<!-- Starting spinner - shown when container is starting up -->
<div x-show="containerStarting">
<div
style="
background: #1e1e2e;
border-radius: 8px;
padding: 24px;
text-align: center;
color: #cdd6f4;
"
>
<div class="spinner-border text-light mb-3" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<div style="color: #a6adc8">
Container starting up... Connection info will appear when ready.
</div>
</div>
</div>

<!-- Connection information - shown when container is running -->
<div x-show="containerRunning">
<div
Expand Down
34 changes: 26 additions & 8 deletions docker_challenges/assets/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
var CONTAINER_POLL_INTERVAL_MS = 30000; // 30 seconds (base interval)
var CONTAINER_POLL_MAX_INTERVAL_MS = 300000; // 5 minutes (max backoff)
var CONTAINER_POLL_BACKOFF_MULTIPLIER = 2; // Double interval on each failure
var CONTAINER_STARTUP_POLL_INTERVAL_MS = 3000; // 3 seconds — rapid poll during startup
var MS_PER_SECOND = 1000;

var HTTP_PORTS = [80, 8080, 3000, 5000, 8000, 8888, 9000];
Expand Down Expand Up @@ -92,6 +93,7 @@ CTFd._internal.challenge.submit = function (preview) {
function containerStatus(container, challengeId) {
return {
containerRunning: false,
containerStarting: false,
host: '',
ports: '',
revertTime: null,
Expand Down Expand Up @@ -179,18 +181,31 @@ function containerStatus(container, challengeId) {
);

if (containerInfo) {
this.containerRunning = true;
this.host = containerInfo.host;
this.ports = String(containerInfo.ports);
this.revertTime = parseInt(containerInfo.revert_time) * MS_PER_SECOND; // Convert to milliseconds
this.updateCountdown();
if (containerInfo.status === 'running') {
this.containerStarting = false;
this.containerRunning = true;
this.host = containerInfo.host;
this.ports = String(containerInfo.ports);
this.revertTime = parseInt(containerInfo.revert_time) * MS_PER_SECOND;
this.updateCountdown();
this.resetPollInterval();
} else {
// status === 'starting': rapid poll, no connection info yet
this.containerStarting = true;
this.containerRunning = false;
this.currentPollInterval = CONTAINER_STARTUP_POLL_INTERVAL_MS;
}
} else {
this.containerRunning = false;
this.containerStarting = false;
this.resetPollInterval();
}
} else {
// No containers in response
this.containerRunning = false;
this.containerStarting = false;
this.resetPollInterval();
}

// Success - reset backoff interval
this.resetPollInterval();
} catch (error) {
console.error('Error polling status:', error);

Expand Down Expand Up @@ -263,6 +278,9 @@ function containerStatus(container, challengeId) {
throw new Error(result.error || 'Container creation failed');
}

// Show spinner immediately — container created but not yet healthy
this.containerStarting = true;
this.currentPollInterval = CONTAINER_STARTUP_POLL_INTERVAL_MS;
await this.pollStatus();
} catch (error) {
ezal({
Expand Down
3 changes: 3 additions & 0 deletions docker_challenges/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,6 @@
PORT_ASSIGNMENT_MIN = 30000 # Minimum port for random assignment
PORT_ASSIGNMENT_MAX = 60000 # Maximum port for random assignment
MAX_PORT_ASSIGNMENT_ATTEMPTS = 100 # Maximum attempts to find available port before failing

# Container health check polling
CONTAINER_STARTUP_POLL_INTERVAL_MS = 3000 # 3 seconds — rapid poll during container startup
2 changes: 1 addition & 1 deletion docker_challenges/functions/containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ def create_container(
method="POST",
data=data,
)
if not r:
if r is None:
return None, None
instance_id = find_existing(docker, container_name) if r.status_code == 409 else r.json()["Id"]
if instance_id is None:
Expand Down
Loading