ctfy 0.1.23__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. ctfy-0.1.23/.gitignore +71 -0
  2. ctfy-0.1.23/PKG-INFO +25 -0
  3. ctfy-0.1.23/README.md +32 -0
  4. ctfy-0.1.23/ctfy/__init__.py +0 -0
  5. ctfy-0.1.23/ctfy/cli/__init__.py +98 -0
  6. ctfy-0.1.23/ctfy/cli/challenge.py +197 -0
  7. ctfy-0.1.23/ctfy/cli/config.py +44 -0
  8. ctfy-0.1.23/ctfy/cli/instance.py +91 -0
  9. ctfy-0.1.23/ctfy/cli/node.py +163 -0
  10. ctfy-0.1.23/ctfy/cli/runtime.py +82 -0
  11. ctfy-0.1.23/ctfy/cli/server.py +397 -0
  12. ctfy-0.1.23/ctfy/core/__init__.py +15 -0
  13. ctfy-0.1.23/ctfy/core/activity.py +76 -0
  14. ctfy-0.1.23/ctfy/core/challenge.py +213 -0
  15. ctfy-0.1.23/ctfy/core/challenge_validate.py +160 -0
  16. ctfy-0.1.23/ctfy/core/challenges_sync.py +105 -0
  17. ctfy-0.1.23/ctfy/core/config.py +240 -0
  18. ctfy-0.1.23/ctfy/core/constants.py +85 -0
  19. ctfy-0.1.23/ctfy/core/enums.py +49 -0
  20. ctfy-0.1.23/ctfy/core/exceptions.py +174 -0
  21. ctfy-0.1.23/ctfy/core/flag.py +65 -0
  22. ctfy-0.1.23/ctfy/core/log.py +30 -0
  23. ctfy-0.1.23/ctfy/core/models.py +34 -0
  24. ctfy-0.1.23/ctfy/core/provider.py +88 -0
  25. ctfy-0.1.23/ctfy/core/state/__init__.py +75 -0
  26. ctfy-0.1.23/ctfy/core/state/filters.py +42 -0
  27. ctfy-0.1.23/ctfy/core/state/memory.py +543 -0
  28. ctfy-0.1.23/ctfy/core/state/models.py +238 -0
  29. ctfy-0.1.23/ctfy/core/state/protocol.py +187 -0
  30. ctfy-0.1.23/ctfy/core/state/sqlite.py +970 -0
  31. ctfy-0.1.23/ctfy/core/target.py +75 -0
  32. ctfy-0.1.23/ctfy/docs/benchmarks.md +76 -0
  33. ctfy-0.1.23/ctfy/mcp/__init__.py +0 -0
  34. ctfy-0.1.23/ctfy/mcp/server.py +287 -0
  35. ctfy-0.1.23/ctfy/providers/__init__.py +0 -0
  36. ctfy-0.1.23/ctfy/providers/docker_provider/__init__.py +0 -0
  37. ctfy-0.1.23/ctfy/providers/docker_provider/paths.py +31 -0
  38. ctfy-0.1.23/ctfy/providers/docker_provider/provider.py +650 -0
  39. ctfy-0.1.23/ctfy/providers/docker_provider/start_helpers.py +22 -0
  40. ctfy-0.1.23/ctfy/providers/docker_provider/utils.py +299 -0
  41. ctfy-0.1.23/ctfy/scripts/capture_screenshots.py +358 -0
  42. ctfy-0.1.23/ctfy/scripts/export_openapi.py +31 -0
  43. ctfy-0.1.23/ctfy/sdk/__init__.py +6 -0
  44. ctfy-0.1.23/ctfy/sdk/base.py +53 -0
  45. ctfy-0.1.23/ctfy/sdk/client.py +419 -0
  46. ctfy-0.1.23/ctfy/sdk/node_client.py +101 -0
  47. ctfy-0.1.23/ctfy/server/__init__.py +0 -0
  48. ctfy-0.1.23/ctfy/server/achievements/__init__.py +20 -0
  49. ctfy-0.1.23/ctfy/server/achievements/catalog.py +202 -0
  50. ctfy-0.1.23/ctfy/server/achievements/engine.py +141 -0
  51. ctfy-0.1.23/ctfy/server/achievements/rules/__init__.py +17 -0
  52. ctfy-0.1.23/ctfy/server/achievements/rules/agent_rules.py +100 -0
  53. ctfy-0.1.23/ctfy/server/achievements/rules/flag_correct_rules.py +229 -0
  54. ctfy-0.1.23/ctfy/server/achievements/rules/flag_wrong_rules.py +84 -0
  55. ctfy-0.1.23/ctfy/server/achievements/rules/team_registered_rules.py +18 -0
  56. ctfy-0.1.23/ctfy/server/app.py +298 -0
  57. ctfy-0.1.23/ctfy/server/app_state.py +106 -0
  58. ctfy-0.1.23/ctfy/server/auth.py +206 -0
  59. ctfy-0.1.23/ctfy/server/background.py +270 -0
  60. ctfy-0.1.23/ctfy/server/error_handlers.py +58 -0
  61. ctfy-0.1.23/ctfy/server/event_payloads.py +211 -0
  62. ctfy-0.1.23/ctfy/server/events.py +144 -0
  63. ctfy-0.1.23/ctfy/server/instance_archive.py +191 -0
  64. ctfy-0.1.23/ctfy/server/instance_lifecycle.py +293 -0
  65. ctfy-0.1.23/ctfy/server/models.py +546 -0
  66. ctfy-0.1.23/ctfy/server/node_app.py +397 -0
  67. ctfy-0.1.23/ctfy/server/node_ops.py +41 -0
  68. ctfy-0.1.23/ctfy/server/node_state.py +152 -0
  69. ctfy-0.1.23/ctfy/server/request_meta.py +19 -0
  70. ctfy-0.1.23/ctfy/server/routes/__init__.py +1 -0
  71. ctfy-0.1.23/ctfy/server/routes/achievements.py +244 -0
  72. ctfy-0.1.23/ctfy/server/routes/activities.py +48 -0
  73. ctfy-0.1.23/ctfy/server/routes/admin_stats.py +220 -0
  74. ctfy-0.1.23/ctfy/server/routes/challenges.py +58 -0
  75. ctfy-0.1.23/ctfy/server/routes/cluster_info.py +32 -0
  76. ctfy-0.1.23/ctfy/server/routes/events.py +59 -0
  77. ctfy-0.1.23/ctfy/server/routes/health.py +25 -0
  78. ctfy-0.1.23/ctfy/server/routes/instances.py +570 -0
  79. ctfy-0.1.23/ctfy/server/routes/invites.py +113 -0
  80. ctfy-0.1.23/ctfy/server/routes/meta.py +53 -0
  81. ctfy-0.1.23/ctfy/server/routes/node_instances.py +227 -0
  82. ctfy-0.1.23/ctfy/server/routes/nodes.py +275 -0
  83. ctfy-0.1.23/ctfy/server/routes/scoreboard.py +163 -0
  84. ctfy-0.1.23/ctfy/server/routes/submissions.py +238 -0
  85. ctfy-0.1.23/ctfy/server/routes/teams.py +99 -0
  86. ctfy-0.1.23/ctfy/server/scheduling.py +38 -0
  87. ctfy-0.1.23/ctfy/server/sse.py +112 -0
  88. ctfy-0.1.23/ctfy/server/static/assets/index-Cs42_v7-.css +1 -0
  89. ctfy-0.1.23/ctfy/server/static/assets/index-DGDP8pSx.js +406 -0
  90. ctfy-0.1.23/ctfy/server/static/index.html +15 -0
  91. ctfy-0.1.23/ctfy/tests/__init__.py +0 -0
  92. ctfy-0.1.23/ctfy/tests/conftest.py +337 -0
  93. ctfy-0.1.23/ctfy/tests/test_achievements_engine.py +596 -0
  94. ctfy-0.1.23/ctfy/tests/test_achievements_routes.py +249 -0
  95. ctfy-0.1.23/ctfy/tests/test_activity.py +204 -0
  96. ctfy-0.1.23/ctfy/tests/test_challenge_spec.py +203 -0
  97. ctfy-0.1.23/ctfy/tests/test_challenge_validate.py +83 -0
  98. ctfy-0.1.23/ctfy/tests/test_challenges_sync.py +161 -0
  99. ctfy-0.1.23/ctfy/tests/test_cli_server_invite.py +112 -0
  100. ctfy-0.1.23/ctfy/tests/test_config.py +36 -0
  101. ctfy-0.1.23/ctfy/tests/test_docker_utils.py +145 -0
  102. ctfy-0.1.23/ctfy/tests/test_e2e_docker.py +228 -0
  103. ctfy-0.1.23/ctfy/tests/test_error_handlers.py +75 -0
  104. ctfy-0.1.23/ctfy/tests/test_flag.py +85 -0
  105. ctfy-0.1.23/ctfy/tests/test_instance_archive.py +272 -0
  106. ctfy-0.1.23/ctfy/tests/test_instance_lifecycle.py +44 -0
  107. ctfy-0.1.23/ctfy/tests/test_mcp_e2e.py +378 -0
  108. ctfy-0.1.23/ctfy/tests/test_multi_node.py +295 -0
  109. ctfy-0.1.23/ctfy/tests/test_node_app.py +85 -0
  110. ctfy-0.1.23/ctfy/tests/test_node_health_history.py +202 -0
  111. ctfy-0.1.23/ctfy/tests/test_node_idempotency.py +94 -0
  112. ctfy-0.1.23/ctfy/tests/test_node_invites.py +223 -0
  113. ctfy-0.1.23/ctfy/tests/test_platform.py +1140 -0
  114. ctfy-0.1.23/ctfy/tests/test_provider.py +271 -0
  115. ctfy-0.1.23/ctfy/tests/test_scheduling.py +114 -0
  116. ctfy-0.1.23/ctfy/tests/test_sdk.py +593 -0
  117. ctfy-0.1.23/ctfy/tests/test_server_bootstrap.py +111 -0
  118. ctfy-0.1.23/ctfy/tests/test_sse_hub.py +121 -0
  119. ctfy-0.1.23/ctfy/tests/test_state.py +413 -0
  120. ctfy-0.1.23/ctfy/tests/test_submissions_flags.py +188 -0
  121. ctfy-0.1.23/ctfy/tests/test_team_isolation.py +281 -0
  122. ctfy-0.1.23/pyproject.toml +100 -0
ctfy-0.1.23/.gitignore ADDED
@@ -0,0 +1,71 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+ *.so
6
+ *.egg-info/
7
+ *.egg
8
+ dist/
9
+ build/
10
+ .Python
11
+ MANIFEST
12
+
13
+ # Virtual environments
14
+ .venv/
15
+ env/
16
+ venv/
17
+
18
+ # Testing & coverage
19
+ .pytest_cache/
20
+ htmlcov/
21
+ .coverage
22
+ .coverage.*
23
+ coverage.xml
24
+
25
+ # Type checkers & linters
26
+ .mypy_cache/
27
+ .ruff_cache/
28
+ .pytype/
29
+
30
+ # IDEs
31
+ .idea/
32
+ .vscode/
33
+
34
+ # Frontend
35
+ frontend/node_modules/
36
+ frontend/dist/
37
+ # openapi.json is a transient build artifact — TS types are generated from it
38
+ # and committed, but the raw schema isn't worth tracking.
39
+ frontend/openapi.json
40
+ # tsc incremental build cache; churns on every build and adds noise to diffs.
41
+ frontend/tsconfig.tsbuildinfo
42
+
43
+ # Frontend build output (all historical paths)
44
+ ctfy/dashboard/static/
45
+ ctfy/server/static/
46
+ llm_pentest_bench/server/static/
47
+ pentest/server/static/
48
+
49
+ # Environment & secrets
50
+ .env
51
+ .envrc
52
+ .pypirc
53
+
54
+ # uv
55
+ # uv.lock is committed for reproducibility
56
+
57
+ # Data & runtime artifacts
58
+ data/results/
59
+ *.sqlite3
60
+ *.sqlite3-*
61
+ *.log
62
+
63
+ # Docker
64
+ docker-compose.override.yml
65
+
66
+ # OS files
67
+ .DS_Store
68
+ Thumbs.db
69
+
70
+ # Data files
71
+ data/
ctfy-0.1.23/PKG-INFO ADDED
@@ -0,0 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: ctfy
3
+ Version: 0.1.23
4
+ Requires-Python: >=3.11
5
+ Requires-Dist: docker>=7.0
6
+ Requires-Dist: fastapi-pagination>=0.12
7
+ Requires-Dist: fastapi>=0.135.3
8
+ Requires-Dist: fastmcp>=3.2.0
9
+ Requires-Dist: httpx>=0.28.1
10
+ Requires-Dist: portpicker>=1.6.0
11
+ Requires-Dist: psutil>=5.9
12
+ Requires-Dist: pydantic>=2.0
13
+ Requires-Dist: python-dotenv>=1.0
14
+ Requires-Dist: pyyaml>=6.0
15
+ Requires-Dist: requests>=2.31.0
16
+ Requires-Dist: rich>=13.0
17
+ Requires-Dist: slowapi>=0.1.9
18
+ Requires-Dist: structlog>=23.0
19
+ Requires-Dist: typer>=0.9
20
+ Requires-Dist: uvicorn>=0.43.0
21
+ Provides-Extra: dev
22
+ Requires-Dist: httpx>=0.28.1; extra == 'dev'
23
+ Requires-Dist: pytest>=9.0.2; extra == 'dev'
24
+ Provides-Extra: screenshots
25
+ Requires-Dist: playwright>=1.49; extra == 'screenshots'
ctfy-0.1.23/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # ctfy
2
+
3
+ Open-source CTF platform — run challenges, register teams, score submissions.
4
+ Ships with a REST API and MCP server for AI-agent evaluation harnesses.
5
+
6
+ ![ctfy dashboard](docs/screenshots/dashboard.png)
7
+
8
+ ## Quick start
9
+
10
+ ```bash
11
+ echo "CTFY_ADMIN_TOKEN=$(openssl rand -hex 32)" > .env && docker compose up -d
12
+ ```
13
+
14
+ Then open <http://localhost:8100>.
15
+
16
+ ## Screens
17
+
18
+ ### Challenges
19
+
20
+ ![](docs/screenshots/challenges.png)
21
+
22
+ ### Scoreboard
23
+
24
+ ![](docs/screenshots/scoreboard.png)
25
+
26
+ ### Activity
27
+
28
+ ![](docs/screenshots/activity.png)
29
+
30
+ ### Achievements
31
+
32
+ ![](docs/screenshots/achievements.png)
File without changes
@@ -0,0 +1,98 @@
1
+ """ctfy — unified CLI entrypoint.
2
+
3
+ Command groups:
4
+ ctfy server … platform server lifecycle + invitations
5
+ ctfy node … worker-node cluster membership
6
+ ctfy instance … running challenge instances
7
+ ctfy challenge … static challenge assets (sync, ls, show, validate)
8
+ ctfy mcp start the MCP server
9
+ ctfy config … inspect active configuration
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from importlib import metadata
15
+
16
+ import typer
17
+ from dotenv import load_dotenv
18
+
19
+ from ctfy.cli.challenge import challenge_app
20
+ from ctfy.cli.config import config_app
21
+ from ctfy.cli.instance import instance_app
22
+ from ctfy.cli.node import node_app
23
+ from ctfy.cli.server import server_app
24
+
25
+ app = typer.Typer(help="ctfy — open-source CTF platform", no_args_is_help=True)
26
+ app.add_typer(server_app, name="server")
27
+ app.add_typer(node_app, name="node")
28
+ app.add_typer(instance_app, name="instance")
29
+ app.add_typer(challenge_app, name="challenge")
30
+ app.add_typer(config_app, name="config")
31
+
32
+
33
+ def _version_callback(value: bool) -> None:
34
+ if not value:
35
+ return
36
+ try:
37
+ version = metadata.version("ctfy")
38
+ except metadata.PackageNotFoundError:
39
+ version = "0.0.0+unknown"
40
+ typer.echo(f"ctfy {version}")
41
+ raise typer.Exit()
42
+
43
+
44
+ @app.callback()
45
+ def root(
46
+ ctx: typer.Context,
47
+ version: bool = typer.Option(
48
+ False,
49
+ "--version",
50
+ "-V",
51
+ callback=_version_callback,
52
+ is_eager=True,
53
+ help="Show the ctfy version and exit.",
54
+ ),
55
+ platform_url: str = typer.Option(
56
+ "http://localhost:8100",
57
+ "--platform-url",
58
+ envvar="CTFY_PLATFORM_URL",
59
+ help="Platform URL used by client subcommands (instance/challenge/mcp).",
60
+ ),
61
+ team_token: str = typer.Option(
62
+ "",
63
+ "--team-token",
64
+ envvar="CTFY_TEAM_TOKEN",
65
+ help="Team bearer token for subcommands that authenticate as a team.",
66
+ ),
67
+ admin_token: str = typer.Option(
68
+ "",
69
+ "--admin-token",
70
+ envvar="CTFY_ADMIN_TOKEN",
71
+ help="Admin bearer token for admin-only subcommands.",
72
+ ),
73
+ ):
74
+ """Populate ctx.obj with shared client settings for every subcommand."""
75
+ ctx.ensure_object(dict)
76
+ ctx.obj["platform_url"] = platform_url.rstrip("/")
77
+ ctx.obj["team_token"] = team_token
78
+ ctx.obj["admin_token"] = admin_token
79
+
80
+
81
+ @app.command()
82
+ def mcp(
83
+ ctx: typer.Context,
84
+ ):
85
+ """Start the MCP server."""
86
+ from ctfy.mcp.server import main as mcp_main
87
+
88
+ mcp_main(platform_url=ctx.obj["platform_url"], team_key=ctx.obj["team_token"])
89
+
90
+
91
+ def main() -> None:
92
+ # Load .env before typer parses args so envvar= options pick up .env values.
93
+ load_dotenv(override=False)
94
+ app()
95
+
96
+
97
+ if __name__ == "__main__":
98
+ main()
@@ -0,0 +1,197 @@
1
+ """`ctfy challenge …` — static challenge assets in challenges_dir."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ import yaml
10
+ from pydantic import ValidationError
11
+ from rich.table import Table
12
+
13
+ from ctfy.cli.runtime import console, err_console
14
+ from ctfy.core.challenge import ChallengeSpec
15
+ from ctfy.core.challenge_validate import validate_dir
16
+ from ctfy.core.config import CtfyConfig
17
+ from ctfy.core.enums import Difficulty
18
+ from ctfy.core.log import log, setup_logging
19
+
20
+ challenge_app = typer.Typer(help="Static challenge assets in challenges_dir", no_args_is_help=True)
21
+
22
+
23
+ def _default_challenges_dir() -> Path:
24
+ return CtfyConfig.model_fields["challenges_dir"].default
25
+
26
+
27
+ CHALLENGES_DIR_OPTION = typer.Option(
28
+ _default_challenges_dir(),
29
+ "--challenges-dir",
30
+ envvar="CTFY_CHALLENGES_DIR",
31
+ help="Directory containing challenge metadata.yaml files.",
32
+ )
33
+
34
+
35
+ def _iter_specs(
36
+ challenges_dir: Path,
37
+ *,
38
+ challenge_id: str | None = None,
39
+ difficulty: Difficulty | None = None,
40
+ tag: str | None = None,
41
+ ) -> Iterable[ChallengeSpec]:
42
+ if not challenges_dir.exists():
43
+ return
44
+ for d in sorted(challenges_dir.iterdir()):
45
+ yml = d / "metadata.yaml"
46
+ if not d.is_dir() or not yml.exists():
47
+ continue
48
+ try:
49
+ spec = ChallengeSpec.from_yaml(yml)
50
+ except (ValidationError, yaml.YAMLError) as e:
51
+ log.warning("Skipping invalid challenge spec", path=str(yml), error=str(e))
52
+ continue
53
+ if challenge_id:
54
+ patterns = [p.strip() for p in challenge_id.split(",")]
55
+ if not any(spec.id == p or spec.id.startswith(p + "-") for p in patterns):
56
+ continue
57
+ if difficulty is not None and spec.difficulty != difficulty:
58
+ continue
59
+ if tag and tag not in spec.tags:
60
+ continue
61
+ yield spec
62
+
63
+
64
+ @challenge_app.command("sync")
65
+ def challenge_sync(
66
+ repo: str = typer.Option(
67
+ "",
68
+ "--repo",
69
+ envvar="CTFY_CHALLENGES_REPO",
70
+ help="Git repo URL (default: from config).",
71
+ ),
72
+ challenges_dir: Path = CHALLENGES_DIR_OPTION,
73
+ ):
74
+ """Clone or fast-forward-pull the challenges repo locally.
75
+
76
+ Run once after install, then whenever you want to pick up upstream
77
+ changes. Server / node startup no longer pulls automatically.
78
+ """
79
+ setup_logging()
80
+ config = CtfyConfig.from_env(
81
+ overrides={
82
+ "challenges_repo": repo,
83
+ "challenges_dir": challenges_dir,
84
+ }
85
+ )
86
+ from ctfy.core.challenges_sync import ensure_challenges
87
+
88
+ ensure_challenges(config.challenges_dir, config.challenges_repo)
89
+ console.print(f"[green]✓ Synced {config.challenges_repo} → {config.challenges_dir}[/green]")
90
+
91
+
92
+ @challenge_app.command("ls")
93
+ def challenge_ls(
94
+ challenge: str | None = typer.Option(
95
+ None, "--challenge", help="Filter by challenge ID (comma-separated, prefix-match)"
96
+ ),
97
+ difficulty: Difficulty | None = typer.Option(
98
+ None, "--difficulty", help="Filter by difficulty (easy | medium | hard)"
99
+ ),
100
+ tag: str | None = typer.Option(None, "--tag", help="Filter by a single tag"),
101
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON instead of a table"),
102
+ challenges_dir: Path = CHALLENGES_DIR_OPTION,
103
+ ):
104
+ """List challenge definitions in challenges_dir."""
105
+ setup_logging()
106
+ specs = list(
107
+ _iter_specs(challenges_dir, challenge_id=challenge, difficulty=difficulty, tag=tag)
108
+ )
109
+
110
+ if json_output:
111
+ console.print_json(data=[s.model_dump(mode="json") for s in specs])
112
+ return
113
+
114
+ table = Table(title="Challenge Definitions")
115
+ table.add_column("ID", style="cyan")
116
+ table.add_column("Name")
117
+ table.add_column("Difficulty", style="magenta")
118
+ table.add_column("Tags", style="dim")
119
+ for spec in specs:
120
+ table.add_row(spec.id, spec.name, spec.difficulty.value, ", ".join(spec.tags))
121
+ console.print(table)
122
+ console.print(f"\n[bold]{len(specs)}[/bold] challenge(s)")
123
+
124
+
125
+ @challenge_app.command("show")
126
+ def challenge_show(
127
+ challenge_id: str = typer.Argument(help="Challenge ID"),
128
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON instead of a summary"),
129
+ challenges_dir: Path = CHALLENGES_DIR_OPTION,
130
+ ):
131
+ """Show full metadata for a single challenge."""
132
+ setup_logging()
133
+ for spec in _iter_specs(challenges_dir, challenge_id=challenge_id):
134
+ if spec.id == challenge_id:
135
+ if json_output:
136
+ console.print_json(data=spec.model_dump(mode="json"))
137
+ else:
138
+ console.print(spec.model_dump(mode="json"))
139
+ return
140
+ err_console.print(f"[red]Challenge not found: {challenge_id}[/red]")
141
+ raise typer.Exit(1)
142
+
143
+
144
+ @challenge_app.command("validate")
145
+ def challenge_validate(
146
+ challenge_id: str | None = typer.Argument(
147
+ None, help="Challenge ID to validate (omit with --all to scan every challenge)"
148
+ ),
149
+ all_: bool = typer.Option(
150
+ False, "--all", help="Validate every metadata.yaml in challenges_dir"
151
+ ),
152
+ strict: bool = typer.Option(
153
+ False, "--strict", help="Passed through to the upstream audit (warnings become errors)."
154
+ ),
155
+ json_output: bool = typer.Option(False, "--json", help="Emit one JSON object per finding"),
156
+ challenges_dir: Path = CHALLENGES_DIR_OPTION,
157
+ ):
158
+ """Validate metadata + compose + source-code hygiene via the upstream audit."""
159
+ setup_logging()
160
+ if not all_ and not challenge_id:
161
+ err_console.print("[red]Pass a challenge ID or --all.[/red]")
162
+ raise typer.Exit(2)
163
+
164
+ findings = validate_dir(
165
+ challenges_dir,
166
+ challenge_id=None if all_ else challenge_id,
167
+ strict=strict,
168
+ )
169
+
170
+ if not findings:
171
+ err_console.print("[red]No challenges matched.[/red]")
172
+ raise typer.Exit(2)
173
+
174
+ failed = [f for f in findings if not f.ok]
175
+
176
+ if json_output:
177
+ import json
178
+
179
+ for f in findings:
180
+ console.print(
181
+ json.dumps(
182
+ {"challenge": f.challenge, "status": f.status, "reason": f.reason},
183
+ ensure_ascii=False,
184
+ )
185
+ )
186
+ else:
187
+ for f in findings:
188
+ if f.ok:
189
+ console.print(f"[green]OK[/green] {f.challenge}")
190
+ else:
191
+ console.print(f"[red]FAIL[/red] {f.challenge}: {f.reason}")
192
+ console.print(
193
+ f"\n[bold]{len(findings) - len(failed)}[/bold] ok, [bold]{len(failed)}[/bold] failed"
194
+ )
195
+
196
+ if failed:
197
+ raise typer.Exit(1)
@@ -0,0 +1,44 @@
1
+ """`ctfy config …` — inspect active configuration.
2
+
3
+ Re-uses :meth:`CtfyConfig.from_env` so what you see here is exactly what
4
+ ``ctfy server start`` / ``ctfy node join`` would resolve from the same
5
+ environment at this moment.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import typer
11
+ from pydantic import SecretStr
12
+
13
+ from ctfy.cli.runtime import console
14
+ from ctfy.core.config import CtfyConfig
15
+
16
+ config_app = typer.Typer(help="Inspect active configuration", no_args_is_help=True)
17
+
18
+
19
+ def _redact(value) -> str:
20
+ if isinstance(value, SecretStr):
21
+ return "<set>" if value.get_secret_value() else "<unset>"
22
+ return str(value)
23
+
24
+
25
+ @config_app.command("show")
26
+ def config_show(
27
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON instead of a table"),
28
+ ):
29
+ """Print the effective settings (secrets redacted)."""
30
+ cfg = CtfyConfig.from_env()
31
+ data = {k: _redact(getattr(cfg, k)) for k in cfg.model_fields}
32
+
33
+ if json_output:
34
+ console.print_json(data=data)
35
+ return
36
+
37
+ from rich.table import Table
38
+
39
+ table = Table(title="ctfy configuration")
40
+ table.add_column("Setting", style="cyan")
41
+ table.add_column("Value")
42
+ for k in sorted(data):
43
+ table.add_row(k, data[k])
44
+ console.print(table)
@@ -0,0 +1,91 @@
1
+ """`ctfy instance …` — running challenge instances."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.table import Table
7
+
8
+ from ctfy.cli.runtime import console, err_console, get_client
9
+ from ctfy.core.enums import InstanceStatus
10
+ from ctfy.core.log import setup_logging
11
+
12
+ instance_app = typer.Typer(help="Running challenge instances", no_args_is_help=True)
13
+
14
+
15
+ @instance_app.command("ls")
16
+ def instance_ls(
17
+ ctx: typer.Context,
18
+ challenge: str | None = typer.Option(None, "--challenge", help="Filter by challenge ID"),
19
+ status: str | None = typer.Option(None, "--status", help="Filter by status"),
20
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON instead of a table"),
21
+ ):
22
+ """List instances currently running on the platform."""
23
+ setup_logging()
24
+ client = get_client(ctx)
25
+ instances = client.list_instances(challenge_id=challenge or "", status=status or "")
26
+
27
+ if json_output:
28
+ console.print_json(data=[i.model_dump() for i in instances])
29
+ return
30
+
31
+ table = Table(title="Running Instances")
32
+ table.add_column("Instance ID", style="cyan")
33
+ table.add_column("Challenge")
34
+ table.add_column("Status")
35
+ table.add_column("Started", style="dim")
36
+ table.add_column("TTL", justify="right")
37
+
38
+ for inst in instances:
39
+ status_style = "green" if inst.status == InstanceStatus.READY else "yellow"
40
+ table.add_row(
41
+ inst.instance_id,
42
+ inst.challenge_id or inst.name,
43
+ f"[{status_style}]{inst.status}[/]",
44
+ str(int(inst.started_at)) if inst.started_at else "",
45
+ str(inst.ttl) if inst.ttl else "",
46
+ )
47
+ console.print(table)
48
+ console.print(f"\n[bold]{len(instances)}[/bold] running")
49
+
50
+
51
+ @instance_app.command("start")
52
+ def instance_start(
53
+ ctx: typer.Context,
54
+ challenge_id: str = typer.Argument(help="Challenge ID to start"),
55
+ ttl: int = typer.Option(3600, "--ttl", help="Time-to-live in seconds"),
56
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON instead of a table"),
57
+ ):
58
+ """Start an instance of a challenge and wait until it is ready."""
59
+ setup_logging()
60
+ client = get_client(ctx)
61
+ console.print(f"Starting [bold]{challenge_id}[/bold]...")
62
+ try:
63
+ ready = client.start_instance(challenge_id, ttl=ttl)
64
+ except Exception as e:
65
+ err_console.print(f"[red]Failed: {e}[/red]")
66
+ raise typer.Exit(1)
67
+
68
+ if json_output:
69
+ console.print_json(data=ready.surface.model_dump())
70
+ return
71
+
72
+ console.print("[green]Ready![/green]")
73
+ for svc in ready.surface.services:
74
+ suffix = f" ({svc.label})" if svc.label else ""
75
+ console.print(f" {svc.service_type.value}://{svc.host}:{svc.port}{suffix}")
76
+
77
+
78
+ @instance_app.command("stop")
79
+ def instance_stop(
80
+ ctx: typer.Context,
81
+ instance_id: str = typer.Argument(help="Instance ID (or challenge ID) to stop"),
82
+ ):
83
+ """Stop a running instance."""
84
+ setup_logging()
85
+ client = get_client(ctx)
86
+ try:
87
+ client.stop_instance(instance_id)
88
+ except Exception as e:
89
+ err_console.print(f"[red]Failed: {e}[/red]")
90
+ raise typer.Exit(1)
91
+ console.print(f"[green]Stopped {instance_id}[/green]")