Revamped to work under docker.

This commit is contained in:
Michael Zhang 2020-11-25 22:46:22 -06:00
parent 4225cc4dde
commit b0f0576fd0
Signed by: michael
GPG key ID: BDA47A31A3C8EE6B
20 changed files with 1283 additions and 108 deletions

45
docker-compose.yml Normal file
View file

@ -0,0 +1,45 @@
version: "3"
services:
web:
build: nginx
ports:
- "8000:80"
links:
- app
- filestore
volumes:
- filestore:/filestore-data
filestore:
build: filestore
volumes:
- filestore:/filestore-data
environment:
- "UPLOAD_FOLDER=/filestore-data"
- "FILESTORE_PORT=80"
app:
build: server
links:
- db
- redis
environment:
- "SECRET_KEY=${SECRET_KEY}"
- "ADMIN_EMAIL=${ADMIN_EMAIL}"
- "ENVIRONMENT=${ENVIRONMENT}"
- "FLASK_APP=app"
- "CACHE_REDIS_HOST=redis"
- "MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}"
- "MYSQL_HOST=db"
- "MYSQL_DATABASE=easyctf"
- "FILESTORE_SAVE_ENDPOINT=http://filestore/save"
db:
image: mariadb:10
expose:
- 3306
environment:
- "MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}"
- "MYSQL_DATABASE=easyctf"
redis:
image: redis:6-alpine
volumes:
filestore:

9
filestore/Dockerfile Normal file
View file

@ -0,0 +1,9 @@
FROM python:3.8-alpine3.11
COPY requirements.txt /
RUN pip install -r requirements.txt
COPY . /filestore
WORKDIR /filestore
EXPOSE 80/tcp
ENTRYPOINT ["python", "-m", "flask", "run", "--host", "0.0.0.0", "--port", "80"]

4
nginx/Dockerfile Normal file
View file

@ -0,0 +1,4 @@
FROM nginx:alpine
COPY nginx.conf /etc/nginx/nginx.conf
COPY easyctf.conf /etc/nginx/easyctf.conf

View file

@ -1,18 +1,13 @@
server { server {
listen 80; listen 80 default_server;
server_name localhost localhost.easyctf.com;
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log error;
underscores_in_headers on; underscores_in_headers on;
root /filestore-data;
location /static { location /static/ {
proxy_set_header HOST $host; rewrite ^/static/(.*) /$1 break;
proxy_set_header X-Forwarded-Proto $scheme; autoindex off;
proxy_set_header X-Real-IP $remote_addr; try_files $uri $uri/ =404;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://filestore/;
} }
location / { location / {
@ -20,6 +15,6 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://easyctf:5000/; proxy_pass http://app:8000/;
} }
} }

View file

@ -27,6 +27,6 @@ http {
gzip_proxied any; gzip_proxied any;
gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript; gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript;
include /etc/nginx/conf.d/*.conf; # include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/easyctf.conf; include /etc/nginx/easyctf.conf;
} }

13
server/Dockerfile Normal file
View file

@ -0,0 +1,13 @@
FROM python:3.8-alpine3.11
RUN apk add build-base musl-dev libffi-dev mariadb-dev jpeg-dev
COPY Pipfile /
COPY Pipfile.lock /
RUN pip install pipenv
RUN pipenv install --deploy --system
COPY . /app
WORKDIR /app
EXPOSE 8000
ENTRYPOINT ["pipenv", "run", "/app/entrypoint.sh", "runserver"]

49
server/Pipfile Normal file
View file

@ -0,0 +1,49 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
Flask = "*"
Flask-Breadcrumbs = "*"
Flask-Caching = "*"
Flask-Celery-Helper = "*"
Flask-Login = "*"
Flask-Migrate = "*"
Flask-SQLAlchemy = "*"
Flask-Script = "*"
Flask-WTF = "*"
GitPython = "*"
Pillow = "*"
PyQRCode = "*"
PyYAML = "*"
SQLAlchemy = "*"
WTForms-Components = "*"
bcrypt = "*"
celery = "*"
coverage = "*"
cryptography = "*"
email-validator = "*"
flask-csp = "*"
gitdb = "*"
markdown2 = "*"
mysqlclient = "*"
onetimepass = "*"
paramiko = "*"
passlib = "*"
pathlib = "*"
pycryptodome = "*"
pytest = "*"
rauth = "*"
raven = {extras = ["flask"], version = "*"}
redis = "*"
requests = "*"
[dev-packages]
pyls = "*"
pyls-mypy = "*"
mypy = "*"
neovim = "*"
[requires]
python_version = "3.8"

1063
server/Pipfile.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -5,10 +5,11 @@ import socket
from flask import Flask, request from flask import Flask, request
from flask_login import current_user from flask_login import current_user
from flask_migrate import Migrate
def create_app(config=None): def create_app(config=None):
app = Flask(__name__, static_folder="assets", static_path="/assets") app = Flask(__name__, static_folder="assets", static_url_path="/assets")
hostname = socket.gethostname() hostname = socket.gethostname()
if not config: if not config:
@ -30,6 +31,7 @@ def create_app(config=None):
app.jinja_env.filters["to_place_str"] = to_place_str app.jinja_env.filters["to_place_str"] = to_place_str
from easyctf.models import Config from easyctf.models import Config
Migrate(app, db)
def get_competition_running(): def get_competition_running():
configs = Config.get_many(["start_time", "end_time"]) configs = Config.get_many(["start_time", "end_time"])

View file

@ -4,20 +4,21 @@ import sys
import logging import logging
import pathlib import pathlib
from werkzeug.contrib.cache import RedisCache
# from werkzeug.contrib.cache import RedisCache
class CTFCache(RedisCache): #
def dump_object(self, value): #
value_type = type(value) # class CTFCache(RedisCache):
if value_type in (int, int): # def dump_object(self, value):
return str(value).encode('ascii') # value_type = type(value)
return b'!' + pickle.dumps(value, -1) # if value_type in (int, int):
# return str(value).encode('ascii')
# return b'!' + pickle.dumps(value, -1)
def cache(app, config, args, kwargs): #
kwargs["host"] = app.config.get("CACHE_REDIS_HOST", "localhost") #
return CTFCache(*args, **kwargs) # def cache(app, config, args, kwargs):
# kwargs["host"] = app.config.get("CACHE_REDIS_HOST", "localhost")
# return CTFCache(*args, **kwargs)
class Config(object): class Config(object):
@ -34,7 +35,7 @@ class Config(object):
self.SQLALCHEMY_TRACK_MODIFICATIONS = False self.SQLALCHEMY_TRACK_MODIFICATIONS = False
self.PREFERRED_URL_SCHEME = "https" self.PREFERRED_URL_SCHEME = "https"
self.CACHE_TYPE = "easyctf.config.cache" self.CACHE_TYPE = "redis" # "easyctf.config.cache"
self.CACHE_REDIS_HOST = os.getenv("CACHE_REDIS_HOST", "redis") self.CACHE_REDIS_HOST = os.getenv("CACHE_REDIS_HOST", "redis")
self.ENVIRONMENT = os.getenv("ENVIRONMENT", "production") self.ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
@ -45,6 +46,7 @@ class Config(object):
"FILESTORE_SAVE_ENDPOINT", "http://filestore:5001/save") "FILESTORE_SAVE_ENDPOINT", "http://filestore:5001/save")
self.FILESTORE_STATIC = os.getenv("FILESTORE_STATIC", "/static") self.FILESTORE_STATIC = os.getenv("FILESTORE_STATIC", "/static")
self.DISABLE_EMAILS = os.getenv("DISABLE_EMAILS", "" if self.ENVIRONMENT == "production" else "1") != ""
self.JUDGE_URL = os.getenv("JUDGE_URL", "http://127.0.0.1/") self.JUDGE_URL = os.getenv("JUDGE_URL", "http://127.0.0.1/")
self.JUDGE_API_KEY = os.getenv("JUDGE_API_KEY", "") self.JUDGE_API_KEY = os.getenv("JUDGE_API_KEY", "")
self.SHELL_HOST = os.getenv("SHELL_HOST", "") self.SHELL_HOST = os.getenv("SHELL_HOST", "")
@ -78,4 +80,4 @@ class Config(object):
url = os.getenv("DATABASE_URL") url = os.getenv("DATABASE_URL")
if url: if url:
return url return url
return "mysql://root:%s@db/%s" % (os.getenv("MYSQL_ROOT_PASSWORD"), os.getenv("MYSQL_DATABASE")) return "mysql://root:%s@%s:3306/%s" % (os.getenv("MYSQL_ROOT_PASSWORD"), os.getenv("MYSQL_HOST"), os.getenv("MYSQL_DATABASE"))

View file

@ -1,23 +1,20 @@
import base64 import base64
import imp import imp
import logging
import os import os
import re import re
import sys import sys
import time import time
from datetime import datetime from datetime import datetime
from functools import partial
import traceback import traceback
from io import BytesIO, StringIO from io import BytesIO, StringIO
from string import Template from string import Template
import onetimepass import onetimepass
import paramiko import paramiko
import requests
import yaml import yaml
from Crypto.PublicKey import RSA from Crypto.PublicKey import RSA
from flask import current_app as app from flask import current_app as app
from flask import flash, url_for from flask import url_for
from flask_login import current_user from flask_login import current_user
from markdown2 import markdown from markdown2 import markdown
from passlib.hash import bcrypt from passlib.hash import bcrypt
@ -621,12 +618,10 @@ class Team(db.Model):
return len(self.members) return len(self.members)
@hybrid_property @hybrid_property
@cache.memoize(timeout=120)
def observer(self): def observer(self):
return User.query.filter(and_(User.tid == self.tid, User.level != USER_REGULAR)).count() return User.query.filter(and_(User.tid == self.tid, User.level != USER_REGULAR)).count()
@observer.expression @observer.expression
@cache.memoize(timeout=120)
def observer(self): def observer(self):
return db.session.query(User).filter(User.tid == self.tid and User.level != USER_REGULAR).count() return db.session.query(User).filter(User.tid == self.tid and User.level != USER_REGULAR).count()

View file

@ -1,4 +1,4 @@
{% from "templates.html" import render_field, %} {% from "templates.html" import render_field %}
{% extends "layout.html" %} {% extends "layout.html" %}
{% block title %}New Classroom{% endblock %} {% block title %}New Classroom{% endblock %}
@ -28,4 +28,4 @@
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -54,6 +54,7 @@ def to_timestamp(date):
def to_place_str(n): def to_place_str(n):
# https://codegolf.stackexchange.com/a/4712
k = n % 10 k = n % 10
return "%d%s" % (n, "tsnrhtdd"[(n / 10 % 10 != 1) * (k < 4) * k::4]) return "%d%s" % (n, "tsnrhtdd"[(n / 10 % 10 != 1) * (k < 4) * k::4])

View file

@ -5,7 +5,7 @@ from string import Template
import pyqrcode import pyqrcode
from flask import (Blueprint, abort, flash, redirect, render_template, request, from flask import (Blueprint, abort, flash, redirect, render_template, request,
url_for) url_for, current_app)
from flask_login import current_user, login_required, login_user, logout_user from flask_login import current_user, login_required, login_user, logout_user
from sqlalchemy import func from sqlalchemy import func
@ -280,8 +280,9 @@ def register_user(name, email, username, password, level, admin=False, **kwargs)
for key, value in list(kwargs.items()): for key, value in list(kwargs.items()):
setattr(new_user, key, value) setattr(new_user, key, value)
code = generate_string() code = generate_string()
new_user.email_token = code if not current_app.config["DISABLE_EMAILS"]:
send_verification_email(username, email, url_for("users.verify", code=code, _external=True)) new_user.email_token = code
send_verification_email(username, email, url_for("users.verify", code=code, _external=True))
db.session.add(new_user) db.session.add(new_user)
db.session.commit() db.session.commit()
return new_user return new_user

View file

@ -1,22 +1,42 @@
#!/bin/bash #!/bin/sh
set -e set -e
cd /var/easyctf/src
PYTHON=python3 PYTHON=python3
echo "determining bind location..." # wait for mysql to be ready
BIND_PORT=8000 LIMIT=30
BIND_ADDR_=$(curl -w "\n" http://169.254.169.254/metadata/v1/interfaces/private/0/ipv4/address --connect-timeout 2 || printf "0.0.0.0") i=0
BIND_ADDR=$(echo $BIND_ADDR_ | xargs) until [ $i -ge $LIMIT ]
do
nc -z db 3306 && break
i=$(( i + 1 ))
echo "$i: Waiting for DB 1 second ..."
sleep 1
done
if [ $i -eq $LIMIT ]
then
echo "DB connection refused, terminating ..."
exit 1
fi
# echo "determining bind location..."
# BIND_PORT=8000
# BIND_ADDR_=$(curl -w "\n" http://169.254.169.254/metadata/v1/interfaces/private/0/ipv4/address --connect-timeout 2 || printf "0.0.0.0")
# BIND_ADDR=$(echo $BIND_ADDR_ | xargs)
echo "starting EasyCTF..." echo "starting EasyCTF..."
COMMAND=${1:-runserver} COMMAND=${1:-runserver}
ENVIRONMENT=${ENVIRONMENT:-production} ENVIRONMENT=${ENVIRONMENT:-production}
WORKERS=${WORKERS:-4} WORKERS=${WORKERS:-4}
$PYTHON manage.py db upgrade flask db upgrade
if [ "$COMMAND" == "runserver" ]; then if [ "$COMMAND" == "runserver" ]; then
if [ "$ENVIRONMENT" == "development" ]; then flask run --host 0.0.0.0 --port 8000
$PYTHON manage.py runserver # if [ "$ENVIRONMENT" == "development" ]; then
else # $PYTHON manage.py runserver
exec gunicorn --bind="$BIND_ADDR:$BIND_PORT" -w $WORKERS 'easyctf:create_app()' # else
fi # exec gunicorn --bind="$BIND_ADDR:$BIND_PORT" -w $WORKERS 'easyctf:create_app()'
# fi
fi fi

0
server/migrations/README Executable file → Normal file
View file

43
server/migrations/env.py Executable file → Normal file
View file

@ -1,8 +1,12 @@
from __future__ import with_statement from __future__ import with_statement
from alembic import context
from sqlalchemy import engine_from_config, pool
from logging.config import fileConfig
import logging import logging
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides # this is the Alembic Config object, which provides
# access to the values within the .ini file in use. # access to the values within the .ini file in use.
@ -18,8 +22,9 @@ logger = logging.getLogger('alembic.env')
# from myapp import mymodel # from myapp import mymodel
# target_metadata = mymodel.Base.metadata # target_metadata = mymodel.Base.metadata
from flask import current_app from flask import current_app
config.set_main_option('sqlalchemy.url', config.set_main_option(
current_app.config.get('SQLALCHEMY_DATABASE_URI')) 'sqlalchemy.url',
str(current_app.extensions['migrate'].db.engine.url).replace('%', '%%'))
target_metadata = current_app.extensions['migrate'].db.metadata target_metadata = current_app.extensions['migrate'].db.metadata
# other values from the config, defined by the needs of env.py, # other values from the config, defined by the needs of env.py,
@ -41,7 +46,9 @@ def run_migrations_offline():
""" """
url = config.get_main_option("sqlalchemy.url") url = config.get_main_option("sqlalchemy.url")
context.configure(url=url) context.configure(
url=url, target_metadata=target_metadata, literal_binds=True
)
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()
@ -65,21 +72,23 @@ def run_migrations_online():
directives[:] = [] directives[:] = []
logger.info('No changes in schema detected.') logger.info('No changes in schema detected.')
engine = engine_from_config(config.get_section(config.config_ini_section), connectable = engine_from_config(
prefix='sqlalchemy.', config.get_section(config.config_ini_section),
poolclass=pool.NullPool) prefix='sqlalchemy.',
poolclass=pool.NullPool,
)
connection = engine.connect() with connectable.connect() as connection:
context.configure(connection=connection, context.configure(
target_metadata=target_metadata, connection=connection,
process_revision_directives=process_revision_directives, target_metadata=target_metadata,
**current_app.extensions['migrate'].configure_args) process_revision_directives=process_revision_directives,
**current_app.extensions['migrate'].configure_args
)
try:
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()
finally:
connection.close()
if context.is_offline_mode(): if context.is_offline_mode():
run_migrations_offline() run_migrations_offline()

0
server/migrations/script.py.mako Executable file → Normal file
View file

View file

@ -1,8 +1,8 @@
"""Initial. """empty message
Revision ID: 59f8fa2f0c98 Revision ID: c226d7f7ad5a
Revises: Revises:
Create Date: 2018-02-21 04:34:27.788175 Create Date: 2020-11-25 21:23:24.378872
""" """
from alembic import op from alembic import op
@ -10,7 +10,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = '59f8fa2f0c98' revision = 'c226d7f7ad5a'
down_revision = None down_revision = None
branch_labels = None branch_labels = None
depends_on = None depends_on = None
@ -161,7 +161,7 @@ def upgrade():
op.create_table('game_states', op.create_table('game_states',
sa.Column('id', sa.Integer(), nullable=False), sa.Column('id', sa.Integer(), nullable=False),
sa.Column('uid', sa.Integer(), nullable=True), sa.Column('uid', sa.Integer(), nullable=True),
sa.Column('last_updated', sa.DateTime(), server_default=sa.text('now()'), nullable=True), sa.Column('last_updated', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
sa.Column('state', sa.UnicodeText(), nullable=False), sa.Column('state', sa.UnicodeText(), nullable=False),
sa.ForeignKeyConstraint(['uid'], ['users.uid'], ), sa.ForeignKeyConstraint(['uid'], ['users.uid'], ),
sa.PrimaryKeyConstraint('id'), sa.PrimaryKeyConstraint('id'),

View file

@ -1,33 +0,0 @@
bcrypt
celery
coverage
cryptography==2.1.4
flask
flask-breadcrumbs
flask-caching
flask-celery-helper
flask-csp
flask-login
flask-migrate
flask-script
flask-sqlalchemy
flask-wtf
gitdb
gitpython
markdown2
mysqlclient
onetimepass
paramiko
passlib
pathlib
pillow
pycryptodome
pyqrcode
pytest
pyyaml
rauth
raven[flask]
redis
requests
sqlalchemy==1.1.0b3
wtforms_components