Konata
CLI tool and CI actions for syncing CTF challenges to rCTF, building images, and rolling out Kubernetes manifests.
Konata is a CTF challenge-management tool, invoked as kona on the command line. It walks a repository of challenge directories, builds and pushes Docker images, applies Kubernetes manifests, and syncs the resulting challenge metadata to rCTF (and CTFd, when configured).
The whole workflow is driven by a single root kona.yml for global settings, plus a per-challenge kona.yml in each challenge directory.
This page covers Konata as it relates to rCTF. For the upstream tool and source, check the Konata repository.
Note (Pre-1.0 status)
Konata is pre-1.0. The schema is settled enough that we’ve used it to run public CTFs end-to-end (the DiceCTF Quals 2026 challenge repository is a good real-world reference), but breaking changes are still on the table until 1.0.
Install#
Konata is published on PyPI:
pip install konatakona --helpPython 3.12 or newer is required. The kona binary exposes two commands. The sync command is the main workflow, and compress is a helper for packaging attachment folders ahead of time.
Repository layout#
Konata assumes a directory tree with one root config at kona.yml and one config per challenge somewhere underneath. The challenge folder structure itself is arbitrary. Konata walks up to discovery.challenge_folder_depth levels deep (default 3), looking for files named kona.yml or kona.yaml.
A typical layout (taken from the DiceCTF Quals 2026 deployment repository):
ctf-challenges/
- kona.yml Global config
web/
dicewallet/
- kona.yml Per-challenge config
- Dockerfile
- challenge/
mirror-temple/
- kona.yml Per-challenge config
pwn/
garden/
- kona.yml Per-challenge config
sanity/
survey/
- kona.yml Skipped because discovery.skip: true
Global config#
The root kona.yml configures credentials, clusters, registries, and shared template overrides. Everything below is optional. A minimum config just needs an rctf block.
secrets: token: env: RCTF_TOKEN
clusters: prod: gcloud: cluster_name: kctf-cluster project: dicectf-2026-quals zone: europe-west1-b main: alias_to: prod
registries: challenges: europe-west1-docker.pkg.dev/dicectf-2026-quals/challenges instancer-challenges: us-central1-docker.pkg.dev/dicectf-2026-quals/challenge-registry
domains: kctf: chals.dicec.tf
rctf: base_url: 'https://ctf.example.com' team_token: secret: tokensecrets#
Named references that the rest of the config can pull from. Each entry must specify exactly one of:
The reference is then used wherever the schema accepts a secret or value field:
rctf: team_token: secret: token # resolves to secrets.tokenrctf#
rCTF API credentials. Konata calls into rCTF over the public admin API.
A ctfd block with the same shape exists for CTFd deployments, and both can coexist if you mirror the same challenge repo to multiple platforms.
clusters#
Named Kubernetes clusters that challenge kubernetes_manifests / kubernetes_inline_manifests deployments target. Each entry picks exactly one auth backend:
alias_to lets a challenge reference one cluster name while routing to another. The DiceCTF example uses this so per-challenge configs say cluster_name: main, while the operator swaps main → prod at the root level.
registries#
Aliases for container registry prefixes. A challenge’s deployment.images[].registry_name looks up an entry here, then prepends the resolved value to the image name. Splitting registries (one for static-hosted images, one for instancer images) is common when the rCTF instancer cluster and the kCTF cluster live in different GCP projects.
domains#
Free-form key/value map of domain names available to the Jinja templates inside challenge configs ({{ config.domains['kctf'] }} in the examples). Keeps the deploy domain out of every per-challenge config.
templates#
Overrides for the rendered challenge description and endpoint block. The defaults wrap the description with a connection-info section followed by **Author**: .... Overrides are Jinja2 templates with challenge, config, and models available.
endpoints_text is per-provider because rCTF and CTFd render markdown differently. Each provider sees its own rendered endpoints, and then challenge_description is run once per provider with that provider’s endpoints_rendered in scope. To override just one side, set only that key. The other keeps its default.
templates: endpoints_text: rctf: | {% for endpoint in challenge.endpoints %} ...your override... {% endfor %}discovery#
Top-level discovery options.
attachment_format#
tar_gz (default) or zip. Picks the archive format Konata uses when bundling attachment files for upload.
Per-challenge config#
Each challenge directory has a kona.yml (or kona.yaml) that declares one or more challenges plus an optional deployment block. The simplest static challenge needs nothing but a category, name, author, description, and flag:
challenges: - category: sanity name: survey author: defund sort_weight: 98 description: | Thanks for participating! Fill out this [survey](https://forms.gle/xxx) to get the flag. flags: rctf: dice{thanks_for_playing_dicectf!!!!!!} scoring: initial_value: 1 minimum_value: 1 rctf: eligible_for_tiebreaks: falsediscovery: skip: truediscovery.skip: true opts the directory out of discovery, which is useful when the file is committed but the challenge isn’t ready to deploy yet.
Challenge fields#
Attachments#
Three forms are accepted:
A bare list:
attachments: - dist/bzImage - dist/initramfs.cpio.gzA full AttachmentConfig:
attachments: files: - 'build.sh' - 'Dockerfile' - 'garden' - 'garden.c' - 'ld-linux-x86-64.so.2' - 'libc.so.6' exclude: - '**/__pycache__' additional: - path: flag.txt str: 'dice{dummy_flag}' pre_compressed: - dist/handout.tar.gzFlags#
flags is a per-platform map. Each platform has its own shape, where rCTF accepts exactly one flag and CTFd accepts a list. Inside each platform, a flag value can be an inline literal string, { str: ... }, or { file: ... } (read at sync time, with the path resolved relative to the challenge directory).
# rCTF flag, inline literal. This form is the most common.flags: rctf: dice{example}
# rCTF flag, explicit str form. Equivalent to the inline literal above.flags: rctf: str: dice{example}
# rCTF flag, read from a file. Pairs well with an `additional` attachment that ships a dummy flag.txt# (see Attachments) so the build context stays self-contained.flags: rctf: file: flag.txtEndpoints#
Static-hosting deployments declare endpoints so Konata can render connection info into the description. Each entry has a type (one of http, https, socat, nc, ncat-ssl), an endpoint host, and an optional port. Jinja templating works on endpoint, so the host can be built from the challenge name and the global domains map:
endpoints: - type: nc endpoint: "{{ challenge.name }}.{{ config.domains['kctf'] }}" port: 1337Deployment#
deployment declares what Konata should build and what manifests to apply on top of the challenge sync. Both blocks are optional, so a static challenge with just a flag and an attachment skips deployment entirely.
Building images#
deployment: images: - build_context: . dockerfile: bot/Dockerfile # optional, defaults to <build_context>/Dockerfile name: '{{ challenges[0].name }}' tag: latest registry_name: instancer-challenges platform: linux/amd64 build_args: ENV: prod no_cache: false exports: - stage: out src: /out dst: ./handoutImage references inside Kubernetes manifests can interpolate {{ images[challenge.name] }} to pull in the fully-resolved registry/name:tag for the current challenge.
Kubernetes manifests#
Two equivalent forms:
deployment: kubernetes_manifests: - paths: - manifests/deployment.yaml - manifests/service.yaml cluster_name: main rollout_restart: image: true…or inline documents:
deployment: kubernetes_inline_manifests: - cluster_name: main documents: - apiVersion: kctf.dev/v1 kind: Challenge metadata: name: '{{ challenges[0].name }}' spec: deployed: true image: '{{ images[challenges[0].name] }}' network: public: true ports: - protocol: TCP port: 1337 targetPort: 5000Instanced challenges#
For challenges using the rCTF instancer, add instancer_config. The schema mirrors the rCTF instancer config. The outer envelope is the same across providers, and only the inner config differs (Docker Compose-like for docker-instancer, pods[] for k8s-instancer). The whole Konata schema accepts both snake_case and camelCase keys.
challenges: - category: web name: mirror-temple author: arcblroth description: | stare long enough at the void and the void stares back attachments: files: - 'Dockerfile' - 'chall/src/' flags: rctf: file: flag.txt instancer_config: challenge_integration_id: '{{ challenge.name }}' timeout_milliseconds: 1800000 extendable: true expose: - kind: https host_prefix: '{{ challenge.name }}' container_name: app container_port: 8080 config: pods: - name: app ports: - protocol: TCP name: http-service port: 8080 spec: containers: - name: app image: '{{ images[challenge.name] }}' resources: requests: cpu: '500m' memory: '500Mi' limits: cpu: '3' memory: '2Gi' restartPolicy: Always terminationGracePeriodSeconds: 0 automountServiceAccountToken: false egress: true
deployment: images: - build_context: . name: '{{ challenges[0].name }}' tag: latest registry_name: instancer-challenges platform: linux/amd64The instanced flow only needs pod definitions. The rCTF instancer’s k8s-controller handles namespaces, services, network policies, and ingress at runtime, so no kubernetes_inline_manifests block is required.
CLI#
kona sync#
The main entry point. Walks the deploy directory, builds and pushes any declared images, applies Kubernetes manifests, then syncs challenge metadata to every configured platform.
kona sync -d ./ctf-challengeskona compress#
Helper for producing an attachment archive from a folder or file. Useful when you want to commit a pre-built handout and reference it through attachments.pre_compressed.
kona compress ./challenge/dist --format zip --output handout.zipBoth formats are deterministic. File mtimes are zeroed (fixed to 1980-01-01 for zip, 0 for tar.gz), uid/gid become 0, uname/gname become kona, permissions are normalized to 0777, the gzip header drops its own filename and mtime, and entries are sorted by archive path. Compressing the same set of files always produces a byte-identical archive (and therefore an identical hash), so attachment dedup and CI cache hits stay stable across machines, runs, and users. Konata applies the same normalization to attachments it builds internally during sync.
CI integration#
The project-sekai-ctf/konata-deploy-action GitHub Action exposes two subactions. The detect action walks the repository and emits a matrix of changed challenges, and sync runs kona sync --challenge-path <one> for one matrix shard. Together they restrict each push to redeploying only the challenges that changed.
The DiceCTF Quals 2026 workflow is a good reference:
name: Deploy challenges
on: push: branches: [main]
permissions: contents: read id-token: write
jobs: detect: runs-on: ubuntu-latest outputs: matrix: ${{ steps.detect.outputs.matrix }} should_sync: ${{ steps.detect.outputs.should-sync }} steps: - uses: actions/checkout@v6 - uses: project-sekai-ctf/konata-deploy-action/detect@main id: detect
sync: needs: detect if: needs.detect.outputs.should_sync == 'true' runs-on: ubuntu-latest strategy: matrix: challenge: ${{ fromJson(needs.detect.outputs.matrix) }} fail-fast: false steps: - uses: actions/checkout@v6
- name: Authenticate to Google Cloud uses: google-github-actions/auth@v3 with: workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }} service_account: ${{ secrets.SERVICE_ACCOUNT }}
# Doing it like this is faster than anything else - name: Install gke-gcloud-auth-plugin run: | curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg | sudo gpg --batch --dearmor -o /usr/share/keyrings/cloud.google.gpg echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" | sudo tee /etc/apt/sources.list.d/google-cloud-sdk.list > /dev/null sudo apt-get update -o Dir::Etc::sourcelist=/etc/apt/sources.list.d/google-cloud-sdk.list -o Dir::Etc::sourceparts="-" sudo apt-get install -yq --no-install-recommends google-cloud-cli-gke-gcloud-auth-plugin
- name: Configure Docker for Artifact Registry run: gcloud auth configure-docker europe-west1-docker.pkg.dev,us-central1-docker.pkg.dev --quiet
- uses: project-sekai-ctf/konata-deploy-action/sync@main with: challenge-path: ${{ matrix.challenge }} env: RCTF_TOKEN: ${{ secrets.RCTF_TOKEN }}Notable bits:
- Workload Identity Federation is preferred over a long-lived JSON key. The
id-token: writepermission is what makesgoogle-github-actions/auth@v3work with WIF. gcloud auth configure-dockeris needed for every Artifact Registry host the matrix shard might push to.RCTF_TOKENis the admin team token referenced by the rootkona.yml(secrets.token.env: RCTF_TOKEN).
Real-world reference#
The DiceCTF Quals 2026 challenges repository is the most complete public Konata + rCTF deployment. It covers static-hosted kCTF challenges, rCTF-instancer challenges, file-backed flags, dummy-flag injection via additional attachments, multi-cluster registries, and the change-detected CI matrix.