diff --git a/cmd/instance.go b/cmd/instance.go index f78fea7..c60977f 100644 --- a/cmd/instance.go +++ b/cmd/instance.go @@ -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{ @@ -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 diff --git a/cmd/instance_plugin_test.go b/cmd/instance_plugin_test.go new file mode 100644 index 0000000..8c951ac --- /dev/null +++ b/cmd/instance_plugin_test.go @@ -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) + } +} diff --git a/cmd/instancer.go b/cmd/instancer.go index a3661d0..d672881 100644 --- a/cmd/instancer.go +++ b/cmd/instancer.go @@ -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{ @@ -77,6 +78,12 @@ 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() @@ -84,7 +91,10 @@ var instancerServeCmd = &cobra.Command{ 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{} @@ -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) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 5b3feff..ab7a142 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -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. diff --git a/docs/spec/plugins.md b/docs/spec/plugins.md index a507e06..0df9a43 100644 --- a/docs/spec/plugins.md +++ b/docs/spec/plugins.md @@ -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 @@ -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 diff --git a/examples/sample-event/plugins/logger.sh b/examples/sample-event/plugins/logger.sh index 44cb075..0a0b72a 100755 --- a/examples/sample-event/plugins/logger.sh +++ b/examples/sample-event/plugins/logger.sh @@ -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" ;; diff --git a/internal/instancer/server.go b/internal/instancer/server.go index 03e177a..d7e672b 100644 --- a/internal/instancer/server.go +++ b/internal/instancer/server.go @@ -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 @@ -25,6 +26,7 @@ type Server struct { spawns keyLock portMu sync.Mutex reservedPorts map[int]bool + pm *plugin.Manager } // Options configures the Server. @@ -32,6 +34,9 @@ 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 + @@ -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, } } @@ -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) } @@ -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() + } +} diff --git a/internal/instancer/server_test.go b/internal/instancer/server_test.go index a0bde36..aa8ae51 100644 --- a/internal/instancer/server_test.go +++ b/internal/instancer/server_test.go @@ -5,9 +5,11 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "net/http" "net/http/httptest" + "os" "path/filepath" "strings" "sync" @@ -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) { @@ -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 diff --git a/internal/plugin/plugin_test.go b/internal/plugin/plugin_test.go index 4ae48db..7688015 100644 --- a/internal/plugin/plugin_test.go +++ b/internal/plugin/plugin_test.go @@ -50,14 +50,14 @@ func TestHelperProcess(t *testing.T) { fmt.Fprintln(os.Stdout, "this is not json") // desync the stream continue } - hooks := []string{"before_deploy", "after_deploy", "before_build", "after_build"} + hooks := []string{"before_deploy", "after_deploy", "before_build", "after_build", "on_instance_spawn"} if mode == "nohooks" { hooks = nil } reply(req.ID, map[string]any{"name": "helper-" + mode, "hooks": hooks}) case "shutdown": os.Exit(0) - case "before_deploy", "after_deploy", "before_build", "after_build": + case "before_deploy", "after_deploy", "before_build", "after_build", "on_instance_spawn": switch mode { case "crash": os.Exit(1) @@ -172,6 +172,49 @@ func TestManager_BuildHooksDelivered(t *testing.T) { } } +func TestManager_InstanceSpawnHookDelivered(t *testing.T) { + out := filepath.Join(t.TempDir(), "spawn_hooks.log") + m := helperManager(t, "echo", out) + if m == nil { + t.Fatal("manager should have one live plugin") + } + defer m.Close() + + ctx := context.Background() + expires := time.Date(2026, 10, 12, 15, 0, 0, 0, time.UTC) + m.Dispatch(ctx, HookOnInstanceSpawn, InstanceSpawnPayload{ + Event: "sample-event", + InstanceID: "cub3-team1-abc", + ChallengeSlug: "web/cub3-clone", + TeamID: "team-1", + Ports: map[int]int{8000: 10042}, + Flag: "mctf{dynamic_flag_123}", + Containers: []string{"c123456"}, + ExpiresAt: expires, + }) + + data, err := os.ReadFile(out) + if err != nil { + t.Fatalf("read hooks log: %v", err) + } + s := string(data) + if !strings.Contains(s, "on_instance_spawn") { + t.Errorf("on_instance_spawn not found in log:\n%s", s) + } + if !strings.Contains(s, `"instance_id":"cub3-team1-abc"`) { + t.Errorf("instance_id not delivered:\n%s", s) + } + if !strings.Contains(s, `"challenge_slug":"web/cub3-clone"`) { + t.Errorf("challenge_slug not delivered:\n%s", s) + } + if !strings.Contains(s, `"team_id":"team-1"`) { + t.Errorf("team_id not delivered:\n%s", s) + } + if !strings.Contains(s, `"flag":"mctf{dynamic_flag_123}"`) { + t.Errorf("flag not delivered:\n%s", s) + } +} + func TestManager_UnsubscribedHookNotDelivered(t *testing.T) { out := filepath.Join(t.TempDir(), "hooks.log") m := helperManager(t, "nohooks", out) diff --git a/internal/plugin/protocol.go b/internal/plugin/protocol.go index 12825c0..baf26fb 100644 --- a/internal/plugin/protocol.go +++ b/internal/plugin/protocol.go @@ -12,7 +12,10 @@ // aborts the MCTF command. package plugin -import "encoding/json" +import ( + "encoding/json" + "time" +) // protocolVersion is bumped when the request/response contract changes. const protocolVersion = 1 @@ -61,10 +64,11 @@ type initResult struct { // Hook names. const ( - HookBeforeDeploy = "before_deploy" - HookAfterDeploy = "after_deploy" - HookBeforeBuild = "before_build" - HookAfterBuild = "after_build" + HookBeforeDeploy = "before_deploy" + HookAfterDeploy = "after_deploy" + HookBeforeBuild = "before_build" + HookAfterBuild = "after_build" + HookOnInstanceSpawn = "on_instance_spawn" ) // DeployPayload is the JSON body for the before_/after_deploy hooks. @@ -108,3 +112,15 @@ type BuildImage struct { Tags []string `json:"tags"` Labels map[string]string `json:"labels,omitempty"` } + +// InstanceSpawnPayload is the JSON body for the on_instance_spawn hook. +type InstanceSpawnPayload struct { + Event string `json:"event"` + InstanceID string `json:"instance_id"` + ChallengeSlug string `json:"challenge_slug"` + TeamID string `json:"team_id"` + Ports map[int]int `json:"ports,omitempty"` + Flag string `json:"flag,omitempty"` + Containers []string `json:"containers,omitempty"` + ExpiresAt time.Time `json:"expires_at"` +}