Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 36 additions & 7 deletions cmd/platform.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"io"
"os"
"strings"
"time"

"github.com/spf13/cobra"

Expand Down Expand Up @@ -141,11 +142,11 @@ var (

var platformConfigureCmd = &cobra.Command{
Use: "configure",
Short: "Sync event-level CTFd config (ctf_name) from mctf.config.yml",
Long: "Pushes ctf_name to CTFd's /api/v1/configs from the loaded\n" +
"event config. Theme settings (theme_name, theme_color, custom\n" +
"HTML) are intentionally not synced — those are typically a one-\n" +
"time UI setup, not per-event automation.\n\n" +
Short: "Sync event-level CTFd config (ctf_name, ctf_description, start, end) from mctf.config.yml",
Long: "Pushes ctf_name, ctf_description, start, and end timestamps to CTFd's\n" +
"/api/v1/configs from the loaded event config. Theme settings\n" +
"(theme_name, theme_color, custom HTML) are intentionally not synced —\n" +
"those are typically a one-time UI setup, not per-event automation.\n\n" +
"Token: export MCTF_CTFD_TOKEN=<your-token> before running.\n\n" +
"Examples:\n" +
" mctf platform configure --dry-run\n" +
Expand All @@ -169,16 +170,44 @@ var platformConfigureCmd = &cobra.Command{
return fmt.Errorf("event.name is empty in mctf.config.yml — nothing to push")
}

if cfg.Event.StartAt != "" && cfg.Event.EndAt != "" {
start, err := time.Parse(time.RFC3339, cfg.Event.StartAt)
if err != nil {
return fmt.Errorf("invalid event.start_at: %w", err)
}
end, err := time.Parse(time.RFC3339, cfg.Event.EndAt)
if err != nil {
return fmt.Errorf("invalid event.end_at: %w", err)
}
if !start.Before(end) {
return fmt.Errorf("event.start_at (%s) must be before event.end_at (%s)", cfg.Event.StartAt, cfg.Event.EndAt)
}
}

fmt.Printf("planned config:\n")
fmt.Printf(" ctf_name = %q\n", cfg.Event.Name)
if cfg.Event.Description != "" {
fmt.Printf(" ctf_description = %q\n", cfg.Event.Description)
}
if cfg.Event.StartAt != "" {
fmt.Printf(" start = %q\n", cfg.Event.StartAt)
}
if cfg.Event.EndAt != "" {
fmt.Printf(" end = %q\n", cfg.Event.EndAt)
}
if flagConfigureDryRun {
return nil
}

client := ctfd.New(cfg.Platform.URL, token)
ctx := context.Background()
if err := client.SetConfig(ctx, "ctf_name", cfg.Event.Name); err != nil {
return fmt.Errorf("set ctf_name: %w", err)
if err := client.Configure(ctx, ctfd.EventConfig{
Name: cfg.Event.Name,
Description: cfg.Event.Description,
StartAt: cfg.Event.StartAt,
EndAt: cfg.Event.EndAt,
}); err != nil {
return err
}
fmt.Println("\nconfig pushed")
return nil
Expand Down
74 changes: 74 additions & 0 deletions cmd/platform_configure_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package cmd

import (
"os"
"path/filepath"
"strings"
"testing"
)

func TestPlatformConfigure_StartAfterEndValidation(t *testing.T) {
dir := t.TempDir()
cfg := []byte(`
version: 1
event:
name: "Bad Dates"
slug: "bad-dates"
start_at: "2026-10-18T10:00:00Z"
end_at: "2026-10-15T10:00:00Z"
platform:
type: ctfd
url: "https://ctfd.example.com"
`)
if err := os.WriteFile(filepath.Join(dir, "mctf.config.yml"), cfg, 0o644); err != nil {
t.Fatal(err)
}

t.Setenv("MCTF_CTFD_TOKEN", "mock-token")
flagDir = dir
flagConfigureDryRun = true
defer func() {
flagDir = ""
flagConfigureDryRun = false
}()

err := platformConfigureCmd.RunE(platformConfigureCmd, nil)
if err == nil {
t.Fatal("expected error when start_at is after end_at")
}
if !strings.Contains(err.Error(), "must be before") {
t.Errorf("unexpected error: %v", err)
}
}

func TestPlatformConfigure_DryRunOutput(t *testing.T) {
dir := t.TempDir()
cfg := []byte(`
version: 1
event:
name: "Valid CTF"
slug: "valid-ctf"
description: "Test description"
start_at: "2026-10-15T10:00:00Z"
end_at: "2026-10-17T18:00:00Z"
platform:
type: ctfd
url: "https://ctfd.example.com"
`)
if err := os.WriteFile(filepath.Join(dir, "mctf.config.yml"), cfg, 0o644); err != nil {
t.Fatal(err)
}

t.Setenv("MCTF_CTFD_TOKEN", "mock-token")
flagDir = dir
flagConfigureDryRun = true
defer func() {
flagDir = ""
flagConfigureDryRun = false
}()

err := platformConfigureCmd.RunE(platformConfigureCmd, nil)
if err != nil {
t.Fatalf("unexpected error in dry-run: %v", err)
}
}
2 changes: 1 addition & 1 deletion docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Open work organized by size. Each line is intentionally GitHub-issue-shaped: tit
- **rCTF + generic-webhook platforms** — second + third platform targets behind the existing platform interface, ideally via plugin once the system lands.
- **Per-team auth on the instancer** — currently bearer tokens have full power; add team-scoped tokens (`POST /spawn` only allows the request when the token's team_id matches the body).
- **Native instancer: docker-network isolation** — currently spawns share the event network; isolate each per-team instance to its own bridge.
- **`mctf platform configure` extensions** — sync `start` / `end` timestamps (needs schema addition `event.start_at` / `event.end_at`), `ctf_description`.
- ~~**`mctf platform configure` extensions** — sync `start` / `end` timestamps (needs schema addition `event.start_at` / `event.end_at`), `ctf_description`.~~ **Shipped** (schema v1 event metadata + CTFd sync; see `docs/spec/CHANGELOG.md`).
- **`mctf release`** — tag git, build via goreleaser, draft a release notes file from conventional-commits since last tag.

## Small wins (~half-day each)
Expand Down
2 changes: 1 addition & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ deterministic deploys and `deploy --rollback`.
| --- | --- |
| `mctf platform sync` | Sync challenges to the configured platform (re-uploads assets on content change) |
| `mctf platform unsync` | Remove a challenge from the platform |
| `mctf platform configure` | Sync event-level config (e.g. `ctf_name`) |
| `mctf platform configure` | Sync event-level config (`ctf_name`, `ctf_description`, `start`, `end`) |

## Inspect

Expand Down
2 changes: 2 additions & 0 deletions docs/spec/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ All features below are merged into `develop` and pass CI. Not yet released to `m

- **`feat(platform)`: CTFd asset content-hash re-upload** — `mctf platform sync` now re-uploads CTFd assets when their content hash changes, not just when the filename changes. Prevents stale handout files on CTFd.

- **`feat(platform)`: `mctf platform configure` event metadata** — `mctf.config.yml` gained `event.description`, `event.start_at`, and `event.end_at` (RFC3339). `mctf platform configure` pushes them to CTFd as `ctf_description`, `start`, and `end` configurations alongside `ctf_name`, with date-time validation and dry-run preview.

- **`feat(deploy)`: immutable image-tag pinning** — `mctf deploy` now writes the resolved image tag **literally** into `docker-compose.mctf.yml` (e.g. `image: registry/evt-web-chall:a1b2c3d`) instead of a `${IMAGE_TAG}` variable. The generated compose is a self-contained, reproducible record of exactly what is deployed — no ambient env var required — which is what makes deterministic deploys and `--rollback` work. The tag is no longer threaded through the environment (`envForUnit` dropped `IMAGE_TAG`). Empty tag falls back to `:latest`. Closes #5.

- **`feat(deploy)`: GitOps state sync + rollback** — `mctf deploy` gained a rollback system. On failure, the previous compose state is restored from git history. Registry push is now gated on `registry.url` presence to avoid crashes in registry-less setups.
Expand Down
3 changes: 3 additions & 0 deletions examples/sample-event/mctf.config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ version: 1
event:
name: "Sample MCTF Event"
slug: "sample-event"
description: "A sample capture-the-flag event illustrating schema v1."
start_at: "2026-10-15T10:00:00Z"
end_at: "2026-10-17T18:00:00Z"
domain: "ctf.example.com"
flag_prefix: "mctf"
waves_number: 3
Expand Down
3 changes: 3 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ type Config struct {
type Event struct {
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description,omitempty"`
StartAt string `json:"start_at,omitempty"`
EndAt string `json:"end_at,omitempty"`
Domain string `json:"domain,omitempty"`
FlagPrefix string `json:"flag_prefix,omitempty"`
WavesNumber int `json:"waves_number,omitempty"`
Expand Down
50 changes: 50 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,56 @@ profile: chaos
}
}

func TestLoadEventMetadata(t *testing.T) {
dir := t.TempDir()
cfg := []byte(`
version: 1
event:
name: "My CTF"
slug: "my-ctf"
description: "Annual security competition"
start_at: "2026-10-15T10:00:00Z"
end_at: "2026-10-17T18:00:00Z"
`)
if err := writeFile(filepath.Join(dir, FileName), cfg); err != nil {
t.Fatal(err)
}
c, err := Load(dir)
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.Event.Description != "Annual security competition" {
t.Errorf("description = %q", c.Event.Description)
}
if c.Event.StartAt != "2026-10-15T10:00:00Z" {
t.Errorf("start_at = %q", c.Event.StartAt)
}
if c.Event.EndAt != "2026-10-17T18:00:00Z" {
t.Errorf("end_at = %q", c.Event.EndAt)
}
}

func TestLoadInvalidEventTimestamps(t *testing.T) {
dir := t.TempDir()
bad := []byte(`
version: 1
event:
name: "Bad"
slug: "bad"
start_at: "not-a-rfc3339-timestamp"
`)
if err := writeFile(filepath.Join(dir, FileName), bad); err != nil {
t.Fatal(err)
}
_, err := Load(dir)
if err == nil {
t.Fatal("expected schema error for invalid date-time format")
}
if !contains(err.Error(), "/event/start_at") {
t.Errorf("error missing /event/start_at path: %v", err)
}
}

// trivial helpers (kept here to avoid importing extra packages just for tests)
func writeFile(path string, data []byte) error {
return _writeFile(path, data)
Expand Down
34 changes: 34 additions & 0 deletions internal/platform/ctfd/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,40 @@ func (c *Client) SetConfig(ctx context.Context, key, value string) error {
return c.do(ctx, "PATCH", "/api/v1/configs/"+key, body, nil)
}

// EventConfig defines event-level metadata pushed to CTFd configs.
type EventConfig struct {
Name string
Description string
StartAt string
EndAt string
}

// Configure pushes event-level settings (ctf_name, ctf_description, start, end)
// to CTFd's /api/v1/configs. Non-empty fields are updated sequentially.
func (c *Client) Configure(ctx context.Context, cfg EventConfig) error {
if cfg.Name != "" {
if err := c.SetConfig(ctx, "ctf_name", cfg.Name); err != nil {
return fmt.Errorf("set ctf_name: %w", err)
}
}
if cfg.Description != "" {
if err := c.SetConfig(ctx, "ctf_description", cfg.Description); err != nil {
return fmt.Errorf("set ctf_description: %w", err)
}
}
if cfg.StartAt != "" {
if err := c.SetConfig(ctx, "start", cfg.StartAt); err != nil {
return fmt.Errorf("set start: %w", err)
}
}
if cfg.EndAt != "" {
if err := c.SetConfig(ctx, "end", cfg.EndAt); err != nil {
return fmt.Errorf("set end: %w", err)
}
}
return nil
}

// GetConfig fetches a single config key.
func (c *Client) GetConfig(ctx context.Context, key string) (string, error) {
var env envelope[ConfigEntry]
Expand Down
88 changes: 88 additions & 0 deletions internal/platform/ctfd/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,3 +320,91 @@ func TestUpdateChallenge_PATCH(t *testing.T) {
t.Errorf("name = %q", out.Name)
}
}

func TestConfigure_AllFields(t *testing.T) {
f, c, _ := newFake(t)
calls := make(map[string]string)
f.handler = func(w http.ResponseWriter, r *http.Request) {
if r.Method != "PATCH" {
t.Errorf("method = %s", r.Method)
}
var b map[string]string
_ = json.Unmarshal(f.bodies[len(f.bodies)-1], &b)
calls[r.URL.Path] = b["value"]
_, _ = w.Write([]byte(`{"success": true, "data": {}}`))
}

cfg := EventConfig{
Name: "Test CTF",
Description: "A great CTF",
StartAt: "2026-10-15T10:00:00Z",
EndAt: "2026-10-17T18:00:00Z",
}
if err := c.Configure(context.Background(), cfg); err != nil {
t.Fatal(err)
}

want := map[string]string{
"/api/v1/configs/ctf_name": "Test CTF",
"/api/v1/configs/ctf_description": "A great CTF",
"/api/v1/configs/start": "2026-10-15T10:00:00Z",
"/api/v1/configs/end": "2026-10-17T18:00:00Z",
}
if len(calls) != len(want) {
t.Fatalf("got %d calls, want %d", len(calls), len(want))
}
for k, v := range want {
if calls[k] != v {
t.Errorf("calls[%q] = %q, want %q", k, calls[k], v)
}
}
}

func TestConfigure_PartialFields(t *testing.T) {
f, c, _ := newFake(t)
var paths []string
f.handler = func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
_, _ = w.Write([]byte(`{"success": true, "data": {}}`))
}

// Only name and description, no timestamps
cfg := EventConfig{
Name: "Only Name",
Description: "Only Desc",
}
if err := c.Configure(context.Background(), cfg); err != nil {
t.Fatal(err)
}

if len(paths) != 2 {
t.Fatalf("got %d calls, want 2: %v", len(paths), paths)
}
if paths[0] != "/api/v1/configs/ctf_name" || paths[1] != "/api/v1/configs/ctf_description" {
t.Errorf("unexpected paths: %v", paths)
}
}

func TestConfigure_ErrorPropagation(t *testing.T) {
f, c, _ := newFake(t)
f.handler = func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/configs/start" {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"success": false, "message": "database error"}`))
return
}
_, _ = w.Write([]byte(`{"success": true, "data": {}}`))
}

cfg := EventConfig{
Name: "Test CTF",
StartAt: "2026-10-15T10:00:00Z",
}
err := c.Configure(context.Background(), cfg)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "set start:") {
t.Errorf("unexpected error message: %v", err)
}
}
Loading
Loading