diff --git a/.github/workflows/cla-assistant.yml b/.github/workflows/cla-assistant.yml index b84dd99..cc6a73f 100644 --- a/.github/workflows/cla-assistant.yml +++ b/.github/workflows/cla-assistant.yml @@ -14,29 +14,7 @@ permissions: jobs: CLAAssistant: name: "CLA Assistant" - runs-on: ubuntu-latest - steps: - - name: "CLA Assistant" - if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' - uses: contributor-assistant/github-action@v2.6.1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_ASSISTANT_PAT }} - with: - path-to-signatures: "signatures/instancing-fallback/v1/cla.json" - path-to-document: "https://github.com/ctfpilot/cla/blob/1a3a56410df569e0672aafd508044da85f194e8b/CLA.md" - - # branch should not be protected - branch: "main" - allowlist: "" - lock-pullrequest-aftermerge: false - use-dco-flag: false - - # Remote CLA repo - remote-organization-name: "ctfpilot" - remote-repository-name: "cla" - create-file-commit-message: "Creating file for storing CLA Signatures" - signed-commit-message: "$contributorName has signed the CLA in $owner/$repo" - custom-notsigned-prcomment: 'Thank you for your contribution! Before we can proceed, please sign the Contributor License Agreement (CLA) by replying to this comment with "I have read the CLA Document and I hereby sign the CLA". You can find the CLA document [here](https://github.com/ctfpilot/cla/blob/1a3a56410df569e0672aafd508044da85f194e8b/CLA.md).' - custom-pr-sign-comment: "I have read the CLA Document and I hereby sign the CLA" - custom-allsigned-prcomment: "All Contributors have signed the CLA. Thank you for your cooperation!" + uses: ctfpilot/ci/.github/workflows/cla-assistant.yml@v1.1.2 + secrets: inherit + with: + repository: instance-fallback diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4d742d6..098b329 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,5 +13,38 @@ jobs: packages: write id-token: write name: Release - if: github.repository == 'ctfpilot/instancing-fallback' - uses: the0mikkel/ci/.github/workflows/semver-release-standalone.yml@v1.4.0 + uses: ctfpilot/ci/.github/workflows/release.yml@v1.1.1 + secrets: + RELEASE_GH_TOKEN: ${{ secrets.RELEASE_GH_TOKEN }} + with: + repository: ctfpilot/instancing-fallback + ENVIRONMENT: Release + + docker: + name: Docker build and push + needs: + - release + uses: ctfpilot/ci/.github/workflows/docker.yml@v1.1.1 + if: needs.release.outputs.version != '' && needs.release.outputs.version != null + permissions: + contents: read + packages: write + id-token: write + with: + repository: ctfpilot/instancing-fallback + semver: ${{ needs.release.outputs.version }} + arguments: | + VERSION=${{ needs.release.outputs.version }} + + update-develop: + if: github.ref == 'refs/heads/main' + name: "Update Develop Branch" + needs: + - release + uses: ctfpilot/ci/.github/workflows/develop-update.yml@v1.2.0 + permissions: + contents: read + pull-requests: write + issues: write + with: + repository: ctfpilot/instancing-fallback diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d298be1 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +public/ \ No newline at end of file diff --git a/.releaserc.json b/.releaserc.json index 1b813d0..19a908b 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -30,14 +30,16 @@ [ "@semantic-release/exec", { - "prepareCmd": "echo ${nextRelease.version} > version.txt", + "prepareCmd": "echo ${nextRelease.version} > version.txt && cp template/k8s.yml k8s/k8s.yml && sed -i 's/{ .Version }/${nextRelease.version}/g' k8s/k8s.yml", "publishCmd": "echo 'Published version ${nextRelease.version}'" } ], [ "@semantic-release/git", { - "assets": [], + "assets": [ + "k8s/k8s.yml" + ], "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" } ] diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..a18b888 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,2 @@ +# Require review from security/maintainers for workflows +.releaserc.json @ctfpilot/devops \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5d92e2c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.14-slim AS builder + +WORKDIR /app + +COPY src/ /app/src + +RUN python3 src/generator.py + +FROM joseluisq/static-web-server + +COPY --from=builder /app/public/ /public + +ENV SERVER_CACHE_CONTROL_HEADERS=false diff --git a/README.md b/README.md index d88eff4..058b5bc 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,16 @@ The service can also be run locally, using the provided Docker compose file: docker compose up -d ``` +### Development + +In order to generate the pages, run the [`generator.py`](./src/generator.py) script in `src`: + +```sh +python3 src/generator.py +``` + +*This is done automatically in the Docker container build process.* + ## Contributing We welcome contributions of all kinds, from **code** and **documentation** to **bug reports** and **feedback**! diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2e90262 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + instancing-fallback: + build: . + ports: + - "8080:80" + restart: always + development: + image: joseluisq/static-web-server + ports: + - "8081:80" + volumes: + - ./public:/public + restart: always diff --git a/k8s/k8s.yml b/k8s/k8s.yml new file mode 100644 index 0000000..fc64e5a --- /dev/null +++ b/k8s/k8s.yml @@ -0,0 +1,63 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: instance-fallback + labels: + app.kubernetes.io/part-of: ctfpilot + app.kubernetes.io/name: instance-fallback + app.kubernetes.io/version: 1.0.0-r.2 + app.kubernetes.io/component: challenges + ctfpilot.com/component: instance-fallback + +spec: + # HA setup + replicas: 3 + selector: + matchLabels: + ctfpilot.com/component: instance-fallback + + template: + metadata: + labels: + ctfpilot.com/component: instance-fallback + spec: + enableServiceLinks: false + automountServiceAccountToken: false + containers: + - name: instancing-fallback + image: ctfpilot/instance-fallback:1.0.0-r.2 + imagePullPolicy: Always + ports: + - name: http + containerPort: 80 + resources: + requests: + memory: "128Mi" + cpu: "10m" + limits: + memory: "256Mi" + cpu: "100m" + livenessProbe: + httpGet: + path: / + port: 80 + initialDelaySeconds: 5 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: instance-fallback + labels: + app.kubernetes.io/part-of: ctfpilot + app.kubernetes.io/name: instance-fallback + app.kubernetes.io/version: 1.0.0-r.2 + app.kubernetes.io/component: challenges + ctfpilot.com/component: instance-fallback +spec: + selector: + ctfpilot.com/component: instance-fallback + ports: + - name: http + port: 80 + targetPort: http diff --git a/src/content/error_404.html b/src/content/error_404.html new file mode 100644 index 0000000..a3d9273 --- /dev/null +++ b/src/content/error_404.html @@ -0,0 +1,7 @@ +

The challenge instance is offline

+

+ Please start your challenge or wait a few moments for the instance to become available, if you have already started + it.
+ This may take a few minutes. You may see "No Available Server", "Bad Gateway" or "Service Unavailable", this is + normal and you will just need to wait a bit longer. +

\ No newline at end of file diff --git a/src/content/error_502.html b/src/content/error_502.html new file mode 100644 index 0000000..4feef70 --- /dev/null +++ b/src/content/error_502.html @@ -0,0 +1,4 @@ +

Bad gateway

+

+ The challenge did not answer correctly. Your challenge may still be starting up. +

diff --git a/src/content/error_503.html b/src/content/error_503.html new file mode 100644 index 0000000..47161db --- /dev/null +++ b/src/content/error_503.html @@ -0,0 +1,4 @@ +

Your personal challenge instance is starting

+

+ The challenge is not yet ready. Please wait a few moments for the instance to become available. +

diff --git a/src/content/error_504.html b/src/content/error_504.html new file mode 100644 index 0000000..d7e7937 --- /dev/null +++ b/src/content/error_504.html @@ -0,0 +1,6 @@ +

Gateway timeout

+

+ The challenge was too slow to respond.
+ This may either indicate that your challenge is still starting up, or that the action you took made the + server not respond in time. +

\ No newline at end of file diff --git a/src/content/standard_index.html b/src/content/standard_index.html new file mode 100644 index 0000000..7b8b3f8 --- /dev/null +++ b/src/content/standard_index.html @@ -0,0 +1,7 @@ +

The challenge instance is offline

+

+ Please start the challenge or wait a few moments for the instance to become available, if you have already + started it.
+ This may take a few minutes. You may see "No Available Server", "Bad Gateway" or "Service Unavailable", this + is normal and you will just need to wait a bit longer. +

diff --git a/src/css/main.css b/src/css/main.css new file mode 100644 index 0000000..00d155b --- /dev/null +++ b/src/css/main.css @@ -0,0 +1,72 @@ +:root { + --header-font: "Hack", monospace, sans-serif; + --body-font: "Hack", monospace, sans-serif; + + --light-theme-color: #eebc1d; + --dark-theme-color: #eebc1d; + --text-light: #232323; + --text-dark: #f9f9f9; + --bg-light: #f9f9f9; + --bg-dark: #232323; + + --theme-color: var(--light-theme-color); + --bg-color: var(--bg-light); + --text-color: var(--text-light); +} + +@media (prefers-color-scheme: dark) { + :root { + --theme-color: var(--dark-theme-color); + --bg-color: var(--bg-dark); + --text-color: var(--text-dark); + } +} + +html, +body { + background-color: var(--bg-color); + color: var(--text-color) !important; + font-family: var(--body-font) !important; + margin: 0; + padding: 0; +} + +main { + padding: 2rem; +} + +h1, +h2, +h3, +h4, +h5 { + font-family: var(--header-font) !important; +} + +a { + color: var(--theme-color); + filter: brightness(100%); + transition: filter 0.2s; +} + +a:hover { + filter: brightness(95%); + transition: filter 0.2s; +} + +.container { + display: flex; + justify-content: center; + text-align: center; + height: 100vh; + overflow-y: hidden; + align-items: center; +} + +.challenge-loading { + display: none; +} + +.wrong-domain { + display: none; +} diff --git a/src/generator.py b/src/generator.py new file mode 100644 index 0000000..5909804 --- /dev/null +++ b/src/generator.py @@ -0,0 +1,107 @@ + +import os +from pathlib import Path + +LAYOUTS = [] +CONTENTS = [] +JS_SCRIPTS = "" +CSS_STYLES = "" + +def load_layouts(): + layouts_path = Path(__file__).parent.absolute() / "layouts" + print("Loading layouts from:", layouts_path) + for layout_file in os.listdir(layouts_path): + if layout_file.endswith(".html"): + filename = layout_file.split(".")[0] + with open(layouts_path / layout_file, "r") as f: + layout_content = f.read() + LAYOUTS.append((filename, layout_content)) + +def load_contents(): + contents_path = Path(__file__).parent.absolute() / "content" + print("Loading contents from:", contents_path) + for content_file in os.listdir(contents_path): + if content_file.endswith(".html"): + filename = content_file.split(".")[0] + with open(contents_path / content_file, "r") as f: + content_data = f.read() + CONTENTS.append((filename, content_data)) + +def load_js_scripts(): + filename = "./js/main.js" + js_path = Path(__file__).parent.absolute() / filename + print("Loading JS scripts from:", js_path) + global JS_SCRIPTS + with open(js_path, "r") as f: + JS_SCRIPTS = f.read() + +def load_css_styles(): + filename = "./css/main.css" + css_path = Path(__file__).parent.absolute() / filename + print("Loading CSS styles from:", css_path) + global CSS_STYLES + with open(css_path, "r") as f: + CSS_STYLES = f.read() + +def replace_indented_placeholder(template, placeholder, content): + """ + Replaces a placeholder in a template string with the given content, preserving indentation. + + For each line in the template containing the placeholder, the function determines the indentation + (the substring before the placeholder) and applies this indentation to each line of the replacement content. + The placeholder line is replaced by the content lines, each indented to match the original placeholder's indentation. + + Args: + template (str): The template string containing the placeholder. + placeholder (str): The placeholder text to replace. + content (str): The content to insert at the placeholder location. + + Returns: + str: The modified template with the placeholder replaced and indentation preserved. + """ + + lines = template.splitlines() + new_lines = [] + for line in lines: + if placeholder in line: + indent = line[:line.index(placeholder)] + content_lines = content.splitlines() + for c_line in content_lines: + new_lines.append(indent + c_line) + else: + new_lines.append(line) + return "\n".join(new_lines) + +def generate(): + for content_name, content_data in CONTENTS: + content_filename = content_name + + layout, filename = content_filename.split("_", 1) + + layout_template = next((l for l_name, l in LAYOUTS if l_name == layout), None) + if layout_template is None: + print(f"Layout '{layout}' not found for content '{content_name}'") + continue + + final_html = replace_indented_placeholder(layout_template, "/**CHALLENGE_HTML**/", content_data) + final_html = replace_indented_placeholder(final_html, "/**CHALLENGE_JS**/", JS_SCRIPTS) + final_html = replace_indented_placeholder(final_html, "/**CHALLENGE_CSS**/", CSS_STYLES) + + if layout == "error": + final_html = final_html.replace("/**ERROR_CODE**/", filename) + + output_path = (Path(__file__).parent.absolute() / ".." / "public" / f"{filename}.html").resolve() + + output_path.parent.mkdir(parents=True, exist_ok=True) + with open(output_path, "w") as f: + f.write(final_html) + print(f"Generated: {output_path}") + + + +load_layouts() +load_contents() +load_js_scripts() +load_css_styles() +generate() + diff --git a/src/js/main.js b/src/js/main.js new file mode 100644 index 0000000..1082785 --- /dev/null +++ b/src/js/main.js @@ -0,0 +1,44 @@ +const POLL_INTERVAL = 5000; + +// Check subdomain matches +const subdomain = window.location.host.split(".")[0]; +const re = new RegExp("^[a-z0-9-]+-[a-f0-9]{16}$"); +const parser = new DOMParser(); + +function errorHandler(e) { + console.error("Error during challenge readiness check:", e); + setTimeout(() => { + location.reload(); + }, 5000); +} + +function checkChallengeReady() { + const host = window.location.origin; + fetch(host) + .then(res => { + if (res.status === 200) { + // Parse response text + res.text() + .then(text => { + // Check if 200 res has same title as this loading page + try { + const doc = parser.parseFromString(text, "text/html"); + if (doc.title !== document.title) { + location.reload(); + } + } catch (e) { + errorHandler(e); + } + }).catch(e => errorHandler(e)); + } else { console.info("instance not ready"); } + }) + .catch(e => errorHandler(e)); +} + +if (re.test(subdomain)) { + document.getElementsByClassName("challenge-loading")[0].style.display = "block"; + checkChallengeReady(); + setInterval(() => checkChallengeReady(), POLL_INTERVAL); +} else { + document.getElementsByClassName("wrong-domain")[0].style.display = "block"; +} diff --git a/src/layouts/error.html b/src/layouts/error.html new file mode 100644 index 0000000..3ae124a --- /dev/null +++ b/src/layouts/error.html @@ -0,0 +1,167 @@ + + + + + + + CTF challenge is loading - /**ERROR_CODE**/ + + + + + + +
+
+
+ /**CHALLENGE_HTML**/ + +

+ This page will update automatically.
+ If this page does not update within 5 minutes please contact support. +

+ +
+
+
+
+
+
+
+
+
+
+
+
+

Invalid URL!

+

+ This URL currently does not resolve to a challenge, please check the + URL and try again. +

+
+ +
+
+ + + + \ No newline at end of file diff --git a/src/layouts/standard.html b/src/layouts/standard.html new file mode 100644 index 0000000..a2e563a --- /dev/null +++ b/src/layouts/standard.html @@ -0,0 +1,167 @@ + + + + + + + CTF challenge is loading + + + + + +
+
+
+ /**CHALLENGE_HTML**/ + +

+ This page will update automatically.
+ If this page does not update within 5 minutes please contact support. +

+ +
+
+
+
+
+
+
+
+
+
+
+
+

Invalid URL!

+

+ This URL currently does not resolve to a challenge, please check the + URL and try again. +

+
+ +
+
+ + + + + \ No newline at end of file diff --git a/template/k8s.yml b/template/k8s.yml new file mode 100644 index 0000000..4f61dda --- /dev/null +++ b/template/k8s.yml @@ -0,0 +1,63 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: instance-fallback + labels: + app.kubernetes.io/part-of: ctfpilot + app.kubernetes.io/name: instance-fallback + app.kubernetes.io/version: { .Version } + app.kubernetes.io/component: challenges + ctfpilot.com/component: instance-fallback + +spec: + # HA setup + replicas: 3 + selector: + matchLabels: + ctfpilot.com/component: instance-fallback + + template: + metadata: + labels: + ctfpilot.com/component: instance-fallback + spec: + enableServiceLinks: false + automountServiceAccountToken: false + containers: + - name: instancing-fallback + image: ctfpilot/instance-fallback:{ .Version } + imagePullPolicy: Always + ports: + - name: http + containerPort: 80 + resources: + requests: + memory: "128Mi" + cpu: "10m" + limits: + memory: "256Mi" + cpu: "100m" + livenessProbe: + httpGet: + path: / + port: 80 + initialDelaySeconds: 5 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: instance-fallback + labels: + app.kubernetes.io/part-of: ctfpilot + app.kubernetes.io/name: instance-fallback + app.kubernetes.io/version: { .Version } + app.kubernetes.io/component: challenges + ctfpilot.com/component: instance-fallback +spec: + selector: + ctfpilot.com/component: instance-fallback + ports: + - name: http + port: 80 + targetPort: http