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
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ CORS_ALLOWED_ORIGINS=http://localhost:3000,https://smctf.example.com

# Stack (Container Provisioner)
STACKS_ENABLED=true
STACKS_MAX_PER_USER=3
STACKS_MAX_SCOPE=team
STACKS_MAX_PER=3
STACKS_PROVISIONER_BASE_URL=http://localhost:8081
STACKS_PROVISIONER_API_KEY=change-me
STACKS_PROVISIONER_TIMEOUT=5s
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ As a result, we decided to develop our own CTF platform as a long term project.

See [SMCTF Docs](https://ctf.null4u.cloud/smctf/) for more details. This README only provides a brief overview.

### Available/Stable features:
<!-- ### Available/Stable features:

- AuthN/AuthZ (JWT), including registration keys management
- Challenge management (Jeopardy CTF style, See [`ctf_service.go`](./internal/service/ctf_service.go) for a list of categories.)
Expand All @@ -73,7 +73,7 @@ See [SMCTF Docs](https://ctf.null4u.cloud/smctf/) for more details. This README
- Ref Issue: [#20](https://github.com/nullforu/smctf/issues/20), PR: [#21](https://github.com/nullforu/smctf/pull/21)
- Per challenge individual Stack(instance/VM) provisioning support via Kubernetes
- Ref PR: [#25](https://github.com/nullforu/smctf/pull/25), See [container-provisioner-k8s](https://github.com/nullforu/container-provisioner-k8s) and [docs](https://ctf.null4u.cloud/container-provisioner/) for more details.
- ... and more! (See [docs](https://github.com/nullforu/smctf-docs) for more details)
- ... and more! (See [docs](https://github.com/nullforu/smctf-docs) for more details) -->

### Planned/Upcoming features:

Expand Down Expand Up @@ -170,7 +170,8 @@ LEADERBOARD_CACHE_TTL=60s

# Stack (Container Provisioner)
STACKS_ENABLED=true
STACKS_MAX_PER_USER=3
STACKS_MAX_SCOPE=team
STACKS_MAX_PER=3
STACKS_PROVISIONER_BASE_URL=http://localhost:8081
STACKS_PROVISIONER_API_KEY=change-me
STACKS_PROVISIONER_TIMEOUT=5s
Expand Down
4 changes: 4 additions & 0 deletions docs/docs/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ Errors:
- 400 `invalid input`
- 401 `invalid credentials`

Notes:

- `stack_count` and `stack_limit` reflect the configured scope. If `STACKS_MAX_SCOPE=team`, these values are team-wide.

---

## Refresh Token
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/challenges.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Notes:

- `points` is dynamically calculated based on solves.
- `has_file` indicates whether a challenge file is available.
- `stack_enabled` indicates if a per-user stack instance is supported for this challenge.
- `stack_enabled` indicates if a stack instance is supported for this challenge. Scope is controlled by `STACKS_MAX_SCOPE` (user or team).
- If a challenge is locked, the response includes only `id`, `title`, `category`, `points`, `initial_points`, `minimum_points`, `solve_count`, `previous_challenge_id`, `previous_challenge_title`, `previous_challenge_category`, `is_active`, and `is_locked`.
- If `ctf_state` is `not_started`, the response only includes `ctf_state`.

Expand Down
14 changes: 13 additions & 1 deletion docs/docs/stacks.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Response 200
{
"stack_id": "stack-716b6384dd477b0b",
"challenge_id": 12,
"challenge_title": "SQLi 101",
"status": "running",
"node_public_ip": "12.34.56.78",
"ports": [
Expand All @@ -34,6 +35,8 @@ Response 200
"ttl_expires_at": "2026-02-10T04:02:26Z",
"created_at": "2026-02-10T02:02:26Z",
"updated_at": "2026-02-10T02:07:29Z",
"created_by_user_id": 17,
"created_by_username": "alice",
"ctf_state": "active"
}
]
Expand All @@ -48,6 +51,7 @@ Errors:
Notes:

- Blocked users can access this endpoint (read-only).
- If `STACKS_MAX_SCOPE=team`, this list includes stacks created by any member of the same team.

---

Expand All @@ -67,6 +71,7 @@ Response 201
{
"stack_id": "stack-716b6384dd477b0b",
"challenge_id": 12,
"challenge_title": "SQLi 101",
"status": "creating",
"node_public_ip": "12.34.56.78",
"ports": [
Expand All @@ -79,6 +84,8 @@ Response 201
"ttl_expires_at": "2026-02-10T04:02:26Z",
"created_at": "2026-02-10T02:02:26Z",
"updated_at": "2026-02-10T02:02:26Z",
"created_by_user_id": 17,
"created_by_username": "alice",
"ctf_state": "active"
}
```
Expand All @@ -96,7 +103,8 @@ Errors:

Notes:

- Stack creation is rate-limited per user. Configure via `STACKS_CREATE_WINDOW` and `STACKS_CREATE_MAX`.
- Stack creation is rate-limited by scope. Configure via `STACKS_CREATE_WINDOW` and `STACKS_CREATE_MAX`.
- If `STACKS_MAX_SCOPE=team`, creating a stack counts against the team limit and team rate limit.

---

Expand All @@ -116,6 +124,7 @@ Response 200
{
"stack_id": "stack-716b6384dd477b0b",
"challenge_id": 12,
"challenge_title": "SQLi 101",
"status": "running",
"node_public_ip": "12.34.56.78",
"ports": [
Expand All @@ -128,6 +137,8 @@ Response 200
"ttl_expires_at": "2026-02-10T04:02:26Z",
"created_at": "2026-02-10T02:02:26Z",
"updated_at": "2026-02-10T02:07:29Z",
"created_by_user_id": 17,
"created_by_username": "alice",
"ctf_state": "active"
}
```
Expand All @@ -141,6 +152,7 @@ Errors:
Notes:

- Blocked users can access this endpoint (read-only).
- If `STACKS_MAX_SCOPE=team`, this returns the team stack for the challenge (if any).

---

Expand Down
8 changes: 8 additions & 0 deletions docs/docs/users.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ Errors:

- 401 `invalid token` or `missing authorization` or `invalid authorization`

Notes:

- `stack_count` and `stack_limit` reflect the configured scope. If `STACKS_MAX_SCOPE=team`, these values are team-wide.

---

## Update Me
Expand Down Expand Up @@ -81,6 +85,10 @@ Errors:
- 401 `invalid token` or `missing authorization` or `invalid authorization`
- 403 `user blocked`

Notes:

- `stack_count` and `stack_limit` reflect the configured scope. If `STACKS_MAX_SCOPE=team`, these values are team-wide.

---

## Solved Challenges
Expand Down
20 changes: 14 additions & 6 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ type S3Config struct {

type StackConfig struct {
Enabled bool
MaxPerUser int
MaxScope string
MaxPer int
ProvisionerBaseURL string
ProvisionerAPIKey string
ProvisionerTimeout time.Duration
Expand Down Expand Up @@ -227,7 +228,9 @@ func Load() (Config, error) {
errs = append(errs, err)
}

stackMaxPerUser, err := getEnvInt("STACKS_MAX_PER_USER", 3)
stackMaxScope := strings.ToLower(strings.TrimSpace(getEnv("STACKS_MAX_SCOPE", "team")))

stackMaxPer, err := getEnvInt("STACKS_MAX_PER", 3)
if err != nil {
errs = append(errs, err)
}
Expand Down Expand Up @@ -315,7 +318,8 @@ func Load() (Config, error) {
},
Stack: StackConfig{
Enabled: stackEnabled,
MaxPerUser: stackMaxPerUser,
MaxScope: stackMaxScope,
MaxPer: stackMaxPer,
ProvisionerBaseURL: getEnv("STACKS_PROVISIONER_BASE_URL", "http://localhost:8081"),
ProvisionerAPIKey: getEnv("STACKS_PROVISIONER_API_KEY", ""),
ProvisionerTimeout: stackTimeout,
Expand Down Expand Up @@ -476,8 +480,11 @@ func validateConfig(cfg Config) error {
}

if cfg.Stack.Enabled {
if cfg.Stack.MaxPerUser <= 0 {
errs = append(errs, errors.New("STACKS_MAX_PER_USER must be positive"))
if cfg.Stack.MaxPer <= 0 {
errs = append(errs, errors.New("STACKS_MAX_PER must be positive"))
}
if cfg.Stack.MaxScope != "user" && cfg.Stack.MaxScope != "team" {
errs = append(errs, errors.New("STACKS_MAX_SCOPE must be user or team"))
}
if cfg.Stack.ProvisionerBaseURL == "" {
errs = append(errs, errors.New("STACKS_PROVISIONER_BASE_URL must not be empty"))
Expand Down Expand Up @@ -608,7 +615,8 @@ func FormatForLog(cfg Config) map[string]any {
},
"stack": map[string]any{
"enabled": cfg.Stack.Enabled,
"max_per_user": cfg.Stack.MaxPerUser,
"max_scope": cfg.Stack.MaxScope,
"max_per": cfg.Stack.MaxPer,
"provisioner_base_url": cfg.Stack.ProvisionerBaseURL,
"provisioner_api_key": cfg.Stack.ProvisionerAPIKey,
"provisioner_timeout": seconds(cfg.Stack.ProvisionerTimeout),
Expand Down
18 changes: 14 additions & 4 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,8 @@ func TestLoadConfigCustomValues(t *testing.T) {
os.Setenv("S3_FORCE_PATH_STYLE", "true")
os.Setenv("S3_PRESIGN_TTL", "20m")
os.Setenv("STACKS_ENABLED", "true")
os.Setenv("STACKS_MAX_PER_USER", "5")
os.Setenv("STACKS_MAX_SCOPE", "team")
os.Setenv("STACKS_MAX_PER", "5")
os.Setenv("STACKS_PROVISIONER_BASE_URL", "http://localhost:18081")
os.Setenv("STACKS_PROVISIONER_API_KEY", "custom-key")
os.Setenv("STACKS_PROVISIONER_TIMEOUT", "9s")
Expand Down Expand Up @@ -191,6 +192,12 @@ func TestLoadConfigCustomValues(t *testing.T) {
if cfg.Stack.CreateMax != 2 {
t.Errorf("expected Stack.CreateMax 2, got %d", cfg.Stack.CreateMax)
}
if cfg.Stack.MaxScope != "team" {
t.Errorf("expected Stack.MaxScope team, got %s", cfg.Stack.MaxScope)
}
if cfg.Stack.MaxPer != 5 {
t.Errorf("expected Stack.MaxPer 5, got %d", cfg.Stack.MaxPer)
}
}

func TestLoadConfigInvalidValues(t *testing.T) {
Expand All @@ -210,6 +217,7 @@ func TestLoadConfigInvalidValues(t *testing.T) {
{"invalid s3 enabled", "S3_ENABLED", "not-a-bool"},
{"invalid s3 presign ttl", "S3_PRESIGN_TTL", "bad-duration"},
{"invalid s3 force path", "S3_FORCE_PATH_STYLE", "bad-bool"},
{"invalid stack max scope", "STACKS_MAX_SCOPE", "org"},
{"invalid leaderboard cache ttl", "LEADERBOARD_CACHE_TTL", "bad-duration"},
{"invalid app config cache ttl", "APP_CONFIG_CACHE_TTL", "bad-duration"},
}
Expand Down Expand Up @@ -623,7 +631,8 @@ func TestValidateConfigInvalidStackConfig(t *testing.T) {
},
Stack: StackConfig{
Enabled: true,
MaxPerUser: 0,
MaxScope: "user",
MaxPer: 0,
ProvisionerBaseURL: "",
ProvisionerAPIKey: "",
ProvisionerTimeout: 0,
Expand All @@ -637,7 +646,7 @@ func TestValidateConfigInvalidStackConfig(t *testing.T) {
t.Fatal("expected stack validation error")
}

if !strings.Contains(err.Error(), "STACKS_MAX_PER_USER") {
if !strings.Contains(err.Error(), "STACKS_MAX_PER") {
t.Fatalf("expected stack error, got %v", err)
}
}
Expand Down Expand Up @@ -823,7 +832,8 @@ func TestFormatForLog(t *testing.T) {
},
Stack: StackConfig{
Enabled: true,
MaxPerUser: 3,
MaxScope: "user",
MaxPer: 3,
ProvisionerBaseURL: "http://localhost:8081",
ProvisionerAPIKey: "stack-key",
ProvisionerTimeout: 5 * time.Second,
Expand Down
Loading
Loading