C2 framework that works on kubernetes and the cloud
docker-compose.yml runs MongoDB 7 and a ReaperC2 container (beacon 8080, admin 8443) on a shared network. Copy .env.example to .env, set passwords, then:
Scythe submodule (third_party/Scythe): for a pinned Scythe revision matching this repo, run git submodule update --init --recursive before building (or use the helper below). The Dockerfile can clone Scythe from GitHub during docker build if third_party/Scythe is missing—so plain docker compose up --build works without a manual submodule step, but the clone uses SCYTHE_GIT_REF (default main) and may differ from the submodule commit until you init the submodule or set build args.
Recommended (submodule + compose):
./scripts/compose-up.shThis runs git submodule update --init --recursive, then docker compose up --build (passes through extra args, e.g. -d).
Or without the script:
git submodule update --init --recursive # optional if you want pinned Scythe in the image
docker compose up --buildSet SCYTHE_GIT_REF (e.g. in .env or the shell) to change the branch/tag used only when the image build clones Scythe. After updating the Scythe submodule pointer in git, rebuild container images and any Scythe.embedded beacons you deployed earlier.
- Admin UI:
http://127.0.0.1:8443/login— first operator comes fromADMIN_BOOTSTRAP_*in.envwhen theoperatorscollection is empty. - MongoDB is also published on 27017 for local tools (override with
MONGO_HOST_PORTin.env). - The app connects with the Mongo root user and
MONGO_AUTH_SOURCE=admin(seepkg/dbconnections/mongoconnections.go); changeMONGO_USERNAME/MONGO_PASSWORD/MONGO_AUTH_SOURCEif you switch to an application user. Passwords with?,@, and other URI characters are supported (credentials are URL-encoded when building the connection string). - If Mongo auth fails after you change
MONGO_ROOT_PASSWORDin.env, themongo_datavolume was probably initialized with an older password — rundocker compose down -vonce (wipes local DB data), thendocker compose up --buildagain. - Scythe embedded binary: the image is based on
golang(includesgoat runtime).docker-compose.ymlsetsREAPERC2_ROOT=/rootso Scythe sources underthird_party/Scytheresolve inside the container. After generating a beacon, use Download Scythe.embedded on the Beacons page to test the full flow. - Operator AI (Ollama on the host): run Ollama on your machine (
ollama serveor the desktop app), then in.envsetREAPER_AI_OLLAMA_ENABLED=1,REAPER_AI_OLLAMA_API_URL=http://host.docker.internal:11434/v1, and list models you have pulled (ollama list, e.g.REAPER_AI_OLLAMA_MODELS=gpt-oss:latest). On Mac/Windows Docker Desktop, usehost.docker.internalonly — do not addextra_hosts: host.docker.internal:host-gateway(it breaks Ollama withEOF). On Linux, optionalCOMPOSE_PROFILES=ollama-hostuses socat on the docker bridge; setREAPER_AI_OLLAMA_API_URL=http://172.17.0.1:11434/v1instead. See Operator AI and Docker Compose.
All helper scripts and the mongoclient image live under test/.
test/run_tests.sh creates a Docker network, starts MongoDB Community in a container, waits until it is ready, builds the mongoclient image, and runs test/setup_mongo.sh inside that image. It is non-interactive and suitable for CI.
cd test
./run_tests.shBy default the Mongo container is removed when the script exits. To leave it running on localhost:27017 for manual work:
KEEP_MONGO=1 ./run_tests.shUseful environment variables (both scripts honor the overlapping ones):
| Variable | Purpose |
|---|---|
MONGO_HOST / MONGO_PORT |
Mongo host and port (defaults: in-cluster service DNS for setup_mongo.sh; run_tests.sh sets host to the Mongo container name on the test network) |
MONGO_ADMIN_USER / MONGO_ADMIN_PASSWORD |
Root user for seeding (defaults match Docker MONGO_INITDB_* in run_tests.sh) |
MONGO_API_USER / MONGO_API_PASSWORD |
Application user created in api_db (defaults: api_user / api_mongoApiPassword) |
IMPORT_DATA_JSON |
Set to 0 to skip importing test/data.json |
DATA_JSON |
Path to JSON array file for mongoimport (default: test/data.json beside the script) |
DATA_JSON_COLLECTION |
Target collection for that import (default: seed_docs) |
DOCKER_NETWORK / MONGO_CONTAINER |
Override Docker network name and Mongo container name in run_tests.sh |
KEEP_MONGO |
1 = do not remove the Mongo container on exit |
KEEP_TEST_NETWORK |
1 = skip removing the test Docker network when cleaning up (only if KEEP_MONGO is not used) |
test/setup_mongo.sh creates api_db with clients, heartbeat, and data collections (plus indexes and sample documents). test/data.json is imported as extra seed documents into seed_docs; it does not replace the scripted fixture data.
Kubernetes: exec into a pod that has mongosh and this repo (or use the mongoclient image), then point at your cluster service, for example:
export MONGO_HOST=mongodb-service.reaperc2-ns.svc.cluster.local
export MONGO_PORT=27017
./setup_mongo.shManual Docker (if you do not use run_tests.sh): build and run from test/ with MONGO_HOST set to a resolvable hostname for the Mongo container on the same Docker network.
The server reads Mongo settings from environment variables (see pkg/dbconnections/mongoconnections.go). After seeding with the defaults above, run locally against Docker Mongo on the published port:
export DEPLOY_ENV=ONPREM
export MONGO_HOST=127.0.0.1
export MONGO_PORT=27017
export MONGO_USERNAME=api_user
export MONGO_PASSWORD=api_mongoApiPassword
export MONGO_DATABASE=api_db
# Optional: when the DB user lives in the admin DB (e.g. root user)
# export MONGO_AUTH_SOURCE=admin
cd cmd && env GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -v -o ReaperC2
./ReaperC2Example log lines:
Connected to MongoDB!
Beacon API listening on :8080
Admin panel listening on :8443
The process serves two HTTP listeners: the beacon API (implants / Scythe) and an operator web UI for signing in and creating clients rows (MongoDB) with a generated Scythe example.
| Variable | Purpose |
|---|---|
BEACON_ADDR |
Beacon API bind address (default :8080) |
ADMIN_ADDR |
Admin panel bind address (default :8443) |
ADMIN_BOOTSTRAP_USERNAME / ADMIN_BOOTSTRAP_PASSWORD |
If no operators exist in MongoDB, create the first account on startup (password stored as Argon2id). Omit to create operators manually in the operators collection. |
BEACON_PUBLIC_BASE_URL |
Default C2 base URL for Scythe examples when none is set per beacon (default http://127.0.0.1:8080, no path). Override per generation with Beacon C2 base URL on the Beacons page or beacon_base_url in POST /api/beacons. |
BEACON_PIVOT_PROXY |
Optional default host:port for Scythe --proxy when the beacon has a parent (pivot). Per-beacon override: Pivot proxy field or pivot_proxy in the generate API. |
SCYTHE_SRC |
Optional absolute path to Scythe (go.mod + ./cmd). If unset, ReaperC2 searches REAPERC2_ROOT/third_party/Scythe, then paths next to the running binary (covers /root/cmd/ReaperC2 → /root/third_party/Scythe in Docker), then Getwd()/third_party/Scythe. |
REAPERC2_ROOT |
Optional; if set, Scythe is $REAPERC2_ROOT/third_party/Scythe. Sample Docker Compose / K8s YAML sets /root for the default image; not strictly required if the binary path alone resolves correctly. |
ADMIN_SESSION_TTL_HOURS |
Server-side session lifetime (default 168). |
ADMIN_COOKIE_SECURE |
Set to true if the admin UI is only served over HTTPS (adds Secure on session cookies). |
ADMIN_DISABLE |
Set to 1 to run only the beacon listener (no admin port). |
ADMIN_ARGON2_TIME |
Argon2id time cost (default 3). |
ADMIN_ARGON2_MEMORY_KIB |
Argon2id memory in KiB (default 65536, i.e. 64 MiB). |
ADMIN_ARGON2_THREADS |
Argon2id parallelism (default 4). |
Operator passwords are stored as Argon2id (serialized in operators.password_hash). Existing bcrypt hashes ($2a$ / $2b$) still verify so you can migrate gradually.
Open https://<host>:8443 (or http:// locally; / redirects to Engagements). Pick a workspace, then use Beacons, Commands, and the other operator pages. Full per-page documentation is in docs/operator-guide.md and in the admin UI under Documentation → Operator guide.
| Area | Purpose |
|---|---|
| Engagements | Workspaces that scope beacons, commands, reports, topology, notes, and chat; assign operators (admins). |
| Beacons | Generate clients, Scythe Http options, Scythe.embedded download (POST /api/beacons/scythe-embedded; Go required on server), saved profiles, kill queue. |
| Commands | Queue tasks; stage uploads; view pending queue, artifacts, and output history. File bytes persist in MongoDB/DocumentDB GridFS (reaper_artifacts). |
| Reports | JSON / CSV / Ghostwriter / ATT&CK Navigator layer exports. |
| Topology | Interactive beacon graph (liveness + pivot chain). |
| Notes & ATT&CK | Engagement notes and MITRE Navigator layer source. |
| Chat | Operator chat per engagement (operator_chat). |
| Engagement logs | Audit trail for the active engagement. |
| Users (admins only) | Portal accounts (/users, POST /api/users). |
| All logs (admins only) | Global audit + JSON / Ghostwriter export (includes operator chat). |
Roles (field operators.role in MongoDB): Admin — full portal access including user management. Operator — beacons, reports, topology, chat, and profile management; cannot create users or call user APIs. Accounts without role are treated as Admin for backward compatibility. The bootstrap account is always Admin.
- Using a client, such as Scythe, we query the API
$ ./Scythe Http --method GET --timeout 5s --url http://127.0.0.1:8080 --headers 'Content-Type:application/json,X-Client-Id:550e8400-e29b-41d4-a716-446655440000,X-API-Secret:mysecurekey1' --directories '/heartbeat/550e8400-e29b-41d4-a716-446655440000,/heartbeat'
A pivot is an upstream beacon built with SOCKS5 listener (UI default port 9050) and a child whose Pivot proxy is that listener’s host:port. Parent ClientId alone does not add -proxy. See Initial proxy.
- If there is no authenticated user, then no access.
The root Makefile builds a multi-arch image (linux/amd64 and linux/arm64) with Docker buildx and pushes to registry.reaper-ut.com (override ECR_REGISTRY if needed).
Prerequisites
- Docker with the buildx plugin
- AWS CLI v2 (
aws sts get-caller-identityworks after auth below) — used to mint the registry password - Git (submodule init)
Authenticate to the Reaper registry
Two steps: set AWS CLI variables in the shell, then Docker-login to registry.reaper-ut.com. make build / ./ship.sh also login, but you can (and should) do this yourself first.
1. AWS CLI variables
unset AWS_PROFILE
export AWS_REGION=us-east-1
export ECR_REPOSITORY=reaperc2
export ECR_REGISTRY=registry.reaper-ut.com
# Temporary creds from SSO / access portal / assume-role (keys start with ASIA):
export AWS_ACCESS_KEY_ID="ASIA..."
export AWS_SECRET_ACCESS_KEY="..."
export AWS_SESSION_TOKEN="..."
# IAM user keys (AKIA…): omit AWS_SESSION_TOKEN.
aws sts get-caller-identityNamed profile instead of key exports: aws sso login --profile my-sso, then export AWS_PROFILE=my-sso (profile name, not a 12-digit account id).
2. Docker login
aws ecr get-login-password --region "${AWS_REGION}" | \
docker login --username AWS --password-stdin "${ECR_REGISTRY}"Expect Login Succeeded. Then push (make build-amd64) and deploy (step 3).
The cluster pull secret is reaperc2-myregistrykey (./deploy-cluster.sh ecr-secret), with --docker-server=registry.reaper-ut.com.
3. Deploy (cluster already running)
Kubernetes does not watch :latest. After a successful push to registry.reaper-ut.com/reaperc2:latest, apply the overlay and restart pods so imagePullPolicy: Always pulls the new digest.
kubectl must be pointed at the EKS cluster (aws eks update-kubeconfig if needed). DocumentDB secrets and the first ./deploy.sh all are already done for a live cluster — do not re-run all or job-docdb-user.
cd deployments/k8s/reaperc2
# Shortcut (apply overlay + rollout restart + wait):
./reroll.sh --apply-core
# same thing: ./ship.sh --deploy-only
# Or raw kubectl:
kubectl apply -k overlays/aws-ecr
kubectl rollout restart deployment/reaperc2-deployment -n reaperc2-ns
kubectl rollout status deployment/reaperc2-deployment -n reaperc2-ns --timeout=300s
kubectl get pods -n reaperc2-ns -o widePods should become Running on a new age. Image on the pod should be registry.reaper-ut.com/reaperc2:latest. Reconnect admin with:
kubectl port-forward -n reaperc2-ns deployment/reaperc2-deployment 8443:8443Build and deploy in one shot (rebuilds even if you just pushed): ./ship.sh.
Daily loop (always :latest)
# after AWS vars + docker login above
make build-amd64 # or: ./ship.sh --push-only
cd deployments/k8s/reaperc2 && ./reroll.sh --apply-coreOn Apple Silicon, go mod download inside Docker buildx often crashes (Go SIGSEGV under QEMU). make build / ./ship.sh cross-compile on your Mac (make vendor + make build-binaries), then Docker only packages the image (Dockerfile.pack). Use make build-docker on native Linux CI after make vendor.
Pin a release tag (still also aliases :latest and the git SHA):
make build IMAGE_TAG=v1.0.0Other registry hosts
make build ECR_REGISTRY=registry.example.com AWS_REGION=us-east-1Makefile targets
| Target | Description |
|---|---|
make help |
List targets and current IMAGE |
make build |
Host cross-compile + multi-arch push to ECR_REGISTRY (recommended on Mac) |
make build-docker |
Full Docker build with vendored modules (Linux CI) |
make build-binaries |
Only bin/linux-amd64 and bin/linux-arm64/ReaperC2 |
make vendor |
go mod vendor (required before build-docker) |
make push |
Same as make build |
make build-amd64 |
Push only ...:$(IMAGE_TAG)-amd64 (default tag latest) |
make build-arm64 |
Push only ...:$(IMAGE_TAG)-arm64 |
make build-local |
Build reaperc2:local for your machine (--load, no registry) |
make ecr-login |
get-login-password → docker login $(ECR_REGISTRY) |
make ecr-create-repo |
Create the AWS ECR repository if ECR_REGISTRY is an *.dkr.ecr.* host |
./ship.sh |
Push :latest (amd64) + apply-core + rollout |
Variables (override on the command line or in the environment)
| Variable | Default | Purpose |
|---|---|---|
ECR_REGISTRY |
registry.reaper-ut.com |
Docker login and push host |
ECR_REPOSITORY |
reaperc2 |
Repository name (registry.reaper-ut.com/reaperc2:latest) |
IMAGE_TAG |
latest |
Image tag; every push also aliases the git SHA, and aliases :latest if you override this |
AWS_REGION |
us-east-1 |
Region for aws ecr get-login-password |
SCYTHE_GIT_REF |
main |
Branch/tag when the Dockerfile must clone Scythe (submodule preferred) |
AWS_ACCESS_KEY_ID |
(unset) | Access key (ASIA… STS or AKIA… IAM user) |
AWS_SECRET_ACCESS_KEY |
(unset) | Secret key paired with AWS_ACCESS_KEY_ID |
AWS_SESSION_TOKEN |
(unset) | Required for temporary/ASIA creds; omit for IAM user keys |
AWS_CLI_PROFILE |
(unset) | Named aws --profile for registry login; takes precedence over env keys |
AWS_PROFILE |
(unset) | Shell profile if no AWS_CLI_PROFILE and no env keys. Must be a profile name, never an account id. Ignored when AWS_ACCESS_KEY_ID is set. |
Deploy to EKS after push
First install: follow deployments/k8s/reaperc2/README.md (./deploy.sh all, DocumentDB Jobs, apply-ingress). After that, do not edit the image tag — keep …/reaperc2:latest and run ./ship.sh.
For local tags or a registry other than ECR:
git submodule update --init --recursive # recommended: embed exact Scythe commit from this repo
docker build -t reaperc2:latest .The Dockerfile uses TARGETARCH (default amd64). For arm64 on a single platform: docker build --build-arg TARGETARCH=arm64 -t reaperc2:arm64 .
If third_party/Scythe is missing, the image build clones Scythe using SCYTHE_GIT_REF (default main):
docker build --build-arg SCYTHE_GIT_REF=your-tag -t reaperc2:latest .Push to your registry, then point deployments/k8s/** manifests at that tag.
DEPLOY_ENV tells ReaperC2 where it is running so it can adjust behavior—today mainly the MongoDB/DocumentDB connection string (pkg/dbconnections/mongoconnections.go):
| Value | Effect |
|---|---|
AWS |
Adds DocumentDB TLS URI params (tls, replicaSet=rs0, CA file path, etc.) |
ONPREM |
Standard Mongo URI; optional MONGO_USE_TLS=true |
AZURE / GCP |
Placeholders in cmd/main.go (“coming soon”) |
Valid values: AWS, AZURE, GCP, ONPREM (pkg/deploymehere/deploymehere.go). Invalid or unset → treated as ONPREM.
The Dockerfile default is ONPREM for local Compose. You do not need to bake AWS into the ECR image for EKS: deployments/k8s/reaperc2/base/deployment.yaml already sets DEPLOY_ENV=AWS, which overrides the image default at pod start.
To change the image default at build time (optional):
docker build --build-arg DEPLOY_ENV=AWS -t reaperc2:aws .
# or
docker buildx build --build-arg DEPLOY_ENV=AWS ...Prefer runtime config (Kubernetes env, Compose environment) so one image tag works everywhere and you can change behavior without rebuilding.
ReaperC2 listens on two ports in one process by default: the beacon API on 8080 and the operator admin UI on 8443 (see BEACON_ADDR / ADMIN_ADDR in the table below). For cluster deployments you should treat them differently:
- Beacons / Scythe: expose 8080 to the Internet via your Ingress or load balancer (TLS termination in front of the Service is fine). The sample manifests under
deployments/k8s/wire only port 8080 onreaperc2-serviceto the public host. - Admin panel: do not put 8443 on a public Ingress or load balancer. Instead, from a trusted workstation, open a tunnel to 8443 on the ReaperC2 Pod or Deployment and use the UI at
http://127.0.0.1:8443on that machine.
Typical: kubectl port-forward
kubectl port-forward -n reaperc2-ns deployment/reaperc2-deployment 8443:8443Leave that process running, then in a browser on the same machine open http://127.0.0.1:8443/login. The admin server uses plain HTTP on that port unless you change the binary or put TLS in front of it yourself.
To forward to a specific Pod (useful if the Deployment name differs):
kubectl port-forward -n reaperc2-ns pod/$(kubectl get pod -n reaperc2-ns -l app=reaperc2-deployment -o jsonpath='{.items[0].metadata.name}') 8443:8443Adjust -n, labels, and Deployment/Pod names to match your YAML.
Jump host: SSH local forward after port-forward on the bastion
If your laptop cannot reach the Kubernetes API directly but you can SSH to a bastion that has kubectl and kubeconfig:
- On the bastion, run
kubectl port-forward … 8443:8443as above (it listens on the bastion’s loopback). - From your laptop:
ssh -N -L 8443:127.0.0.1:8443 user@bastion.example.com - Open
http://127.0.0.1:8443/loginon the laptop.
Beacon base URL for implants
Configure BEACON_PUBLIC_BASE_URL (and/or each beacon’s Beacon C2 base URL in the UI) to the public origin that beacons should call—e.g. https://c2.example.com where your Ingress terminates and forwards to 8080. That must not be http://127.0.0.1:8443; localhost is only for operators via the tunnel.
Optional: set ADMIN_DISABLE=1 on the workload if you want no admin listener at all (beacon-only); you lose the web UI unless you run a separate pattern.
- Kubernetes cluster (e.g. EKS) with
kubectlconfigured - Container image in a registry (for AWS:
make build→ ECR; see Building the container image) - Traefik (or another ingress) for beacon traffic on 8080
- A public hostname for implants (
BEACON_PUBLIC_BASE_URL); keep admin 8443 off public ingress—use port-forward instead
| Path | Use when |
|---|---|
deployments/k8s/ |
EKS or k3s + DocumentDB + Traefik/cert-manager (ship.sh, deploy-cluster.sh, deploy.sh, reroll.sh; quick index deployments/k8s/DEPLOY.md; kubectl apply -k deployments/k8s/AWS still works as aws-ecr shim) |
deployments/k8s/OnPrem/ |
In-cluster MongoDB |
deployments/k8s/full-deployment.yaml |
Sample all-in-one with in-cluster Mongo |
AWS deploy (summary)
cd deployments/k8s/reaperc2
# First time: ./deploy.sh all then job-docdb-user / job-docdb-init / apply-ingress
# Every image update:
./ship.sh- Point Ingress / IngressRoute only at Service port 8080 (beacon).
- Set your subdomain and TLS issuer in
ingress.yaml/ingressroute.yaml(staging vs prod cert-manager issuer). - DocumentDB: split secret keys (
host,username, …), not a single URI; runfetch-docdb-ca-bundle.shthendocdb-init-job.yamlfor collections/indexes. - Operator AI on EKS: copy
deployments/k8s/operator-ai.yaml→operator-ai.local.yaml, apply locally (ConfigMap + secrets) — see AWS README and Operator AI.
Full checklist (read DocumentDB pitfalls first): deployments/k8s/reaperc2/README.md and docs/kubernetes.md.
