Importing & Repo Bindings

How organizers get challenges onto the platform — by hand for a one-off, or as a continuously-synced GitHub repository for a whole event. This page covers the import endpoints, the per-game submission gate, and the repo-binding poller that turns a tree of .gzevent manifests into live games.

Tip

If you are authoring the challenges themselves, start with Challenge YAML and the Templates — this page assumes you already have challenge.yml files in hand and want to get them imported at scale.

The five import paths

There are five ways a challenge (or a whole game) reaches the platform — four one-shot endpoints plus the continuous repo binding. They differ in who can call them, whether the result is auto-approved or lands in a review queue, and whether it's a one-shot or a continuous sync.

PathEndpointWhoApprovalShape
Admin tarball importPOST /api/Edit/Games/{id}/Challenges/ImportGame adminAuto-approved (Active)One-shot
Public web submitPOST /api/Edit/Games/{id}/Challenges/SubmitAny logged-in userLands Pending reviewOne-shot
One-shot GitHub importPOST /api/Edit/Games/{id}/Challenges/ImportFromGitHubGame adminAuto-approvedOne-shot
Game package importPOST /api/Edit/Games/ImportAdmin— (whole game)One-shot
Repo bindingregistered in admin UIAdminAuto-approvedContinuous

The repo-binding poller is the scalable option and gets its own section below. The one-shot endpoints are described first.

(a) Admin tarball import — auto-approved

A game admin uploads a single-challenge tarball; the resulting challenge(s) are imported and approved in one shot (they go straight to Active and are visible to participants once enabled).

curl -X POST "https://PUBLIC_ENTRY/api/Edit/Games/42/Challenges/Import" \
  -H "Cookie: GZCTF_Token=<admin-session>" \
  -F "archive=@my-challenge.tar"

The endpoint is gated by game-admin permission and passes AutoApprove: true into the import service. There is no per-game opt-in required for this path — being a game admin is the authorization.

(b) Public web submit — gated, lands Pending

Any logged-in user can submit a single-challenge tarball, but only if the game has user submissions enabled. This is the path the web upload templates point at (/games/<id>/submit).

curl -X POST "https://PUBLIC_ENTRY/api/Edit/Games/42/Challenges/Submit" \
  -H "Cookie: GZCTF_Token=<user-session>" \
  -F "archive=@my-challenge.tar"

Behaviour:

  • The game's AllowUserSubmissions flag must be on (admin → game → Info). When it is off, the endpoint returns 403 Forbidden with "User submissions are disabled for this game." and the submit page is disabled in the UI.
  • On success the challenge is imported with AutoApprove: false, so it lands with ChallengeReviewStatus.Pending and is hidden from participants until an admin approves it via the review queue (GET /api/Edit/Games/{id}/PendingChallenges, then POST .../Challenges/{cId}/Approve).
Info

A&D and KotH challenges carry no author flag. For these types the platform plants and rotates the flag itself (A&D) or scores a planted control token (KotH), so a submitted AttackDefense / KingOfTheHill archive does not need a flags: list to pass import. See Attack & Defense and King of the Hill for the flag/scoring flow.

(c) One-shot import from GitHub

A game admin can pull challenges directly from a GitHub location (repo URL + optional ref + optional subpath) without registering a persistent binding. Results are auto-approved.

curl -X POST "https://PUBLIC_ENTRY/api/Edit/Games/42/Challenges/ImportFromGitHub" \
  -H "Cookie: GZCTF_Token=<admin-session>" \
  -H "Content-Type: application/json" \
  -d '{
        "repoUrl": "https://github.com/org/ctf-2026",
        "ref": "main",
        "subpath": "final",
        "gitHubToken": "ghp_xxx"
      }'

The URL/ref/subpath are validated by the GitHub locator; an unparseable URL returns 400. The token is optional (only needed for private repos) and is used for that single pull — it is not persisted. For an ongoing sync, register a binding instead.

(d) Bulk game-package import

POST /api/Edit/Games/Import takes a full exported game ZIP (the counterpart to the game export endpoint) and rebuilds the entire game. Admin-only.

curl -X POST "https://PUBLIC_ENTRY/api/Edit/Games/Import" \
  -H "Cookie: GZCTF_Token=<admin-session>" \
  -F "file=@game-42-export.zip"

It accepts only .zip files with a zip content type and returns the new game ID on success.

Size limits (from the code)

These limits are enforced server-side. Exceeding them returns 400 before anything is unpacked.

PathLimitSource
Challenge Submit / Import tarball64 MB (MaxTarballBytes = 64L * 1024 * 1024)RequestSizeLimit + explicit length check
Game package Games/Import ZIP512 MBexplicit case > 512 * 1024 * 1024
Warning

The 64 MB tarball ceiling is per challenge archive. Large container challenges should ship their build context (src/, Dockerfile) — not a pre-built image — and put only player-facing files in dist/. If your archive is bigger than 64 MB, prefer a repo binding (which clones via git, not a single multipart upload).

Repo bindings

A repo binding is a platform-level registration of a GitHub repository. A background poller keeps it in sync: it clones the repo, walks the tree for every .gzevent manifest, turns each manifest into a Game, and imports every challenge.yml under that event root. This is the path for running a whole event — or many events — out of one repo.

A binding is distinct from a per-game repo watch: a watch updates one already-existing game, whereas a binding creates games automatically and lives at the platform level.

What you register

FieldMeaningDefault / clamp
RepoUrlGitHub repository URL (unique per binding)required
RefBranch / tag / ref to trackoptional (repo default)
GitHubTokenEncryptedPAT, encrypted at rest — required for private repos, null for publicoptional
IntervalSecondsRe-scan cadencedefault 60, clamped to [60, 86400]
StatusActive polls, Paused skipsActive
PushOnEditSerialize admin-approved edits back to the repooff

The PAT is stored encrypted via ASP.NET DataProtection and is [JsonIgnore]d, so it is never returned by the API.

How the poller works

The RepoBindingScanService background worker ticks every 30 seconds. On each tick it:

  1. Runs a stale-activity watchdog — clears the live CurrentActivity field on any binding whose scan hasn't completed in 15 minutes (recovers a UI that's stuck on "Syncing…" after a mid-fetch crash).
  2. Selects bindings that are Active and due (NextScanUtc is null or in the past), oldest first, at most 8 per tick (Take(8)) to bound GitHub work.
  3. Scans each due binding, then always advances NextScanUtc by the clamped interval — even on failure — so a permanently-broken repo can't hot-loop the poller.

So a binding with the default IntervalSeconds = 60 is re-scanned roughly once a minute (subject to the 30 s tick granularity and the 8-per-tick fan-out cap).

Persistent shallow clone + SHA short-circuit

Each binding keeps a persistent shallow git checkout at /app/repos/binding/{id} (on the gzctf-repos Docker volume, so it survives container restarts):

  • First scan = git clone --depth 1 (one full payload).
  • Every later scan = git fetch --depth 1 (a small delta).

After the fetch, the worker compares the resolved commit SHA against the binding's LastCommitSha. If they match and the scan was not forced, the import is skipped entirely — the status row records "No change since <sha7> — skipped import.". On an unchanged repo this is the cheap (~150 ms) common case, which is why a 60 s cadence is affordable.

"Scan now" (force)

The admin "Scan now" action calls the scan with force = true, which bypasses the SHA short-circuit and re-downloads + re-imports unconditionally even when git hasn't moved. Use it to recover from a partial scan or to re-run the import pipeline against an unchanged tree. Because discovery is idempotent (games are upserted by (BindingId, EventManifestPath)), forcing a re-scan is safe.

What a scan does to each manifest

For every .gzevent found anywhere in the tree:

  1. The manifest YAML is parsed into the event model (see below); a missing title or invalid YAML is recorded as a per-manifest failure and the scan moves on.
  2. The Game is upserted — keyed on (BindingId, manifestRelPath). A detached game with a matching title is adopted rather than duplicated (the delete-then-rebind recovery path).
  3. Every challenge.yml under that event root directory is imported via the standard challenge-import pipeline with AutoApprove: true.

Sparse update semantics: on a re-scan, only the manifest fields that are actually present override the Game; omitted fields keep their current value, so a re-scan never resets hand-tuned settings.

Live progress is written to CurrentActivity as the scan walks ("Syncing git checkout", "Discovering .gzevent manifests", "Processing event 2/5: final/.gzevent", …) and surfaced on the admin repo-bindings card. A summary like games +1 ~0, challenges +12 ~3, failures 0 lands in LastScanMessage when it finishes.

The .gzevent manifest

A .gzevent file at the root of an event directory tells the binding "this folder is one game." Every challenge.yml beneath it becomes a challenge in that game. All fields are optional except title; omitted fields keep platform defaults on create (and current values on update).

# final/.gzevent  → becomes one Game; every challenge.yml under final/ is imported
title: "MyCTF 2026 Finals"
summary: "The on-site finals."
content: |
  Welcome to the finals. Read the rules carefully.
start: "2026-06-01T09:00:00+08:00"
end:   "2026-06-01T21:00:00+08:00"
hidden: false
practiceMode: true
acceptWithoutReview: false
inviteCode: "finals-2026"
teamMemberCountLimit: 4      # 0 = unlimited
containerCountLimit: 3
writeupRequired: false
writeupDeadline: "2026-06-08T21:00:00+08:00"
bloodBonus: 5000000          # packed first/second/third-blood bonus value

# Event-wide Attack & Defense knobs — shared by EVERY A&D/KotH challenge in
# this event, because a round spans the whole game. Omit the block to keep
# all platform defaults.
ad:
  tickSeconds: 60            # round/tick length              (default 60)
  flagLifetimeTicks: 5       # ticks a planted flag stays submittable (default 5)
  warmupSeconds: 1800        # grace before scoring begins    (default 1800)
  resetCooldownMinutes: 5    # min wait between team self-resets (default 5)
  allowSnapshotDownload: true        # post-game container snapshot (default true)
  snapshotRetentionDays: 30          # null = keep indefinitely
  getflagWindowFraction: 0.5         # getflag jitter as fraction of tick (default 0.5)
  minGracePeriodSeconds: 3           # delay after round start before getflag (default 3)
Info

Dates use ISO-8601 and may carry a local offset (e.g. +08:00); the importer normalizes everything to UTC before persisting.

What the event-wide ad: block configures

The ad: block on the manifest carries only the game-wide A&D/KotH timing knobs — the ones that must be shared because a round spans the whole game (tick length, flag lifetime, warmup, reset cooldown, snapshot policy, and the getflag jitter/grace timing that are fractions of the tick).

It is separate from the per-challenge ad: block inside each challenge.yml, which carries that one service's own properties (checkerImage, allowEgress, allowSelfReset). Don't confuse the two. See Attack & Defense for the per-challenge block and the checker/flag flow.

Warning

The KotH refresh interval and hold-points-per-tick are not carried in the manifest and are not exposed in the admin UI — they are currently fixed at their defaults. Tuning them today means editing the Game row directly (KothRefreshTicks / KothHoldPointsPerTick). See King of the Hill.

ignore: true — stop a challenge syncing

Deleting a challenge in the admin UI removes it from the platform but not from the repo, so the next scan would re-import (resurrect) it. To keep it gone, add ignore: true to its challenge.yml:

name: "old-challenge"
type: "StaticAttachment"
ignore: true   # importer skips this challenge entirely — never created/updated

ignore: true makes the importer skip the challenge (no create, no update), so a one-time UI delete sticks. It does not delete an already-imported copy — remove that in the UI (after which it simply stops receiving updates). Drop the key to resume syncing.

Tip

value: and any visible: / enabled: fields in challenge.yml are ignored on import — points and visibility are admin-controlled after review, not set by the repo.

PushOnEdit — write approved edits back to the repo

By default a binding is read-only: the platform imports from the repo and never writes to it. Enabling PushOnEdit reverses this for one specific flow — when an admin approves a user-submitted challenge, the platform serializes that challenge back into the binding repo as a fresh commit.

The written path mirrors the layout the scanner imports from:

{eventDir}/{category}/{slug}/challenge.yml
{eventDir}/{category}/{slug}/{attachment}     # local attachment, best-effort

where eventDir is the directory of the game's EventManifestPath, category is the challenge category, and slug is the normalized challenge title. After pushing, the challenge's SourceYamlPath is updated to that new path so subsequent edits push back to the same file. The push is best-effort and fire-and-forget — approval succeeds in the database even if the push later fails.

Gates (all must hold, or the approval is purely DB-side):

  • the game has a RepoBindingId and a non-empty EventManifestPath,
  • the binding has PushOnEdit = true,
  • the binding has a stored token that decrypts successfully.
Warning

The PAT must have Contents:write scope for push-back. A read-only token is fine for import but will fail the push.

Also note: regenerating the YAML drops comments and reorders fields per the serializer. Don't enable PushOnEdit on a repo whose challenge.yml files carry hand-edited comments you want to keep.

Binding health: Status and TokenStatus

Two fields, both surfaced on the admin repo-bindings card, tell you whether a binding is healthy.

Status — Active (polled every interval) or Paused (skipped by the poller).

TokenStatus — updated on every scan so the UI can distinguish a bad token from an unrelated scan failure:

TokenStatusMeaning
NotConfiguredNo token stored — the public-repo path.
OkToken decrypted and was used successfully on the last scan.
DecryptFailedStored ciphertext could not be decrypted (e.g. the DataProtection key changed). The scan aborts with "Stored token could not be decrypted."
Info

A private or missing repo, or a missing/expired token, does not crash the poller — it surfaces as a concise scan warning on the binding row (a friendly hint plus git's stderr), logged once per tick as a single warning rather than a stack-trace flood. There is no TokenStatus value for "present but rejected," because a rejected token can't be told apart from a correct one without a probe; the actionable hint rides in LastScanMessage instead.

Worked example: a ready-to-run A&D + KotH event

If you just want to try the Attack & Defense and King of the Hill engine without authoring anything, point a binding at the public testing repo github.com/TCP1P/TCP1PADTesting. It's a focused four-challenge event — two A&D services and two KotH hills — that exercises the whole A&D/KotH pipeline end to end (the round engine, per-team containers, the shared hill, the auto-built ./src services and ./checker SLA images, and scoring). The server clones the repo itself, so there is nothing to build or run locally first; just register the binding.

What's in it

A single .gzevent under events/tcp1p-testing/ defines the game. Each challenge ships its service in ./src, a functionality/health SLA checker in ./checker (both auto-built on import — no containerImage/checkerImage is pinned), a reference ./solver, and — for the two KotH hills — a player handout in ./dist:

TCP1PADTesting/
└── events/tcp1p-testing/
    ├── .gzevent                       # → one Game: "TCP1P A&D + KotH Testing"
    ├── Web/
    │   ├── owasp-portal/              # type: AttackDefense — OWASP Top-10 notes portal; every feature leaks the flag
    │   │   ├── challenge.yml
    │   │   ├── src/                   #   service image (auto-built from ./src/Dockerfile)
    │   │   ├── checker/               #   functionality-only SLA checker (auto-built)
    │   │   └── solver/                #   reference exploit
    │   └── koth-throne/               # type: KingOfTheHill — OWASP web hill; write your token into /koth/king
    │       ├── challenge.yml
    │       ├── src/  checker/  solver/
    │       └── dist/                  #   player handout (provide: "./dist")
    └── Pwn/
        ├── pwn-armory/                # type: AttackDefense — heap binary, classic memory-corruption bug classes
        │   ├── challenge.yml
        │   └── src/  checker/  solver/
        └── koth-pwn/                  # type: KingOfTheHill — binary hill; flip is_admin / ret2 do_crown()
            ├── challenge.yml
            └── src/  checker/  solver/  dist/
PathTypeWhat it exercises
Web/owasp-portal/AttackDefenseOWASP Top 10 (2021) notes portal; every feature carries one vuln and all ten leak the per-tick flag. allowSelfReset: true.
Web/koth-throne/KingOfTheHillOWASP web hill — write your per-round control token into /koth/king. One shared hill, so allowSelfReset: false.
Pwn/pwn-armory/AttackDefenseMenu-driven heap binary (no canary/PIE/RELRO) with classic memory-corruption bug classes; every bug reaches the flag. allowSelfReset: true.
Pwn/koth-pwn/KingOfTheHillBinary hill — flip is_admin or ret2 do_crown() to enthrone your token in /koth/king. Shared hill, allowSelfReset: false.

The manifest

events/tcp1p-testing/.gzevent defines the one game and carries the event-wide ad: block (tick length, flag lifetime, warmup, reset cooldown) that every A&D/KotH challenge in this event shares — abridged:

title: "TCP1P A&D + KotH Testing"
summary: "Four targets: OWASP-Top-10 and PWN, in both Attack & Defense and King of the Hill."
start: "2026-05-31T10:00:00+08:00"   # local offsets are fine — normalized to UTC on import
end:   "2026-05-31T22:00:00+08:00"
hidden: true                         # imported hidden — you flip it visible in the admin UI
practiceMode: true
acceptWithoutReview: false
teamMemberCountLimit: 5
containerCountLimit: 5
ad:
  tickSeconds: 60                    # round length in seconds
  flagLifetimeTicks: 5               # ticks a planted flag stays submittable
  warmupSeconds: 600                 # grace after start before scoring begins
  resetCooldownMinutes: 5            # min minutes between a team's self-resets
  allowSnapshotDownload: true

A challenge — A&D and KotH side by side

Web/owasp-portal/challenge.yml — an Attack & Defense service whose image is auto-built from ./src/Dockerfile (no containerImage), with a functionality-only checker auto-built from ./checker:

name: owasp-portal
author: TCP1P
type: AttackDefense
category: Web
container:
  memoryLimit: 512
  cpuCount: 1
  storageLimit: 512
  exposePort: 8080
ad:
  allowEgress: true
  allowSelfReset: true
  sshRequiresFlag: false   # true ⇒ a team may SSH into its own box only after capturing a flag here

Web/koth-throne/challenge.yml — a King of the Hill challenge (one shared hill). KotH is hold-scored, not flag-scored, so value is fixed; provide: "./dist" ships the hill source as the player handout:

name: "koth-throne"
author: "TCP1P"
category: "Web"
type: "KingOfTheHill"      # don't touch this value
value: 1000                # don't touch this value (KotH is hold-scored, not flag-scored)
provide: "./dist"          # player handout, zipped from ./dist
container:
  exposePort: 8080
  memoryLimit: 512
  cpuCount: 1
  storageLimit: 512
ad:
  allowEgress: true
  allowSelfReset: false    # MUST stay false — the hill is SHARED; a self-reset wipes everyone's foothold + the king
Tip

challenge.yaml and challenge.yml are both accepted. Neither entry pins a containerImage or checkerImage, so the platform auto-builds each service from ./src/Dockerfile and each SLA checker from ./checker/Dockerfile — eight images for the four challenges. See Challenge Build Pipeline for the build flow and Challenge YAML for the per-challenge ad: block.

Bind it

The repo is public, so no token is needed:

  1. Admin → Repo Bindings → Add, with RepoUrl = https://github.com/TCP1P/TCP1PADTesting, an empty ref (default branch), and IntervalSeconds = 60. Leave the token blank.
  2. Hit Scan now (or wait one poll cycle). The events/tcp1p-testing/.gzevent manifest becomes the TCP1P A&D + KotH Testing game and the four challenge.yml files import; LastScanMessage shows games +1, challenges +4, failures 0. The eight images (one ./src service + one ./checker per challenge) build in the background — watch them under admin → Builds.
  3. The game imports hidden — open admin → game → Info, set your own start/end time, and unhide it. The Info page also exposes the round settings; those game-level edits are yours to keep, and a later sync refreshes the challenges without reverting your Info-page changes (see the event-wide ad: block).
Tip

This is the fastest way to see a populated A&D/KotH scoreboard, the live attack feed (/games/{id}/attack), and the per-game cheat analysis with real data on a fresh deploy. The auto-built checker images are local-only — if an aggressive docker image prune removes one, the platform rebuilds it from the persisted build context on the next tick.

Info

For a private repo, the only difference is adding a PAT on the binding (and Contents:write if you want PushOnEdit). The layout, manifest, and challenge files are identical.

For the engine these challenges drive, see Attack & Defense and King of the Hill.