From 117bfae46313f8911ba6d4c1dccdb0dd9281bbd2 Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Tue, 13 Oct 2026 01:33:12 +0100 Subject: [PATCH 1/4] feat(instancer): extend APIClient with spawn, extend, and flag methods --- internal/instancer/apiclient.go | 124 ++++++++++++++++++++++++++- internal/instancer/apiclient_test.go | 122 ++++++++++++++++++++++++++ 2 files changed, 242 insertions(+), 4 deletions(-) diff --git a/internal/instancer/apiclient.go b/internal/instancer/apiclient.go index 2563df5..add2b39 100644 --- a/internal/instancer/apiclient.go +++ b/internal/instancer/apiclient.go @@ -48,12 +48,25 @@ func NewAPIClient(baseURL, adminKey string) *APIClient { } } +// AdminSpawnRequest is the payload sent to POST /admin/api/instances/spawn. +type AdminSpawnRequest struct { + ChallengeID string `json:"challenge_id"` + UserID string `json:"user_id,omitempty"` + TeamID string `json:"team_id,omitempty"` + Flag string `json:"flag,omitempty"` +} + +// AdminExtendRequest is the payload sent to POST /admin/api/instances/{id}/extend. +type AdminExtendRequest struct { + DurationMinutes int `json:"duration_minutes"` +} + // List returns every active instance known to the remote instancer. func (c *APIClient) List(ctx context.Context) ([]RemoteInstance, error) { var resp struct { Instances []RemoteInstance `json:"instances"` } - if err := c.do(ctx, http.MethodGet, "/admin/api/instances", &resp); err != nil { + if err := c.do(ctx, http.MethodGet, "/admin/api/instances", nil, &resp); err != nil { return nil, err } return resp.Instances, nil @@ -67,7 +80,7 @@ func (c *APIClient) Destroy(ctx context.Context, id string) (string, error) { Message string `json:"message"` } path := "/admin/api/instances/" + url.PathEscape(id) - if err := c.do(ctx, http.MethodDelete, path, &resp); err != nil { + if err := c.do(ctx, http.MethodDelete, path, nil, &resp); err != nil { return "", err } if !resp.Success { @@ -80,18 +93,121 @@ func (c *APIClient) Destroy(ctx context.Context, id string) (string, error) { return resp.Message, nil } +// Spawn requests the remote instancer to spin up a new challenge instance. +func (c *APIClient) Spawn(ctx context.Context, req AdminSpawnRequest) (*RemoteInstance, error) { + var resp struct { + Success bool `json:"success"` + Message string `json:"message"` + InstanceID string `json:"instance_id"` + ChallengeID string `json:"challenge_id"` + UserID string `json:"user_id"` + Username string `json:"username"` + TeamID string `json:"team_id"` + TeamName string `json:"team_name"` + Status string `json:"status"` + Ports map[string]int `json:"ports"` + PublicURL string `json:"public_url"` + CreatedAt string `json:"created_at"` + ExpiresAt string `json:"expires_at"` + Instance *RemoteInstance `json:"instance"` + } + if err := c.do(ctx, http.MethodPost, "/admin/api/instances/spawn", req, &resp); err != nil { + return nil, err + } + if resp.Instance != nil { + return resp.Instance, nil + } + if resp.InstanceID != "" { + return &RemoteInstance{ + InstanceID: resp.InstanceID, + ChallengeID: resp.ChallengeID, + UserID: resp.UserID, + Username: resp.Username, + TeamID: resp.TeamID, + TeamName: resp.TeamName, + Status: resp.Status, + Ports: resp.Ports, + PublicURL: resp.PublicURL, + CreatedAt: resp.CreatedAt, + ExpiresAt: resp.ExpiresAt, + }, nil + } + if resp.Message != "" { + return nil, fmt.Errorf("spawn failed: %s", resp.Message) + } + return nil, fmt.Errorf("instancer returned empty spawn response") +} + +// Extend requests the remote instancer to extend an active instance's lifetime. +func (c *APIClient) Extend(ctx context.Context, id string, durationMinutes int) (string, error) { + var resp struct { + Success bool `json:"success"` + Message string `json:"message"` + ExpiresAt string `json:"expires_at"` + } + req := AdminExtendRequest{DurationMinutes: durationMinutes} + path := "/admin/api/instances/" + url.PathEscape(id) + "/extend" + if err := c.do(ctx, http.MethodPost, path, req, &resp); err != nil { + return "", err + } + if !resp.Success && resp.Message != "" { + return "", fmt.Errorf("extend %s: %s", id, resp.Message) + } + if resp.ExpiresAt != "" { + return resp.ExpiresAt, nil + } + if resp.Message != "" { + return resp.Message, nil + } + return "extended", nil +} + +// Flag retrieves the per-team flag for an active instance. +func (c *APIClient) Flag(ctx context.Context, id string) (string, error) { + var resp struct { + Success bool `json:"success"` + Flag string `json:"flag"` + Message string `json:"message"` + } + path := "/admin/api/instances/" + url.PathEscape(id) + "/flag" + if err := c.do(ctx, http.MethodGet, path, nil, &resp); err != nil { + return "", err + } + if resp.Flag != "" { + return resp.Flag, nil + } + if resp.Message != "" { + return "", fmt.Errorf("flag %s: %s", id, resp.Message) + } + return "", fmt.Errorf("no flag returned for instance %s", id) +} + // do performs the request, sets auth headers, checks for a 2xx status, and // decodes the JSON body into out (when non-nil). -func (c *APIClient) do(ctx context.Context, method, path string, out any) error { +func (c *APIClient) do(ctx context.Context, method, path string, in any, out any) error { if c.baseURL == "" { return fmt.Errorf("instancer url is not set") } - req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, nil) + if c.adminKey == "" { + return fmt.Errorf("MCTF_INSTANCER_ADMIN_KEY is not set") + } + var bodyReader io.Reader + if in != nil { + data, err := json.Marshal(in) + if err != nil { + return fmt.Errorf("marshal %s %s request: %w", method, path, err) + } + bodyReader = bytes.NewReader(data) + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bodyReader) if err != nil { return err } req.Header.Set("X-Admin-Key", c.adminKey) req.Header.Set("Accept", "application/json") + if in != nil { + req.Header.Set("Content-Type", "application/json") + } res, err := c.http.Do(req) if err != nil { diff --git a/internal/instancer/apiclient_test.go b/internal/instancer/apiclient_test.go index 3dc1670..8878ef1 100644 --- a/internal/instancer/apiclient_test.go +++ b/internal/instancer/apiclient_test.go @@ -2,6 +2,7 @@ package instancer import ( "context" + "encoding/json" "net/http" "net/http/httptest" "strings" @@ -100,3 +101,124 @@ func TestAPIClientEmptyURL(t *testing.T) { t.Errorf("expected url-not-set error, got %v", err) } } + +func TestAPIClientMissingAdminKey(t *testing.T) { + _, err := NewAPIClient("https://instancer.example.com", "").List(context.Background()) + if err == nil || !strings.Contains(err.Error(), "MCTF_INSTANCER_ADMIN_KEY is not set") { + t.Errorf("expected missing admin key error, got %v", err) + } +} + +func TestAPIClientSpawn(t *testing.T) { + var gotKey, gotMethod, gotPath, gotContentType string + var gotBody AdminSpawnRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotKey = r.Header.Get("X-Admin-Key") + gotContentType = r.Header.Get("Content-Type") + gotMethod, gotPath = r.Method, r.URL.Path + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "success": true, + "instance_id": "echo-inst-1", + "challenge_id": "web/echo", + "team_id": "42", + "status": "running", + "ports": {"80": 30001}, + "public_url": "http://echo.example.com:30001", + "expires_at": "2026-10-13T12:00:00Z" + }`)) + })) + defer srv.Close() + + c := NewAPIClient(srv.URL, "admin-secret") + req := AdminSpawnRequest{ + ChallengeID: "web/echo", + TeamID: "42", + UserID: "42", + Flag: "mctf{custom}", + } + inst, err := c.Spawn(context.Background(), req) + if err != nil { + t.Fatal(err) + } + if gotKey != "admin-secret" { + t.Errorf("admin key = %q, want admin-secret", gotKey) + } + if gotMethod != http.MethodPost || gotPath != "/admin/api/instances/spawn" { + t.Errorf("request = %s %s, want POST /admin/api/instances/spawn", gotMethod, gotPath) + } + if !strings.Contains(gotContentType, "application/json") { + t.Errorf("content-type = %q, want application/json", gotContentType) + } + if gotBody.ChallengeID != "web/echo" || gotBody.TeamID != "42" || gotBody.Flag != "mctf{custom}" { + t.Errorf("unexpected body parsed: %+v", gotBody) + } + if inst.InstanceID != "echo-inst-1" || inst.Ports["80"] != 30001 { + t.Errorf("unexpected instance returned: %+v", inst) + } +} + +func TestAPIClientExtend(t *testing.T) { + var gotKey, gotMethod, gotPath string + var gotBody AdminExtendRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotKey = r.Header.Get("X-Admin-Key") + gotMethod, gotPath = r.Method, r.URL.Path + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "success": true, + "message": "extended", + "expires_at": "2026-10-13T12:30:00Z" + }`)) + })) + defer srv.Close() + + c := NewAPIClient(srv.URL, "admin-secret") + expires, err := c.Extend(context.Background(), "echo-inst-1", 15) + if err != nil { + t.Fatal(err) + } + if gotKey != "admin-secret" { + t.Errorf("admin key = %q, want admin-secret", gotKey) + } + if gotMethod != http.MethodPost || gotPath != "/admin/api/instances/echo-inst-1/extend" { + t.Errorf("request = %s %s, want POST /admin/api/instances/echo-inst-1/extend", gotMethod, gotPath) + } + if gotBody.DurationMinutes != 15 { + t.Errorf("duration_minutes = %d, want 15", gotBody.DurationMinutes) + } + if expires != "2026-10-13T12:30:00Z" { + t.Errorf("expires = %q, want 2026-10-13T12:30:00Z", expires) + } +} + +func TestAPIClientFlag(t *testing.T) { + var gotKey, gotMethod, gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotKey = r.Header.Get("X-Admin-Key") + gotMethod, gotPath = r.Method, r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "success": true, + "flag": "mctf{team_flag_123}" + }`)) + })) + defer srv.Close() + + c := NewAPIClient(srv.URL, "admin-secret") + flag, err := c.Flag(context.Background(), "echo-inst-1") + if err != nil { + t.Fatal(err) + } + if gotKey != "admin-secret" { + t.Errorf("admin key = %q, want admin-secret", gotKey) + } + if gotMethod != http.MethodGet || gotPath != "/admin/api/instances/echo-inst-1/flag" { + t.Errorf("request = %s %s, want GET /admin/api/instances/echo-inst-1/flag", gotMethod, gotPath) + } + if flag != "mctf{team_flag_123}" { + t.Errorf("flag = %q, want mctf{team_flag_123}", flag) + } +} From 0a9775d6fff3ccb98a3fdcadbd239dadd5aa6f1a Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Tue, 13 Oct 2026 01:35:55 +0100 Subject: [PATCH 2/4] feat(instance): wire remote instancer API for spawn, extend, and flag --- cmd/instance.go | 130 ++++++++++++++++++++++++++- cmd/instance_remote_test.go | 171 ++++++++++++++++++++++++++++++++++++ 2 files changed, 297 insertions(+), 4 deletions(-) create mode 100644 cmd/instance_remote_test.go diff --git a/cmd/instance.go b/cmd/instance.go index c60977f..af208a9 100644 --- a/cmd/instance.go +++ b/cmd/instance.go @@ -39,7 +39,7 @@ var ( var instanceSpawnCmd = &cobra.Command{ Use: "spawn /", - Short: "Spawn a per-team instance of an instanced challenge", + Short: "Spawn a per-team instance of an instanced challenge (remote instancer when instancer.url is set, else local store)", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { category, slug, ok := splitCategorySlug(args[0]) @@ -50,6 +50,10 @@ var instanceSpawnCmd = &cobra.Command{ return fmt.Errorf("--team is required") } + if url, ok := remoteInstancerURL(); ok { + return spawnRemoteInstance(cmd.Context(), url, category, slug, flagInstSpawnTeam, flagInstSpawnFlag) + } + cfg, ch, err := loadInstanced(category, slug) if err != nil { return err @@ -237,9 +241,13 @@ var flagInstExtendBy time.Duration var instanceExtendCmd = &cobra.Command{ Use: "extend ", - Short: "Bump an instance's expiry by --by (default 15m)", + Short: "Bump an instance's expiry by --by (remote instancer when instancer.url is set, else local store)", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if url, ok := remoteInstancerURL(); ok { + return extendRemoteInstance(cmd.Context(), url, args[0], flagInstExtendBy) + } + store, err := openStore() if err != nil { return err @@ -265,12 +273,16 @@ var instanceExtendCmd = &cobra.Command{ var instanceFlagCmd = &cobra.Command{ Use: "flag ", - Short: "Print the flag for a live instance", + Short: "Print the flag for a live instance (remote instancer when instancer.url is set, else local store)", Long: "Reads the per-team flag captured when the instance was spawned\n" + "(`--flag` override or the manifest's static flag). Falls back to\n" + "the manifest if the store record predates the flag field.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if url, ok := remoteInstancerURL(); ok { + return flagRemoteInstance(cmd.Context(), url, args[0]) + } + store, err := openStore() if err != nil { return err @@ -356,13 +368,17 @@ func inspectContainersViaDocker(ctx context.Context, ids []string) ([]containerS var instancePsCmd = &cobra.Command{ Use: "ps ", - Short: "Show live docker state for an instance", + Short: "Show live state for an instance (remote instancer when instancer.url is set, else local store)", Long: "Looks up the instance in the store and queries `docker ps` for\n" + "each container in it. Reports up-time / health status, image, and\n" + "the host-port mapping captured at spawn.\n\n" + "The flag is intentionally not printed — use `mctf instance flag `.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if url, ok := remoteInstancerURL(); ok { + return psRemoteInstance(cmd.Context(), url, args[0]) + } + store, err := openStore() if err != nil { return err @@ -508,6 +524,112 @@ func remoteInstancerURL() (string, bool) { return "", false } +// spawnRemoteInstance requests the remote instancer's admin API to spawn a new instance. +func spawnRemoteInstance(ctx context.Context, baseURL, category, slug, teamID, flagOverride string) error { + client := instancer.NewAPIClient(baseURL, os.Getenv("MCTF_INSTANCER_ADMIN_KEY")) + challengeID := category + "/" + slug + req := instancer.AdminSpawnRequest{ + ChallengeID: challengeID, + TeamID: teamID, + UserID: teamID, + Flag: flagOverride, + } + inst, err := client.Spawn(ctx, req) + if err != nil { + return err + } + if flagJSON { + body, err := json.MarshalIndent(inst, "", " ") + if err != nil { + return err + } + fmt.Println(string(body)) + return nil + } + fmt.Printf("spawning %s for team %q (remote: %s)\n", challengeID, teamID, baseURL) + fmt.Printf(" id: %s\n", inst.InstanceID) + if inst.PublicURL != "" { + fmt.Printf(" url: %s\n", inst.PublicURL) + } + if len(inst.Ports) > 0 { + fmt.Printf(" ports: %s\n", remotePortsString(inst.Ports)) + } + if inst.ExpiresAt != "" { + fmt.Printf("\nspawn complete (expires %s)\n", inst.ExpiresAt) + } else { + fmt.Printf("\nspawn complete\n") + } + return nil +} + +// extendRemoteInstance extends an instance's lifetime via the remote instancer's admin API. +func extendRemoteInstance(ctx context.Context, baseURL, id string, by time.Duration) error { + client := instancer.NewAPIClient(baseURL, os.Getenv("MCTF_INSTANCER_ADMIN_KEY")) + durationMinutes := int(by.Minutes()) + if durationMinutes <= 0 { + durationMinutes = 1 + } + expires, err := client.Extend(ctx, id, durationMinutes) + if err != nil { + return err + } + fmt.Printf("extended %s (remote: %s) — now expires %s\n", id, baseURL, expires) + return nil +} + +// flagRemoteInstance reads the per-team flag via the remote instancer's admin API. +func flagRemoteInstance(ctx context.Context, baseURL, id string) error { + client := instancer.NewAPIClient(baseURL, os.Getenv("MCTF_INSTANCER_ADMIN_KEY")) + flag, err := client.Flag(ctx, id) + if err != nil { + return err + } + fmt.Println(flag) + return nil +} + +// psRemoteInstance fetches instance status via the remote instancer's admin API. +func psRemoteInstance(ctx context.Context, baseURL, id string) error { + client := instancer.NewAPIClient(baseURL, os.Getenv("MCTF_INSTANCER_ADMIN_KEY")) + instances, err := client.List(ctx) + if err != nil { + return err + } + for _, inst := range instances { + if inst.InstanceID == id { + if flagJSON { + body, err := json.MarshalIndent(inst, "", " ") + if err != nil { + return err + } + fmt.Println(string(body)) + return nil + } + fmt.Printf("instance: %s\n", inst.InstanceID) + fmt.Printf("challenge: %s\n", inst.ChallengeID) + team := inst.TeamName + if team == "" { + team = inst.TeamID + } + if team != "" { + fmt.Printf("team: %s\n", team) + } + fmt.Printf("status: %s\n", inst.Status) + if inst.PublicURL != "" { + fmt.Printf("url: %s\n", inst.PublicURL) + } + if len(inst.Ports) > 0 { + fmt.Printf("ports: %s\n", remotePortsString(inst.Ports)) + } + if inst.ExpiresAt != "" { + fmt.Printf("expires: %s\n", inst.ExpiresAt) + } + return nil + } + } + return fmt.Errorf("no instance with id %q found on remote instancer", id) +} + // destroyRemoteInstance force-stops one instance via the remote instancer's // admin API. The server owns teardown; the local --force/--volumes flags do // not apply to this path. diff --git a/cmd/instance_remote_test.go b/cmd/instance_remote_test.go new file mode 100644 index 0000000..0d52574 --- /dev/null +++ b/cmd/instance_remote_test.go @@ -0,0 +1,171 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/MicroClub-USTHB/mctf-cli/internal/instancer" +) + +func TestRemoteInstanceSpawn(t *testing.T) { + var gotKey, gotPath, gotMethod string + var gotReq instancer.AdminSpawnRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotKey = r.Header.Get("X-Admin-Key") + gotPath, gotMethod = r.URL.Path, r.Method + _ = json.NewDecoder(r.Body).Decode(&gotReq) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "success": true, + "instance_id": "inst-remote-123", + "challenge_id": "web/sqli", + "team_id": "team-42", + "status": "running", + "ports": {"80": 32768}, + "public_url": "http://sqli.ctf.example.com:32768", + "expires_at": "2026-10-13T16:00:00Z" + }`)) + })) + defer srv.Close() + + t.Setenv("MCTF_INSTANCER_ADMIN_KEY", "super-secret-admin-key") + + err := spawnRemoteInstance(context.Background(), srv.URL, "web", "sqli", "team-42", "mctf{custom}") + if err != nil { + t.Fatalf("spawnRemoteInstance returned unexpected error: %v", err) + } + + if gotKey != "super-secret-admin-key" { + t.Errorf("got X-Admin-Key %q, want super-secret-admin-key", gotKey) + } + if gotMethod != http.MethodPost || gotPath != "/admin/api/instances/spawn" { + t.Errorf("got %s %s, want POST /admin/api/instances/spawn", gotMethod, gotPath) + } + if gotReq.ChallengeID != "web/sqli" || gotReq.TeamID != "team-42" || gotReq.Flag != "mctf{custom}" { + t.Errorf("unexpected spawn request payload: %+v", gotReq) + } +} + +func TestRemoteInstanceExtend(t *testing.T) { + var gotKey, gotPath, gotMethod string + var gotReq instancer.AdminExtendRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotKey = r.Header.Get("X-Admin-Key") + gotPath, gotMethod = r.URL.Path, r.Method + _ = json.NewDecoder(r.Body).Decode(&gotReq) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "success": true, + "message": "extended", + "expires_at": "2026-10-13T17:00:00Z" + }`)) + })) + defer srv.Close() + + t.Setenv("MCTF_INSTANCER_ADMIN_KEY", "admin-key-extend") + + err := extendRemoteInstance(context.Background(), srv.URL, "inst-remote-123", 30*time.Minute) + if err != nil { + t.Fatalf("extendRemoteInstance returned unexpected error: %v", err) + } + + if gotKey != "admin-key-extend" { + t.Errorf("got X-Admin-Key %q, want admin-key-extend", gotKey) + } + if gotMethod != http.MethodPost || gotPath != "/admin/api/instances/inst-remote-123/extend" { + t.Errorf("got %s %s, want POST /admin/api/instances/inst-remote-123/extend", gotMethod, gotPath) + } + if gotReq.DurationMinutes != 30 { + t.Errorf("got duration_minutes %d, want 30", gotReq.DurationMinutes) + } +} + +func TestRemoteInstanceFlag(t *testing.T) { + var gotKey, gotPath, gotMethod string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotKey = r.Header.Get("X-Admin-Key") + gotPath, gotMethod = r.URL.Path, r.Method + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "success": true, + "flag": "mctf{team_42_remote_flag}" + }`)) + })) + defer srv.Close() + + t.Setenv("MCTF_INSTANCER_ADMIN_KEY", "admin-key-flag") + + err := flagRemoteInstance(context.Background(), srv.URL, "inst-remote-123") + if err != nil { + t.Fatalf("flagRemoteInstance returned unexpected error: %v", err) + } + + if gotKey != "admin-key-flag" { + t.Errorf("got X-Admin-Key %q, want admin-key-flag", gotKey) + } + if gotMethod != http.MethodGet || gotPath != "/admin/api/instances/inst-remote-123/flag" { + t.Errorf("got %s %s, want GET /admin/api/instances/inst-remote-123/flag", gotMethod, gotPath) + } +} + +func TestRemoteInstancePs(t *testing.T) { + var gotKey, gotPath, gotMethod string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotKey = r.Header.Get("X-Admin-Key") + gotPath, gotMethod = r.URL.Path, r.Method + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "instances": [ + { + "instance_id": "inst-remote-123", + "challenge_id": "web/sqli", + "team_id": "team-42", + "team_name": "CyberKnights", + "status": "running", + "ports": {"80": 32768}, + "public_url": "http://sqli.ctf.example.com:32768", + "expires_at": "2026-10-13T16:00:00Z" + } + ] + }`)) + })) + defer srv.Close() + + t.Setenv("MCTF_INSTANCER_ADMIN_KEY", "admin-key-ps") + + err := psRemoteInstance(context.Background(), srv.URL, "inst-remote-123") + if err != nil { + t.Fatalf("psRemoteInstance returned unexpected error: %v", err) + } + + if gotKey != "admin-key-ps" { + t.Errorf("got X-Admin-Key %q, want admin-key-ps", gotKey) + } + if gotMethod != http.MethodGet || gotPath != "/admin/api/instances" { + t.Errorf("got %s %s, want GET /admin/api/instances", gotMethod, gotPath) + } +} + +func TestRemoteInstanceMissingAdminKeyFailsCleanly(t *testing.T) { + t.Setenv("MCTF_INSTANCER_ADMIN_KEY", "") + + err := spawnRemoteInstance(context.Background(), "http://localhost:8080", "web", "sqli", "team-42", "") + if err == nil || !strings.Contains(err.Error(), "MCTF_INSTANCER_ADMIN_KEY is not set") { + t.Errorf("expected error mentioning MCTF_INSTANCER_ADMIN_KEY is not set, got %v", err) + } + + err = extendRemoteInstance(context.Background(), "http://localhost:8080", "inst-1", 15*time.Minute) + if err == nil || !strings.Contains(err.Error(), "MCTF_INSTANCER_ADMIN_KEY is not set") { + t.Errorf("expected error mentioning MCTF_INSTANCER_ADMIN_KEY is not set, got %v", err) + } + + err = flagRemoteInstance(context.Background(), "http://localhost:8080", "inst-1") + if err == nil || !strings.Contains(err.Error(), "MCTF_INSTANCER_ADMIN_KEY is not set") { + t.Errorf("expected error mentioning MCTF_INSTANCER_ADMIN_KEY is not set, got %v", err) + } +} From 34ca6b7f096a61f34c157370fe16be6a9daf1f89 Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Tue, 13 Oct 2026 01:37:00 +0100 Subject: [PATCH 3/4] docs(instance): document remote instancer control plane and update backlog --- cmd/instancer.go | 2 +- docs/BACKLOG.md | 1 + docs/commands.md | 10 +++++----- docs/spec/architecture.md | 5 +++-- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/cmd/instancer.go b/cmd/instancer.go index d672881..a0a2b97 100644 --- a/cmd/instancer.go +++ b/cmd/instancer.go @@ -26,7 +26,7 @@ var instancerCmd = &cobra.Command{ Long: "Runs the built-in, single-host instancer daemon.\n\n" + "DEPRECATED: for production, run the dedicated MCTF Instancer service\n" + "(github.com/MicroClub-USTHB/mctf-instancer) and point the CLI at it with\n" + - "instancer.url in mctf.config.yml — `mctf instance list` / `destroy` then\n" + + "instancer.url in mctf.config.yml — `mctf instance` subcommands then\n" + "drive it over its admin API. This built-in daemon remains for offline\n" + "local development and will be removed in a future release.", } diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index ab7a142..2760483 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -11,6 +11,7 @@ Open work organized by size. Each line is intentionally GitHub-issue-shaped: tit ## Medium features (1–2 days each) +- ~~**Remote instancer control plane** — wire `mctf instance spawn/extend/flag/ps` to remote API.~~ **Shipped:** all subcommands drive the remote instancer when `instancer.url` is set with fallback to local store. - **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. diff --git a/docs/commands.md b/docs/commands.md index 9e9e7b4..c2db9e9 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -39,11 +39,11 @@ deterministic deploys and `deploy --rollback`. | Command | Purpose | | --- | --- | -| `mctf instancer` | Run the per-team instancer daemon | -| `mctf instance spawn` | Spawn a per-team instance of an instanced challenge | -| `mctf instance list` / `ps` / `flag` | Inspect live instances | -| `mctf instance extend` | Bump an instance's expiry (`--by`, default 15m) | -| `mctf instance destroy` / `reap` | Tear down one / all expired instances | +| `mctf instancer` | Run the per-team instancer daemon (local dev; deprecated) | +| `mctf instance spawn` | Spawn a per-team instance (remote when `instancer.url` is set, else local) | +| `mctf instance list` / `ps` / `flag` | Inspect live instances (remote when `instancer.url` is set, else local) | +| `mctf instance extend` | Bump an instance's expiry (`--by`, default 15m; remote or local) | +| `mctf instance destroy` / `reap` | Tear down one / all expired instances (remote or local) | ## Platform (CTFd) diff --git a/docs/spec/architecture.md b/docs/spec/architecture.md index 281dd3a..0fbfe07 100644 --- a/docs/spec/architecture.md +++ b/docs/spec/architecture.md @@ -101,8 +101,9 @@ deterministic deploys and `deploy --rollback`. for production; kept for offline local dev. - **Remote ([MCTF Instancer](https://github.com/MicroClub-USTHB/mctf-instancer))** — the production Python service (CTFd-integrated, per-team auth, Traefik - routing). When `instancer.url` is set, `mctf instance list` / `destroy` drive - it over its admin API (`internal/instancer.APIClient`). + routing). When `instancer.url` is set, all `mctf instance` subcommands + (`spawn`, `list`, `destroy`, `extend`, `flag`, `ps`) drive it over its admin + API (`internal/instancer.APIClient`). ## Design notes From e7d23e15280f710ac2c2f421ec88b71ca3ed0705 Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Tue, 13 Oct 2026 01:39:24 +0100 Subject: [PATCH 4/4] refactor(instance): DRY remote API client creation and add edge-case tests --- cmd/instance.go | 16 +++++++++------ cmd/instance_remote_test.go | 39 +++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/cmd/instance.go b/cmd/instance.go index af208a9..71cded5 100644 --- a/cmd/instance.go +++ b/cmd/instance.go @@ -524,9 +524,13 @@ func remoteInstancerURL() (string, bool) { return "", false } +func newRemoteClient(baseURL string) *instancer.APIClient { + return instancer.NewAPIClient(baseURL, os.Getenv("MCTF_INSTANCER_ADMIN_KEY")) +} + // spawnRemoteInstance requests the remote instancer's admin API to spawn a new instance. func spawnRemoteInstance(ctx context.Context, baseURL, category, slug, teamID, flagOverride string) error { - client := instancer.NewAPIClient(baseURL, os.Getenv("MCTF_INSTANCER_ADMIN_KEY")) + client := newRemoteClient(baseURL) challengeID := category + "/" + slug req := instancer.AdminSpawnRequest{ ChallengeID: challengeID, @@ -564,7 +568,7 @@ func spawnRemoteInstance(ctx context.Context, baseURL, category, slug, teamID, f // extendRemoteInstance extends an instance's lifetime via the remote instancer's admin API. func extendRemoteInstance(ctx context.Context, baseURL, id string, by time.Duration) error { - client := instancer.NewAPIClient(baseURL, os.Getenv("MCTF_INSTANCER_ADMIN_KEY")) + client := newRemoteClient(baseURL) durationMinutes := int(by.Minutes()) if durationMinutes <= 0 { durationMinutes = 1 @@ -579,7 +583,7 @@ func extendRemoteInstance(ctx context.Context, baseURL, id string, by time.Durat // flagRemoteInstance reads the per-team flag via the remote instancer's admin API. func flagRemoteInstance(ctx context.Context, baseURL, id string) error { - client := instancer.NewAPIClient(baseURL, os.Getenv("MCTF_INSTANCER_ADMIN_KEY")) + client := newRemoteClient(baseURL) flag, err := client.Flag(ctx, id) if err != nil { return err @@ -590,7 +594,7 @@ func flagRemoteInstance(ctx context.Context, baseURL, id string) error { // psRemoteInstance fetches instance status via the remote instancer's admin API. func psRemoteInstance(ctx context.Context, baseURL, id string) error { - client := instancer.NewAPIClient(baseURL, os.Getenv("MCTF_INSTANCER_ADMIN_KEY")) + client := newRemoteClient(baseURL) instances, err := client.List(ctx) if err != nil { return err @@ -634,7 +638,7 @@ func psRemoteInstance(ctx context.Context, baseURL, id string) error { // admin API. The server owns teardown; the local --force/--volumes flags do // not apply to this path. func destroyRemoteInstance(ctx context.Context, baseURL, id string) error { - client := instancer.NewAPIClient(baseURL, os.Getenv("MCTF_INSTANCER_ADMIN_KEY")) + client := newRemoteClient(baseURL) msg, err := client.Destroy(ctx, id) if err != nil { return err @@ -649,7 +653,7 @@ func destroyRemoteInstance(ctx context.Context, baseURL, id string) error { // listRemoteInstances lists instances from the remote instancer's admin API. // The admin key is read from MCTF_INSTANCER_ADMIN_KEY (a secret, never config). func listRemoteInstances(ctx context.Context, baseURL string) error { - client := instancer.NewAPIClient(baseURL, os.Getenv("MCTF_INSTANCER_ADMIN_KEY")) + client := newRemoteClient(baseURL) instances, err := client.List(ctx) if err != nil { return err diff --git a/cmd/instance_remote_test.go b/cmd/instance_remote_test.go index 0d52574..1335f90 100644 --- a/cmd/instance_remote_test.go +++ b/cmd/instance_remote_test.go @@ -169,3 +169,42 @@ func TestRemoteInstanceMissingAdminKeyFailsCleanly(t *testing.T) { t.Errorf("expected error mentioning MCTF_INSTANCER_ADMIN_KEY is not set, got %v", err) } } + +func TestRemoteInstancePsNotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"instances":[]}`)) + })) + defer srv.Close() + + t.Setenv("MCTF_INSTANCER_ADMIN_KEY", "admin-key") + + err := psRemoteInstance(context.Background(), srv.URL, "nonexistent-id") + if err == nil || !strings.Contains(err.Error(), "no instance with id \"nonexistent-id\" found") { + t.Errorf("expected not found error, got %v", err) + } +} + +func TestRemoteInstanceSpawnJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "success": true, + "instance_id": "inst-json-456", + "challenge_id": "pwn/bof", + "team_id": "team-99", + "status": "running" + }`)) + })) + defer srv.Close() + + t.Setenv("MCTF_INSTANCER_ADMIN_KEY", "admin-key-json") + prevJSON := flagJSON + flagJSON = true + defer func() { flagJSON = prevJSON }() + + err := spawnRemoteInstance(context.Background(), srv.URL, "pwn", "bof", "team-99", "") + if err != nil { + t.Fatalf("unexpected error in JSON mode: %v", err) + } +}