From db8d82bbd948cb1392e5ae075b7accd5a91ff980 Mon Sep 17 00:00:00 2001 From: The0Mikkel Date: Sun, 16 Nov 2025 18:59:03 +0100 Subject: [PATCH 01/12] feat: add error fallback webserver with custom error pages and deployment configuration - Implemented a webserver to handle error responses with custom HTML pages for various HTTP errors (404, 500, 502, 503, 504). - Added Dockerfile and docker-compose configuration for local development. - Created Kubernetes deployment and service configuration for production deployment. - Introduced a generator script to automate the creation of error pages. - Updated README with instructions on running the service and generating pages. --- .github/workflows/cla-assistant.yml | 2 +- .github/workflows/release.yml | 23 ++-- .gitignore | 1 + .releaserc.json | 47 +++++++++ Dockerfile | 13 +++ README.md | 33 ++++-- docker-compose.yml | 6 ++ src/content/error_404.html | 4 + src/content/error_500.html | 7 ++ src/content/error_502.html | 4 + src/content/error_503.html | 5 + src/content/error_504.html | 5 + src/content/error_index.html | 4 + src/css/main.css | 64 +++++++++++ src/generator.py | 107 +++++++++++++++++++ src/js/main.js | 35 ++++++ src/layouts/error.html | 158 ++++++++++++++++++++++++++++ template/k8s.yml | 63 +++++++++++ 18 files changed, 564 insertions(+), 17 deletions(-) create mode 100644 .gitignore create mode 100644 .releaserc.json create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 src/content/error_404.html create mode 100644 src/content/error_500.html create mode 100644 src/content/error_502.html create mode 100644 src/content/error_503.html create mode 100644 src/content/error_504.html create mode 100644 src/content/error_index.html create mode 100644 src/css/main.css create mode 100644 src/generator.py create mode 100644 src/js/main.js create mode 100644 src/layouts/error.html create mode 100644 template/k8s.yml diff --git a/.github/workflows/cla-assistant.yml b/.github/workflows/cla-assistant.yml index 671747d..69d0868 100644 --- a/.github/workflows/cla-assistant.yml +++ b/.github/workflows/cla-assistant.yml @@ -17,4 +17,4 @@ jobs: uses: ctfpilot/ci/.github/workflows/cla-assistant.yml@v1.3.0 secrets: inherit with: - repository: + repository: error-fallback diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 43bf337..364a904 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,27 +8,30 @@ on: jobs: release: - name: Release - uses: ctfpilot/ci/.github/workflows/release.yml@v1.3.0 permissions: contents: write packages: write id-token: write + name: Release + uses: ctfpilot/ci/.github/workflows/release.yml@v1.3.0 secrets: RELEASE_GH_TOKEN: ${{ secrets.RELEASE_GH_TOKEN }} with: - repository: ctfpilot/ + repository: ctfpilot/error ENVIRONMENT: Release - update-develop: - if: github.ref == 'refs/heads/main' - name: "Update Develop Branch" + docker: + name: Docker build and push needs: - release - uses: ctfpilot/ci/.github/workflows/develop-update.yml@v1.3.0 + uses: ctfpilot/ci/.github/workflows/docker.yml@v1.3.0 + if: needs.release.outputs.version != '' && needs.release.outputs.version != null permissions: contents: read - pull-requests: write - issues: write + packages: write + id-token: write with: - repository: ctfpilot/ + repository: ctfpilot/error + semver: ${{ needs.release.outputs.version }} + arguments: | + VERSION=${{ needs.release.outputs.version }} 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 new file mode 100644 index 0000000..19a908b --- /dev/null +++ b/.releaserc.json @@ -0,0 +1,47 @@ +{ + "branches": [ + "main", + { + "name": "develop", + "prerelease": "r" + } + ], + "plugins": [ + [ + "@semantic-release/commit-analyzer", + { + "preset": "conventionalcommits" + } + ], + [ + "@semantic-release/release-notes-generator", + { + "preset": "conventionalcommits" + } + ], + [ + "@semantic-release/github", + { + "successComment": false, + "assets": [ + ] + } + ], + [ + "@semantic-release/exec", + { + "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": [ + "k8s/k8s.yml" + ], + "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" + } + ] + ] +} \ 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 f4043fa..c68e5f4 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,34 @@ -# CTF Pilot's Template Repository +# CTF Pilot's Error Fallback -## Template information +**Error fallback webserver** -This repository, is a template repository for open-source projects within CTF Pilot. +This repository contains a webserver, which is used as the fallback webserver for errors. +It allows for custom error pages to be shown when errors occur in the CTF Pilot ecosystem. -It provices a EUPL-1.2 License, release system and other standard files. +## How to run -Please remove this section, and replace with relevant information. -Replace `` with the repository name in `.github/workflows/cla-assistant.yml`and `.github/workflows/release.yml`. +For Kubernetes environments, deploy the deployment file provided in `k8s`. +This can be done with `kubectl`: + +```sh +kubectl apply -f k8s/k8s.yml +``` + +The service can also be run locally, using the provided Docker compose file: + +```sh +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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..254a1d0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,6 @@ +services: + instancing-fallback: + build: . + ports: + - "8080:80" + restart: always diff --git a/src/content/error_404.html b/src/content/error_404.html new file mode 100644 index 0000000..5b7a20d --- /dev/null +++ b/src/content/error_404.html @@ -0,0 +1,4 @@ +

Not found

+

+ The page you are looking for could not be found. +

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

Error

+

+ An unexpected error has occurred. +

+

+ Go back to the home page.
+

\ 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..7857407 --- /dev/null +++ b/src/content/error_502.html @@ -0,0 +1,4 @@ +

Bad gateway

+

+ The service did not answer correctly. +

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

No service available

+

+ The service is currently unavailable. Please try again later.
+ The service may be restarting or undergoing maintenance. +

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

Gateway timeout

+

+ The service was unable to respond in a timely manner.
+ The service may be restarting or undergoing maintenance. +

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

Error

+

+ An unexpected error has occurred. +

\ No newline at end of file diff --git a/src/css/main.css b/src/css/main.css new file mode 100644 index 0000000..6bef50c --- /dev/null +++ b/src/css/main.css @@ -0,0 +1,64 @@ +: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; +} 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..e8adf58 --- /dev/null +++ b/src/js/main.js @@ -0,0 +1,35 @@ +const POLL_INTERVAL = 5000; +const parser = new DOMParser(); + +function errorHandler(e) { + console.error("Error during challenge readiness check:", e); + setTimeout(() => { + location.reload(); + }, 5000); +} + +function checkService() { + 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)); +} + +checkService(); +setInterval(() => checkService(), POLL_INTERVAL); diff --git a/src/layouts/error.html b/src/layouts/error.html new file mode 100644 index 0000000..114e61a --- /dev/null +++ b/src/layouts/error.html @@ -0,0 +1,158 @@ + + + + + + + Error occured - /**ERROR_CODE**/ + + + + + + +
+
+ /**CHALLENGE_HTML**/ + +

+ This page will update automatically, if the problem solves itself.
+ If the issue persists, please contact support. +

+ +
+
+
+
+
+
+
+
+
+
+ +
+
+ + + + \ No newline at end of file diff --git a/template/k8s.yml b/template/k8s.yml new file mode 100644 index 0000000..166fccd --- /dev/null +++ b/template/k8s.yml @@ -0,0 +1,63 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: error-fallback + labels: + app.kubernetes.io/part-of: ctfpilot + app.kubernetes.io/name: error-fallback + app.kubernetes.io/version: { .Version } + app.kubernetes.io/component: challenges + ctfpilot.com/component: error-fallback + +spec: + # HA setup + replicas: 3 + selector: + matchLabels: + ctfpilot.com/component: error-fallback + + template: + metadata: + labels: + ctfpilot.com/component: error-fallback + spec: + enableServiceLinks: false + automountServiceAccountToken: false + containers: + - name: instancing-fallback + image: ctfpilot/error-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: error-fallback + labels: + app.kubernetes.io/part-of: ctfpilot + app.kubernetes.io/name: error-fallback + app.kubernetes.io/version: { .Version } + app.kubernetes.io/component: challenges + ctfpilot.com/component: error-fallback +spec: + selector: + ctfpilot.com/component: error-fallback + ports: + - name: http + port: 80 + targetPort: http From a6c81e4cb936340f4aff768ef05c7b60740f90b6 Mon Sep 17 00:00:00 2001 From: The0Mikkel Date: Sun, 16 Nov 2025 19:01:26 +0100 Subject: [PATCH 02/12] Add empty depoyment file for automation --- k8s/k8s.yml | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 k8s/k8s.yml diff --git a/k8s/k8s.yml b/k8s/k8s.yml new file mode 100644 index 0000000..e69de29 From 7f997f7ba28c0c38c074d2d637bffd838f2257b7 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 16 Nov 2025 18:02:02 +0000 Subject: [PATCH 03/12] chore(release): 1.0.0-r.1 [skip ci] ## 1.0.0-r.1 (2025-11-16) ### Features * add error fallback webserver with custom error pages and deployment configuration ([db8d82b](https://github.com/ctfpilot/error-fallback/commit/db8d82bbd948cb1392e5ae075b7accd5a91ff980)) --- k8s/k8s.yml | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/k8s/k8s.yml b/k8s/k8s.yml index e69de29..326a9f6 100644 --- a/k8s/k8s.yml +++ b/k8s/k8s.yml @@ -0,0 +1,63 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: error-fallback + labels: + app.kubernetes.io/part-of: ctfpilot + app.kubernetes.io/name: error-fallback + app.kubernetes.io/version: 1.0.0-r.1 + app.kubernetes.io/component: challenges + ctfpilot.com/component: error-fallback + +spec: + # HA setup + replicas: 3 + selector: + matchLabels: + ctfpilot.com/component: error-fallback + + template: + metadata: + labels: + ctfpilot.com/component: error-fallback + spec: + enableServiceLinks: false + automountServiceAccountToken: false + containers: + - name: instancing-fallback + image: ctfpilot/error-fallback:1.0.0-r.1 + 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: error-fallback + labels: + app.kubernetes.io/part-of: ctfpilot + app.kubernetes.io/name: error-fallback + app.kubernetes.io/version: 1.0.0-r.1 + app.kubernetes.io/component: challenges + ctfpilot.com/component: error-fallback +spec: + selector: + ctfpilot.com/component: error-fallback + ports: + - name: http + port: 80 + targetPort: http From 9736bd509f7039a7bef83d546364e979055900c9 Mon Sep 17 00:00:00 2001 From: The0Mikkel Date: Sun, 16 Nov 2025 19:02:15 +0100 Subject: [PATCH 04/12] Rename service from instancing-fallback to error-fallback in docker-compose --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 254a1d0..64aa7bf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,5 @@ services: - instancing-fallback: + error-fallback: build: . ports: - "8080:80" From 93439b12881364ca7a1c29049b5b7b15b8decdd0 Mon Sep 17 00:00:00 2001 From: The0Mikkel Date: Sun, 16 Nov 2025 19:06:15 +0100 Subject: [PATCH 05/12] Correct minor errors in language and validation --- src/generator.py | 6 +++++- src/layouts/error.html | 2 +- template/k8s.yml | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/generator.py b/src/generator.py index 5909804..a428422 100644 --- a/src/generator.py +++ b/src/generator.py @@ -76,7 +76,11 @@ def generate(): for content_name, content_data in CONTENTS: content_filename = content_name - layout, filename = content_filename.split("_", 1) + split_result = content_filename.split("_", 1) + if len(split_result) != 2: + print(f"Warning: Content filename '{content_filename}' does not contain an underscore separator. Skipping.") + continue + layout, filename = split_result layout_template = next((l for l_name, l in LAYOUTS if l_name == layout), None) if layout_template is None: diff --git a/src/layouts/error.html b/src/layouts/error.html index 114e61a..4208e1f 100644 --- a/src/layouts/error.html +++ b/src/layouts/error.html @@ -4,7 +4,7 @@ - Error occured - /**ERROR_CODE**/ + Error occurred - /**ERROR_CODE**/ diff --git a/template/k8s.yml b/template/k8s.yml index 166fccd..2a80914 100644 --- a/template/k8s.yml +++ b/template/k8s.yml @@ -24,7 +24,7 @@ spec: enableServiceLinks: false automountServiceAccountToken: false containers: - - name: instancing-fallback + - name: error-fallback image: ctfpilot/error-fallback:{ .Version } imagePullPolicy: Always ports: From 5d496d60d79941ca25830a16330bfab6abd5c6a9 Mon Sep 17 00:00:00 2001 From: The0Mikkel Date: Sun, 16 Nov 2025 19:13:02 +0100 Subject: [PATCH 06/12] Change placeholder naming --- src/generator.py | 6 +++--- src/layouts/error.html | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/generator.py b/src/generator.py index a428422..54194a9 100644 --- a/src/generator.py +++ b/src/generator.py @@ -87,9 +87,9 @@ def generate(): 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) + final_html = replace_indented_placeholder(layout_template, "/**ERROR_HTML**/", content_data) + final_html = replace_indented_placeholder(final_html, "/**ERROR_JS**/", JS_SCRIPTS) + final_html = replace_indented_placeholder(final_html, "/**ERROR_CSS**/", CSS_STYLES) if layout == "error": final_html = final_html.replace("/**ERROR_CODE**/", filename) diff --git a/src/layouts/error.html b/src/layouts/error.html index 4208e1f..941fe94 100644 --- a/src/layouts/error.html +++ b/src/layouts/error.html @@ -6,7 +6,7 @@ Error occurred - /**ERROR_CODE**/