├── 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 |

Create your access token at {{ ctfd_url }}/settings#tokens

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 |
23 |
24 | $ 25 | 26 |
27 | 28 |
29 |  > 30 | 39 |
40 | 41 |
42 | 43 |
44 |
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 | 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 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 61 | 62 | 63 |
ID CTFd TeamCTFd UsernameDocker ImageDomainPortsInstance NameCreation Date Actions
59 | Loading instances... 60 |
64 |
65 |
66 | 67 | 68 |
69 | 82 |
83 | 84 | 85 |
86 | 98 |
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 |
18 |

{{ message }}

19 |
20 | {% endfor %} 21 | {% endif %} 22 | {% endwith %} 23 | 24 |
25 |
26 | $ 27 | 28 |
29 | 30 |
31 |  > 32 | 38 |
39 | 40 | {% if config['ENABLE_RECAPTCHA'] %} 41 |
42 | {{ recaptcha }} 43 |
44 | {% endif %} 45 | 46 |
47 | $ 48 |
Run instance
49 | 50 |
51 |
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 |
65 |
66 |

67 | {{ container['name'] }} by {{ container['user_name'] }} 68 |

69 |
70 |
71 | Host: 72 | {{ container['host'] }} 73 |
74 | {% if container['hostname'] %} 75 |
76 | Hostname: 77 | {{ container['hostname'] }} 78 |
79 | {% endif %} 80 |
81 | {% if container['ports'] %} 82 |
83 |
84 | Ports: 85 | {{ container['ports'] }} 86 |
87 |
    88 | {% set ports_list = container['ports'].split(',') %} 89 | {% for port_entry in ports_list %} 90 | {% set port_entry = port_entry.strip() %} 91 | {% if '/' in port_entry %} 92 | {% set port_parts = port_entry.split('/') %} 93 | {% set port_num = port_parts[0].strip() %} 94 | {% set port_type = port_parts[1].strip().lower() %} 95 |
  • 96 | {% if port_type == 'ssh' %} 97 | ssh -p {{ port_num }} <user>@{{ container['host'] }} 98 | {% elif port_type == 'http' %} 99 | 100 | http://{{ container['host'] }}:{{ port_num }} 101 | 102 | {% elif port_type == 'tcp' %} 103 | nc {{ container['host'] }} {{ port_num }} 104 | {% else %} 105 | {{ container['host'] }}:{{ port_num }} 106 | {% endif %} 107 |
  • 108 | {% endif %} 109 | {% endfor %} 110 |
111 |
112 | {% endif %} 113 |
114 |
115 |
116 | Time remaining: {{ container['time_remaining'] }} 117 |
118 | {% if container['user_name'] == session['user_name'] %} 119 | 122 | {% endif %} 123 |
124 |
125 | {% else %} 126 |
127 |
128 |
129 | Host: 130 | {{ container['host'] }} 131 |
132 | {% if container['hostname'] %} 133 |
134 | Hostname: 135 | {{ container['hostname'] }} 136 |
137 | {% endif %} 138 |
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 |
185 | 198 |
199 | 200 | 201 |
202 | 214 |
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 | --------------------------------------------------------------------------------