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
25 changes: 25 additions & 0 deletions cmd/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/MicroClub-USTHB/mctf-cli/internal/challenge"
"github.com/MicroClub-USTHB/mctf-cli/internal/config"
"github.com/MicroClub-USTHB/mctf-cli/internal/instancer"
"github.com/MicroClub-USTHB/mctf-cli/internal/plugin"
)

var instanceCmd = &cobra.Command{
Expand Down Expand Up @@ -107,11 +108,35 @@ var instanceSpawnCmd = &cobra.Command{
if err := store.Put(inst); err != nil {
return fmt.Errorf("persist instance: %w", err)
}

// Lifecycle plugins (best-effort; a no-op without a `plugins:` block).
pm := plugin.Load(ctx, cfg.Plugins, plugin.Options{HostVersion: version, Dir: flagDir})
defer pm.Close()
pm.Dispatch(ctx, plugin.HookOnInstanceSpawn, instanceSpawnPayload(cfg, inst))

fmt.Printf("\nspawn complete (expires %s)\n", inst.ExpiresAt.Format(time.RFC3339))
return nil
},
}

// instanceSpawnPayload converts an instancer.Instance into the plugin hook body.
func instanceSpawnPayload(cfg *config.Config, inst instancer.Instance) plugin.InstanceSpawnPayload {
eventSlug := ""
if cfg != nil {
eventSlug = cfg.Event.Slug
}
return plugin.InstanceSpawnPayload{
Event: eventSlug,
InstanceID: inst.ID,
ChallengeSlug: inst.ChallengeSlug,
TeamID: inst.TeamID,
Ports: inst.Ports,
Flag: inst.Flag,
Containers: inst.Containers,
ExpiresAt: inst.ExpiresAt,
}
}

var (
flagInstDestroyForce bool
flagInstDestroyVolumes bool
Expand Down
63 changes: 63 additions & 0 deletions cmd/instance_plugin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package cmd

import (
"testing"
"time"

"github.com/MicroClub-USTHB/mctf-cli/internal/config"
"github.com/MicroClub-USTHB/mctf-cli/internal/instancer"
)

func TestInstanceSpawnPayload(t *testing.T) {
cfg := &config.Config{
Event: config.Event{Slug: "test-event"},
}
expires := time.Date(2026, 10, 12, 16, 0, 0, 0, time.UTC)
inst := instancer.Instance{
ID: "cub3-team9-xyz",
ChallengeSlug: "web/cub3-clone",
TeamID: "team-9",
ProjectName: "cub3-team9-xyz",
Containers: []string{"cont-123", "cont-456"},
Ports: map[int]int{8000: 10042},
Flag: "mctf{dynamic_flag_test}",
ExpiresAt: expires,
}

payload := instanceSpawnPayload(cfg, inst)
if payload.Event != "test-event" {
t.Errorf("Event = %q, want test-event", payload.Event)
}
if payload.InstanceID != "cub3-team9-xyz" {
t.Errorf("InstanceID = %q, want cub3-team9-xyz", payload.InstanceID)
}
if payload.ChallengeSlug != "web/cub3-clone" {
t.Errorf("ChallengeSlug = %q, want web/cub3-clone", payload.ChallengeSlug)
}
if payload.TeamID != "team-9" {
t.Errorf("TeamID = %q, want team-9", payload.TeamID)
}
if payload.Ports[8000] != 10042 {
t.Errorf("Ports = %+v, want 8000 -> 10042", payload.Ports)
}
if payload.Flag != "mctf{dynamic_flag_test}" {
t.Errorf("Flag = %q, want mctf{dynamic_flag_test}", payload.Flag)
}
if len(payload.Containers) != 2 || payload.Containers[0] != "cont-123" {
t.Errorf("Containers = %+v", payload.Containers)
}
if !payload.ExpiresAt.Equal(expires) {
t.Errorf("ExpiresAt = %v, want %v", payload.ExpiresAt, expires)
}
}

func TestInstanceSpawnPayload_NilConfigSafe(t *testing.T) {
inst := instancer.Instance{ID: "inst-1"}
payload := instanceSpawnPayload(nil, inst)
if payload.Event != "" {
t.Errorf("Event = %q, want empty", payload.Event)
}
if payload.InstanceID != "inst-1" {
t.Errorf("InstanceID = %q, want inst-1", payload.InstanceID)
}
}
15 changes: 11 additions & 4 deletions cmd/instancer.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/MicroClub-USTHB/mctf-cli/internal/challenge"
"github.com/MicroClub-USTHB/mctf-cli/internal/config"
"github.com/MicroClub-USTHB/mctf-cli/internal/instancer"
"github.com/MicroClub-USTHB/mctf-cli/internal/plugin"
)

var instancerCmd = &cobra.Command{
Expand Down Expand Up @@ -77,14 +78,23 @@ var instancerServeCmd = &cobra.Command{
return err
}

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

pm := plugin.Load(ctx, cfg.Plugins, plugin.Options{HostVersion: version, Dir: flagDir})
defer pm.Close()

var runner instancer.Runner
if flagServeQuiet {
runner = instancer.NewQuietDockerRunner()
} else {
runner = instancer.NewDockerRunner()
}

srv := instancer.NewServer(cfg, store, runner, challs, instancer.Options{Tokens: tokens})
srv := instancer.NewServer(cfg, store, runner, challs, instancer.Options{
Tokens: tokens,
PluginManager: pm,
})

// Build the catalog for the reaper.
idx := map[string]*challenge.Challenge{}
Expand All @@ -99,9 +109,6 @@ var instancerServeCmd = &cobra.Command{
Logger: os.Stdout,
}

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

go func() {
if err := reaper.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
fmt.Fprintf(os.Stderr, "reaper exited: %v\n", err)
Expand Down
2 changes: 1 addition & 1 deletion docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Open work organized by size. Each line is intentionally GitHub-issue-shaped: tit
## Big rocks (multi-day each)

- **K8s deploy mode** — `mctf deploy --mode kubernetes`. Generate K8s Deployment / Service / Ingress manifests from per-challenge compose, `kubectl apply -f`. Honor `cfg.Deploy.Kubernetes.{Kubeconfig, Namespace, IngressClass}`.
- **Plugin system** — subprocess + JSON-RPC over stdio (Terraform-style), language-agnostic, crash-isolated. **Foundation + `before_deploy`/`after_deploy` + `before_build`/`after_build` shipped** (`internal/plugin`, `docs/spec/plugins.md`). *Remaining:* `on_instance_spawn` (two sites — `cmd/instance.go` + `internal/instancer/server.go`), `on_health_failure` (needs a health-monitoring loop — largest), dispatch on `down`/kubernetes paths, and a blocking/`required` mode (a plugin that can veto).
- **Plugin system** — subprocess + JSON-RPC over stdio (Terraform-style), language-agnostic, crash-isolated. **Foundation + `before_deploy`/`after_deploy` + `before_build`/`after_build` + `on_instance_spawn` shipped** (`internal/plugin`, `docs/spec/plugins.md`). *Remaining:* `on_health_failure` (needs a health-monitoring loop — largest), dispatch on `down`/kubernetes paths, and a blocking/`required` mode (a plugin that can veto).
- ~~**Prometheus / Grafana wiring** — `mctf monitoring up` brings up prom + grafana + cAdvisor with prebuilt dashboards. Generate scrape config from challenge labels.~~ **Shipped:** `mctf monitoring up` / `down` render and run the stack under `monitoring/stack/` (see `docs/spec/monitoring.md`).
- **Terraform wrapper** — `mctf infra plan|apply|destroy` for VPS, clusters, networking, DNS. Templates for DigitalOcean, Hetzner, DOKS, EKS, GKE.

Expand Down
25 changes: 24 additions & 1 deletion docs/spec/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ killed. (This mirrors the `notify` package.)
| `after_deploy` | deploy payload + `outcome` (`success`/`failed`) + `error` | **shipped** |
| `before_build` | build payload (event, sha, dry_run, images) | **shipped** |
| `after_build` | build payload + `outcome` (`success`/`failed`) + `error` | **shipped** |
| `on_instance_spawn` | instance payload | planned |
| `on_instance_spawn` | instance spawn payload (event, instance_id, challenge_slug, team_id, ports, flag, containers, expires_at) | **shipped** |
| `on_health_failure` | — | planned (needs a health-monitoring loop) |

### Deploy payload
Expand Down Expand Up @@ -134,6 +134,29 @@ killed. (This mirrors the `notify` package.)
`before_build` fires before `build.Apply` executes image builds.
`after_build` fires after building, leak scanning, and optional registry pushing complete (with `outcome: "success"` or `outcome: "failed"` and `error`).

### Instance spawn payload

```json
{
"event": "myctf",
"instance_id": "inst-a1b2c3d4e5f6",
"challenge_slug": "web-sqli",
"team_id": "team-42",
"ports": {
"80": 32768
},
"flag": "mctf{demo_flag}",
"containers": [
"mctf-myctf-web-sqli-inst-a1b2c3d4e5f6"
],
"expires_at": "2026-10-12T16:00:00Z"
}
```

`on_instance_spawn` fires whenever a new challenge instance is spawned (both via
the `mctf instance spawn` CLI command and the `mctf instancer serve` HTTP daemon).
Fields `ports`, `flag`, and `containers` are omitted when empty.

## Example

A minimal POSIX-`sh` plugin lives at
Expand Down
4 changes: 2 additions & 2 deletions examples/sample-event/plugins/logger.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,12 @@ while IFS= read -r line; do
cfgfile=$(field file "$line")
[ -n "$cfgfile" ] && logfile="$cfgfile"
echo "[logger] initialized, logging to $logfile" >&2
printf '{"jsonrpc":"2.0","id":%s,"result":{"name":"logger","hooks":["before_deploy","after_deploy","before_build","after_build"]}}\n' "$id"
printf '{"jsonrpc":"2.0","id":%s,"result":{"name":"logger","hooks":["before_deploy","after_deploy","before_build","after_build","on_instance_spawn"]}}\n' "$id"
;;
shutdown)
exit 0
;;
before_deploy|after_deploy|before_build|after_build)
before_deploy|after_deploy|before_build|after_build|on_instance_spawn)
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $method $line" >> "$logfile"
printf '{"jsonrpc":"2.0","id":%s,"result":{"ok":true}}\n' "$id"
;;
Expand Down
31 changes: 31 additions & 0 deletions internal/instancer/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/MicroClub-USTHB/mctf-cli/internal/challenge"
"github.com/MicroClub-USTHB/mctf-cli/internal/config"
"github.com/MicroClub-USTHB/mctf-cli/internal/plugin"
)

// Server is the HTTP daemon. It owns the store, runner, and challenge
Expand All @@ -25,13 +26,17 @@ type Server struct {
spawns keyLock
portMu sync.Mutex
reservedPorts map[int]bool
pm *plugin.Manager
}

// Options configures the Server.
type Options struct {
// Tokens are the bearer tokens accepted on every authed route.
// At least one token must be set; an empty set means "deny all".
Tokens []string
// PluginManager dispatches lifecycle hooks (e.g. on_instance_spawn)
// to registered plugins. Optional; nil disables plugin dispatch.
PluginManager *plugin.Manager
}

// NewServer constructs a Server from the given config + challenges +
Expand All @@ -57,6 +62,7 @@ func NewServer(cfg *config.Config, store *Store, runner Runner, challs []*challe
chalIdx: idx,
spawns: keyLock{m: make(map[string]*refLock)},
reservedPorts: make(map[int]bool),
pm: opts.PluginManager,
}
}

Expand Down Expand Up @@ -180,6 +186,24 @@ func (s *Server) spawn(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "persist: "+err.Error())
return
}

if s.pm != nil {
eventSlug := ""
if s.cfg != nil {
eventSlug = s.cfg.Event.Slug
}
s.pm.Dispatch(r.Context(), plugin.HookOnInstanceSpawn, plugin.InstanceSpawnPayload{
Event: eventSlug,
InstanceID: inst.ID,
ChallengeSlug: inst.ChallengeSlug,
TeamID: inst.TeamID,
Ports: inst.Ports,
Flag: inst.Flag,
Containers: inst.Containers,
ExpiresAt: inst.ExpiresAt,
})
}

writeJSON(w, http.StatusCreated, inst)
}

Expand Down Expand Up @@ -316,3 +340,10 @@ func (l *keyLock) Lock(key string) func() {
entry.mu.Unlock()
}
}

// Close terminates any active plugin subprocesses owned by the server.
func (s *Server) Close() {
if s.pm != nil {
s.pm.Close()
}
}
84 changes: 84 additions & 0 deletions internal/instancer/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
Expand All @@ -16,6 +18,7 @@ import (

"github.com/MicroClub-USTHB/mctf-cli/internal/challenge"
"github.com/MicroClub-USTHB/mctf-cli/internal/config"
"github.com/MicroClub-USTHB/mctf-cli/internal/plugin"
)

func newTestServer(t *testing.T) (*Server, *httptest.Server, *FakeRunner) {
Expand Down Expand Up @@ -328,6 +331,87 @@ func TestServer_ConcurrentDuplicateSpawn(t *testing.T) {
}
}

func TestServer_Spawn_DispatchesPluginHook(t *testing.T) {
tmp := t.TempDir()
logFile := filepath.Join(tmp, "spawn.log")
script := filepath.Join(tmp, "plugin.sh")
scriptContent := fmt.Sprintf(`#!/bin/sh
while IFS= read -r line; do
method=$(printf '%%s' "$line" | sed -n 's/.*"method":"\([^"]*\)".*/\1/p')
id=$(printf '%%s' "$line" | sed -n 's/.*"id":\([0-9]*\).*/\1/p')
case "$method" in
initialize)
printf '{"jsonrpc":"2.0","id":%%s,"result":{"name":"test-plugin","hooks":["on_instance_spawn"]}}\n' "$id"
;;
shutdown)
exit 0
;;
on_instance_spawn)
echo "$line" >> "%s"
printf '{"jsonrpc":"2.0","id":%%s,"result":{"ok":true}}\n' "$id"
;;
esac
done
`, logFile)
if err := os.WriteFile(script, []byte(scriptContent), 0o755); err != nil {
t.Fatal(err)
}

raw := []map[string]any{{
"name": "test",
"command": script,
}}
pm := plugin.Load(context.Background(), raw, plugin.Options{
HostVersion: "test",
StartTimeout: 5 * time.Second,
})
if pm == nil {
t.Fatal("expected live plugin manager")
}
defer pm.Close()

cfg := &config.Config{
Version: 1,
Event: config.Event{Slug: "test-event"},
Instancer: &config.Instancer{DefaultTimeout: 600},
}
store, err := NewStore(filepath.Join(tmp, "instances.json"))
if err != nil {
t.Fatal(err)
}
runner := NewFakeRunner()
c := instancedChall("cub3", "web", 8000)
srv := NewServer(cfg, store, runner, []*challenge.Challenge{c}, Options{
Tokens: []string{"secret"},
PluginManager: pm,
})
defer srv.Close()

httpSrv := httptest.NewServer(srv.Handler())
defer httpSrv.Close()

body := `{"team_id": "team-42", "challenge_slug": "web/cub3"}`
resp := authedPOST(t, httpSrv.URL, "/spawn", "secret", body)
if resp.StatusCode != 201 {
t.Fatalf("expected 201, got %d", resp.StatusCode)
}

data, err := os.ReadFile(logFile)
if err != nil {
t.Fatalf("failed to read plugin log: %v", err)
}
s := string(data)
if !strings.Contains(s, "on_instance_spawn") {
t.Errorf("on_instance_spawn hook not found in plugin log: %s", s)
}
if !strings.Contains(s, `"team_id":"team-42"`) {
t.Errorf("team_id not found in plugin log: %s", s)
}
if !strings.Contains(s, `"challenge_slug":"web/cub3"`) {
t.Errorf("challenge_slug not found in plugin log: %s", s)
}
}

// Ensure the server builds with the bytes package — unused-import guard.
var _ = bytes.NewReader

Expand Down
Loading
Loading