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.
+