├── app
├── __init__.py
├── static
│ ├── js
│ │ └── .gitkeep
│ ├── img
│ │ ├── bkg.png
│ │ └── favicon.ico
│ ├── font
│ │ └── Monaco.woff
│ └── css
│ │ └── style.css
├── database.py
├── auth.py
├── app.py
├── templates
│ ├── run.html
│ ├── login.html
│ ├── base.html
│ ├── admin.html
│ └── index.html
├── config.py
├── models.py
└── utils.py
├── nginx
├── certs
│ └── .gitkeep
└── nginx.conf
├── .dockerignore
├── .gitignore
├── .env.sample
├── Dockerfile
├── requirements.txt
├── docker-compose.dev.yml
├── docker-compose.yml
├── docs
└── install_worker.sh
├── config.sample.json
├── README.md
└── run.py
/app/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/static/js/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/nginx/certs/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | __pycache__/
2 | .idea/
--------------------------------------------------------------------------------
/app/database.py:
--------------------------------------------------------------------------------
1 | from flask_sqlalchemy import SQLAlchemy
2 |
3 | db = SQLAlchemy()
--------------------------------------------------------------------------------
/app/static/img/bkg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HeroCTF/deploy-dynamic/HEAD/app/static/img/bkg.png
--------------------------------------------------------------------------------
/app/static/font/Monaco.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HeroCTF/deploy-dynamic/HEAD/app/static/font/Monaco.woff
--------------------------------------------------------------------------------
/app/static/img/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HeroCTF/deploy-dynamic/HEAD/app/static/img/favicon.ico
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/
2 | data/
3 | config.json
4 |
5 | # Byte-compiled / optimized / DLL files
6 | __pycache__/
7 | *.py[cod]
8 | *$py.class
9 |
10 | # Environments
11 | .env
12 | .venv
13 | env/
14 | venv/
--------------------------------------------------------------------------------
/.env.sample:
--------------------------------------------------------------------------------
1 | # --- PostgreSQL Database ---
2 | POSTGRES_USER=pguser
3 | POSTGRES_PASSWORD=xxxxxxxxxxxxxxx
4 | POSTGRES_DB=db
5 |
6 | # --- Flask Application ---
7 | DEBUG=0
8 | ADMIN_ONLY=0
9 | ENABLE_RECAPTCHA=0
10 | RECAPTCHA_SITE_KEY=xxxxxxxxxxxxxxx
11 | RECAPTCHA_SECRET_KEY=xxxxxxxxxxxxxxx
12 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.13-alpine
2 |
3 | WORKDIR /app/
4 |
5 | COPY requirements.txt .
6 |
7 | RUN python3 -m pip install --no-cache-dir -r requirements.txt && \
8 | sed -i 's/from jinja2 import/from markupsafe import/g' /usr/local/lib/python3.13/site-packages/flask_recaptcha.py
9 |
10 | EXPOSE 5000
11 | CMD ["waitress-serve", "--host=0.0.0.0", "--port=5000", "--threads=8", "run:app"]
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | blinker==1.9.0
2 | certifi==2025.11.12
3 | charset-normalizer==3.4.4
4 | click==8.3.1
5 | docker==7.1.0
6 | docker-pycreds==0.4.0
7 | Flask==3.1.2
8 | Flask-ReCaptcha==0.4.2
9 | Flask-SQLAlchemy==3.1.1
10 | greenlet==3.2.4
11 | idna==3.11
12 | itsdangerous==2.2.0
13 | Jinja2==3.1.6
14 | MarkupSafe==3.0.3
15 | packaging==25.0
16 | psycopg2-binary==2.9.11
17 | requests==2.32.5
18 | six==1.17.0
19 | SQLAlchemy==2.0.44
20 | typing_extensions==4.15.0
21 | urllib3==2.5.0
22 | waitress==3.0.2
23 | websocket-client==1.9.0
24 | Werkzeug==3.1.3
25 |
--------------------------------------------------------------------------------
/app/auth.py:
--------------------------------------------------------------------------------
1 | from functools import wraps
2 |
3 | from flask import redirect, session, url_for
4 |
5 |
6 | def login_required(f):
7 | @wraps(f)
8 | def wrap(*args, **kwargs):
9 | if session and session["verified"]:
10 | return f(*args, **kwargs)
11 | else:
12 | return redirect(url_for("login"))
13 |
14 | return wrap
15 |
16 |
17 | def admin_required(f):
18 | @wraps(f)
19 | def wrap(*args, **kwargs):
20 | if session and session["admin"]:
21 | return f(*args, **kwargs)
22 | else:
23 | return redirect(url_for("index"))
24 |
25 | return wrap
26 |
--------------------------------------------------------------------------------
/nginx/nginx.conf:
--------------------------------------------------------------------------------
1 | worker_processes auto;
2 |
3 | events {
4 | worker_connections 1024;
5 | }
6 |
7 | http {
8 |
9 | server {
10 | listen 443 ssl http2;
11 | listen [::]:443 ssl http2;
12 | server_name _;
13 |
14 | ssl_certificate /etc/nginx/certs/fullchain.pem;
15 | ssl_certificate_key /etc/nginx/certs/privkey.pem;
16 |
17 | location / {
18 | proxy_pass http://app:5000;
19 | proxy_redirect off;
20 | proxy_set_header Host $host;
21 | proxy_set_header X-Real-IP $remote_addr;
22 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
23 | proxy_set_header X-Forwarded-Host $server_name;
24 | }
25 | }
26 |
27 | }
28 |
--------------------------------------------------------------------------------
/docker-compose.dev.yml:
--------------------------------------------------------------------------------
1 | services:
2 |
3 | app:
4 | build: .
5 | restart: unless-stopped
6 | ports:
7 | - "5000:5000"
8 | volumes:
9 | - .:/app/
10 | - /var/run/docker.sock:/var/run/docker.sock
11 | depends_on:
12 | - postgres
13 | environment:
14 | DEBUG: 1
15 | DATABASE_URI: postgresql://postgres:password@postgres/deploy_dynamic
16 | ENABLE_RECAPTCHA: 0
17 |
18 | postgres:
19 | image: postgres:18-alpine
20 | restart: unless-stopped
21 | volumes:
22 | - ./data/:/var/lib/postgresql/
23 | environment:
24 | POSTGRES_USER: postgres
25 | POSTGRES_PASSWORD: password
26 | POSTGRES_DB: deploy_dynamic
27 |
--------------------------------------------------------------------------------
/app/app.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from os import getenv
3 | from secrets import token_hex
4 |
5 | from flask import Flask
6 |
7 | from app.database import db
8 |
9 |
10 | def create_app():
11 | app = Flask(__name__)
12 |
13 | app.secret_key = token_hex()
14 | app.debug = getenv("DEBUG", "").strip().upper() in ["1", "TRUE"]
15 | app.logger.setLevel(logging.DEBUG)
16 |
17 | app.config["SQLALCHEMY_DATABASE_URI"] = getenv("DATABASE_URI")
18 | app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
19 |
20 | app.config["ENABLE_RECAPTCHA"] = getenv("ENABLE_RECAPTCHA", "").strip().upper() in [
21 | "1",
22 | "TRUE",
23 | ]
24 | app.config["RECAPTCHA_SITE_KEY"] = getenv("RECAPTCHA_SITE_KEY", "")
25 | app.config["RECAPTCHA_SECRET_KEY"] = getenv("RECAPTCHA_SECRET_KEY", "")
26 |
27 | db.init_app(app)
28 | with app.app_context():
29 | db.create_all()
30 |
31 | return app
32 |
--------------------------------------------------------------------------------
/app/templates/run.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 |
3 | {% block main %}
4 |
5 |
Deploy unique instance
6 |
7 |
8 |
9 |
10 |
11 |
Challenge : {{ challenge }}
12 |
13 |
Instance is starting on host {{ host }} and port {{ port }}
14 |
15 |
16 |
17 |
Connection
18 |
19 | {% if type == 'tcp' %}
20 |
nc {{ host }} {{ port }}
21 | {% elif type == 'web' %}
22 |
http://{{ host }}:{{ port }}
23 | {% elif type == 'ssh' %}
24 |
ssh change_me@{{ host }} -p {{ port }}
25 |
26 |
User and password for SSH are in the challenge description.
27 | {% endif %}
28 |
29 |
30 |
Go back
31 |
32 |
33 | {% endblock %}
--------------------------------------------------------------------------------
/app/config.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | from json import load
3 | from os import getenv
4 |
5 | from docker import DockerClient
6 |
7 | DEBUG = getenv("DEBUG", "").strip().upper() in ["1", "TRUE"]
8 | ADMIN_ONLY = getenv("ADMIN_ONLY", "").strip().upper() in ["1", "TRUE"]
9 |
10 | with open("config.json", "r") as config_file:
11 | config = load(config_file)
12 |
13 | WEBSITE_TITLE = config["website_title"]
14 | CTFD_URL = config["ctfd_url"].rstrip("/")
15 |
16 | MAX_INSTANCE_COUNT = config["max_instance_count"]
17 | MAX_INSTANCE_DURATION = config["max_instance_duration"]
18 | MAX_INSTANCE_PER_TEAM = config["max_instance_per_team"]
19 | MIN_PORTS = config["random_ports"]["min"]
20 | MAX_PORTS = config["random_ports"]["max"]
21 |
22 | CHALLENGES = config["challenges"]
23 | DOCKER_HOSTS = config["hosts"]
24 |
25 | for host in DOCKER_HOSTS:
26 | host["client"] = DockerClient(base_url=host["api"])
27 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 |
3 | nginx:
4 | image: nginx:stable-alpine
5 | restart: unless-stopped
6 | volumes:
7 | - ./nginx/nginx.conf:/etc/nginx/nginx.conf
8 | - ./nginx/certs/:/etc/nginx/certs/
9 | ports:
10 | - "443:443"
11 | depends_on:
12 | - app
13 |
14 | app:
15 | build: .
16 | restart: unless-stopped
17 | volumes:
18 | - .:/app/
19 | - /var/run/docker.sock:/var/run/docker.sock
20 | depends_on:
21 | - postgres
22 | environment:
23 | DEBUG: ${DEBUG}
24 | ADMIN_ONLY: ${ADMIN_ONLY}
25 | DATABASE_URI: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres/${POSTGRES_DB}
26 | ENABLE_RECAPTCHA: ${ENABLE_RECAPTCHA}
27 | RECAPTCHA_SITE_KEY: ${RECAPTCHA_SITE_KEY}
28 | RECAPTCHA_SECRET_KEY: ${RECAPTCHA_SECRET_KEY}
29 |
30 | postgres:
31 | image: postgres:18-alpine
32 | restart: unless-stopped
33 | volumes:
34 | - ./data/:/var/lib/postgresql/
35 | environment:
36 | POSTGRES_USER: ${POSTGRES_USER:?Please provide a PostgreSQL username}
37 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Please provide a PostgreSQL password.}
38 | POSTGRES_DB: ${POSTGRES_DB:?Please provide a PostgreSQL database name.}
39 |
--------------------------------------------------------------------------------
/app/templates/login.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 |
3 | {% block main %}
4 |
5 |
6 |
Deploy Unique Instance
7 |
8 |
Do not forget to set a valid expiration date for the token.
9 |
10 |
11 |
12 |
13 |
14 |
Login
15 |
16 | {% if error %}
17 |
18 |
Error: {{ message }}
19 |
20 | {% endif %}
21 |
22 |
45 |
46 |
47 | {% endblock %}
48 |
--------------------------------------------------------------------------------
/app/models.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | from app.database import db
4 |
5 |
6 | class Instances(db.Model):
7 | """
8 | id (int) : Primary key.
9 | user_id (int) : CTFd User ID.
10 | user_name (str) : CTFd Username.
11 | team_id (int) : CTFd Team ID.
12 | team_name (str) : CTFd Team name.
13 | docker_image (str) : Docker image deployed by the user.
14 | ports (str) : Port mapped for the docker instance.
15 | instance_name (str) : Random name for the instance.
16 | docker_client_id (int) : Challenges hosts ID.
17 | creation_date (date) : Date of instance creation.
18 | """
19 |
20 | id = db.Column(db.Integer, primary_key=True)
21 |
22 | user_id = db.Column(db.Integer, unique=False, nullable=False)
23 | user_name = db.Column(db.String(128), unique=False, nullable=False)
24 | team_id = db.Column(db.Integer, unique=False, nullable=False)
25 | team_name = db.Column(db.String(128), unique=False, nullable=False)
26 |
27 | challenge_name = db.Column(db.String(128), unique=False, nullable=False)
28 | network_name = db.Column(db.String(128), unique=False, nullable=False)
29 | hostname = db.Column(db.String(128), unique=False, nullable=False)
30 | ip_address = db.Column(db.String(32), unique=False, nullable=False)
31 | instance_name = db.Column(db.String(128), unique=True, nullable=False)
32 | docker_image = db.Column(db.String(128), unique=False, nullable=False)
33 | host_domain = db.Column(db.String(128), unique=False, nullable=False)
34 | ports = db.Column(db.String(256), unique=False, nullable=True)
35 |
36 | creation_date = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
37 |
38 | def __repr__(self):
39 | return f"[{self.id}] {self.docker_image} at {self.creation_date}"
40 |
--------------------------------------------------------------------------------
/docs/install_worker.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Check if user is root
4 | if [ $(id -u) -ne 0 ]; then
5 | echo "You need to be root to install the worker"
6 | exit 1
7 | fi
8 |
9 | # Install dependencies
10 | apt-get update && apt-get install -y \
11 | curl git vim ufw
12 |
13 | # Read hostname and change it
14 | echo "[!] Enter the hostname for the worker: "
15 | read hostname
16 | hostnamectl set-hostname "$hostname"
17 |
18 | # Install Docker
19 | curl -fsSL https://get.docker.com -o get-docker.sh && \
20 | sh get-docker.sh && \
21 | rm get-docker.sh
22 |
23 | # Setup Docker daemon
24 | cat < /etc/docker/daemon.json
25 | {
26 | "default-address-pools": [
27 | {
28 | "base":"172.17.0.0/12",
29 | "size":16
30 | },
31 | {
32 | "base":"192.168.0.0/16",
33 | "size":20
34 | },
35 | {
36 | "base":"10.99.0.0/16",
37 | "size":24
38 | }
39 | ]
40 | }
41 | EOF
42 |
43 | # Restrict port 2375
44 | iptables -A INPUT -p tcp --dport 2375 -s 192.168.0.0/16 -j REJECT
45 | iptables -A INPUT -p tcp --dport 2375 -s 172.17.0.0/12 -j REJECT
46 | iptables -A INPUT -p tcp --dport 2375 -s 10.99.0.0/16 -j REJECT
47 |
48 | # Setup UFW
49 | ufw limit ssh
50 | echo "[!] Enter the IP of the master node: "
51 | read master_ip
52 | ufw allow from "$master_ip" proto tcp to any port 2375
53 | ufw --force enable
54 |
55 | # Open Docker ports
56 | sed -i 's|ExecStart=/usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock|ExecStart=/usr/bin/dockerd -H fd:// -H tcp://0.0.0.0:2375 --containerd=/run/containerd/containerd.sock|' /lib/systemd/system/docker.service
57 | sed -i 's|ExecStart=/usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock|ExecStart=/usr/bin/dockerd -H fd:// -H tcp://0.0.0.0:2375 --containerd=/run/containerd/containerd.sock|' /usr/lib/systemd/system/docker.service
58 |
59 | echo '[!] Restarting Docker...'
60 | systemctl daemon-reload
61 | systemctl restart docker
62 | echo '[!] Done!'
63 |
64 | echo '[!] Do not forget to build all docker images on each worker !!!'
65 |
--------------------------------------------------------------------------------
/config.sample.json:
--------------------------------------------------------------------------------
1 | {
2 | "website_title": "HeroCTF - Deploy dynamic challenges",
3 | "ctfd_url": "https://ctf.heroctf.fr",
4 | "max_instance_count": 100,
5 | "max_instance_duration": 100,
6 | "max_instance_per_team": 5,
7 | "random_ports": {
8 | "min": 10000,
9 | "max": 15000
10 | },
11 | "hosts": [
12 | {
13 | "domain": "127.0.0.1",
14 | "api": "unix:///var/run/docker.sock"
15 | },
16 | {
17 | "domain": "dyn-01.heroctf.fr",
18 | "api": "tcp://192.168.172.7:2375"
19 | },
20 | {
21 | "domain": "dyn-02.heroctf.fr",
22 | "api": "tcp://192.168.172.174:2375"
23 | }
24 | ],
25 | "challenges": [
26 | {
27 | "name": "Nginx Default Page",
28 | "containers": [
29 | {
30 | "docker_image": "nginx:stable-alpine",
31 | "ports": [
32 | {
33 | "port": "80/tcp",
34 | "protocol": "http"
35 | }
36 | ],
37 | "mem_limit": "512m",
38 | "read_only": false
39 | }
40 | ]
41 | },
42 | {
43 | "name": "Multi-Container Web App",
44 | "containers": [
45 | {
46 | "docker_image": "nginx:stable-alpine",
47 | "hostname": "web_backup",
48 | "ports": [
49 | {
50 | "port": "22/tcp",
51 | "protocol": "ssh"
52 | }
53 | ],
54 | "mem_limit": "512m",
55 | "read_only": true,
56 | "environment": {
57 | "ADMIN_PASSWORD": "13fd035636b32f12d859bb5cafab74ca95d1b11d61fabf959f1984dafeda7184",
58 | "APP_URL": "http://web_app:8000"
59 | },
60 | "cap_add": [
61 | "NET_ADMIN"
62 | ]
63 | },
64 | {
65 | "docker_image": "nginx:stable-alpine",
66 | "hostname": "web_app",
67 | "ports": [
68 | {
69 | "port": "8000/tcp",
70 | "protocol": "http"
71 | }
72 | ],
73 | "mem_limit": "1024m",
74 | "read_only": false,
75 | "cpu_period": 100000,
76 | "cpu_quota": 100000
77 | }
78 | ]
79 | }
80 | ]
81 | }
82 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Deploy dynamic CTF challenges
2 |
3 | ## Features
4 |
5 | - Multi containers
6 | - Multi exposed ports
7 | - Each challenge are in a separate network
8 | - Relation between containers using `hostname`
9 | - Supports for environment variables, capabilities, resource limitation, read only filesystem, ...
10 | - Max instances time and duration
11 | - Configure website name and favicon
12 |
13 | ## Getting started
14 |
15 | 1. Move [.env.sample](.env.sample) to `.env` and configure it.
16 | 2. Move [config.sample.json](config.sample.json) to `config.json` and configure it.
17 | 3. (optional) Add your HTTPs certificates (`fullchain.pem` and `privkey.pem`) to [nginx/certs](nginx/certs). Command: `openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout privkey.pem -out fullchain.pem`
18 | 4. (optional) Change the [favicon](./app/static/img/favicon.ico).
19 | 5. Run the application:
20 |
21 | Using `docker-compose`:
22 |
23 | ```bash
24 | docker-compose build
25 | docker-compose up -d
26 | # or
27 | docker-compose -f docker-compose.dev.yml build
28 | docker-compose -f docker-compose.dev.yml up -d
29 | ```
30 |
31 | Using `docker`:
32 |
33 | ```bash
34 | docker build . -t deployapp
35 |
36 | docker run --rm -p 5000:5000 \
37 | -v /var/run/docker.sock:/var/run/docker.sock \
38 | --env-file .env deployapp
39 | ```
40 |
41 | Using `python3`:
42 |
43 | ```bash
44 | python3 -m venv venv
45 | source venv/bin/activate
46 | python3 -m pip install -r requirements.txt
47 |
48 | export DATABASE_URI="sqlite:////tmp/sqlite.db"
49 | export DEBUG=1
50 | sudo -E python3 run.py
51 | ```
52 |
53 | > You can utilize the `ADMIN_ONLY` flag to restrict login to administrators only. It's useful for testing your challenges before the beginning of the CTF.
54 |
55 | ## Deployment
56 |
57 | ### Hosts
58 |
59 | List of hosts:
60 |
61 | - `master`: Web application.
62 | - `slaves`: Where instances/containers are started.
63 |
64 | > You need at least one host, the master and the slave can be the same host but its not recommended in production.
65 | > You can setup as many slave as you want, each time a challenge is run, a slave is taken randomly to host it.
66 |
67 | Firewall configuration:
68 |
69 | - `master`: expose HTTP/HTTPs ports (default: 80, 443)
70 | - `slaves`: expose containers range (default: 10000-15000) and docker API to master (default: 2375)
71 |
72 | > WARNING: Do NOT expose a docker API on internet !!!
73 |
74 | ### Docker configuration
75 |
76 | You need to increase the number of Docker networks for each `slaves` machine (default: 29). With the following configuration (`/etc/docker/daemon.json`) from [stackoverflow](https://stackoverflow.com/a/69027727/11428808), you will have 255 more network:
77 |
78 | ```json
79 | {
80 | "default-address-pools": [
81 | {
82 | "base":"172.17.0.0/12",
83 | "size":16
84 | },
85 | {
86 | "base":"192.168.0.0/16",
87 | "size":20
88 | },
89 | {
90 | "base":"10.99.0.0/16",
91 | "size":24
92 | }
93 | ]
94 | }
95 | ```
96 |
97 | You also need to expose your docker API to the `master`. To do that, you need to add `-H tcp://0.0.0.0:2375` to the execution command of the systemd service located at `/lib/systemd/system/docker.service`. More information on [stackoverflow](https://stackoverflow.com/a/60954417/11428808).
98 |
99 | All the slaves must build all docker images present in the `config.json` file (image names must match exactly).
100 |
101 | ## Todo
102 |
103 | - pylint
104 | - add more docs about `config.json` format
105 | - Extend instance feature
106 | - Display connection string (ex: ssh -p ..., http://host:port, nc host port, ...)
107 | - Better admin panel
108 | - Add challenge host to HTML table
109 | - Monitoring on each hosts
110 | - Search/Select actions filter on HTML table
111 | - Show internal ip: boolean by challenges
112 | - Migrate to FastAPI + React
113 |
114 | ## Made with
115 |
116 | - [Flask](https://flask.palletsprojects.com/)
117 | - [docker-py](https://docker-py.readthedocs.io/en/stable/)
118 |
119 | ## Authors
120 |
121 | - xanhacks
122 | - Log\_s
123 |
--------------------------------------------------------------------------------
/app/templates/base.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | {{ title }}
8 |
9 |
10 |
11 |
12 | {% block navigation %}
13 | {% if session and session.get("verified") %}
14 |
28 | {% endif %}
29 | {% endblock %}
30 |
31 | {% block modal %}
32 | {% if session and session.get("verified") %}
33 |
34 |
35 |
36 |
40 |
41 |
42 | - Deploy unique instances for your challenges
43 | - An instance will last {{ max_instance_duration }} minutes.
44 | - One instance per player at a time.
45 | - Maximum of {{ max_instance_per_team }} challenge{% if max_instance_per_team > 1 %}s{% endif %} per team.
46 |
47 |
48 |
51 |
52 |
53 | {% endif %}
54 | {% endblock %}
55 |
56 | {% block main %}
57 | {% endblock %}
58 |
59 |
60 | {% block scripts %}
61 | {% if session and session.get("verified") %}
62 |
137 | {% endif %}
138 | {% endblock %}
139 |
140 |
--------------------------------------------------------------------------------
/run.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | from datetime import datetime, timedelta
3 | from typing import Any
4 |
5 | from flask import (
6 | current_app,
7 | flash,
8 | jsonify,
9 | redirect,
10 | render_template,
11 | request,
12 | session,
13 | url_for,
14 | )
15 | from flask_recaptcha import ReCaptcha
16 |
17 | from app.app import create_app
18 | from app.auth import admin_required, login_required
19 | from app.config import (
20 | ADMIN_ONLY,
21 | CHALLENGES,
22 | CTFD_URL,
23 | MAX_INSTANCE_COUNT,
24 | MAX_INSTANCE_DURATION,
25 | MAX_INSTANCE_PER_TEAM,
26 | WEBSITE_TITLE,
27 | )
28 | from app.models import Instances
29 | from app.utils import (
30 | check_access_key,
31 | check_challenge_name,
32 | create_instances,
33 | get_challenge_count_per_team,
34 | get_challenge_info,
35 | get_total_instance_count,
36 | remove_all_instances,
37 | remove_container_by_id,
38 | remove_old_instances,
39 | remove_user_running_instance,
40 | )
41 |
42 | app = create_app()
43 | recaptcha = ReCaptcha(app)
44 | recaptcha.theme = "dark"
45 |
46 |
47 | def render(template: str, **kwargs: Any) -> str:
48 | """
49 | Shortcut for the render_template flask function.
50 | """
51 | return render_template(
52 | template,
53 | title=WEBSITE_TITLE,
54 | ctfd_url=CTFD_URL,
55 | max_instance_duration=MAX_INSTANCE_DURATION,
56 | max_instance_per_team=MAX_INSTANCE_PER_TEAM,
57 | challenges_option=CHALLENGES,
58 | instances_count=get_total_instance_count(),
59 | **kwargs,
60 | )
61 |
62 |
63 | @app.route("/admin", methods=["GET"])
64 | @admin_required
65 | def admin():
66 | """
67 | Admin dashboard with all instances.
68 | """
69 | return render("admin.html")
70 |
71 |
72 | @app.route("/login", methods=["GET", "POST"])
73 | def login():
74 | """
75 | Handle login process and form.
76 | """
77 | if session and session["verified"]:
78 | return redirect(url_for("index"))
79 |
80 | if request.method == "GET":
81 | return render("login.html")
82 |
83 | if request.method == "POST":
84 | access_key = (
85 | request.form["access_key"] if "access_key" in request.form else None
86 | )
87 |
88 | if not access_key:
89 | return render(
90 | "login.html", error=True, message="Please provide an access key."
91 | )
92 |
93 | success, message, user = check_access_key(access_key)
94 | if not success:
95 | return render("login.html", error=True, message=message)
96 |
97 | if ADMIN_ONLY and not user["is_admin"]:
98 | return render(
99 | "login.html",
100 | error=True,
101 | message="You need to be an administrator to login.",
102 | )
103 |
104 | session["verified"] = True
105 | session["user_id"] = user["user_id"]
106 | session["user_name"] = user["username"]
107 | session["team_id"] = user["team_id"]
108 | session["team_name"] = user["team_name"]
109 | session["admin"] = user["is_admin"]
110 |
111 | return redirect(url_for("index"))
112 | return redirect(url_for("login"))
113 |
114 |
115 | @app.route("/", methods=["GET"])
116 | @login_required
117 | def index():
118 | """
119 | Display running instances of your team and allows you to submit new instances.
120 | """
121 | instances = Instances.query.filter_by(team_id=session["team_id"]).all()
122 |
123 | if instances:
124 | challenges_info = {}
125 |
126 | for instance in instances:
127 | if instance.network_name not in challenges_info:
128 | challenges_info[instance.network_name] = []
129 |
130 | remaining = timedelta(minutes=MAX_INSTANCE_DURATION) - (
131 | datetime.utcnow() - instance.creation_date
132 | )
133 | if remaining > timedelta(seconds=0):
134 | remaining = (
135 | f"{remaining.seconds // 60:02d}m{remaining.seconds % 60:02d}s"
136 | )
137 | else:
138 | remaining = "This instance will be deleted shortly..."
139 |
140 | challenges_info[instance.network_name].append(
141 | {
142 | "name": instance.challenge_name,
143 | "host": instance.host_domain,
144 | "hostname": instance.hostname,
145 | "ip_address": instance.ip_address,
146 | "ports": instance.ports,
147 | "user_name": instance.user_name,
148 | "time_remaining": remaining,
149 | }
150 | )
151 |
152 | return render(
153 | "index.html",
154 | challenges=CHALLENGES,
155 | captcha=recaptcha,
156 | challenges_info=challenges_info,
157 | )
158 | return render("index.html", challenges=CHALLENGES, captcha=recaptcha)
159 |
160 |
161 | @app.route("/container/all", methods=["GET"])
162 | @admin_required
163 | def get_all_containers():
164 | """
165 | Admin restricted function to retrieve all containers.
166 | """
167 | return jsonify(
168 | {
169 | "success": True,
170 | "data": [
171 | {
172 | "id": instance.id,
173 | "team": instance.team_name,
174 | "username": instance.user_name,
175 | "image": instance.docker_image,
176 | "domain": instance.host_domain,
177 | "ports": instance.ports,
178 | "instance_name": instance.instance_name,
179 | "date": instance.creation_date,
180 | }
181 | for instance in Instances.query.all()
182 | ],
183 | }
184 | )
185 |
186 |
187 | @app.route("/container/all", methods=["DELETE"])
188 | @admin_required
189 | def remove_containers():
190 | """
191 | Admin restricted function to remove all containers.
192 | """
193 | remove_all_instances()
194 |
195 | return jsonify({"success": True, "message": "Instances removed successfully."})
196 |
197 |
198 | @app.route("/container/", methods=["DELETE"])
199 | @admin_required
200 | def remove_container(container_id=None):
201 | """
202 | Admin restricted function to remove a container with its ID.
203 | """
204 | remove_container_by_id(container_id)
205 |
206 | return jsonify({"success": True, "message": "Instances removed successfully."})
207 |
208 |
209 | @app.route("/remove/me", methods=["GET"])
210 | @login_required
211 | def remove_me():
212 | """
213 | Allow a user to remove their current instance.
214 | """
215 | if remove_user_running_instance(session["user_id"]):
216 | return jsonify({"success": True, "message": "Instance removed successfully."})
217 |
218 | return jsonify(
219 | {"success": False, "message": "Unable to find an instance to remove."}
220 | )
221 |
222 |
223 | @app.route("/logout", methods=["GET"])
224 | def logout():
225 | """
226 | Logout the user.
227 | """
228 | keys = list(session.keys())
229 | for key in keys:
230 | session.pop(key, None)
231 |
232 | return redirect(url_for("login"))
233 |
234 |
235 | @app.route("/run_instance", methods=["POST"])
236 | @login_required
237 | def run_instance():
238 | """
239 | Allow a user to create a new instance.
240 | """
241 | challenge_name = request.form.get("challenge_name", None)
242 |
243 | if current_app.config["ENABLE_RECAPTCHA"] and not recaptcha.verify():
244 | flash("Captcha failed.", "red")
245 | return redirect(url_for("index"))
246 |
247 | if not challenge_name or challenge_name.strip() == "":
248 | flash("Please provide a challenge name.", "red")
249 | return redirect(url_for("index"))
250 |
251 | remove_old_instances()
252 |
253 | if not check_challenge_name(challenge_name):
254 | flash("The challenge name is not valid.", "red")
255 | return redirect(url_for("index"))
256 |
257 | if get_challenge_count_per_team(session["team_id"]) >= MAX_INSTANCE_PER_TEAM:
258 | flash(
259 | f"Your team has reached the maximum number of concurrent running instances ({MAX_INSTANCE_PER_TEAM}).",
260 | "red",
261 | )
262 | return redirect(url_for("index"))
263 |
264 | remove_user_running_instance(session["user_id"])
265 |
266 | if get_total_instance_count() > MAX_INSTANCE_COUNT:
267 | flash(
268 | f"The maximum number of dynamic instances has been reached (max: {MAX_INSTANCE_COUNT}).",
269 | "red",
270 | )
271 | return redirect(url_for("index"))
272 |
273 | challenge_info = get_challenge_info(challenge_name)
274 |
275 | nb_container = 0
276 | try:
277 | nb_container = create_instances(session, challenge_info)
278 | except Exception as e:
279 | current_app.logger.error(f"Error while creating instances: {e}")
280 |
281 | if nb_container == 1:
282 | flash(f"{nb_container} container is starting for {challenge_name}...", "green")
283 | elif nb_container > 1:
284 | flash(
285 | f"{nb_container} containers are starting for {challenge_name}...", "green"
286 | )
287 | else:
288 | flash(
289 | "An error occurred while creating your instance. Please contact an administrator.",
290 | "red",
291 | )
292 |
293 | return redirect(url_for("index"))
294 |
295 |
296 | if __name__ == "__main__":
297 | from waitress import serve
298 |
299 | serve(app, host="0.0.0.0", port=5000)
300 | # app.run(host="0.0.0.0", port=5000)
301 |
--------------------------------------------------------------------------------
/app/utils.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | import random
3 | import re
4 | import secrets
5 | from datetime import datetime, timedelta
6 | from typing import Optional
7 |
8 | import requests
9 | from docker.errors import APIError, ImageNotFound, NotFound
10 | from flask import current_app
11 | from flask.sessions import SessionMixin
12 |
13 | from app.config import (
14 | CHALLENGES,
15 | CTFD_URL,
16 | DOCKER_HOSTS,
17 | MAX_INSTANCE_DURATION,
18 | MAX_PORTS,
19 | MIN_PORTS,
20 | )
21 | from app.database import db
22 | from app.models import Instances
23 |
24 |
25 | def remove_old_instances():
26 | """
27 | Remove old instances (creation_date > X minutes).
28 | """
29 | instances = (
30 | db.session.query(Instances)
31 | .filter(
32 | Instances.creation_date
33 | < datetime.utcnow() - timedelta(minutes=MAX_INSTANCE_DURATION)
34 | )
35 | .all()
36 | )
37 |
38 | # TODO: Remove all instances of a challenge.
39 | for instance in instances:
40 | remove_container_by_id(instance.id)
41 |
42 |
43 | def remove_user_running_instance(user_id):
44 | """
45 | Remove instance if the user has already run an instance.
46 | """
47 | instances = Instances.query.filter_by(user_id=user_id).all()
48 |
49 | for instance in instances:
50 | current_app.logger.debug(
51 | "User n°%d is removing '%s'...", user_id, instance.instance_name
52 | )
53 | remove_container_by_id(instance.id)
54 |
55 | return len(instances) > 0
56 |
57 |
58 | def find_ip_address(container):
59 | """
60 | Find IP address of a running container.
61 | """
62 | ret = container.exec_run("hostname -i")
63 | if ret.exit_code == 0:
64 | return ret.output.decode().strip()
65 |
66 | ret = container.exec_run("cat /etc/hosts")
67 | if ret.exit_code == 0:
68 | return ret.output.split()[-2].decode()
69 |
70 | return "UNKNOWN"
71 |
72 |
73 | def create_instances(session: SessionMixin, challenge_info: dict) -> int:
74 | """
75 | Create new instances.
76 | """
77 | # Generate deploy environment
78 | deploy_config = {
79 | "network_name": secrets.token_hex(16),
80 | "host": random.choice(DOCKER_HOSTS),
81 | "containers": [],
82 | }
83 | worker = deploy_config["host"]["client"]
84 | worker.networks.create(deploy_config["network_name"], driver="bridge")
85 | current_app.logger.debug(
86 | "Starting deployment '%s' for challenge '%s'.",
87 | deploy_config["network_name"],
88 | challenge_info["name"],
89 | )
90 |
91 | # Generate containers environment
92 | for container in challenge_info["containers"]:
93 | instance_name = secrets.token_hex(16)
94 | ports = {
95 | pinfo["port"]: find_unused_port(deploy_config["host"])
96 | for pinfo in container["ports"]
97 | }
98 | environment = container.get("environment", {})
99 | environment["DEPLOY_HOST"] = deploy_config["host"]["domain"]
100 | environment["DEPLOY_PORTS"] = ",".join(f"{p}->{ports[p]}" for p in ports)
101 |
102 | deploy_config["containers"].append(
103 | {
104 | "docker_image": container["docker_image"],
105 | "command": container.get("command", None),
106 | "hostname": container.get("hostname", instance_name),
107 | "instance_name": instance_name,
108 | "ports": ports,
109 | "protocols": [pinfo["protocol"] for pinfo in container["ports"]],
110 | "environment": environment,
111 | "tmpfs": container.get("tmpfs", {}),
112 | "mem_limit": container.get("mem_limit", "512m"),
113 | "privileged": container.get("privileged", False),
114 | "read_only": container.get("read_only", False),
115 | "cpu_period": container.get("cpu_period", None),
116 | "cpu_quota": container.get("cpu_quota", None),
117 | "cap_add": container.get("cap_add", []),
118 | "cap_drop": container.get("cap_drop", []),
119 | }
120 | )
121 |
122 | current_app.logger.debug(
123 | "Environment for deployment '%s': %s",
124 | deploy_config["network_name"],
125 | deploy_config,
126 | )
127 |
128 | # Save instances in DB and run containers
129 | for container in deploy_config["containers"]:
130 | instance = Instances(
131 | user_id=session["user_id"],
132 | user_name=session["user_name"],
133 | team_id=session["team_id"],
134 | team_name=session["team_name"],
135 | docker_image=container["docker_image"],
136 | challenge_name=challenge_info["name"],
137 | network_name=deploy_config["network_name"],
138 | hostname=container["hostname"],
139 | ports=", ".join(
140 | f"{port}/{proto}"
141 | for port, proto in zip(
142 | container["ports"].values(), container["protocols"]
143 | )
144 | ),
145 | host_domain=deploy_config["host"]["domain"],
146 | instance_name=container["instance_name"],
147 | )
148 |
149 | try:
150 | container = worker.containers.run(
151 | image=container["docker_image"],
152 | command=container["command"],
153 | hostname=container["hostname"],
154 | name=container["instance_name"],
155 | ports=container["ports"],
156 | environment=container["environment"],
157 | tmpfs=container["tmpfs"],
158 | network=deploy_config["network_name"],
159 | auto_remove=True,
160 | detach=True,
161 | mem_limit=container["mem_limit"],
162 | privileged=container["privileged"],
163 | read_only=container["read_only"],
164 | cpu_period=container["cpu_period"],
165 | cpu_quota=container["cpu_quota"],
166 | cap_add=container["cap_add"],
167 | cap_drop=container["cap_drop"],
168 | )
169 | instance.ip_address = find_ip_address(container)
170 | except ImageNotFound as err:
171 | current_app.logger.error(
172 | "ImageNotFound: Unable to find %s, %s", container["docker_image"], err
173 | )
174 | return 0
175 |
176 | db.session.add(instance)
177 | db.session.commit()
178 |
179 | return len(challenge_info["containers"])
180 |
181 |
182 | def get_challenge_count_per_team(team_id: int) -> int:
183 | """
184 | Returns the number of challenges running for a specific team.
185 | """
186 | return (
187 | Instances.query.filter_by(team_id=team_id)
188 | .distinct(Instances.network_name)
189 | .count()
190 | )
191 |
192 |
193 | def find_unused_port(docker_host) -> Optional[int]:
194 | """
195 | Find a port that is not used by any instances (on a specific challenge host).
196 | """
197 | containers = []
198 | try:
199 | containers = docker_host["client"].containers.list()
200 | except Exception as err:
201 | current_app.logger.error(
202 | "Unable to list containers on host '%s': %s", docker_host["domain"], err
203 | )
204 | return None
205 |
206 | found = False
207 | while not found:
208 | found = True
209 | rand_port = random.randint(MIN_PORTS, MAX_PORTS + 1)
210 |
211 | for container in containers:
212 | if rand_port in container.ports.values():
213 | found = False
214 |
215 | if found:
216 | return rand_port
217 |
218 | return None
219 |
220 |
221 | def get_total_instance_count() -> int:
222 | """
223 | Returns the number of challenges instance running.
224 | """
225 | return Instances.query.count()
226 |
227 |
228 | def get_challenge_info(challenge_name: str) -> Optional[dict]:
229 | """
230 | Returns challenge information with a challenge_name as parameter.
231 | """
232 | for challenge in CHALLENGES:
233 | if challenge["name"] == challenge_name:
234 | return challenge
235 | return None
236 |
237 |
238 | def check_challenge_name(challenge_name):
239 | """
240 | Returns True if the challenge_name is valid, else False.
241 | """
242 | for challenge in CHALLENGES:
243 | if challenge["name"] == challenge_name:
244 | return True
245 | return False
246 |
247 |
248 | def check_access_key(key: str) -> tuple[bool, str, dict]:
249 | """
250 | Returns the user_id, user_name, team_id, team_name and is_admin.
251 | """
252 | user = {
253 | "user_id": None,
254 | "username": None,
255 | "team_id": None,
256 | "team_name": None,
257 | "is_admin": False,
258 | }
259 | if current_app.config["DEBUG"]:
260 | return (
261 | True,
262 | "",
263 | {
264 | "user_id": 1,
265 | "username": "xanhacks",
266 | "team_id": 1,
267 | "team_name": "toto",
268 | "is_admin": True,
269 | },
270 | )
271 |
272 | pattern = r"^ctfd_[a-zA-Z0-9]+$"
273 | if not re.match(pattern, key):
274 | return False, "Invalid access key, wrong format!", user
275 |
276 | base_url = CTFD_URL.strip("/")
277 | try:
278 | resp_json = requests.get(
279 | f"{base_url}/api/v1/users/me",
280 | headers={
281 | "Authorization": f"Token {key}",
282 | "Content-Type": "application/json",
283 | },
284 | ).json()
285 |
286 | success = resp_json.get("success", False)
287 | user["user_id"] = resp_json.get("data", {}).get("id", "")
288 | user["username"] = resp_json.get("data", {}).get("name", "")
289 | user["team_id"] = resp_json.get("data", {}).get("team_id", False)
290 |
291 | # User is not in a team
292 | if not success or not user["team_id"]:
293 | return False, "User not in a team or invalid token.", user
294 |
295 | resp_json = requests.get(
296 | f"{base_url}/api/v1/teams/{user['team_id']}",
297 | headers={
298 | "Authorization": f"Token {key}",
299 | "Content-Type": "application/json",
300 | },
301 | ).json()
302 | user["team_name"] = resp_json.get("data", {}).get("name", "")
303 |
304 | resp_json = requests.get(
305 | f"{base_url}/api/v1/configs",
306 | headers={
307 | "Authorization": f"Token {key}",
308 | "Content-Type": "application/json",
309 | },
310 | ).json()
311 | user["is_admin"] = resp_json.get("success", False)
312 | return True, "", user
313 | except Exception as err:
314 | current_app.logger.error("Unable to reach CTFd with access key: %s", key)
315 | current_app.logger.error("Error: %s", str(err))
316 |
317 | return False, "An error has occured.", user
318 |
319 |
320 | def remove_all_instances():
321 | """
322 | Remove all running containers.
323 | """
324 | for instance in Instances.query.all():
325 | remove_container_by_id(instance.id)
326 |
327 |
328 | def remove_container_by_name(host_domain: str, name: str) -> None:
329 | """
330 | Remove running container using its random name.
331 | """
332 | for docker_host in DOCKER_HOSTS:
333 | if host_domain in docker_host["domain"]:
334 | client = docker_host["client"]
335 | containers = client.containers.list(filters={"name": name})
336 |
337 | if len(containers):
338 | current_app.logger.debug("Removing container '%s'...", name)
339 | network_name = "UNKNOWN"
340 | try:
341 | containers[0].remove(force=True)
342 |
343 | network_name = list(
344 | containers[0].attrs["NetworkSettings"]["Networks"].keys()
345 | )[0]
346 | networks = client.networks.list(filters={"name": network_name})
347 | networks[0].remove()
348 | except NotFound as err:
349 | current_app.logger.warning(
350 | "Unable to find the container to remove (name: '%s'): %s",
351 | name,
352 | err,
353 | )
354 | except KeyError as err:
355 | current_app.logger.warning(
356 | "Unable to find the network to remove (name: '%s'): %s",
357 | network_name,
358 | err,
359 | )
360 | except APIError as err:
361 | current_app.logger.warning(
362 | "Unable to remove the network (name: '%s'): %s",
363 | network_name,
364 | err,
365 | )
366 | return
367 |
368 |
369 | def remove_container_by_id(instance_id: str) -> None:
370 | """
371 | Remove running container using its instance ID.
372 | """
373 | instance = Instances.query.filter_by(id=instance_id).first()
374 | if instance:
375 | remove_container_by_name(instance.host_domain, instance.instance_name)
376 | db.session.delete(instance)
377 | db.session.commit()
378 |
--------------------------------------------------------------------------------
/app/templates/admin.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 |
3 | {% block main %}
4 |
5 |
6 |
Admin Panel
7 | {{ instances_count }} container{% if instances_count > 1 %}s{% endif %} running
8 |
9 |
10 |
11 |
12 |
13 |
All Instances
14 |
15 |
16 |
17 |
18 |
24 |
25 |
34 |
35 |
36 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | | ID |
46 | CTFd Team |
47 | CTFd Username |
48 | Docker Image |
49 | Domain |
50 | Ports |
51 | Instance Name |
52 | Creation Date |
53 | Actions |
54 |
55 |
56 |
57 |
58 | |
59 | Loading instances...
60 | |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
83 |
84 |
85 |
99 |
100 |
455 | {% endblock %}
456 |
--------------------------------------------------------------------------------
/app/templates/index.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 |
3 | {% block main %}
4 |
5 |
6 |
Welcome {{ session["user_name"] }}!
7 |
8 |
9 |
10 |
11 |
12 |
Deploy New Instance
13 |
14 | {% with messages = get_flashed_messages(with_categories=true) %}
15 | {% if messages %}
16 | {% for color, message in messages %}
17 |
20 | {% endfor %}
21 | {% endif %}
22 | {% endwith %}
23 |
24 |
52 |
53 |
54 |
55 | {% if challenges_info %}
56 |
57 |
58 |
Instances of your team {{ session['team_name'] }}:
59 |
60 | {% for key, challenge in challenges_info.items() %}
61 |
62 | {% for container in challenge %}
63 | {% if loop.index == 1 %}
64 |
125 | {% else %}
126 |
127 |
139 | {% if container['ports'] %}
140 |
141 |
142 | Ports:
143 | {{ container['ports'] }}
144 |
145 |
146 | {% set ports_list = container['ports'].split(',') %}
147 | {% for port_entry in ports_list %}
148 | {% set port_entry = port_entry.strip() %}
149 | {% if '/' in port_entry %}
150 | {% set port_parts = port_entry.split('/') %}
151 | {% set port_num = port_parts[0].strip() %}
152 | {% set port_type = port_parts[1].strip().lower() %}
153 | -
154 | {% if port_type == 'ssh' %}
155 | ssh -p {{ port_num }} <user>@{{ container['host'] }}
156 | {% elif port_type == 'http' %}
157 |
158 | http://{{ container['host'] }}:{{ port_num }}
159 |
160 | {% elif port_type == 'tcp' %}
161 | nc {{ container['host'] }} {{ port_num }}
162 | {% else %}
163 | {{ container['host'] }}:{{ port_num }}
164 | {% endif %}
165 |
166 | {% endif %}
167 | {% endfor %}
168 |
169 |
170 | {% endif %}
171 | {% endif %}
172 | {% endfor %}
173 |
174 | {% endfor %}
175 |
176 |
177 | {% endif %}
178 |
179 |
180 |
{{ instances_count }} container{% if instances_count > 1 %}s{% endif %} running across all teams
181 |
182 |
183 |
184 |
199 |
200 |
201 |
215 |
216 |
390 | {% endblock %}
391 |
--------------------------------------------------------------------------------
/app/static/css/style.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: moncao;
3 | src: url("/static/font/Monaco.woff");
4 | }
5 |
6 | /* ===== CSS Variables ===== */
7 | :root {
8 | --terminal-green: #b5e853;
9 | --terminal-green-hover: #7bbd01;
10 | --bg-dark: #1a1a1a;
11 | --bg-darker: #0d0d0d;
12 | --text-white: #ffffff;
13 | --border-color: rgba(181, 232, 83, 0.3);
14 | --transition-speed: 0.3s;
15 | --spacing-unit: 1em;
16 | --border-radius: 10px;
17 | --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.3);
18 | --shadow-md: 0 4px 8px rgba(0, 0, 0, 0.5);
19 | --shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.7);
20 | }
21 |
22 | /* ===== Base Styles ===== */
23 | * {
24 | box-sizing: border-box;
25 | }
26 |
27 | body {
28 | font-family: moncao, monospace;
29 | color: var(--text-white);
30 | background-color: var(--bg-dark);
31 | background-image: url("/static/img/bkg.png");
32 | background-repeat: repeat;
33 | background-attachment: fixed;
34 | min-height: 100vh;
35 | line-height: 1.6;
36 | margin: 0;
37 | padding: 0;
38 | position: relative;
39 | }
40 |
41 | /* Dark overlay for better readability */
42 | body::before {
43 | content: '';
44 | position: fixed;
45 | top: 0;
46 | left: 0;
47 | width: 100%;
48 | height: 100%;
49 | background: rgba(13, 13, 13, 0.6);
50 | z-index: -1;
51 | pointer-events: none;
52 | }
53 |
54 | /* ===== Typography ===== */
55 | h1, h2, h3, h4, h5, h6 {
56 | margin: 0.5em 0;
57 | line-height: 1.3;
58 | color: var(--terminal-green);
59 | text-shadow: 0 0 10px rgba(181, 232, 83, 0.3);
60 | }
61 |
62 | h1 {
63 | font-size: 2.5em;
64 | letter-spacing: 0.05em;
65 | }
66 |
67 | h2 {
68 | font-size: 2em;
69 | }
70 |
71 | h3 {
72 | font-size: 1.5em;
73 | }
74 |
75 | h4 {
76 | font-size: 1.25em;
77 | }
78 |
79 | label {
80 | font-family: moncao, monospace;
81 | color: var(--terminal-green);
82 | font-size: 1em;
83 | }
84 |
85 | /* ===== Form Elements ===== */
86 | input, select {
87 | background-color: var(--bg-darker);
88 | color: var(--terminal-green);
89 | border: 1px solid var(--border-color);
90 | border-radius: 5px;
91 | outline: none;
92 | font-size: 1em;
93 | font-family: moncao, monospace;
94 | padding: 0.6em 0.8em;
95 | transition: all var(--transition-speed) ease;
96 | box-sizing: border-box;
97 | line-height: 1.6;
98 | }
99 |
100 | input:focus, select:focus {
101 | border-color: var(--terminal-green);
102 | box-shadow: 0 0 10px rgba(181, 232, 83, 0.3);
103 | background-color: rgba(0, 0, 0, 0.8);
104 | }
105 |
106 | input:hover, select:hover {
107 | border-color: var(--terminal-green);
108 | cursor: pointer;
109 | background-color: rgba(0, 0, 0, 0.9);
110 | }
111 |
112 | input::placeholder {
113 | color: rgba(181, 232, 83, 0.5);
114 | }
115 |
116 | /* ===== Buttons ===== */
117 | button {
118 | background-color: var(--bg-darker);
119 | color: var(--terminal-green);
120 | border: 2px solid var(--terminal-green);
121 | border-radius: 5px;
122 | outline: none;
123 | font-size: 1em;
124 | font-weight: bold;
125 | font-family: moncao, monospace;
126 | padding: 0.6em 1.2em;
127 | transition: all var(--transition-speed) ease;
128 | cursor: pointer;
129 | text-transform: uppercase;
130 | letter-spacing: 0.1em;
131 | box-shadow: var(--shadow-sm);
132 | }
133 |
134 | button:hover {
135 | background-color: var(--terminal-green);
136 | color: var(--bg-darker);
137 | box-shadow: 0 0 15px rgba(181, 232, 83, 0.5);
138 | transform: translateY(-2px);
139 | }
140 |
141 | button:active {
142 | transform: translateY(0);
143 | box-shadow: var(--shadow-sm);
144 | }
145 |
146 | button:focus {
147 | outline: 2px solid var(--terminal-green);
148 | outline-offset: 2px;
149 | }
150 |
151 | /* ===== Layout ===== */
152 | .row {
153 | display: flex;
154 | flex-direction: row;
155 | width: 100%;
156 | gap: var(--spacing-unit);
157 | flex-wrap: wrap;
158 | }
159 |
160 | .center {
161 | justify-content: center;
162 | align-items: center;
163 | }
164 |
165 | .align_right {
166 | justify-content: flex-end;
167 | }
168 |
169 | .full_width {
170 | width: 100%;
171 | }
172 |
173 | .width_90 {
174 | width: 90%;
175 | }
176 |
177 | /* ===== Terminal Cards ===== */
178 | .terminal {
179 | border: 2px solid var(--terminal-green);
180 | background-color: rgba(0, 0, 0, 0.9);
181 | border-radius: var(--border-radius);
182 | padding: 1.5em;
183 | margin: 1em;
184 | width: 45%;
185 | box-shadow: var(--shadow-lg), 0 0 20px rgba(181, 232, 83, 0.1);
186 | transition: all var(--transition-speed) ease;
187 | animation: fadeIn 0.5s ease-in;
188 | position: relative;
189 | overflow: hidden;
190 | }
191 |
192 | .terminal.full_width {
193 | width: 100%;
194 | max-width: 100%;
195 | margin-left: 0;
196 | margin-right: 0;
197 | }
198 |
199 | .terminal::before {
200 | content: '';
201 | position: absolute;
202 | top: 0;
203 | left: 0;
204 | right: 0;
205 | height: 3px;
206 | background: linear-gradient(90deg, transparent, var(--terminal-green), transparent);
207 | opacity: 0.5;
208 | }
209 |
210 | .terminal:hover {
211 | box-shadow: var(--shadow-lg), 0 0 30px rgba(181, 232, 83, 0.2);
212 | transform: translateY(-3px);
213 | border-color: var(--terminal-green-hover);
214 | }
215 |
216 | /* ===== Terminal Text Elements ===== */
217 | .green_prefix {
218 | color: var(--terminal-green);
219 | text-shadow: 0 0 5px rgba(181, 232, 83, 0.5);
220 | }
221 |
222 | .size_up {
223 | font-size: 1.2em;
224 | }
225 |
226 | /* ===== Links ===== */
227 | a:link, a:visited {
228 | color: var(--terminal-green);
229 | text-decoration: none;
230 | transition: all var(--transition-speed) ease;
231 | border-bottom: 1px solid transparent;
232 | }
233 |
234 | a:hover, a:active {
235 | color: var(--terminal-green-hover);
236 | border-bottom-color: var(--terminal-green-hover);
237 | text-shadow: 0 0 8px rgba(181, 232, 83, 0.6);
238 | }
239 |
240 | /* ===== Utility Classes ===== */
241 | .bold {
242 | font-weight: bold;
243 | }
244 |
245 | .pointer {
246 | cursor: pointer;
247 | }
248 |
249 | #error {
250 | color: #ff4444;
251 | }
252 |
253 | #success {
254 | color: #44ff44;
255 | }
256 |
257 | #info-challenge {
258 | text-align: center;
259 | }
260 |
261 | .debug {
262 | border: 2px solid red;
263 | }
264 |
265 | /* ===== Tables ===== */
266 | table {
267 | width: 100%;
268 | border-collapse: collapse;
269 | margin: 1em 0;
270 | }
271 |
272 | thead {
273 | background-color: rgba(39, 39, 39, 0.8);
274 | position: sticky;
275 | top: 0;
276 | z-index: 10;
277 | }
278 |
279 | th, td {
280 | border: 1px solid var(--border-color);
281 | padding: 0.8em;
282 | text-align: center;
283 | transition: background-color var(--transition-speed) ease;
284 | }
285 |
286 | th {
287 | color: var(--terminal-green);
288 | font-weight: bold;
289 | text-transform: uppercase;
290 | letter-spacing: 0.1em;
291 | }
292 |
293 | th.sortable {
294 | cursor: pointer;
295 | user-select: none;
296 | transition: background-color var(--transition-speed) ease;
297 | position: relative;
298 | padding-right: 1.5em;
299 | }
300 |
301 | th.sortable:hover {
302 | background-color: rgba(181, 232, 83, 0.1);
303 | }
304 |
305 | .sort-indicator {
306 | position: absolute;
307 | right: 0.5em;
308 | opacity: 0;
309 | transition: opacity var(--transition-speed) ease;
310 | color: var(--terminal-green);
311 | font-weight: bold;
312 | }
313 |
314 | tbody tr {
315 | background-color: rgba(0, 0, 0, 0.5);
316 | }
317 |
318 | tbody tr:hover {
319 | background-color: rgba(181, 232, 83, 0.1);
320 | }
321 |
322 | tbody tr:nth-child(even) {
323 | background-color: rgba(0, 0, 0, 0.3);
324 | }
325 |
326 | tbody tr:nth-child(even):hover {
327 | background-color: rgba(181, 232, 83, 0.15);
328 | }
329 |
330 | /* ===== Typing Animation ===== */
331 | .typing {
332 | width: fit-content;
333 | overflow: hidden;
334 | border-right: 0.15em solid var(--terminal-green);
335 | white-space: nowrap;
336 | letter-spacing: 0.15em;
337 | animation-delay: 15s;
338 | animation:
339 | typing 2.5s steps(19, end),
340 | blink-caret 0.75s step-end infinite;
341 | }
342 |
343 | @keyframes typing {
344 | from { width: 0; }
345 | to { width: 10em; }
346 | }
347 |
348 | @keyframes blink-caret {
349 | from, to { border-color: transparent; }
350 | 50% { border-color: var(--terminal-green); }
351 | }
352 |
353 | /* ===== Fade In Animation ===== */
354 | @keyframes fadeIn {
355 | from {
356 | opacity: 0;
357 | transform: translateY(10px);
358 | }
359 | to {
360 | opacity: 1;
361 | transform: translateY(0);
362 | }
363 | }
364 |
365 | /* ===== Responsive Design ===== */
366 |
367 | /* Tablet breakpoint */
368 | @media only screen and (max-width: 768px) {
369 | :root {
370 | --spacing-unit: 0.8em;
371 | }
372 |
373 | h1 {
374 | font-size: 2em;
375 | }
376 |
377 | h2 {
378 | font-size: 1.75em;
379 | }
380 |
381 | h3 {
382 | font-size: 1.35em;
383 | }
384 |
385 | .terminal {
386 | width: 90%;
387 | margin: 0.8em auto;
388 | padding: 1.2em;
389 | }
390 |
391 | .row {
392 | flex-direction: column;
393 | align-items: stretch;
394 | gap: 0.8em;
395 | }
396 |
397 | table {
398 | font-size: 0.9em;
399 | }
400 |
401 | th, td {
402 | padding: 0.5em 0.3em;
403 | }
404 | }
405 |
406 | /* Mobile breakpoint */
407 | @media only screen and (max-width: 680px) {
408 | :root {
409 | --spacing-unit: 0.6em;
410 | }
411 |
412 | body {
413 | font-size: 0.95em;
414 | padding: 0.5em;
415 | }
416 |
417 | h1 {
418 | font-size: 1.75em;
419 | }
420 |
421 | h2 {
422 | font-size: 1.5em;
423 | }
424 |
425 | h3 {
426 | font-size: 1.2em;
427 | }
428 |
429 | .terminal {
430 | width: 100%;
431 | margin: 0.5em 0;
432 | padding: 1em;
433 | border-width: 1.5px;
434 | }
435 |
436 | .row {
437 | flex-direction: column;
438 | align-items: stretch;
439 | gap: 0.6em;
440 | }
441 |
442 | input, select, button {
443 | padding: 0.7em;
444 | font-size: 0.95em;
445 | }
446 |
447 | table {
448 | font-size: 0.8em;
449 | display: block;
450 | overflow-x: auto;
451 | -webkit-overflow-scrolling: touch;
452 | }
453 |
454 | table thead {
455 | display: table-header-group;
456 | }
457 |
458 | table tbody {
459 | display: table-row-group;
460 | }
461 |
462 | table tr {
463 | display: table-row;
464 | }
465 |
466 | table th, table td {
467 | display: table-cell;
468 | padding: 0.4em 0.3em;
469 | min-width: 80px;
470 | }
471 |
472 | /* Alternative card-based layout for very small screens */
473 | @media only screen and (max-width: 480px) {
474 | table, thead, tbody, th, td, tr {
475 | display: block;
476 | }
477 |
478 | thead {
479 | display: none;
480 | }
481 |
482 | tbody tr {
483 | display: block;
484 | margin-bottom: 1em;
485 | border: 2px solid var(--terminal-green);
486 | border-radius: 5px;
487 | padding: 0.5em;
488 | background-color: rgba(0, 0, 0, 0.7);
489 | }
490 |
491 | tbody td {
492 | display: block;
493 | text-align: left;
494 | border: none;
495 | padding: 0.4em 0.5em;
496 | position: relative;
497 | padding-left: 40%;
498 | }
499 |
500 | tbody td::before {
501 | content: attr(data-label);
502 | position: absolute;
503 | left: 0.5em;
504 | width: 35%;
505 | text-align: left;
506 | font-weight: bold;
507 | color: var(--terminal-green);
508 | }
509 | }
510 | }
511 |
512 | /* Small mobile breakpoint */
513 | @media only screen and (max-width: 480px) {
514 | h1 {
515 | font-size: 1.5em;
516 | }
517 |
518 | h2 {
519 | font-size: 1.3em;
520 | }
521 |
522 | h3 {
523 | font-size: 1.1em;
524 | }
525 |
526 | .terminal {
527 | padding: 0.8em;
528 | }
529 |
530 | button {
531 | padding: 0.6em 1em;
532 | font-size: 0.9em;
533 | }
534 | }
535 |
536 | /* ===== Navigation Styles ===== */
537 | nav {
538 | background-color: rgba(0, 0, 0, 0.8);
539 | border-bottom: 2px solid var(--terminal-green);
540 | padding: 1em;
541 | margin-bottom: 2em;
542 | box-shadow: var(--shadow-md);
543 | }
544 |
545 | nav ul {
546 | list-style: none;
547 | margin: 0;
548 | padding: 0;
549 | display: flex;
550 | gap: 1.5em;
551 | flex-wrap: wrap;
552 | justify-content: flex-start;
553 | align-items: center;
554 | }
555 |
556 | nav li {
557 | margin: 0;
558 | }
559 |
560 | nav li:last-child {
561 | margin-left: auto;
562 | }
563 |
564 | nav a {
565 | padding: 0.5em 1em;
566 | border-radius: 5px;
567 | transition: all var(--transition-speed) ease;
568 | display: inline-block;
569 | }
570 |
571 | nav a:hover {
572 | background-color: rgba(181, 232, 83, 0.1);
573 | transform: translateY(-2px);
574 | }
575 |
576 | .info-button {
577 | background-color: transparent;
578 | border: 2px solid var(--terminal-green);
579 | border-radius: 50%;
580 | width: 2em;
581 | height: 2em;
582 | padding: 0;
583 | font-size: 1.2em;
584 | color: var(--terminal-green);
585 | cursor: pointer;
586 | display: flex;
587 | align-items: center;
588 | justify-content: center;
589 | transition: all var(--transition-speed) ease;
590 | font-weight: bold;
591 | }
592 |
593 | .info-button:hover {
594 | background-color: var(--terminal-green);
595 | color: var(--bg-darker);
596 | transform: scale(1.1);
597 | }
598 |
599 | /* ===== Form Improvements ===== */
600 | form {
601 | width: 100%;
602 | }
603 |
604 | form label {
605 | display: block;
606 | margin-bottom: 0.5em;
607 | }
608 |
609 | form .form-group {
610 | margin-bottom: 1.5em;
611 | display: flex;
612 | align-items: center;
613 | gap: 0.5em;
614 | flex-wrap: nowrap;
615 | }
616 |
617 | form .form-group > span {
618 | flex: 0 0 auto;
619 | white-space: nowrap;
620 | }
621 |
622 | form .form-group label {
623 | margin-bottom: 0;
624 | flex: 0 0 auto;
625 | white-space: nowrap;
626 | }
627 |
628 | form .form-group select,
629 | form .form-group input {
630 | flex: 1 1 auto;
631 | min-width: 0;
632 | max-width: 100%;
633 | }
634 |
635 | form .form-row {
636 | display: flex;
637 | align-items: center;
638 | gap: 0.5em;
639 | margin-bottom: 1em;
640 | }
641 |
642 | .clear-search-btn {
643 | background: transparent;
644 | border: 1px solid var(--terminal-green);
645 | border-radius: 5px;
646 | color: var(--terminal-green);
647 | font-size: 1em;
648 | font-family: moncao, monospace;
649 | cursor: pointer;
650 | padding: 0.6em 0.8em;
651 | width: auto;
652 | min-width: 2em;
653 | display: inline-block;
654 | transition: all var(--transition-speed) ease;
655 | line-height: 1.6;
656 | margin: 0;
657 | opacity: 1;
658 | visibility: visible;
659 | box-sizing: border-box;
660 | outline: none;
661 | vertical-align: top;
662 | text-align: center;
663 | }
664 |
665 | .clear-search-btn:hover {
666 | color: var(--bg-darker);
667 | background-color: var(--terminal-green);
668 | }
669 |
670 | .clear-search-btn:active {
671 | transform: scale(0.98);
672 | }
673 |
674 | /* ===== Instance Cards ===== */
675 | .instance-card {
676 | background-color: rgba(0, 0, 0, 0.6);
677 | border: 1px solid var(--border-color);
678 | border-radius: 8px;
679 | padding: 1em;
680 | margin: 0.8em 0;
681 | transition: all var(--transition-speed) ease;
682 | display: flex;
683 | flex-direction: column;
684 | gap: 0.5em;
685 | }
686 |
687 | .instance-card:hover {
688 | border-color: var(--terminal-green);
689 | background-color: rgba(0, 0, 0, 0.8);
690 | box-shadow: 0 0 15px rgba(181, 232, 83, 0.2);
691 | }
692 |
693 | .instance-card-header {
694 | display: flex;
695 | justify-content: space-between;
696 | align-items: flex-start;
697 | gap: 1em;
698 | flex-wrap: wrap;
699 | }
700 |
701 | .instance-card-info {
702 | flex: 1;
703 | min-width: 0;
704 | }
705 |
706 | .instance-card-actions {
707 | flex: 0 0 auto;
708 | min-width: fit-content;
709 | }
710 |
711 | .instance-card-title {
712 | margin: 0 0 0.3em 0;
713 | font-size: 1.1em;
714 | }
715 |
716 | .instance-card-meta {
717 | display: flex;
718 | flex-wrap: wrap;
719 | gap: 1em;
720 | margin-top: 0.5em;
721 | font-size: 0.9em;
722 | }
723 |
724 | .instance-card-meta-item {
725 | display: flex;
726 | align-items: center;
727 | gap: 0.3em;
728 | }
729 |
730 | .instance-card hr {
731 | border: none;
732 | border-top: 1px solid var(--border-color);
733 | margin: 0.5em 0;
734 | }
735 |
736 | .instance-card small {
737 | color: rgba(181, 232, 83, 0.8);
738 | font-size: 0.9em;
739 | }
740 |
741 | .instance-card strong {
742 | color: var(--terminal-green);
743 | }
744 |
745 | .port-connections {
746 | list-style: none;
747 | }
748 |
749 | .port-connections li {
750 | position: relative;
751 | padding-left: 1em;
752 | }
753 |
754 | .port-connections li::before {
755 | content: "•";
756 | color: var(--terminal-green);
757 | position: absolute;
758 | left: 0;
759 | font-weight: bold;
760 | }
761 |
762 | /* ===== Flash Messages ===== */
763 | .flash-message {
764 | padding: 1em;
765 | margin: 1em 0;
766 | border-radius: 5px;
767 | border: 1px solid;
768 | animation: fadeIn 0.3s ease-in;
769 | }
770 |
771 | .flash-message.red {
772 | background-color: rgba(255, 68, 68, 0.2);
773 | border-color: #ff4444;
774 | color: #ffaaaa;
775 | }
776 |
777 | .flash-message.green {
778 | background-color: rgba(68, 255, 68, 0.2);
779 | border-color: #44ff44;
780 | color: #aaffaa;
781 | }
782 |
783 | /* ===== Modal Styles ===== */
784 | .modal {
785 | display: none;
786 | position: fixed;
787 | z-index: 1000;
788 | left: 0;
789 | top: 0;
790 | width: 100%;
791 | height: 100%;
792 | overflow: auto;
793 | background-color: rgba(0, 0, 0, 0.8);
794 | animation: fadeIn 0.3s ease-in;
795 | }
796 |
797 | .modal.show {
798 | display: flex;
799 | align-items: center;
800 | justify-content: center;
801 | }
802 |
803 | .modal-content {
804 | background-color: rgba(0, 0, 0, 0.95);
805 | margin: auto;
806 | padding: 0;
807 | border: 2px solid var(--terminal-green);
808 | border-radius: var(--border-radius);
809 | width: 90%;
810 | max-width: 600px;
811 | box-shadow: var(--shadow-lg), 0 0 30px rgba(181, 232, 83, 0.3);
812 | animation: slideDown 0.3s ease-out;
813 | position: relative;
814 | }
815 |
816 | @keyframes slideDown {
817 | from {
818 | opacity: 0;
819 | transform: translateY(-50px);
820 | }
821 | to {
822 | opacity: 1;
823 | transform: translateY(0);
824 | }
825 | }
826 |
827 | .modal-header {
828 | display: flex;
829 | justify-content: space-between;
830 | align-items: center;
831 | padding: 1.5em;
832 | border-bottom: 1px solid var(--border-color);
833 | }
834 |
835 | .modal-close {
836 | background: transparent;
837 | border: none;
838 | color: var(--terminal-green);
839 | font-size: 2em;
840 | cursor: pointer;
841 | padding: 0;
842 | width: 1.5em;
843 | height: 1.5em;
844 | display: flex;
845 | align-items: center;
846 | justify-content: center;
847 | transition: all var(--transition-speed) ease;
848 | line-height: 1;
849 | }
850 |
851 | .modal-close:hover {
852 | color: var(--terminal-green-hover);
853 | transform: scale(1.2);
854 | }
855 |
856 | .modal-body {
857 | padding: 1.5em;
858 | }
859 |
860 | .modal-body p {
861 | margin: 1em 0;
862 | line-height: 1.6;
863 | }
864 |
865 | .modal-footer {
866 | padding: 1.5em;
867 | border-top: 1px solid var(--border-color);
868 | display: flex;
869 | justify-content: flex-end;
870 | }
871 |
872 | .modal-ok-btn {
873 | min-width: 100px;
874 | }
875 |
876 | .modal-cancel-btn {
877 | min-width: 100px;
878 | background-color: rgba(0, 0, 0, 0.5);
879 | border-color: var(--border-color);
880 | color: var(--text-white);
881 | }
882 |
883 | .modal-cancel-btn:hover {
884 | background-color: rgba(0, 0, 0, 0.7);
885 | border-color: var(--terminal-green);
886 | }
887 |
888 | .custom-modal {
889 | display: none;
890 | position: fixed;
891 | z-index: 10000;
892 | left: 0;
893 | top: 0;
894 | width: 100%;
895 | height: 100%;
896 | overflow: auto;
897 | background-color: rgba(0, 0, 0, 0.8);
898 | animation: fadeIn 0.3s ease-in;
899 | }
900 |
901 | .custom-modal.show {
902 | display: flex;
903 | align-items: center;
904 | justify-content: center;
905 | }
906 |
907 | .alert-success {
908 | border-color: #44ff44;
909 | }
910 |
911 | .alert-success .modal-header h2 {
912 | color: #44ff44;
913 | }
914 |
915 | .alert-error {
916 | border-color: #ff4444;
917 | }
918 |
919 | .alert-error .modal-header h2 {
920 | color: #ff4444;
921 | }
922 |
--------------------------------------------------------------------------------