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.
- ctfy-0.1.23/.gitignore +71 -0
- ctfy-0.1.23/PKG-INFO +25 -0
- ctfy-0.1.23/README.md +32 -0
- ctfy-0.1.23/ctfy/__init__.py +0 -0
- ctfy-0.1.23/ctfy/cli/__init__.py +98 -0
- ctfy-0.1.23/ctfy/cli/challenge.py +197 -0
- ctfy-0.1.23/ctfy/cli/config.py +44 -0
- ctfy-0.1.23/ctfy/cli/instance.py +91 -0
- ctfy-0.1.23/ctfy/cli/node.py +163 -0
- ctfy-0.1.23/ctfy/cli/runtime.py +82 -0
- ctfy-0.1.23/ctfy/cli/server.py +397 -0
- ctfy-0.1.23/ctfy/core/__init__.py +15 -0
- ctfy-0.1.23/ctfy/core/activity.py +76 -0
- ctfy-0.1.23/ctfy/core/challenge.py +213 -0
- ctfy-0.1.23/ctfy/core/challenge_validate.py +160 -0
- ctfy-0.1.23/ctfy/core/challenges_sync.py +105 -0
- ctfy-0.1.23/ctfy/core/config.py +240 -0
- ctfy-0.1.23/ctfy/core/constants.py +85 -0
- ctfy-0.1.23/ctfy/core/enums.py +49 -0
- ctfy-0.1.23/ctfy/core/exceptions.py +174 -0
- ctfy-0.1.23/ctfy/core/flag.py +65 -0
- ctfy-0.1.23/ctfy/core/log.py +30 -0
- ctfy-0.1.23/ctfy/core/models.py +34 -0
- ctfy-0.1.23/ctfy/core/provider.py +88 -0
- ctfy-0.1.23/ctfy/core/state/__init__.py +75 -0
- ctfy-0.1.23/ctfy/core/state/filters.py +42 -0
- ctfy-0.1.23/ctfy/core/state/memory.py +543 -0
- ctfy-0.1.23/ctfy/core/state/models.py +238 -0
- ctfy-0.1.23/ctfy/core/state/protocol.py +187 -0
- ctfy-0.1.23/ctfy/core/state/sqlite.py +970 -0
- ctfy-0.1.23/ctfy/core/target.py +75 -0
- ctfy-0.1.23/ctfy/docs/benchmarks.md +76 -0
- ctfy-0.1.23/ctfy/mcp/__init__.py +0 -0
- ctfy-0.1.23/ctfy/mcp/server.py +287 -0
- ctfy-0.1.23/ctfy/providers/__init__.py +0 -0
- ctfy-0.1.23/ctfy/providers/docker_provider/__init__.py +0 -0
- ctfy-0.1.23/ctfy/providers/docker_provider/paths.py +31 -0
- ctfy-0.1.23/ctfy/providers/docker_provider/provider.py +650 -0
- ctfy-0.1.23/ctfy/providers/docker_provider/start_helpers.py +22 -0
- ctfy-0.1.23/ctfy/providers/docker_provider/utils.py +299 -0
- ctfy-0.1.23/ctfy/scripts/capture_screenshots.py +358 -0
- ctfy-0.1.23/ctfy/scripts/export_openapi.py +31 -0
- ctfy-0.1.23/ctfy/sdk/__init__.py +6 -0
- ctfy-0.1.23/ctfy/sdk/base.py +53 -0
- ctfy-0.1.23/ctfy/sdk/client.py +419 -0
- ctfy-0.1.23/ctfy/sdk/node_client.py +101 -0
- ctfy-0.1.23/ctfy/server/__init__.py +0 -0
- ctfy-0.1.23/ctfy/server/achievements/__init__.py +20 -0
- ctfy-0.1.23/ctfy/server/achievements/catalog.py +202 -0
- ctfy-0.1.23/ctfy/server/achievements/engine.py +141 -0
- ctfy-0.1.23/ctfy/server/achievements/rules/__init__.py +17 -0
- ctfy-0.1.23/ctfy/server/achievements/rules/agent_rules.py +100 -0
- ctfy-0.1.23/ctfy/server/achievements/rules/flag_correct_rules.py +229 -0
- ctfy-0.1.23/ctfy/server/achievements/rules/flag_wrong_rules.py +84 -0
- ctfy-0.1.23/ctfy/server/achievements/rules/team_registered_rules.py +18 -0
- ctfy-0.1.23/ctfy/server/app.py +298 -0
- ctfy-0.1.23/ctfy/server/app_state.py +106 -0
- ctfy-0.1.23/ctfy/server/auth.py +206 -0
- ctfy-0.1.23/ctfy/server/background.py +270 -0
- ctfy-0.1.23/ctfy/server/error_handlers.py +58 -0
- ctfy-0.1.23/ctfy/server/event_payloads.py +211 -0
- ctfy-0.1.23/ctfy/server/events.py +144 -0
- ctfy-0.1.23/ctfy/server/instance_archive.py +191 -0
- ctfy-0.1.23/ctfy/server/instance_lifecycle.py +293 -0
- ctfy-0.1.23/ctfy/server/models.py +546 -0
- ctfy-0.1.23/ctfy/server/node_app.py +397 -0
- ctfy-0.1.23/ctfy/server/node_ops.py +41 -0
- ctfy-0.1.23/ctfy/server/node_state.py +152 -0
- ctfy-0.1.23/ctfy/server/request_meta.py +19 -0
- ctfy-0.1.23/ctfy/server/routes/__init__.py +1 -0
- ctfy-0.1.23/ctfy/server/routes/achievements.py +244 -0
- ctfy-0.1.23/ctfy/server/routes/activities.py +48 -0
- ctfy-0.1.23/ctfy/server/routes/admin_stats.py +220 -0
- ctfy-0.1.23/ctfy/server/routes/challenges.py +58 -0
- ctfy-0.1.23/ctfy/server/routes/cluster_info.py +32 -0
- ctfy-0.1.23/ctfy/server/routes/events.py +59 -0
- ctfy-0.1.23/ctfy/server/routes/health.py +25 -0
- ctfy-0.1.23/ctfy/server/routes/instances.py +570 -0
- ctfy-0.1.23/ctfy/server/routes/invites.py +113 -0
- ctfy-0.1.23/ctfy/server/routes/meta.py +53 -0
- ctfy-0.1.23/ctfy/server/routes/node_instances.py +227 -0
- ctfy-0.1.23/ctfy/server/routes/nodes.py +275 -0
- ctfy-0.1.23/ctfy/server/routes/scoreboard.py +163 -0
- ctfy-0.1.23/ctfy/server/routes/submissions.py +238 -0
- ctfy-0.1.23/ctfy/server/routes/teams.py +99 -0
- ctfy-0.1.23/ctfy/server/scheduling.py +38 -0
- ctfy-0.1.23/ctfy/server/sse.py +112 -0
- ctfy-0.1.23/ctfy/server/static/assets/index-Cs42_v7-.css +1 -0
- ctfy-0.1.23/ctfy/server/static/assets/index-DGDP8pSx.js +406 -0
- ctfy-0.1.23/ctfy/server/static/index.html +15 -0
- ctfy-0.1.23/ctfy/tests/__init__.py +0 -0
- ctfy-0.1.23/ctfy/tests/conftest.py +337 -0
- ctfy-0.1.23/ctfy/tests/test_achievements_engine.py +596 -0
- ctfy-0.1.23/ctfy/tests/test_achievements_routes.py +249 -0
- ctfy-0.1.23/ctfy/tests/test_activity.py +204 -0
- ctfy-0.1.23/ctfy/tests/test_challenge_spec.py +203 -0
- ctfy-0.1.23/ctfy/tests/test_challenge_validate.py +83 -0
- ctfy-0.1.23/ctfy/tests/test_challenges_sync.py +161 -0
- ctfy-0.1.23/ctfy/tests/test_cli_server_invite.py +112 -0
- ctfy-0.1.23/ctfy/tests/test_config.py +36 -0
- ctfy-0.1.23/ctfy/tests/test_docker_utils.py +145 -0
- ctfy-0.1.23/ctfy/tests/test_e2e_docker.py +228 -0
- ctfy-0.1.23/ctfy/tests/test_error_handlers.py +75 -0
- ctfy-0.1.23/ctfy/tests/test_flag.py +85 -0
- ctfy-0.1.23/ctfy/tests/test_instance_archive.py +272 -0
- ctfy-0.1.23/ctfy/tests/test_instance_lifecycle.py +44 -0
- ctfy-0.1.23/ctfy/tests/test_mcp_e2e.py +378 -0
- ctfy-0.1.23/ctfy/tests/test_multi_node.py +295 -0
- ctfy-0.1.23/ctfy/tests/test_node_app.py +85 -0
- ctfy-0.1.23/ctfy/tests/test_node_health_history.py +202 -0
- ctfy-0.1.23/ctfy/tests/test_node_idempotency.py +94 -0
- ctfy-0.1.23/ctfy/tests/test_node_invites.py +223 -0
- ctfy-0.1.23/ctfy/tests/test_platform.py +1140 -0
- ctfy-0.1.23/ctfy/tests/test_provider.py +271 -0
- ctfy-0.1.23/ctfy/tests/test_scheduling.py +114 -0
- ctfy-0.1.23/ctfy/tests/test_sdk.py +593 -0
- ctfy-0.1.23/ctfy/tests/test_server_bootstrap.py +111 -0
- ctfy-0.1.23/ctfy/tests/test_sse_hub.py +121 -0
- ctfy-0.1.23/ctfy/tests/test_state.py +413 -0
- ctfy-0.1.23/ctfy/tests/test_submissions_flags.py +188 -0
- ctfy-0.1.23/ctfy/tests/test_team_isolation.py +281 -0
- 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
|
+

|
|
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
|
+

|
|
21
|
+
|
|
22
|
+
### Scoreboard
|
|
23
|
+
|
|
24
|
+

|
|
25
|
+
|
|
26
|
+
### Activity
|
|
27
|
+
|
|
28
|
+

|
|
29
|
+
|
|
30
|
+
### Achievements
|
|
31
|
+
|
|
32
|
+

|
|
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]")
|