CTFx 0.2.2__py3-none-any.whl
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.
- ctfx/__init__.py +3 -0
- ctfx/cli.py +81 -0
- ctfx/commands/__init__.py +1 -0
- ctfx/commands/ai.py +228 -0
- ctfx/commands/awd.py +214 -0
- ctfx/commands/challenge.py +211 -0
- ctfx/commands/competition.py +227 -0
- ctfx/commands/config.py +109 -0
- ctfx/commands/interactive.py +32 -0
- ctfx/commands/platform.py +383 -0
- ctfx/commands/serve.py +48 -0
- ctfx/commands/setup.py +125 -0
- ctfx/commands/terminal.py +168 -0
- ctfx/commands/token.py +55 -0
- ctfx/commands/webui.py +41 -0
- ctfx/exceptions.py +17 -0
- ctfx/managers/__init__.py +1 -0
- ctfx/managers/awd.py +106 -0
- ctfx/managers/config.py +248 -0
- ctfx/managers/platform/__init__.py +1 -0
- ctfx/managers/platform/base.py +23 -0
- ctfx/managers/platform/ctfd.py +68 -0
- ctfx/managers/scaffold.py +88 -0
- ctfx/managers/workspace.py +310 -0
- ctfx/server/__init__.py +1 -0
- ctfx/server/api.py +714 -0
- ctfx/server/app.py +164 -0
- ctfx/server/auth.py +174 -0
- ctfx/server/mcp.py +7 -0
- ctfx/server/mcp_server.py +258 -0
- ctfx/utils/__init__.py +1 -0
- ctfx/utils/process.py +56 -0
- ctfx-0.2.2.dist-info/METADATA +273 -0
- ctfx-0.2.2.dist-info/RECORD +36 -0
- ctfx-0.2.2.dist-info/WHEEL +4 -0
- ctfx-0.2.2.dist-info/entry_points.txt +2 -0
ctfx/__init__.py
ADDED
ctfx/cli.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Click entry point — registers all command groups and top-level aliases."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
from ctfx import __version__
|
|
7
|
+
from ctfx.exceptions import CTFxError
|
|
8
|
+
from ctfx.commands.competition import comp_group, cmd_use, cmd_init
|
|
9
|
+
from ctfx.commands.challenge import chal_group, cmd_add
|
|
10
|
+
from ctfx.commands.terminal import cmd_cli, cmd_wsl, cmd_explorer, cmd_code, cmd_py
|
|
11
|
+
from ctfx.commands.platform import cmd_fetch, cmd_submit, cmd_import
|
|
12
|
+
from ctfx.commands.serve import cmd_serve
|
|
13
|
+
from ctfx.commands.ai import cmd_ai, cmd_mcp
|
|
14
|
+
from ctfx.commands.awd import awd_group
|
|
15
|
+
from ctfx.commands.setup import cmd_setup
|
|
16
|
+
from ctfx.commands.token import token_group
|
|
17
|
+
from ctfx.commands.webui import cmd_webui
|
|
18
|
+
from ctfx.commands.interactive import cmd_interactive
|
|
19
|
+
from ctfx.commands.config import config_group
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@click.group(invoke_without_command=True)
|
|
23
|
+
@click.version_option(__version__, prog_name="ctfx")
|
|
24
|
+
@click.pass_context
|
|
25
|
+
def main(ctx: click.Context) -> None:
|
|
26
|
+
"""ctfx — CTF workspace manager and assistant.
|
|
27
|
+
|
|
28
|
+
Local-first CTF workspace manager. Run 'ctfx setup' on first use.
|
|
29
|
+
Docs: https://github.com/liyanqwq/CTFx
|
|
30
|
+
"""
|
|
31
|
+
if ctx.invoked_subcommand is None:
|
|
32
|
+
click.echo(ctx.get_help())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _main() -> None:
|
|
36
|
+
"""Wrapper that catches CTFxError for clean user-facing output."""
|
|
37
|
+
try:
|
|
38
|
+
main(standalone_mode=True)
|
|
39
|
+
except CTFxError as e:
|
|
40
|
+
click.secho(f"Error: {e}", fg="red", err=True)
|
|
41
|
+
sys.exit(1)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Command groups
|
|
45
|
+
main.add_command(comp_group, name="comp")
|
|
46
|
+
main.add_command(chal_group, name="chal")
|
|
47
|
+
main.add_command(awd_group, name="awd")
|
|
48
|
+
main.add_command(token_group, name="token")
|
|
49
|
+
main.add_command(config_group, name="config")
|
|
50
|
+
|
|
51
|
+
# Direct commands
|
|
52
|
+
main.add_command(cmd_cli, name="cli")
|
|
53
|
+
main.add_command(cmd_wsl, name="wsl")
|
|
54
|
+
main.add_command(cmd_explorer, name="explorer")
|
|
55
|
+
main.add_command(cmd_explorer, name="e")
|
|
56
|
+
main.add_command(cmd_code, name="code")
|
|
57
|
+
main.add_command(cmd_py, name="py")
|
|
58
|
+
main.add_command(cmd_fetch, name="fetch")
|
|
59
|
+
main.add_command(cmd_submit, name="submit")
|
|
60
|
+
main.add_command(cmd_import, name="import")
|
|
61
|
+
main.add_command(cmd_serve, name="serve")
|
|
62
|
+
main.add_command(cmd_ai, name="ai")
|
|
63
|
+
main.add_command(cmd_mcp, name="mcp")
|
|
64
|
+
main.add_command(cmd_setup, name="setup")
|
|
65
|
+
main.add_command(cmd_webui, name="webui")
|
|
66
|
+
main.add_command(cmd_webui, name="web")
|
|
67
|
+
main.add_command(cmd_webui, name="ui")
|
|
68
|
+
main.add_command(cmd_interactive, name="interactive")
|
|
69
|
+
main.add_command(cmd_interactive, name="i")
|
|
70
|
+
|
|
71
|
+
# Top-level aliases
|
|
72
|
+
main.add_command(cmd_use, name="use")
|
|
73
|
+
main.add_command(cmd_init, name="init")
|
|
74
|
+
main.add_command(cmd_add, name="add")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@main.command("help")
|
|
78
|
+
@click.pass_context
|
|
79
|
+
def cmd_help(ctx: click.Context) -> None:
|
|
80
|
+
"""Show this help message."""
|
|
81
|
+
click.echo(ctx.find_root().get_help())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI command modules."""
|
ctfx/commands/ai.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""AICommand — ctfx ai / ctfx mcp"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from ctfx.managers.config import ConfigManager
|
|
13
|
+
from ctfx.managers.workspace import WorkspaceManager
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
# Marker for the user-editable section preserved across regenerations
|
|
18
|
+
_EXTRA_INFO_HEADER = "## Extra Info"
|
|
19
|
+
_EXTRA_INFO_DEFAULT = (
|
|
20
|
+
"<!-- preserved across regenerations; "
|
|
21
|
+
"add manual notes, known context, team strategy here -->"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _load_active() -> tuple[ConfigManager, WorkspaceManager, dict]:
|
|
26
|
+
cfg = ConfigManager.load()
|
|
27
|
+
if not cfg.active_competition:
|
|
28
|
+
console.print("[red]No active competition.[/red] Run [cyan]ctfx use[/cyan] first.")
|
|
29
|
+
raise SystemExit(1)
|
|
30
|
+
wm = WorkspaceManager(cfg.basedir, cfg.active_competition)
|
|
31
|
+
try:
|
|
32
|
+
data = wm.load_ctf_json()
|
|
33
|
+
except FileNotFoundError:
|
|
34
|
+
console.print(f"[red]ctf.json not found for {cfg.active_competition}[/red]")
|
|
35
|
+
raise SystemExit(1)
|
|
36
|
+
return cfg, wm, data
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _extract_extra_info(existing_text: str) -> str:
|
|
40
|
+
"""Extract the ## Extra Info section from an existing prompt.md, preserving user notes."""
|
|
41
|
+
match = re.search(
|
|
42
|
+
rf"^{re.escape(_EXTRA_INFO_HEADER)}\s*\n(.*)",
|
|
43
|
+
existing_text,
|
|
44
|
+
re.MULTILINE | re.DOTALL,
|
|
45
|
+
)
|
|
46
|
+
if match:
|
|
47
|
+
return match.group(1).rstrip()
|
|
48
|
+
return _EXTRA_INFO_DEFAULT
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_MCP_SECTION = """\
|
|
52
|
+
## MCP Tools
|
|
53
|
+
|
|
54
|
+
When connected via MCP (Cursor, Claude Desktop, or any MCP-capable client), use these tools:
|
|
55
|
+
|
|
56
|
+
| Tool | Description |
|
|
57
|
+
|------|-------------|
|
|
58
|
+
| `list_competitions` | List all competitions in the workspace |
|
|
59
|
+
| `get_competition` | Get active (or specified) competition metadata |
|
|
60
|
+
| `list_challenges` | List challenges — filter by `category` or `status` |
|
|
61
|
+
| `get_challenge` | Get full detail for a specific challenge by name |
|
|
62
|
+
| `add_challenge` | Create a new challenge directory with scaffold |
|
|
63
|
+
| `set_challenge_status` | Update status: `fetched` → `seen` → `working` → `solved` |
|
|
64
|
+
| `get_prompt` | Read this prompt.md file |
|
|
65
|
+
| `submit_flag` | Submit a flag to the platform (or record locally) |
|
|
66
|
+
| `run_exploit` | Run `solve/exploit.py` via the configured Python command |
|
|
67
|
+
| `list_awd_exploits` | List AWD exploit files for a service *(AWD mode only)* |
|
|
68
|
+
| `list_awd_patches` | List AWD patch files for a service *(AWD mode only)* |
|
|
69
|
+
| `get_config` | Read CTFx global configuration |
|
|
70
|
+
| `set_config` | Write a CTFx config value by dot-notation key |"""
|
|
71
|
+
|
|
72
|
+
_CLI_SECTION = """\
|
|
73
|
+
## CLI Reference
|
|
74
|
+
|
|
75
|
+
Run these from your shell (prefix with `ctfx`):
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
# Competition management
|
|
79
|
+
ctfx use <dir> Switch active competition
|
|
80
|
+
ctfx comp list List all competitions
|
|
81
|
+
ctfx comp init Create a new competition
|
|
82
|
+
ctfx comp info Show active competition metadata
|
|
83
|
+
|
|
84
|
+
# Challenge management
|
|
85
|
+
ctfx chal list [--cat CAT] [--status STATUS]
|
|
86
|
+
ctfx chal add <name> [cat] Add challenge (interactive category picker if cat omitted)
|
|
87
|
+
ctfx chal status <name> <status> [flag]
|
|
88
|
+
ctfx chal info <name>
|
|
89
|
+
ctfx chal rm <name>
|
|
90
|
+
|
|
91
|
+
# Platform integration
|
|
92
|
+
ctfx fetch [--cat CAT] Fetch challenges from CTFd
|
|
93
|
+
ctfx submit <flag> [--chal NAME] Submit flag to platform
|
|
94
|
+
ctfx import <url> LLM-assisted challenge import from URL
|
|
95
|
+
|
|
96
|
+
# Terminal helpers (path relative to competition root)
|
|
97
|
+
ctfx cli [path] Open terminal at path
|
|
98
|
+
ctfx wsl [path] Open WSL shell at path
|
|
99
|
+
ctfx code [path] Open VS Code at path
|
|
100
|
+
ctfx e [path] Open file explorer at path
|
|
101
|
+
ctfx py [file] Run Python (or open REPL)
|
|
102
|
+
|
|
103
|
+
# Server
|
|
104
|
+
ctfx serve [--port PORT] Start WebUI + API + MCP server
|
|
105
|
+
ctfx webui Open WebUI in browser (one-time login)
|
|
106
|
+
ctfx mcp [--out PATH] Generate MCP client config
|
|
107
|
+
|
|
108
|
+
# Other
|
|
109
|
+
ctfx ai [--print] Regenerate this prompt.md
|
|
110
|
+
ctfx token update Rotate root token
|
|
111
|
+
ctfx config show Show full config
|
|
112
|
+
ctfx config set <key> <value> Edit config by dot-notation key
|
|
113
|
+
ctfx i Interactive REPL (no ctfx prefix needed)
|
|
114
|
+
```"""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _build_prompt(data: dict, challenges: list[dict], extra_info: str) -> str:
|
|
118
|
+
name = data.get("name", "Unknown CTF")
|
|
119
|
+
year = data.get("year", "")
|
|
120
|
+
team_name = data.get("team_name", "")
|
|
121
|
+
flag_format = data.get("flag_format", "flag{...}")
|
|
122
|
+
mode = data.get("mode", "jeopardy")
|
|
123
|
+
platform = data.get("platform", "manual")
|
|
124
|
+
url = data.get("url", "")
|
|
125
|
+
|
|
126
|
+
lines = [
|
|
127
|
+
"You are a professional CTF (Capture The Flag) competition assistant with deep expertise",
|
|
128
|
+
"in all CTF categories including pwn, crypto, web, forensics, rev, and misc.",
|
|
129
|
+
"",
|
|
130
|
+
f"You are assisting team {team_name or '(unnamed)'} in the {name} {year} competition.",
|
|
131
|
+
"",
|
|
132
|
+
"## Competition Info",
|
|
133
|
+
]
|
|
134
|
+
if url:
|
|
135
|
+
lines.append(f"- Platform: {url}")
|
|
136
|
+
lines += [
|
|
137
|
+
f"- Flag format: `{flag_format}`",
|
|
138
|
+
f"- Mode: {mode}",
|
|
139
|
+
f"- Platform adapter: {platform}",
|
|
140
|
+
"",
|
|
141
|
+
"Do not assume any challenge is a known or previously seen challenge. Treat every",
|
|
142
|
+
"challenge as original and reason from first principles.",
|
|
143
|
+
"",
|
|
144
|
+
"## Challenge Status",
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
# Group challenges by category
|
|
148
|
+
by_cat: dict[str, list[dict]] = {}
|
|
149
|
+
for chal in challenges:
|
|
150
|
+
by_cat.setdefault(chal["category"] or "misc", []).append(chal)
|
|
151
|
+
|
|
152
|
+
for cat in sorted(by_cat):
|
|
153
|
+
lines.append(f"\n### {cat}")
|
|
154
|
+
for chal in by_cat[cat]:
|
|
155
|
+
pts = f" ({chal['points']} pts)" if chal.get("points") is not None else ""
|
|
156
|
+
status_marker = "✓" if chal["status"] == "solved" else "·"
|
|
157
|
+
lines.append(f"- {status_marker} `{chal['name']}` [{chal['status']}]{pts}")
|
|
158
|
+
|
|
159
|
+
lines += [
|
|
160
|
+
"",
|
|
161
|
+
_MCP_SECTION,
|
|
162
|
+
"",
|
|
163
|
+
_CLI_SECTION,
|
|
164
|
+
"",
|
|
165
|
+
_EXTRA_INFO_HEADER,
|
|
166
|
+
extra_info,
|
|
167
|
+
"",
|
|
168
|
+
]
|
|
169
|
+
return "\n".join(lines)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@click.command("ai")
|
|
173
|
+
@click.option("--print", "do_print", is_flag=True, help="Also print prompt to stdout")
|
|
174
|
+
def cmd_ai(do_print: bool) -> None:
|
|
175
|
+
"""Generate prompt.md with competition context for LLM use.
|
|
176
|
+
|
|
177
|
+
Preserves the '## Extra Info' section across regenerations.
|
|
178
|
+
"""
|
|
179
|
+
cfg, wm, data = _load_active()
|
|
180
|
+
challenges = wm.list_challenges()
|
|
181
|
+
|
|
182
|
+
prompt_path = wm.competition_root() / "prompt.md"
|
|
183
|
+
|
|
184
|
+
# Preserve the ## Extra Info section if the file already exists
|
|
185
|
+
if prompt_path.exists():
|
|
186
|
+
existing = prompt_path.read_text(encoding="utf-8")
|
|
187
|
+
extra_info = _extract_extra_info(existing)
|
|
188
|
+
else:
|
|
189
|
+
extra_info = _EXTRA_INFO_DEFAULT
|
|
190
|
+
|
|
191
|
+
prompt = _build_prompt(data, challenges, extra_info)
|
|
192
|
+
|
|
193
|
+
if do_print:
|
|
194
|
+
console.print(prompt)
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
prompt_path.write_text(prompt, encoding="utf-8")
|
|
198
|
+
console.print(f"[green]Wrote[/green] {prompt_path}")
|
|
199
|
+
console.print(
|
|
200
|
+
f" [dim]{len(challenges)} challenges listed | "
|
|
201
|
+
f"edit '## Extra Info' to add manual context[/dim]"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@click.command("mcp")
|
|
206
|
+
@click.option("--out", default=None, help="Write MCP client config JSON to a file")
|
|
207
|
+
def cmd_mcp(out: str | None) -> None:
|
|
208
|
+
"""Generate MCP client config for Claude Desktop / Cursor."""
|
|
209
|
+
cfg = ConfigManager.load()
|
|
210
|
+
payload = {
|
|
211
|
+
"mcpServers": {
|
|
212
|
+
"ctfx": {
|
|
213
|
+
"url": f"http://{cfg.serve_host}:{cfg.serve_port}/mcp/",
|
|
214
|
+
"headers": {
|
|
215
|
+
"Authorization": f"Bearer {cfg.root_token}",
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
text = json.dumps(payload, indent=2, ensure_ascii=False) + "\n"
|
|
221
|
+
if out:
|
|
222
|
+
Path(out).write_text(text, encoding="utf-8")
|
|
223
|
+
console.print(f"[green]Wrote[/green] {out}")
|
|
224
|
+
console.print(
|
|
225
|
+
f"[dim]MCP endpoint: http://{cfg.serve_host}:{cfg.serve_port}/mcp[/dim]"
|
|
226
|
+
)
|
|
227
|
+
else:
|
|
228
|
+
console.print(text)
|
ctfx/commands/awd.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""AWDCommand — ctfx awd ssh / scp / cmd (AWD mode only)"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from ctfx.managers.config import ConfigManager
|
|
11
|
+
from ctfx.managers.workspace import WorkspaceManager
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _load_awd() -> tuple[ConfigManager, WorkspaceManager, dict]:
|
|
17
|
+
cfg = ConfigManager.load()
|
|
18
|
+
if not cfg.active_competition:
|
|
19
|
+
console.print("[red]No active competition.[/red] Run [cyan]ctfx use[/cyan] first.")
|
|
20
|
+
raise SystemExit(1)
|
|
21
|
+
wm = WorkspaceManager(cfg.basedir, cfg.active_competition)
|
|
22
|
+
try:
|
|
23
|
+
data = wm.load_ctf_json()
|
|
24
|
+
except FileNotFoundError:
|
|
25
|
+
console.print("[red]ctf.json not found.[/red]")
|
|
26
|
+
raise SystemExit(1)
|
|
27
|
+
if data.get("mode") != "awd":
|
|
28
|
+
console.print(
|
|
29
|
+
"[red]AWD commands are only available in AWD mode.[/red] "
|
|
30
|
+
"Set [cyan]\"mode\": \"awd\"[/cyan] in ctf.json."
|
|
31
|
+
)
|
|
32
|
+
raise SystemExit(1)
|
|
33
|
+
return cfg, wm, data
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _resolve_host(wm: WorkspaceManager, service: str, team: str | None) -> tuple[str, str]:
|
|
37
|
+
"""Return (username, ip) for the target team/host."""
|
|
38
|
+
try:
|
|
39
|
+
hosts = wm.load_hostlist(service)
|
|
40
|
+
except ValueError as e:
|
|
41
|
+
console.print(f"[red]{e}[/red]")
|
|
42
|
+
raise SystemExit(1)
|
|
43
|
+
if not hosts:
|
|
44
|
+
console.print(
|
|
45
|
+
f"[red]No hosts found for service '{service}'.[/red] "
|
|
46
|
+
f"Create [cyan]{service}/hostlist.txt[/cyan] in the competition directory."
|
|
47
|
+
)
|
|
48
|
+
raise SystemExit(1)
|
|
49
|
+
|
|
50
|
+
if team:
|
|
51
|
+
match = next((h for h in hosts if h[0].lower() == team.lower()), None)
|
|
52
|
+
if not match:
|
|
53
|
+
available = ", ".join(h[0] for h in hosts)
|
|
54
|
+
console.print(
|
|
55
|
+
f"[red]Team '{team}' not found.[/red] Available: {available}"
|
|
56
|
+
)
|
|
57
|
+
raise SystemExit(1)
|
|
58
|
+
return "root", match[1]
|
|
59
|
+
|
|
60
|
+
if len(hosts) == 1:
|
|
61
|
+
return "root", hosts[0][1]
|
|
62
|
+
|
|
63
|
+
from rich.table import Table
|
|
64
|
+
table = Table(show_header=True, header_style="bold cyan", box=None)
|
|
65
|
+
table.add_column("#", width=3, style="dim")
|
|
66
|
+
table.add_column("Team")
|
|
67
|
+
table.add_column("IP")
|
|
68
|
+
for i, (t, ip) in enumerate(hosts, 1):
|
|
69
|
+
table.add_row(str(i), t, ip)
|
|
70
|
+
console.print(table)
|
|
71
|
+
|
|
72
|
+
while True:
|
|
73
|
+
raw = click.prompt("Select host number (or q to cancel)", default="q")
|
|
74
|
+
if raw.strip().lower() in ("q", "quit", ""):
|
|
75
|
+
raise SystemExit(0)
|
|
76
|
+
try:
|
|
77
|
+
idx = int(raw.strip()) - 1
|
|
78
|
+
if 0 <= idx < len(hosts):
|
|
79
|
+
return "root", hosts[idx][1]
|
|
80
|
+
except ValueError:
|
|
81
|
+
pass
|
|
82
|
+
console.print(f"[red]Invalid.[/red] Enter 1–{len(hosts)} or q.")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@click.group("awd")
|
|
86
|
+
def awd_group() -> None:
|
|
87
|
+
"""AWD mode commands (SSH, SCP, remote exec)."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@awd_group.command("ssh")
|
|
91
|
+
@click.argument("service")
|
|
92
|
+
@click.option("--team", default=None, help="Team name to SSH into")
|
|
93
|
+
@click.option("--port", default=22, type=int, show_default=True)
|
|
94
|
+
@click.option("--user", default="root", show_default=True)
|
|
95
|
+
def awd_ssh(service: str, team: str | None, port: int, user: str) -> None:
|
|
96
|
+
"""Open an interactive SSH shell into a service host."""
|
|
97
|
+
_, wm, _ = _load_awd()
|
|
98
|
+
_, ip = _resolve_host(wm, service, team)
|
|
99
|
+
try:
|
|
100
|
+
key_path = wm.get_service_key_path(service)
|
|
101
|
+
except ValueError as e:
|
|
102
|
+
console.print(f"[red]{e}[/red]")
|
|
103
|
+
raise SystemExit(1)
|
|
104
|
+
|
|
105
|
+
console.print(
|
|
106
|
+
f"Connecting to [bold]{ip}:{port}[/bold] "
|
|
107
|
+
+ (f"(key: {key_path.name})" if key_path else "(password auth)")
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
from ctfx.managers.awd import AWDSession
|
|
112
|
+
with AWDSession(ip, port=port, username=user, key_path=key_path) as sess:
|
|
113
|
+
sess.interactive_shell()
|
|
114
|
+
except Exception as e:
|
|
115
|
+
console.print(f"[red]SSH failed:[/red] {e}")
|
|
116
|
+
raise SystemExit(1)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@awd_group.command("scp")
|
|
120
|
+
@click.argument("service")
|
|
121
|
+
@click.argument("src")
|
|
122
|
+
@click.argument("dst")
|
|
123
|
+
@click.option("--team", default=None, help="Target team name")
|
|
124
|
+
@click.option("--port", default=22, type=int, show_default=True)
|
|
125
|
+
@click.option("--user", default="root", show_default=True)
|
|
126
|
+
def awd_scp(service: str, src: str, dst: str, team: str | None, port: int, user: str) -> None:
|
|
127
|
+
"""SCP file transfer. Prefix remote paths with ':' (e.g. :/tmp/file)."""
|
|
128
|
+
_, wm, _ = _load_awd()
|
|
129
|
+
_, ip = _resolve_host(wm, service, team)
|
|
130
|
+
try:
|
|
131
|
+
key_path = wm.get_service_key_path(service)
|
|
132
|
+
except ValueError as e:
|
|
133
|
+
console.print(f"[red]{e}[/red]")
|
|
134
|
+
raise SystemExit(1)
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
from ctfx.managers.awd import AWDSession
|
|
138
|
+
with AWDSession(ip, port=port, username=user, key_path=key_path) as sess:
|
|
139
|
+
if src.startswith(":"):
|
|
140
|
+
remote = src[1:]
|
|
141
|
+
local = Path(dst)
|
|
142
|
+
console.print(f"[dim]Downloading[/dim] {ip}:{remote} → {local}")
|
|
143
|
+
sess.get(remote, local)
|
|
144
|
+
console.print(f"[green]Done.[/green] Saved to {local}")
|
|
145
|
+
elif dst.startswith(":"):
|
|
146
|
+
local = Path(src)
|
|
147
|
+
remote = dst[1:]
|
|
148
|
+
if not local.exists():
|
|
149
|
+
console.print(f"[red]Local file not found:[/red] {local}")
|
|
150
|
+
raise SystemExit(1)
|
|
151
|
+
console.print(f"[dim]Uploading[/dim] {local} → {ip}:{remote}")
|
|
152
|
+
sess.put(local, remote)
|
|
153
|
+
console.print(f"[green]Done.[/green]")
|
|
154
|
+
else:
|
|
155
|
+
console.print("[red]Prefix the remote path with ':' (e.g. :/tmp/file).[/red]")
|
|
156
|
+
raise SystemExit(1)
|
|
157
|
+
except SystemExit:
|
|
158
|
+
raise
|
|
159
|
+
except Exception as e:
|
|
160
|
+
console.print(f"[red]SCP failed:[/red] {e}")
|
|
161
|
+
raise SystemExit(1)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@awd_group.command("cmd")
|
|
165
|
+
@click.argument("service")
|
|
166
|
+
@click.argument("command", nargs=-1, required=True)
|
|
167
|
+
@click.option("--team", default=None, help="Run on a specific team's host")
|
|
168
|
+
@click.option("--all-teams", "all_teams", is_flag=True, help="Run on all hosts in hostlist")
|
|
169
|
+
@click.option("--port", default=22, type=int, show_default=True)
|
|
170
|
+
@click.option("--user", default="root", show_default=True)
|
|
171
|
+
@click.option("--timeout", "cmd_timeout", default=30.0, type=float, show_default=True)
|
|
172
|
+
def awd_cmd(
|
|
173
|
+
service: str,
|
|
174
|
+
command: tuple[str, ...],
|
|
175
|
+
team: str | None,
|
|
176
|
+
all_teams: bool,
|
|
177
|
+
port: int,
|
|
178
|
+
user: str,
|
|
179
|
+
cmd_timeout: float,
|
|
180
|
+
) -> None:
|
|
181
|
+
"""Run a remote command on service host(s) via SSH."""
|
|
182
|
+
_, wm, _ = _load_awd()
|
|
183
|
+
cmd_str = " ".join(command)
|
|
184
|
+
try:
|
|
185
|
+
key_path = wm.get_service_key_path(service)
|
|
186
|
+
except ValueError as e:
|
|
187
|
+
console.print(f"[red]{e}[/red]")
|
|
188
|
+
raise SystemExit(1)
|
|
189
|
+
|
|
190
|
+
if all_teams:
|
|
191
|
+
hosts = wm.load_hostlist(service)
|
|
192
|
+
if not hosts:
|
|
193
|
+
console.print(f"[red]No hosts found for service '{service}'.[/red]")
|
|
194
|
+
raise SystemExit(1)
|
|
195
|
+
targets = [(t, ip) for t, ip in hosts]
|
|
196
|
+
else:
|
|
197
|
+
_, ip = _resolve_host(wm, service, team)
|
|
198
|
+
targets = [(team or ip, ip)]
|
|
199
|
+
|
|
200
|
+
from ctfx.managers.awd import AWDSession
|
|
201
|
+
|
|
202
|
+
for team_name, ip in targets:
|
|
203
|
+
console.print(f"\n[bold cyan]{team_name}[/bold cyan] ({ip})")
|
|
204
|
+
try:
|
|
205
|
+
with AWDSession(ip, port=port, username=user, key_path=key_path) as sess:
|
|
206
|
+
stdout, stderr, code = sess.run(cmd_str, timeout=cmd_timeout)
|
|
207
|
+
if stdout.strip():
|
|
208
|
+
console.print(stdout.rstrip())
|
|
209
|
+
if stderr.strip():
|
|
210
|
+
console.print(f"[dim]{stderr.rstrip()}[/dim]")
|
|
211
|
+
if code != 0:
|
|
212
|
+
console.print(f"[yellow]exit {code}[/yellow]")
|
|
213
|
+
except Exception as e:
|
|
214
|
+
console.print(f" [red]Failed:[/red] {e}")
|