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 {
listen 80;
server_name localhost localhost.easyctf.com;
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log error;
listen 80 default_server;
underscores_in_headers on;
root /filestore-data;
location /static {
proxy_set_header HOST $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://filestore/;
location /static/ {
rewrite ^/static/(.*) /$1 break;
autoindex off;
try_files $uri $uri/ =404;
}
location / {
@ -20,6 +15,6 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
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_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/sites-enabled/easyctf.conf;
# include /etc/nginx/conf.d/*.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_login import current_user
from flask_migrate import Migrate
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()
if not config:
@ -30,6 +31,7 @@ def create_app(config=None):
app.jinja_env.filters["to_place_str"] = to_place_str
from easyctf.models import Config
Migrate(app, db)
def get_competition_running():
configs = Config.get_many(["start_time", "end_time"])

View file

@ -4,20 +4,21 @@ import sys
import logging
import pathlib
from werkzeug.contrib.cache import RedisCache
class CTFCache(RedisCache):
def dump_object(self, value):
value_type = type(value)
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)
# from werkzeug.contrib.cache import RedisCache
#
#
# class CTFCache(RedisCache):
# def dump_object(self, value):
# value_type = type(value)
# 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)
class Config(object):
@ -34,7 +35,7 @@ class Config(object):
self.SQLALCHEMY_TRACK_MODIFICATIONS = False
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.ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
@ -45,6 +46,7 @@ class Config(object):
"FILESTORE_SAVE_ENDPOINT", "http://filestore:5001/save")
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_API_KEY = os.getenv("JUDGE_API_KEY", "")
self.SHELL_HOST = os.getenv("SHELL_HOST", "")
@ -78,4 +80,4 @@ class Config(object):
url = os.getenv("DATABASE_URL")
if 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 imp
import logging
import os
import re
import sys
import time
from datetime import datetime
from functools import partial
import traceback
from io import BytesIO, StringIO
from string import Template
import onetimepass
import paramiko
import requests
import yaml
from Crypto.PublicKey import RSA
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 markdown2 import markdown
from passlib.hash import bcrypt
@ -621,12 +618,10 @@ class Team(db.Model):
return len(self.members)
@hybrid_property
@cache.memoize(timeout=120)
def observer(self):
return User.query.filter(and_(User.tid == self.tid, User.level != USER_REGULAR)).count()
@observer.expression
@cache.memoize(timeout=120)
def observer(self):
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" %}
{% block title %}New Classroom{% endblock %}

View file

@ -54,6 +54,7 @@ def to_timestamp(date):
def to_place_str(n):
# https://codegolf.stackexchange.com/a/4712
k = n % 10
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
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 sqlalchemy import func
@ -280,6 +280,7 @@ def register_user(name, email, username, password, level, admin=False, **kwargs)
for key, value in list(kwargs.items()):
setattr(new_user, key, value)
code = generate_string()
if not current_app.config["DISABLE_EMAILS"]:
new_user.email_token = code
send_verification_email(username, email, url_for("users.verify", code=code, _external=True))
db.session.add(new_user)

View file

@ -1,22 +1,42 @@
#!/bin/bash
#!/bin/sh
set -e
cd /var/easyctf/src
PYTHON=python3
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)
# wait for mysql to be ready
LIMIT=30
i=0
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..."
COMMAND=${1:-runserver}
ENVIRONMENT=${ENVIRONMENT:-production}
WORKERS=${WORKERS:-4}
$PYTHON manage.py db upgrade
flask db upgrade
if [ "$COMMAND" == "runserver" ]; then
if [ "$ENVIRONMENT" == "development" ]; then
$PYTHON manage.py runserver
else
exec gunicorn --bind="$BIND_ADDR:$BIND_PORT" -w $WORKERS 'easyctf:create_app()'
fi
flask run --host 0.0.0.0 --port 8000
# if [ "$ENVIRONMENT" == "development" ]; then
# $PYTHON manage.py runserver
# else
# exec gunicorn --bind="$BIND_ADDR:$BIND_PORT" -w $WORKERS 'easyctf:create_app()'
# fi
fi

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

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

@ -1,8 +1,12 @@
from __future__ import with_statement
from alembic import context
from sqlalchemy import engine_from_config, pool
from logging.config import fileConfig
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
# access to the values within the .ini file in use.
@ -18,8 +22,9 @@ logger = logging.getLogger('alembic.env')
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
from flask import current_app
config.set_main_option('sqlalchemy.url',
current_app.config.get('SQLALCHEMY_DATABASE_URI'))
config.set_main_option(
'sqlalchemy.url',
str(current_app.extensions['migrate'].db.engine.url).replace('%', '%%'))
target_metadata = current_app.extensions['migrate'].db.metadata
# 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")
context.configure(url=url)
context.configure(
url=url, target_metadata=target_metadata, literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
@ -65,21 +72,23 @@ def run_migrations_online():
directives[:] = []
logger.info('No changes in schema detected.')
engine = engine_from_config(config.get_section(config.config_ini_section),
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix='sqlalchemy.',
poolclass=pool.NullPool)
poolclass=pool.NullPool,
)
connection = engine.connect()
context.configure(connection=connection,
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
process_revision_directives=process_revision_directives,
**current_app.extensions['migrate'].configure_args)
**current_app.extensions['migrate'].configure_args
)
try:
with context.begin_transaction():
context.run_migrations()
finally:
connection.close()
if context.is_offline_mode():
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:
Create Date: 2018-02-21 04:34:27.788175
Create Date: 2020-11-25 21:23:24.378872
"""
from alembic import op
@ -10,7 +10,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '59f8fa2f0c98'
revision = 'c226d7f7ad5a'
down_revision = None
branch_labels = None
depends_on = None
@ -161,7 +161,7 @@ def upgrade():
op.create_table('game_states',
sa.Column('id', sa.Integer(), nullable=False),
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.ForeignKeyConstraint(['uid'], ['users.uid'], ),
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